ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

动态数据源切换失效?根因在路由与事务的连接绑定时机

动态数据源切换失效?根因在路由与事务的连接绑定时机

如果你维护过一套基于 Spring 的多数据源系统,大概率经历过这种场景:代码里明明在 Service 方法第一行调用了DynamicDataSourceContextHolder.set("slave"),日志里也打出了路由 key 切换成功,可后面 Mapper 查出来的数据却还是主库的。更诡异的是,把 set 挪到 Controller 层就正常了,或者把某个 Mapper 方法上的@Transactional去掉就正常了。

这类"动态数据源切换失效"的问题,我前后排查了不下十次,几乎每一次的根因都不一样,但共性是:问题不在路由本身,而在于路由和事务、线程、代理之间谁先谁后。这篇文章不打算停留在"给你一个切面就完事"的层面,而是把失效的典型表现、底层原理、完整排查链路和每种场景的修复方案串起来,希望能让你下次遇到类似问题的时候,能在半小时内定位,而不是靠试错。

1. 掰开揉碎:动态数据源切换失效的典型表现和根因分布

1.1 "看起来没生效"的三种现象

我习惯把失效问题先按现象分类,因为现象往往直接指向根因:

现象具体表现高频根因
完全没切换设置 key 后,查询结果和主库一致,日志里 key 一直是 null切面未生效、自调用绕过代理、配置绑错数据源
部分切换第一次查询用了从库,后面同一事务内又回到主库事务先于切换开启,连接已被绑定
偶发失效同一个方法有时正常,有时切错库,多线程下尤其频繁异步线程丢失上下文,或线程池复用导致的上下文串线

先说第一种。如果你把@DataSource("slave")注解加在 Mapper 接口方法上,但调用链路过的是this.save()这种同类自调用,那么 AOP 切面根本不会执行,key 始终是 null,路由自然走默认库。这种问题最迷惑人,因为代码看起来每一个环节都对。

第二种是"切换被事务吃掉",也是绝大多数人遇到的情况。@Transactional方法一旦进入,Spring 事务管理器会立刻从数据源拿连接,并把连接绑定到当前线程。之后你无论怎么改 key,DAO 拿到的还是那个已经打开的连接,路由结果自然“失效”。

第三种和线程有关。ThreadLocal只属于当前线程,Spring 的@Async默认不会把父线程的 ThreadLocal 值传过去。异步任务里如果没有重新设置数据源 key,路由就会走默认数据源,表现出来就是"时好时坏"。

1.2 失效根因:连接绑定在事务,而不是数据源

很多人对动态数据源有个误解,以为切换 key 之后,已经持有的连接会跟着换库。实际上AbstractRoutingDataSource的本质是数据源路由器,它决定的是"下一次getConnection()从哪个真实数据源拿连接",它不能改变"已经拿到的那个连接属于哪个库"。

Spring 使用DataSourceTransactionManager管理事务时,事务开启的瞬间会执行一次DataSourceUtils.getConnection(dataSource),拿到连接后通过TransactionSynchronizationManager.bindResource()把它绑定到当前线程。后续同一个事务内的所有 DAO 调用,拿到的都是这个 ThreadLocal 里缓存的连接。

所以可以这样记:连接一旦被事务绑定,路由就只在事务外有效。很多修复方案之所以有效,本质都是把"切换 key"这件事挪到了"事务获取连接"之前。

2. 先吃透原理:路由、ThreadLocal 和事务三者的协作关系

2.1 AbstractRoutingDataSource 的路由时机

Spring 的动态数据源基础类AbstractRoutingDataSource核心逻辑并不复杂。它内部维护了一个Map<Object, Object> targetDataSources,getConnection()时调用determineCurrentLookupKey()拿到当前线程的路由 key,再从 map 里取出真实数据源来建立连接。

一个典型实现长这样:

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

