资讯动态

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

发布时间:2026/10/13 8:47:53 来源:尧图企业网站定制
如果你维护过一套基于 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 事务管理器会立刻从数据源拿连接并把连接绑定到当前线程。之后你无论怎么改 keyDAO 拿到的还是那个已经打开的连接路由结果自然“失效”。第三种和线程有关。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核心逻辑并不复杂。它内部维护了一个MapObject, Object targetDataSourcesgetConnection()时调用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 保存数据源 keyThreadLocal在这个场景里几乎是必须的因为路由 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 方法上标了TransactionalService 内部直接注入 MapperSpring 容器里的 Service 是代理对象但 Mapper 本身也走了代理切面理应生效——真正的问题其实是 Service 内部先开启事务代理对象调用 Mapper 时连接已经绑定了。这种情况下你打determineCurrentLookupKey()日志会发现 key 其实是有的只是连接没换。所以要同时观察两个信号切面方法有没有进入进没进都打日志。进入切面时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 步走到主库 } }实际执行顺序Spring 代理拦截createOrder()。事务拦截器开启事务立即从 DynamicDataSource 获取连接绑定到当前线程。业务代码设置slave路由 key。MyBatis 执行 SQL 时从当前线程取连接取到的是第 2 步已经绑定的主库连接。路由 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而不是dynamicDataSourceBean 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 ThreadLocalDequeString CONTEXT_HOLDER ThreadLocal.withInitial(ArrayDeque::new); public static void push(String ds) { CONTEXT_HOLDER.get().push(ds); } public static String pop() { DequeString deque CONTEXT_HOLDER.get(); if (deque.isEmpty()) { return null; } return deque.pop(); } public static String peek() { DequeString 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 兜底与监控让失效问题提前暴露动态数据源失效最可怕的地方是静默失败——你以为查的是从库实际查的是主库数据一致性问题可能很晚才显现。所以要给路由加上可观测性。我的做法通常包括三件事在determineCurrentLookupKey()返回 null 或未知 key 时不要直接 fallback 到默认库而是记录告警或者干脆抛出异常让开发在联调阶段就发现问题。每次路由决策都输出一条结构化日志包含线程名、key、事务状态、目标数据源 URL。线上排查时用 traceId 串起来一眼就能看到切换链路。在管理端做一个简单的实时计数器统计各数据源 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再确认事务最后才怀疑配置。只要把握住连接一旦绑定路由即失效这条主线多数据源问题并没有想象中那么复杂。希望这篇文章能帮你少走几趟弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