Spring Boot多数据源动态路由实战:读写分离与分库场景连接管理

多数据源场景为什么需要动态路由

Spring Boot应用连接单一数据源是默认方案,但业务增长到一定规模后必然遇到多数据源需求:读写分离(主库写、从库读)、分库分表(按业务线或租户拆分数据库)、跨系统数据聚合(同时访问多个业务库)。静态配置多数据源可以通过@Qualifier注入不同DataSource实现,但当数据源数量不固定或需要运行时切换时,就必须引入动态路由机制。Spring Boot 3.x配合AbstractRoutingDataSource可以优雅地解决这个问题。

动态数据源核心实现

AbstractRoutingDataSource是Spring JDBC提供的抽象类,核心只需实现determineCurrentLookupKey()方法,返回当前线程需要使用的数据源标识:

public class DynamicDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return DataSourceContextHolder.get();
    }
}

// 线程上下文持有者
public class DataSourceContextHolder {
    private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();

    public static void set(String dsKey) {
        CONTEXT.set(dsKey);
    }

    public static String get() {
        return CONTEXT.get();
    }

    public static void clear() {
        CONTEXT.remove();
    }
}

ThreadLocal保证线程隔离,在并发环境下每个请求切换数据源互不影响。但ThreadLocal有一个坑:线程池场景下,请求结束后如果不调用clear(),下一个复用该线程的请求会读到上一次的数据源标识。务必在Filter或Interceptor的finally块中调用clear()。

数据源配置与注册

Spring Boot 3.x使用HikariCP作为默认连接池,多数据源场景下每个DataSource需要独立的连接池配置:

@Configuration
public class DataSourceConfig {

    @Bean
    @ConfigurationProperties("app.datasource.master")
    public DataSource masterDataSource() {
        return DataSourceBuilder.create().type(HikariDataSource.class).build();
    }

    @Bean
    @ConfigurationProperties("app.datasource.slave")
    public DataSource slaveDataSource() {
        return DataSourceBuilder.create().type(HikariDataSource.class).build();
    }

    @Bean
    @ConfigurationProperties("app.datasource.order")
    public DataSource orderDataSource() {
        return DataSourceBuilder.create().type(HikariDataSource.class).build();
    }

    @Bean
    public DynamicDataSource dynamicDataSource(
            DataSource masterDataSource,
            DataSource slaveDataSource,
            DataSource orderDataSource) {
        Map<Object, Object> targetDataSources = new HashMap<>(4);
        targetDataSources.put("master", masterDataSource);
        targetDataSources.put("slave", slaveDataSource);
        targetDataSources.put("order", orderDataSource);

        DynamicDataSource dynamicDataSource = new DynamicDataSource();
        dynamicDataSource.setTargetDataSources(targetDataSources);
        dynamicDataSource.setDefaultTargetDataSource(masterDataSource);
        return dynamicDataSource;
    }

    @Bean
    public SqlSessionFactory sqlSessionFactory(DynamicDataSource dynamicDataSource) throws Exception {
        MybatisSqlSessionFactoryBean factory = new MybatisSqlSessionFactoryBean();
        factory.setDataSource(dynamicDataSource);
        factory.setMapperLocations(new PathMatchingResourcePatternResolver()
            .getResources("classpath:mapper/**/*.xml"));
        return factory.getObject();
    }
}

application.yml中的连接池配置:

app:
  datasource:
    master:
      jdbc-url: jdbc:mysql://10.0.1.10:3306/app_main
      username: root
      password: ${DB_PASSWORD}
      hikari:
        maximum-pool-size: 20
        minimum-idle: 5
        connection-timeout: 3000
    slave:
      jdbc-url: jdbc:mysql://10.0.1.11:3306/app_main
      username: readonly
      password: ${DB_PASSWORD}
      hikari:
        maximum-pool-size: 30
        minimum-idle: 10
    order:
      jdbc-url: jdbc:mysql://10.0.2.10:3306/app_order
      username: root
      password: ${DB_PASSWORD}
      hikari:
        maximum-pool-size: 15
        minimum-idle: 3

从库的连接池可以适当放大,因为读请求通常比写请求多。主库连接池不宜过大,写事务持有连接的时间较长,过多连接会导致锁等待。

自定义注解 + AOP实现声明式切换

手动调用DataSourceContextHolder.set()侵入性太强,通过自定义注解+AOP可以实现声明式数据源切换:

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface DS {
    String value() default "master";
}