这里的关键点是:determineCurrentLookupKey()在每次需要新连接时都会被调用。如果没有事务存在,每次 DAO 操作都是新的连接获取流程,路由自然是"每次切换都生效"的。可一旦事务介入,情况就变了——因为 Spring 的事务管理器会优先从当前线程已绑定的资源里取连接,取到了就不会再去走AbstractRoutingDataSource的完整路由逻辑。

我把这个心智模型总结成一句话:动态数据源路由发生在"连接获取"这一层,而事务把"连接的生命周期"拉长了,长到你的 key 已经过期。

2.2 事务为什么会“锁死”路由结果

看一下 Spring 事务开启时发生了什么:

// DataSourceTransactionManager.doBegin 的简化流程 Connection newCon = DataSourceUtils.getConnection(dataSource); // newCon 被包装成 ConnectionHolder TransactionSynchronizationManager.bindResource(dataSource, txObject.getConnectionHolder());

从这一步开始,当前线程的TransactionSynchronizationManager里已经存了一个ConnectionHolder。之后 MyBatis 或 JdbcTemplate 要拿连接时,DataSourceUtils.getConnection()会先检查当前线程有没有绑定连接:

ConnectionHolder conHolder = (ConnectionHolder) TransactionSynchronizationManager.getResource(dataSource); if (conHolder != null) { return conHolder.getConnection(); }

注意这里比较dataSource的时候,是拿创建SqlSessionFactory时传入的那个 DataSource 对象去比较。如果事务管理器和 MyBatis 用的是同一个 DynamicDataSource 实例,那么线程里已绑定的连接会被直接复用,路由 key 根本来不及参与决策。

所以@Transactional方法内部再调用DynamicDataSourceContextHolder.set("slave"),其实是在对着一个已经开往主库的车喊"换道",司机不会理你。

2.3 为什么要用 ThreadLocal 保存数据源 key

ThreadLocal在这个场景里几乎是必须的,因为路由 key 本质上是一个"调用链上下文"。从切面设置 key,到 DAO 获取连接,中间隔着若干层方法调用,这些调用都在同一个线程内串联,用ThreadLocal能在不修改方法签名的情况下把状态传递下去。

但 ThreadLocal 也是一把双刃剑。它的值在线程结束后不会自动清理(在线程池场景下更严重),一旦你在切换后忘了调用remove(),线程归还到池里后,下一个任务会继承上一个任务残留的 key,导致数据源串库。

我自己在项目里见过一次诡异故障:一个线程池处理批量任务,奇数任务走从库查询,偶数任务走主库写入,结果偶发出现写入数据丢失。最后发现就是因为切面里只做了set()没做finally { remove(); },线程复用时路由上下文被污染了。

一个稍微稳一点的上下文工具类至少要有set、get、clear三个方法,如果支持嵌套切换,还要考虑栈式恢复。

3. 一个真实问题的完整排查链路:从现象到根因

3.1 第一步:先确认路由 key 到底是什么

排查动态数据源失效,第一步永远是看 key。我通常会在determineCurrentLookupKey()里临时加上日志,把当前线程名、key 值、事务激活状态一起打出来:

@Override protected Object determineCurrentLookupKey() { String key = DynamicDataSourceContextHolder.get(); if (logger.isDebugEnabled()) { logger.debug("route key = {}, thread = {}, txActive = {}", key, Thread.currentThread().getName(), TransactionSynchronizationManager.isActualTransactionActive()); } return key; }

这一步能快速区分两种情况:

  • key 为 null:问题在"上下文没设置成功",重点检查切面是否执行、注解是否拼对、是否在同一个线程。
  • key 正确但依然走主库:问题大概率在"连接已被事务绑定",或者SqlSessionFactory压根没用这个路由数据源。

很多开发者在排查时喜欢盯着 DAO 层看,实际上最该先看的是路由层。一次日志就能把大方向定住。

3.2 第二步:区分“切面没生效”还是“连接已绑定”

