一、为什么读写分离配置会出错

很多朋友在使用PolarDB的时候,都会遇到读写分离配置错误的问题。这就像开车时把油门和刹车搞混了一样,虽然看起来都是踏板,但功能完全不同。读写分离的核心思想是把读操作和写操作分开处理,写操作走主节点,读操作走只读节点。但配置时稍不注意,就可能出现各种奇怪的问题。

最常见的情况是,明明配置了读写分离,但所有请求还是都跑到了主节点上。这就好比在超市开了10个收银台,结果所有顾客都挤在1号柜台排队。造成这种现象的原因通常有三种:连接池配置不当、负载均衡策略错误,或者是应用程序中没有正确区分读写操作。

二、典型错误场景分析

让我们看一个典型的Java Spring Boot应用连接PolarDB的配置示例。这个例子展示了最常见的配置错误:

// 错误示例:缺少读写分离配置的DataSource
@Bean
public DataSource dataSource() {
    HikariConfig config = new HikariConfig();
    config.setJdbcUrl("jdbc:mysql://polar-master-instance:3306/db");
    config.setUsername("user");
    config.setPassword("password");
    return new HikariDataSource(config);
    // 问题点:这里只配置了主节点地址,没有配置只读节点
    // 后果:所有查询都会走主节点,造成主节点压力过大
}

另一个常见错误是在MyBatis配置中没有明确区分读写操作:

<!-- 错误示例:MyBatis映射文件没有使用@Master或@Slave注解 -->
<select id="getUserById" resultType="User">
    SELECT * FROM users WHERE id = #{id}
    <!-- 问题点:这是一个读操作,但没有标记为走只读节点 -->
    <!-- 后果:这个查询可能会被路由到主节点执行 -->
</select>

三、正确的配置方法

现在让我们看看如何正确配置PolarDB的读写分离。这里以Java Spring Boot + MyBatis为例:

// 正确示例:配置主从数据源
@Bean
@ConfigurationProperties(prefix = "spring.datasource.master")
public DataSource masterDataSource() {
    return DataSourceBuilder.create().build();
}

@Bean
@ConfigurationProperties(prefix = "spring.datasource.slave")
public DataSource slaveDataSource() {
    return DataSourceBuilder.create().build();
}

@Bean
public AbstractRoutingDataSource routingDataSource(
        @Qualifier("masterDataSource") DataSource master,
        @Qualifier("slaveDataSource") DataSource slave) {
    
    Map<Object, Object> targetDataSources = new HashMap<>();
    targetDataSources.put("master", master);
    targetDataSources.put("slave", slave);
    
    AbstractRoutingDataSource dataSource = new AbstractRoutingDataSource() {
        @Override
        protected Object determineCurrentLookupKey() {
            return DynamicDataSourceHolder.getDataSource();
        }
    };
    
    dataSource.setDefaultTargetDataSource(master);
    dataSource.setTargetDataSources(targetDataSources);
    return dataSource;
    // 关键点:这里实现了动态数据源路由
    // 优点:可以根据需要灵活切换主从数据源
}

还需要一个辅助类来管理当前使用的数据源:

// 数据源上下文持有类
public class DynamicDataSourceHolder {
    private static final ThreadLocal<String> contextHolder = new ThreadLocal<>();
    
    public static void setDataSource(String dataSource) {
        contextHolder.set(dataSource);
    }
    
    public static String getDataSource() {
        return contextHolder.get();
    }
    
    public static void clearDataSource() {
        contextHolder.remove();
    }
}

四、事务处理的注意事项

读写分离配置中,事务处理是最容易出问题的地方。很多人不知道,在事务中的读操作也必须走主节点,否则会出现脏读的问题。

// 事务中的读操作示例
@Transactional
public User updateUser(User user) {
    // 必须走主节点
    DynamicDataSourceHolder.setDataSource("master");
    
    User existing = userMapper.selectById(user.getId());
    if(existing != null) {
        userMapper.update(user);
    }
    
    // 事务提交后可以恢复默认路由
    DynamicDataSourceHolder.clearDataSource();
    return user;
    // 关键点:事务中的所有操作都必须走主节点
    // 注意事项:记得在方法结束时清理数据源标记
}

对于纯读操作,我们可以使用自定义注解来标记:

// 只读操作注解
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ReadOnly {
}

// 使用AOP自动切换数据源
@Aspect
@Component
public class ReadOnlyAspect {
    @Before("@annotation(readOnly)")
    public void before(ReadOnly readOnly) {
        DynamicDataSourceHolder.setDataSource("slave");
    }
    
    @After("@annotation(readOnly)")
    public void after(ReadOnly readOnly) {
        DynamicDataSourceHolder.clearDataSource();
    }
    // 优点:通过注解自动管理数据源切换
    // 使用方式:在只读方法上添加@ReadOnly注解即可
}

五、性能调优与监控

配置好读写分离后,还需要关注性能调优和监控。这里给出一个监控SQL执行情况的示例:

// SQL执行监控切面
@Aspect
@Component
public class SqlMonitorAspect {
    private static final Logger logger = LoggerFactory.getLogger(SqlMonitorAspect.class);
    
    @Around("execution(* com..mapper.*.*(..))")
    public Object monitorSql(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.currentTimeMillis();
        String methodName = joinPoint.getSignature().toShortString();
        String dataSource = DynamicDataSourceHolder.getDataSource();
        
        try {
            Object result = joinPoint.proceed();
            long elapsed = System.currentTimeMillis() - start;
            
            if(elapsed > 200) { // 超过200ms的SQL记录警告
                logger.warn("Slow SQL detected - Method: {}, DataSource: {}, Time: {}ms", 
                    methodName, dataSource, elapsed);
            }
            
            return result;
        } catch (Exception e) {
            logger.error("SQL Error - Method: {}, DataSource: {}", methodName, dataSource, e);
            throw e;
        }
        // 功能:监控SQL执行时间和错误
        // 输出:记录慢查询和错误查询,帮助定位性能问题
    }
}

六、常见问题解决方案

在实际应用中,可能会遇到各种边缘情况。这里列举几个常见问题及其解决方案:

  1. 主从延迟问题:写后立即读可能导致数据不一致
// 解决方案:关键业务强制走主节点
public User getImportantUser(String userId) {
    // 重要数据强制走主节点
    DynamicDataSourceHolder.setDataSource("master");
    try {
        return userMapper.selectById(userId);
    } finally {
        DynamicDataSourceHolder.clearDataSource();
    }
    // 应用场景:账户余额等关键数据的查询
    // 注意事项:不要滥用,否则会失去读写分离的意义
}
  1. 连接池配置问题:主从节点连接数分配不均
# 正确的主从连接池配置示例
spring.datasource.master.hikari.maximum-pool-size=20
spring.datasource.slave.hikari.maximum-pool-size=50
# 配置原则:读多写少的场景,从库连接池可以设置更大
# 经验值:从库连接数通常是主库的2-3倍

七、最佳实践总结

经过上面的分析和示例,我们可以总结出PolarDB读写分离配置的几个最佳实践:

  1. 明确区分读写操作:通过注解或命名规范明确标识读写方法
  2. 事务处理要小心:事务内的所有操作必须走主节点
  3. 合理设置连接池:根据业务特点分配主从连接数比例
  4. 监控不能少:建立完善的SQL监控机制,及时发现性能问题
  5. 考虑主从延迟:关键业务场景要考虑强制读主库

最后提醒大家,读写分离不是银弹,它适用于读多写少的场景。如果你的应用是写密集型,或者对数据一致性要求极高,可能需要考虑其他方案,如分库分表或者使用分布式事务。