@Aspect
@Component
public class DataSourceAspect {

    @Around("@annotation(ds)")
    public Object around(ProceedingJoinPoint joinPoint, DS ds) throws Throwable {
        String dsKey = ds.value();
        try {
            DataSourceContextHolder.set(dsKey);
            return joinPoint.proceed();
        } finally {
            DataSourceContextHolder.clear();
        }
    }
}

使用方式:

@Service
public class OrderService {

    @DS("master")
    public void createOrder(Order order) {
        orderMapper.insert(order);
    }

    @DS("slave")
    public Order getOrder(Long id) {
        return orderMapper.selectById(id);
    }

    @DS("order")
    public List<Order> queryOrderDb(OrderQuery query) {
        return orderMapper.queryList(query);
    }
}

读写分离的自动路由

手动标注@DS(“slave”)虽然清晰,但容易遗漏——新同事写查询方法时忘了加注解,读请求就会打到主库。更好的方案是基于Spring事务的只读标记自动路由:

@Aspect
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class ReadOnlyRouteAspect {

    @Around("@annotation(org.springframework.transaction.annotation.Transactional)")
    public Object routeReadOnly(ProceedingJoinPoint joinPoint) throws Throwable {
        // 如果已经手动指定了数据源,不做覆盖
        if (DataSourceContextHolder.get() != null) {
            return joinPoint.proceed();
        }

        // 检查@Transactional的readOnly属性
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Transactional tx = signature.getMethod().getAnnotation(Transactional.class);
        if (tx != null && tx.readOnly()) {
            DataSourceContextHolder.set("slave");
        }

        try {
            return joinPoint.proceed();
        } finally {
            DataSourceContextHolder.clear();
        }
    }
}

配合@Transactional(readOnly = true)自动路由到从库,写操作默认走主库,无需手动标注@DS注解。这种约定优于配置的方式在大团队协作中更可靠。

分布式事务下的数据源切换陷阱

当引入Seata等分布式事务框架时,动态数据源切换会遇到冲突:Seata通过代理DataSource来管理全局锁和分支事务,而AbstractRoutingDataSource也是DataSource的代理。两层代理的顺序如果搞反,Seata拿到的Connection可能来自错误的数据源。

解决方案是确保Seata的DataSourceProxy包裹在DynamicDataSource内部而非外部:

// 正确:Seata代理每个实际数据源,DynamicDataSource做路由
@Bean
public DataSource seataMasterProxy(DataSource masterDataSource) {
    return new DataSourceProxy(masterDataSource);
}

// 错误:Seata代理DynamicDataSource,路由信息丢失
@Bean
public DataSource seataProxy(DynamicDataSource dynamicDataSource) {
    return new DataSourceProxy(dynamicDataSource); // BUG!
}

另一个常见陷阱是@DS注解与@Transactional的AOP执行顺序。如果DataSourceAspect比TransactionInterceptor先执行,数据源切换正常;如果顺序反转,事务开启时拿到的还是默认数据源。通过@Order注解确保DataSourceAspect的优先级高于事务切面:

@Aspect
@Component
@Order(Ordered.HIGHEST_PRECEDENCE) // 最高优先级
public class DataSourceAspect { ... }

连接池监控与泄漏检测

多数据源环境下连接泄漏更难发现,因为问题可能出在任何一个连接池。HikariCP内置了泄漏检测:

hikari:
  leak-detection-threshold: 60000  # 60秒未归还视为泄漏
  maximum-pool-size: 20
  metrics-tracker: true

超过阈值的连接会打印WARN日志,包含堆栈信息,直接定位泄漏点。生产环境建议设为业务最大事务耗时的2倍,避免误报。

多数据源的监控指标需要按数据源分别采集。Spring Boot Actuator的/actuator/metrics端点暴露了hikaricp.connections前缀的指标,加上tags区分数据源:

# Prometheus采集配置
- pattern: 'hikaricp_connections_active{pool="master"}'
- pattern: 'hikaricp_connections_active{pool="slave"}'
- pattern: 'hikaricp_connections_active{pool="order"}'

当某个数据源的活跃连接数持续接近maximum-pool-size时,需要排查是否存在慢查询或事务超时,而非简单调大连接池——更多的连接只会让数据库压力更大,问题根因未被解决。

原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/springboot-duo-shu-ju-yuan-dong-tai-lu-you-shi-zhan-du-xie/

(0)
小编小编
上一篇 19小时前
下一篇 19小时前

相关推荐