如果 key 打出来始终是 null,那么你要回答的问题是:切面到底有没有进入调用链。

我见过一个模拟项目 X,注解写在 Mapper 接口上,切面定义为@Before("@annotation(dataSource))",单独测 Mapper 的时候是好的,但 Service 里一调用就失效。原因是 Service 方法上标了@Transactional,Service 内部直接注入 Mapper,Spring 容器里的 Service 是代理对象,但 Mapper 本身也走了代理,切面理应生效——真正的问题其实是 Service 内部先开启事务,代理对象调用 Mapper 时连接已经绑定了。

这种情况下,你打determineCurrentLookupKey()日志会发现 key 其实是有的,只是连接没换。所以要同时观察两个信号:

  1. 切面方法有没有进入?进没进都打日志。
  2. 进入切面时,TransactionSynchronizationManager.isActualTransactionActive()是不是 true?

如果是 true,说明事务拦截器已经在切面之前动手了,切面做的 set 只能影响后续"下一次连接获取",而当前事务已经持有的连接不会变。

3.3 第三步:线程、代理和配置三条线逐一排查

当 key 和事务状态都确认之后,剩下的问题基本就在三条线里。

第一条是线程线。在切面和路由里都加上Thread.currentThread().getId(),比较设置 key 的线程和获取 key 的线程是否是同一个。凡是出现异步、消息消费、定时任务,都要特别留意。很多@Async方法内部虽然看起来调用了同一个 Service,但子线程根本看不到父线程的ThreadLocal。

第二条是代理线。检查目标方法是不是通过 Spring 容器里的代理对象调用。除了自调用问题,还有一种隐蔽场景:类被final修饰,或者方法被private修饰,CGLIB 代理无法正常织入,切面就会静默跳过。

第三条是配置线。看看SqlSessionFactory创建时注入的 DataSource 是不是DynamicDataSource。如果直接注入了一个普通masterDataSourceBean,那么路由 key 设置得再漂亮也是白搭。

3.4 线索汇总:一个典型的 Service 方法为什么救不回来

把前面这些线索拼在一起,一个典型失效案例的完整链路是这样的:

@Service public class OrderService { @Transactional public void createOrder() { DynamicDataSourceContextHolder.set("slave"); // 第 2 步 masterMapper.insertOrder(...); // 第 3 步走到主库 } }

实际执行顺序:

  1. Spring 代理拦截createOrder()。
  2. 事务拦截器开启事务,立即从 DynamicDataSource 获取连接,绑定到当前线程。
  3. 业务代码设置slave路由 key。
  4. MyBatis 执行 SQL 时从当前线程取连接,取到的是第 2 步已经绑定的主库连接。
  5. 路由 key 的 set 形同虚设。

如果你能在排查时把这个顺序可视化出来,基本就能理解为什么很多人把set("slave")挪到 Controller 层或者把@Transactional去掉之后,问题奇迹般消失。

4. 四大高频失效场景与对症修复

4.1 自调用与代理失效:切面没进去

同类内部调用是最常见的切面失效场景。比如:

@Service public class DataService { @DataSource("slave") public void readFromSlave() { // ... } public void execute() { readFromSlave(); // 自调用,AOP 拦截不到 } }

这里的readFromSlave()是this调用,没有经过 Spring 代理,@DataSource切面不会执行。解决办法有三个:

  • 把切面逻辑改为基于@annotation且允许在代理对象层级织入,但自调用依然救不了。
  • 将需要切换的方法放到另一个独立的 Bean 中,由 DataService 注入后调用。
  • 通过AopContext.currentProxy()获取当前代理对象再调用。

最省心的还是第一种:把"数据源切换"和"业务方法"拆到不同 Bean。因为动态数据源本身是横切关注点,让它依赖代理机制,不如把它放在一个明确的门面类里。

4.2 事务先于切面:@Transactional 与切面执行顺序

即便切面正常织入,也可能因为执行顺序问题导致无用。Spring AOP 链上,多个切面是有顺序的,@Order值越小越先执行。如果你的数据源切面没有设置@Order(0),而事务拦截器的顺序是默认值,那么很可能是事务先开,切面后切。

一个常见修复是在切面类上加:

@Aspect @Order(0) @Component public class DataSourceAspect { // ... }

让数据源切面排在事务拦截器之前。但注意:这只解决了"切面先设置 key"的问题,如果你的事务传播是REQUIRED,且外层已经有事务在跑,内层方法进入时并不会新开事务,也不会重新取连接,@Order(0)也救不了嵌套事务场景。

所以我更推荐另一种做法:不要把数据源切换放在事务方法内部,而是在调用事务方法之前设置好 key。比如:

public void createOrder() { DynamicDataSourceContextHolder.set("slave"); try { orderTxService.createOrder(); // 方法上带 @Transactional } finally { DynamicDataSourceContextHolder.clear(); } }

这样事务开启时,路由 key 已经就位,事务第一次获取连接就会拿到从库连接。

4.3 异步线程丢失上下文:ThreadLocal 不过线程

@Async+ 动态数据源是另一个高频组合,也是排查成本最高的一种,因为它在单测环境很难复现。你在主线程里set("slave"),但线程池里的 worker 线程读取DynamicDataSourceContextHolder.get()时,拿到的是新线程自己的空 ThreadLocal。

我通常用TaskDecorator在任务提交时把上下文快照传递进去,执行前再恢复。Spring 的ThreadPoolTaskExecutor支持这个扩展点:

@Bean("asyncExecutor") public ThreadPoolTaskExecutor asyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(100); executor.setThreadNamePrefix("async-"); executor.setTaskDecorator(new DataSourceContextDecorator()); executor.initialize(); return executor; }

装饰器实现大致如下:

public class DataSourceContextDecorator implements TaskDecorator { @Override public Runnable decorate(Runnable runnable) { String ds = DynamicDataSourceContextHolder.get(); return () -> { DynamicDataSourceContextHolder.set(ds); try { runnable.run(); } finally { DynamicDataSourceContextHolder.clear(); } }; } }

这样异步线程在执行任务时会先恢复主线程的数据源 key,任务结束再清理,避免线程池复用造成串库。如果项目里异步链路很深,上下文传递点很多,可以考虑把上下文对象换成支持父子传递的线程局部变量实现,但装饰器方案最直观,也最容易在代码评审时讲清楚。

4.4 配置指向错误:SqlSessionFactory 绑错了数据源

这个场景最隐蔽,因为代码层面看不出任何问题:数据源切面工作正常,key 也正确,但 SQL 始终走默认库。最后排查发现SqlSessionFactoryBean配置时注入的是masterDataSource,而不是dynamicDataSource:

@Bean public SqlSessionFactory sqlSessionFactory(@Qualifier("masterDataSource") DataSource dataSource) throws Exception { // 动态数据源没接进来 }

MyBatis一旦在启动时绑定了某个具体 DataSource,运行期所有 SqlSession 都从它身上获取连接。哪怕你的路由 key 设置得再准,也不会走进AbstractRoutingDataSource的targetDataSources映射。

修复很简单,改成:

@Bean public SqlSessionFactory sqlSessionFactory(@Qualifier("dynamicDataSource") DataSource dataSource) throws Exception { // dataSource 指向动态路由数据源 }

另外,如果你用多个 SqlSessionFactory 或同时引入多个数据源 starter,也要确认没有哪个组件在启动时"偷偷"把另一个真实数据源绑定到了 MyBatis 上。配置线的排查一定要看启动日志里的 DataSource 类型和 URL。

5. 我建议的落地设计:事务边界与切换边界对齐

5.1 编程式事务 + 显式切换:把边界握在手里

注解式事务在简单场景下很爽,但在动态数据源场景里容易埋雷。因为注解是加在方法上的,方法一进入代理,事务拦截器就抢先动手,切换逻辑却可能在方法体某个角落才执行,两者天然存在顺序错位。

我更推荐的模式是:在事务开启之前显式切换,在事务结束之后清理。使用TransactionTemplate可以做到边界清晰:

@Service public class OrderService { private final TransactionTemplate transactionTemplate; public Long createOrderFromSlave() { DynamicDataSourceContextHolder.set("slave"); try { return transactionTemplate.execute(status -> { Order order = new Order(); orderMapper.insert(order); return order.getId(); }); } finally { DynamicDataSourceContextHolder.clear(); } } }

这样事务模板执行时,当前线程的路由 key 已经确定,事务管理器第一次获取连接就会拿从库连接。如果你确实需要在同一个事务内读主库又读从库,那不应该在同一个事务里做,要么拆成两个事务,用REQUIRES_NEW开子事务,要么把读从库的逻辑放到事务外。

记住一个原则:事务边界和数据源切换边界必须对齐。事务开始之前,key 要确定;事务结束之后,key 要清理。

5.2 封装数据源上下文工具类:set/restore/clear 全套

前面提到 ThreadLocal 需要清理,但简单的set/get/clear在嵌套场景下仍然不够。比如你的方法 A 先切到 slave,方法 A 内部又调用方法 B,方法 B 切到 archive,方法 B 执行完如果直接 clear,方法 A 后续期望的 slave 上下文就丢了。

我习惯用一个支持压栈和弹栈的上下文工具:

public class DynamicDataSourceContextHolder { private static final ThreadLocal<Deque<String>> CONTEXT_HOLDER = ThreadLocal.withInitial(ArrayDeque::new); public static void push(String ds) { CONTEXT_HOLDER.get().push(ds); } public static String pop() { Deque<String> deque = CONTEXT_HOLDER.get(); if (deque.isEmpty()) { return null; } return deque.pop(); } public static String peek() { Deque<String> deque = CONTEXT_HOLDER.get(); return deque.isEmpty() ? null : deque.peek(); } public static void clear() { CONTEXT_HOLDER.remove(); } }

切面里围绕目标方法执行 push 和 pop,确保嵌套切换能够正确恢复上层上下文:

@Aspect @Order(0) @Component public class DataSourceAspect { @Around("@annotation(dataSource)") public Object switchDataSource(ProceedingJoinPoint joinPoint, DataSource dataSource) throws Throwable { DynamicDataSourceContextHolder.push(dataSource.value()); try { return joinPoint.proceed(); } finally { DynamicDataSourceContextHolder.pop(); if (DynamicDataSourceContextHolder.peek() == null) { DynamicDataSourceContextHolder.clear(); } } } }

这种做法相比简单 set,最大的好处是切换与恢复是成对出现的,不会因为异常路径漏掉恢复。

5.3 异步环境下的上下文传递方案

异步环境下,除了用TaskDecorator恢复 key,还有一个容易被忽略的点:异步任务内部可能又要再开事务。如果异步任务里走的是REQUIRED,那么它会在子线程上重新获取连接,这时候 key 必须已经设置好。装饰器方案刚好能在任务执行前完成这一步。

如果异步链路很长,每个任务都可能切换数据源,我建议把上下文快照设计成一个不可变对象,而不是简单快照一个字符串。因为后续可能还要携带 traceId、用户信息、分库分表键等。你可以先定义一个DataSourceContext,包含lookupKey、extra等字段,然后通过一个上下文管理器统一读写。

如果项目里已经大量使用@Async,且不想每个地方都感知上下文传递,可以定义一个统一的ThreadPoolTaskExecutor单例,在TaskDecorator里做快照恢复。只要所有异步任务都用同一个 executor,这套机制就全局生效。

5.4 兜底与监控:让失效问题提前暴露

动态数据源失效最可怕的地方是"静默失败"——你以为查的是从库,实际查的是主库,数据一致性问题可能很晚才显现。所以要给路由加上可观测性。

我的做法通常包括三件事:

  1. 在determineCurrentLookupKey()返回 null 或未知 key 时,不要直接 fallback 到默认库,而是记录告警,或者干脆抛出异常,让开发在联调阶段就发现问题。
  2. 每次路由决策都输出一条结构化日志,包含线程名、key、事务状态、目标数据源 URL。线上排查时用 traceId 串起来,一眼就能看到切换链路。
  3. 在管理端做一个简单的实时计数器,统计各数据源 key 的调用次数和耗时。如果某个 key 的调用次数为 0,说明切面大概率没生效。

这些监控在项目初期看似多余,但一旦数据源数量超过三个,复杂度会指数级上升,提前布好监控能省大量排查时间。

6. 最后补充几个容易踩的坑

6.1 动态添加数据源后没有刷新内部路由表

AbstractRoutingDataSource在初始化时会遍历targetDataSources,生成一份resolvedDataSources内部缓存。如果你在运行期通过 setter 加入了新的数据源,但不调用afterPropertiesSet(),新数据源永远不会被路由到:

dynamicDataSource.setTargetDataSources(newTargetDataSources); dynamicDataSource.afterPropertiesSet(); // 必须重新初始化

这个坑我踩过一次,因为项目里做了一个"控制台动态新增从库"的功能,界面上提示新增成功,路由日志也显示 key 正确,但连接一直建立不了。最后怀疑到内部缓存才发现需要手动刷新。建议:所有对 targetDataSources 的修改都封装在一个 service 方法里,统一处理刷新逻辑。

6.2 默认数据源兜底掩盖了 key 错误

很多开发图省事,给DynamicDataSource设置了setDefaultTargetDataSource(masterDataSource),结果 key 设置错误时,系统不报错而是静默走主库。这在读多写少场景下很容易掩盖问题。

我后来改成:determineCurrentLookupKey()返回的 key 如果在 targetDataSources 里不存在,直接抛出异常,宁可让业务快速失败,也不要让错误的 SQL 落在错误的库上。设置默认库当然可以,至少应该打印 error 级别的日志。

6.3 连接池复用带来的"假切换"误解

有些人会遇到一种情况:第一次切换从库成功后,第二次切回主库,结果拿到的连接还是从库连接。这个问题通常不是路由失效,而是连接池里存在空闲连接被复用。HikariCP、Druid 都有连接复用机制,路由 key 变化后,连接池可能从池里捞出一个旧连接给你。

如果你确实需要每次路由都拿到全新的物理连接,要么把池的最小空闲设为 0,要么在关键切换点上显式清理连接。但更合理的思路是:连接复用不是 bug,你要保证的是业务数据不错。同一个连接只属于同一个数据源,路由 key 变化后连接池内部也会按数据源维度管理,所以"假切换"大多是观察方式不对。

6.4 多套数据源框架叠加导致切面被重复执行

有的项目既用「自定义 AbstractRoutingDataSource」,又引入了「dynamic-datasource-spring-boot-starter」,两者同时生效,切面被触发两次,后执行的切面把前一个切面设置好的 key 覆盖掉。这种叠加问题排查起来很头疼。

我的建议是二选一。如果项目复杂度不高,自己基于AbstractRoutingDataSource写一套就够了;如果需要 @DS 注解、seata 集成等开箱能力,就用现成框架,但不要再叠加自定义切面。框架的文档里通常明确说明"不要和自定义动态数据源同时使用",这个警告是真实会发生的。


动态数据源切换失效,归根结底是"路由时机"和"连接生命周期"的错位问题。我个人排查多次后最深的一个体会是:永远先确认 key,再确认事务,最后才怀疑配置。只要把握住"连接一旦绑定,路由即失效"这条主线,多数据源问题并没有想象中那么复杂。希望这篇文章能帮你少走几趟弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进