做后端开发这些年,连接池切换这套东西我踩过不少坑。最近在改一个报表项目,同一个接口要根据传入的租户标识落到不同的业务库,刚开始图省事,在代码里写死两个数据源,需要哪个库就把DataSource对象传来传去。没过几轮,链路一长就乱了:有的Service忘了传,有的方法内部开起事务之后又切库,最后查出来的数据对不上,排查起来特别费劲。
后来我把整套逻辑收敛成了一套Druid多数据源配置加动态切换的方案。核心思路不复杂:用Spring自带的AbstractRoutingDataSource做路由,用ThreadLocal保存当前线程需要使用的数据源Key,上层再用AOP和注解把切换动作做成透明操作。这篇文章就把这套方案从原理到落地完整拆开讲,内容包括:为什么要做动态切换而不是单纯配多数据源、AbstractRoutingDataSource和ThreadLocal是怎么配合的、Druid连接池在中间扮演什么角色,以及我实际踩过的几个坑。如果你正在Spring Boot加MyBatis的项目里做读写分离、租户分库,或者只是不想为每个数据源复制一堆重复代码,这篇应该能帮上忙。
1. 为什么需要“动态”切换,而不是只多配几个DataSource
1.1 静态配置解决的是连接池共存,解决不了调用时选择
Spring Boot里想连接两个库,最朴素的写法就是定义两个DataSource Bean,一个叫masterDataSource,一个叫reportDataSource,业务代码里按需注入。这种写法能跑,但它把“当前应该连哪个库”的判断责任全部压给了业务方法。你得自己封装DAO工具类,或者每次手工指定数据源。一旦调用链多几层,比如ServiceA调用ServiceB,ServiceB再点MapperC,很容易出现某层忘记指定,最终连错库的情况。
动态切换要解决的就是这个问题。它不是把两个数据源摆在那里,而是在每次获取数据库连接的那一刻,根据当前线程的上下文自动判断应该用哪个连接池。这样,业务代码只需要声明一个意图,比如“这个方法走报表库”,底层自然会在getConnection()时拿到对应的连接。
1.2 读写分离、租户分库、模块隔离:会用到动态切换的场景
我见过会真正需要这套能力的项目大概分三类。第一类是读写分离,主库负责增删改,从库负责查询,表结构一样,但是接口级别要区分读写;第二类是租户分库,SaaS系统里每个租户的数据落在不同的库,调用方只需要传一个租户ID,底层要把请求路由到对应的库;第三类是模块隔离,订单库、商品库、日志库已经物理拆开了,但Service层希望用统一入口访问多个库。
这三类场景的共同点,是数据源的选择不固定,跟请求参数、操作类型、当前调用上下文强相关。动态切换就是把这种“相关性”变成一套统一机制,而不是散落在各个业务方法里的if-else。
1.3 为什么这个方案里选Druid而不是默认连接池
Druid本身解决的是连接池管理问题。连接怎么创建、空闲连接怎么检测、连接不够了怎么扩容、慢SQL怎么记录、SQL注入怎么拦截,这些能力Druid都打包好了。相比Spring Boot默认池,Druid的监控体系更完整,一个StatFilter开起来就能看到每个连接池的活跃连接数、执行SQL次数、慢SQL条数。
动态切换需要一个稳定的底库。连接池管理本身不能出幺蛾子,否则路由做得再好,底层连接一旦泄漏或者断连,上层全崩。所以我的定位很明确:Druid负责物理连接的生命周期管理,AbstractRoutingDataSource负责逻辑数据源的路由,两边各司其职,不混在一起。
2. 核心机制:AbstractRoutingDataSource、ThreadLocal和Druid的分工
2.1 AbstractRoutingDataSource:路由发生在getConnection()那一层
很多人把AbstractRoutingDataSource想得很神秘,其实它本质上是一个“带路由能力的DataSource代理”。它内部维护两个关键字段:targetDataSources存放的是多个真实数据源,defaultTargetDataSource是默认数据源。当你调用它的getConnection()时,它会先调用子类重写的determineCurrentLookupKey()拿到当前要用哪个Key,然后从targetDataSources里取出对应的真实数据源,再调用这个真实数据源的getConnection()去拿物理连接。
关键点就在这里:路由判断发生在“获取连接”这一步,而不是“执行SQL”那一步。同一时刻,一个连接只能属于一个物理库。如果某个Connection已经被拿到手里并且被缓存了,后面你再怎么改ThreadLocal里的Key,这个连接也不会切换过去。这一点对理解后面的坑非常重要。
2.2 ThreadLocal:用一个静态上下文隔离不同调用链
在Web请求场景里,一个请求通常由一条线程从头处理到尾。ThreadLocal天生适合保存“只对当前线程有效”的临时状态,这里这个临时状态就是数据源Key。我把这个Key的读写封装在一个独立类里:
public class DynamicDataSourceContextHolder { private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>(); public static void setDataSource(String dataSource) { CONTEXT.set(dataSource); } public static String getDataSource() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); } }它的作用很简单:在方法入口set一个Key,让determineCurrentLookupKey()能从ThreadLocal里读出来;方法结束之后,必须在finally里remove。如果不清理,一旦连接池的线程被复用来处理下一个请求,下一个请求就可能读到上一个请求遗留的Key,出现订单查到了报表库这种极其隐蔽的问题。
2.3 Druid在这个架构里到底扮演什么
Druid在整套架构里不负责路由,它只负责连接池本身。每一个物理库对应一个独立的DruidDataSource实例,这个实例里有自己独立的连接池、独立的监控数据、独立的WallFilter规则。从Spring容器角度看,业务只感知到一个路由数据源;但路由数据源往下铺开,实际上是好几个DruidDataSource。
理解这一点对排查监控问题非常关键。比如后面会讲到,Druid监控页面上会列出多个数据源实例,但有时候某个实例的统计一直是零,不是监控坏了,而是那个连接池压根没有被真实调用过,或者切换动作根本没生效。
3. 配置落地:从依赖到三个核心Bean的完整步骤
3.1 引入依赖与YAML外置参数
先引入Druid的Spring Boot Starter和MyBatis Starter。Druid的Starter会自动装配一部分默认行为,但多数据源场景下,我更倾向自己定义核心Bean,这样每个连接池的参数都可控。
<dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId> <version>1.2.20</version> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>YAML里的配置我习惯把每个库的连接池参数单独拆开,不要图省事共用一套参数。报表库查询量大,max-active可以放开到20;订单库写入频繁,max-wait就不要设太长,否则数据库抖动时请求会全部卡住。
spring: datasource: druid: master: url: jdbc:mysql://localhost:3306/order_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver initial-size: 5 min-idle: 5 max-active: 20 max-wait: 60000 validation-query: SELECT 1 test-while-idle: true report: url: jdbc:mysql://localhost:3306/report_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: report_user password: report_pwd driver-class-name: com.mysql.cj.jdbc.Driver initial-size: 3 min-idle: 3 max-active: 10 max-wait: 30000 validation-query: SELECT 1 test-while-idle: true这里有个容易忽略的点:Druid的参数绑定前缀是spring.datasource.druid.master,而Spring Boot原生数据源前缀是spring.datasource。如果写错了前缀,连接池会启动失败,或者所有参数都用默认值,Xmx看到的现象就是连接池好像只建了几个连接,跟配置完全对不上。
3.2 多数据源Bean与路由源Definition
接下来写一个DataSourceConfig,把master数据源和report数据源分别初始化成独立的DruidDataSource,然后再建一个routingDataSource作为路由入口。
@Configuration public class DataSourceConfig { @Bean("masterDataSource") @ConfigurationProperties(prefix = "spring.datasource.druid.master") public DataSource masterDataSource() { return DruidDataSourceBuilder.create().build(); } @Bean("reportDataSource") @ConfigurationProperties(prefix = "spring.datasource.druid.report") public DataSource reportDataSource() { return DruidDataSourceBuilder.create().build(); } @Bean("routingDataSource") @Primary public DataSource routingDataSource( @Qualifier("masterDataSource") DataSource master, @Qualifier("reportDataSource") DataSource report) { Map<Object, Object> targetDataSources = new HashMap<>(4); targetDataSources.put(DsKeyEnum.MASTER.name(), master); targetDataSources.put(DsKeyEnum.REPORT.name(), report); DynamicRoutingDataSource routingDataSource = new DynamicRoutingDataSource(); routingDataSource.setDefaultTargetDataSource(master); routingDataSource.setTargetDataSources(targetDataSources); return routingDataSource; } }对应的路由数据源实现类长这样:
public class DynamicRoutingDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { return DynamicDataSourceContextHolder.getDataSource(); } }数据源Key我建议用枚举统一收口,不要散落在代码里写字符串,否则后期库一多,大小写不一致问题就会冒出来:
public enum DsKeyEnum { MASTER, REPORT }@Primary一定不要漏。Spring容器里现在有多个DataSource类型的Bean,如果不标记哪个是主要的,MyBatis自动配置、事务管理器自动配置在注入DataSource时就会报“expected single matching bean but found 2”,或者说选了错误的那一个,导致路由直接绕过。
3.3 把路由源接入MyBatis和事务管理器
数据源定义完之后,还要让MyBatis的SqlSessionFactory和Spring事务管理器都拿到routingDataSource。这里是最容易出错的一步:如果你在SqlSessionFactory上绑定了具体的masterDataSource,那不管路由数据源怎么切换,MyBatis最终还是会去master连接池拿连接。
@Configuration @MapperScan(basePackages = "com.example.project.mapper") public class MyBatisConfig { @Bean @Primary public SqlSessionFactory sqlSessionFactory( @Qualifier("routingDataSource") DataSource routingDataSource) throws Exception { SqlSessionFactoryBean sessionFactoryBean = new SqlSessionFactoryBean(); sessionFactoryBean.setDataSource(routingDataSource); sessionFactoryBean.setMapperLocations( new PathMatchingResourcePatternResolver() .getResources("classpath:mapper/*.xml")); org.apache.ibatis.session.Configuration configuration = new org.apache.ibatis.session.Configuration(); configuration.setMapUnderscoreToCamelCase(true); sessionFactoryBean.setConfiguration(configuration); return sessionFactoryBean.getObject(); } @Bean @Primary public DataSourceTransactionManager transactionManager( @Qualifier("routingDataSource") DataSource routingDataSource) { return new DataSourceTransactionManager(routingDataSource); } }需要说清楚一个限制:我这里只给Spring配置了一个DataSourceTransactionManager,它管理的逻辑数据源是routingDataSource。它支持“路由之后的同库事务”,但不支持一个事务横跨两个物理库。如果你在一个事务里既写了订单库又写了报表库,那必须引入分布式事务方案,比如XA或最终一致性组件,这不是单靠动态切换能解决的。
4. 注解+AOP实现透明切换:DS注解、ContextHolder与切面顺序
4.1 定义@DS注解和数据源Key
配置都就位之后,接下来的目标就是让业务代码用一行注解完成切换。定义一个@DS注解,既能标在方法上,也能标在类上;标记在类上时,类里所有方法默认走这个数据源,方法上的注解可以覆盖类上的。
@Documented @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface DS { String value() default "MASTER"; }注意value的类型用字符串,默认值给MASTER。这样做的原因是,注解的value在编译期就必须是一个常量,枚举的name()拿不到,用字符串最直接。但业务侧调用时,我会用DsKeyEnum.REPORT.name()传入,保证两侧Key一致。
4.2 切面实现:前置设置Key,finally清理Key
切换的核心是一个AOP切面。用@Around同时拦截方法注解和类注解,在目标方法执行前把Key设置到DynamicDataSourceContextHolder,执行完之后在finally里清理。
@Aspect @Component @Order(Ordered.HIGHEST_PRECEDENCE) public class DataSourceAspect { @Around("@annotation(dataSource) || @within(dataSource)") public Object switchDataSource(ProceedingJoinPoint joinPoint, DS dataSource) throws Throwable { DynamicDataSourceContextHolder.setDataSource(dataSource.value()); try { return joinPoint.proceed(); } finally { DynamicDataSourceContextHolder.clear(); } } }注意切面要放在@Order(Ordered.HIGHEST_PRECEDENCE),也就是优先级最高、最先执行。这里非常关键,后面会详细讲原因。
4.3 为什么切面必须最高优先级
Spring里处理事务是通过TransactionInterceptor完成的,它也是一个AOP增强。当事务拦截器和数据源切面同时存在时,谁先执行,谁就决定了在打开数据库连接的那一刻ThreadLocal里的Key是什么。
如果数据源切面的优先级比事务拦截器低,事务拦截器会先运行,它会先从routingDataSource拿一个连接并绑定到当前事务资源里。等数据源切面再设置新的Key,已经晚了,事务上下文拿到的还是旧连接。所以数据源切面必须是优先级最高的那一个,确保在事务还没开始之前,ThreadLocal里就已经放好了目标数据源的Key。
还有一个隐蔽问题是“自调用绕过代理”。如果同一个Service类里的方法A调用方法B,而B上有@DS注解,这种this.method()调用不会经过Spring AOP代理,切面不会执行。解决方法要么把B方法放到另一个Spring Bean里,要么在类上统一标注@DS。
4.4 业务侧的实际用法
配置完成之后,业务代码写起来就非常简单了:
@Service public class OrderQueryService { @DS(DsKeyEnum.MASTER) public OrderDetail getOrder(Long orderId) { // 查询订单主库 } @DS(DsKeyEnum.REPORT) public List<ReportRecord> listReport() { // 查询报表库 } }如果你有几十个方法都要走同一个报表库,可以直接在类上标@DS(DsKeyEnum.REPORT),类里的方法就不用每个都写了。需要某个方法例外时,再在方法上用注解覆盖。
5. 踩坑实录:切了没生效、监控不显示、初始化冲突的排查链路
5.1 加上@Transactional之后切换失效:连接在切面之前被绑定
这是我遇到最多的一类问题。表现是方法上明明标了@DS("REPORT"),日志里ThreadLocal也被设置成了REPORT,但SQL实际执行的库还是默认库。
排查第一步,先看是否有外层事务。假设Controller调ServiceA,ServiceA没有@DS也没有@Transactional,但是ServiceA内部调用了ServiceB,ServiceB上同时有@Transactional和@DS("REPORT")。这里的隐患不在ServiceB,而在更外层:如果调用链上有任何一个方法率先开启了事务,Spring的事务管理器会把一个连接绑定到当前线程的ConnectionHolder里,MyBatis后续执行SQL时优先复用这个已经绑定的连接,根本不会再去routingDataSource.getConnection()走路由逻辑。
排查链路我一般按这个顺序走:
- 检查目标方法及所有调用方,看有没有更早开启事务的地方;
- 在切面里加日志,确认
determineCurrentLookupKey()返回的Key确实已经变成REPORT; - 看Druid监控或者连接池日志,确认实际创建连接的池是哪个;
- 如果确认连接被事务提前绑定,就把“开启事务”和“切换数据源”放在同一个方法层级,让事务边界跟着数据源边界走。
一句话总结:数据源必须在事务开始之前定下来,切库千万不能被包在已有事务里。
5.2 注解和切面都正常,但查到的还是主库:SqlSessionFactory绑错了地方
另一个常见问题是:服务启动不报错,注解也在生效,日志显示ThreadLocal里是正确的Key,但结果还是master的数据。这时候怀疑对象应该从切面转到SqlSessionFactory。
Spring Boot自动配置会在类路径里发现DataSource之后,自动创建SqlSessionFactory。如果你的自定义配置没有生效,自动配置很可能把某个具体的DruidDataSource塞给了MyBatis,而不是塞routingDataSource。一旦MyBatis拿着一个固定的物理数据源,那它连接里的所有路由逻辑都不存在,切面只是改了ThreadLocal,根本不会被读取。
排查方法很简单,启动后在调试模式里看SqlSessionFactory对象的数据源实现类。如果它是DruidDataSource而不是DynamicRoutingDataSource,那就说明MyBatis没有走路由源。这时候需要确认:
- 自定义
SqlSessionFactory是否被Spring扫描到,有没有和自动配置产生冲突; routingDataSource上的@Primary是否还在;- 如果用了MyBatis-Plus,要配置的是
MybatisPlusSqlSessionFactoryBean,不是普通SqlSessionFactoryBean。
5.3 Druid监控里只看到一个池有数据,另一个始终是零
Druid的监控统计是按DruidDataSource实例区分的。你对master单独建了一个池,对report单独建了一个池,两个池在监控页面会以两个独立实例呈现,各自的统计互不相干。
如果你发现监控页面里report库的实例统计一直是0,基本可以断定这个池在运行期根本没有被动用过,也就是切换动作没有真正落到连接获取层面。这时候不要怀疑Druid监控坏了,先回过头查上面两点:是切面没执行,还是MyBatis绑错了数据源。监控统计在这里是最好的“验尸报告”,能直接反映真实连接分配去哪了。
另外还要注意,个别同学配置了StatViewServlet但没配置StatFilter,或者YAML里spring.datasource.druid.filter.stat.enabled没有打开,这种情况下监控页面上连SQL统计都没有。要确保每个物理数据源都挂上StatFilter:
spring: datasource: druid: filter: stat: enabled: true log-slow-sql: true slow-sql-millis: 10005.4 Bean初始化顺序和@Primary引发的启动冲突
多数据源搭建过程中,启动直接报错的情况也不少见。比较典型的是“expected single matching bean but found 2: masterDataSource, reportDataSource”这类错误,原因就是没有给routingDataSource加@Primary,或者加了@Primary但被自动配置绕过了。
还有一类更隐蔽的问题和@ConfigurationProperties绑定顺序有关。DruidDataSourceBuilder.create().build()创建出来的DataSource一开始并没有URL,Spring容器要等@ConfigurationProperties把YAML里的配置绑定上去之后才有完整的连接信息。如果某个Bean在绑定期之前就被其他初始化逻辑拿去使用了,比如在@PostConstruct里执行查询,就会拿到一个没配好URL的空连接池,启动时直接连库失败。
遇到这类问题,我一般会启动参数里加--debug,看Bean的创建顺序,或者直接在routingDataSource的方法里打日志,确认master和report两个Bean已经先一步创建完成。多数据源启动问题绝大多数都是Bean依赖顺序和@Primary选择造成的,不是路由逻辑本身的问题。
6. 让动态切换跑得更稳的增强思路:异步线程、分库路由与连接池调优
6.1 @Async异步线程里的ThreadLocal丢失和线程池复用污染
动态切换的另一个天然边界是线程。ThreadLocal只对当前线程可见,如果你在一个方法里设置了REPORT,然后通过@Async把任务丢到另一个线程执行,新线程的ThreadLocal是空的,路由会回落到defaultTargetDataSource,也就是master库。更危险的是,如果线程池里的线程复用,任务A设置的Key可能被任务B读取到,造成串库。
处理办法有两种。一种是在异步任务执行体内部重新设置数据源Key;另一种是给Runnable做一个包装,把父线程的数据源Key带进去,执行前设置,执行完清理:
public class DataSourceContextRunnable implements Runnable { private final Runnable target; private final String dsKey; public DataSourceContextRunnable(Runnable target, String dsKey) { this.target = target; this.dsKey = dsKey; } @Override public void run() { DynamicDataSourceContextHolder.setDataSource(dsKey); try { target.run(); } finally { DynamicDataSourceContextHolder.clear(); } } }提交任务时用new DataSourceContextRunnable(originalTask, DynamicDataSourceContextHolder.getDataSource())包装一层,就能保证线程池环境下的路由上下文不丢失、不串用。
6.2 按租户分库时,路由Key不要用裸ID
如果路由是靠租户ID计算出来的,推荐把Key设计成“有业务含义、可枚举”的形式,而不是直接拿租户ID当Key。比如路由规则是“租户ID对2取模”,那数据源就设计成两个:tenant_db_0和tenant_db_1,业务代码里用"tenant_db_" + (tenantId % 2)拼出Key。
这样设计的理由是:AbstractRoutingDataSource的targetDataSources是预先注册好的Map,Key必须提前可枚举。如果你的分库数量很大,或者未来要扩容,可以用一个独立的注册中心保存“租户ID到数据源实例”的映射,让路由层动态往targetDataSources里注册新连接池。但这时要特别小心连接池数量膨胀的问题,不能每来一个新租户就建一个新池,否则内存和线程都会出问题。比较稳妥的做法还是预先分配固定数量的物理库,用取模或哈希做分片,库里再按表划分。
如果需要靠请求参数计算路由Key,比写死注解更灵活的做法是在切面里取方法参数自行计算。@DS注解负责固定场景,分库路由规则则应该集中在专门的路由服务里,不要在业务方法里散落计算逻辑。
6.3 Druid连接池参数建议与最后的提醒
最后聊聊连接池调优。多数据源环境下,每个库的连接池参数最好独立调整,不要复制粘贴同一套。报表库查询量大,max-active可以放宽到20甚至30;订单库是高频写入,max-wait不要超过10秒,否则数据库抖动时请求全部堵在等待获取连接上。常规参数里,initial-size和min-idle可以保持一致,避免高峰期临时建连接;validation-query配成SELECT 1,配合test-while-idle=true做空闲检测,但不要开test-on-borrow=true,那会在每次拿连接时增加一次额外探测,高并发下损耗很明显。
Druid的慢SQL监控也建议优先打开。切换数据源本身排查起来麻烦,慢SQL日志至少能帮你快速判断某个SQL到底打到哪个库、执行了多久。我一般会把slow-sql-millis设成1000,运行一段时候后去监控页看统计,哪个库的池出现大量慢SQL,哪个库的连接数偏紧,一目了然。
这套动态切换方案我用了很长时间,最大的感受是:动态切换本质上是一个约定。注解、切面、ThreadLocal、路由源四个部分必须形成闭环,任何一环没接上,现象都是“代码没写错,但数据不对”。如果以后再碰到切换不生效的问题,不要急着加日志,先回答三个问题:数据源Key有没有在正确的时间设置?MyBatis和事务管理器有没有绑定路由源?有没有外层事务提前绑定连接?这三个问题排查完,大部分疑难杂症都能定位到根因。