先直接说结论在 Spring Boot 项目里把 DS 和 Transactional 同时堆在同一个方法上是动态数据源开发里最容易踩的坑没有之一。这个坑我专门花了两天排查过期间一度以为是 MyBatis 的缓存问题、以为是 Druid 的连接池污染最后翻到两个注解的代理执行顺序和事务连接绑定的源码才彻底想明白。今天这篇就是想把这件事从原理到实践给你一次性讲透顺便给出几个能直接抄作业的解决办法。适合正在做多租户、读写分离、按业务拆库以及被“一个事务里想访问两个库”折磨过的后端开发者。网上讨论 DS 和 Transactional 的文章其实不少但大多停留在“不要一起用”这个结论层面很少有人讲清楚为什么不能用、用了以后底层到底发生了什么。如果只是知道结论你换一个场景还是会踩雷。我下面会从动态数据源的工作原理讲起把这两个注解的冲突根源、故障现象、可行方案、排查步骤都过一遍。1. 多数据源与 DS 到底做了什么1.1 为什么需要 DS先说业务背景。做一个多租户 SaaS每个租户一个独立数据库这是常见的隔离方案做一个订单中心订单表在主库、日志表在归档库、统计查询走从库这也是常见的数据分片方式。这种情况下一个 Spring Boot 服务里就得同时维护多个数据源并且在一次请求过程中根据业务需要动态地去连不同的库。在没有好用的框架前大家会手写AbstractRoutingDataSource核心逻辑是继承这个类自己维护一个 Map 存数据源再实现determineCurrentLookupKey()方法通过某个 ThreadLocal 变量决定当前线程返回哪个 key。这套方案能用但代码很裸数据源上下文管理、切面切换、嵌套恢复都要自己维护写几次就会发现大量重复逻辑。dynamic-datasource 这种三方 starter 就是把这件事封装好了对外提供的形式就是DS 注解。你在需要切换数据源的方法上加一个注解比如DS(order)框架就自动帮你把路由 key 设置到当前线程的上下文里等真正去获取连接时动态路由数据源会根据这个 key 找到对应的真实数据源。1.2 DS 底层核心机制DS 的实现说白了就两大块AOP 拦截框架定义了一个DsAnnotationInterceptor当你调用某个被 DS 标记的方法时它会先取注解里配置的数据源名称放入内部的数据源上下文DataSourceContextHolder这个类底层就是一个ThreadLocalString保存的是当前线程的数据源路由 key。动态路由框架内部有个DynamicRoutingDataSource它实现了 Spring 的AbstractDataSource接口每次getConnection()时会先从DataSourceContextHolder里拿到路由 key再去真正对应的数据源上去获取连接。拿到连接后业务代码就正常执行方法结束后切面在finally里清掉或恢复原来的上下文。所以一句话概括DS 解决的是“在运行时决定这一次数据库操作应该连接哪个数据源”。注意是“这一次”不是“一整段方法“。这个细节后面会和事务绑定连接的产生冲突直接相关。1.3 实际项目中的使用形态一般配置大概是这样的spring: datasource: dynamic: primary: master strict: false datasource: master: url: jdbc:mysql://localhost:3306/master_db username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver order: url: jdbc:mysql://localhost:3306/order_db username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver user: url: jdbc:mysql://localhost:3306/user_db username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver使用的时候在方法或类上标注数据源即可Service public class OrderService { DS(order) public void createOrder(OrderDTO dto) { orderMapper.insert(dto); // ... } }DS 可以加在 Service 类上、Service 方法上、Mapper 接口上、Mapper 方法上。原则是这样的方法上的注解优先级高于类上的注解Mapper 层的注解优先级高于 Service 层。框架解析顺序不复杂复杂的是它和 Spring 事务管理器搅在一起时这些优先级可能全部失效。2. Transactional 是如何和 DS 起冲突的2.1 事务的本质是“连接绑定”Spring 的 Transactional 为什么能让同一个事务里的多个数据库操作要么一起成功、要么一起回滚核心在于它的 AOP 机制方法执行前事务管理器从数据源里获取一个数据库连接把autoCommit关闭然后把连接绑定到当前线程上方法执行过程中所有数据库操作通过同一个连接执行方法结束时根据有没有抛出异常决定 commit 还是 rollback最后解除绑定并关闭连接。这里的“绑定”非常关键。Spring 的TransactionSynchronizationManager内部会维护一个ThreadLocalMapDataSource, ConnectionHolder。同一个数据源对应同一个线程在事务开启期间只允许存在一个被绑定的连接。后面的代码无论调用几次dataSource.getConnection()只要当前线程已经绑定了连接Spring 的DataSourceUtils都会直接返回这个已绑定的连接而不是重新从连接池里取。2.2 两个注解放在同一个方法上会发生什么假设你有这样的代码DS(order) Transactional public void createOrder(OrderDTO dto) { orderMapper.insert(dto); userMapper.updateBalance(dto.getUserId()); }你的本意是第一个 mapper 操作 order 库第二个 mapper 操作 user 库两个操作放在一个事务里要么都成功要么都失败。但实际执行时由于两个注解都是通过 AOP 切面实现的会形成一条拦截器链。DS 做的事是设置路由 keyTransactional 做的事是开启事务、绑定连接、提交 / 回滚。这两个切面的执行顺序在不同版本、不同配置下不完全一样但无论哪个先执行最终都会遇到同一个致命问题一旦 TransactionSynchronizationManager 把某个连接绑定到了当前线程后续不管你把 ThreadLocal 里的路由 key 改成什么只要还是通过同一个 DynamicRoutingDataSource 去拿连接拿到的都是事务刚开始时绑定的那一个。换句话说事务会把数据源锁死。更常见的情况是两个注解叠加时事务切面先执行。事务切面先执行意味着它在执行业务方法前就去获取连接了而这时 DS 还没执行路由 key 还是默认库的 key所以事务绑定的是默认库的连接。接下来 DS 把 key 切到 order但业务代码执行到 orderMapper 时Spring 发现当前线程已经绑定了连接直接返回默认库的连接等于 DS 完全失效。就算切面顺序反过来了先执行 DS 再执行 Transactional事务绑定的是 order 库的连接方法里访问其他数据源的 Mapper 同样拿到的还是 order 库的连接。所以关键不是顺序而是你试图在同一个事务里访问多个数据源这在 Spring 的本地事务模型里本身就是不成立的。2.3 为什么有些时候看起来能用一定有人跳出来说我就在方法上同时加了 DS 和 Transactional跑起来一切正常啊。这种情况大概率是这个事务里所有的 Mapper 操作恰好都指向同一个真实数据源。比如你只连了 order 库DS 切换后事务绑定的连接就是 order 库后面所有数据库访问都在这个连接上执行自然没问题。这时候你根本不需要担心 DS 失效因为所有操作都在同一个库里。真正出问题的是“跨库”或者说是你期望一个事务里能同时访问两个甚至多个数据库。只要进入这个场景本地事务机制就被玩坏了。这里要用一个生活化的比喻你就能秒懂事务像一根绳子连接像一间房子里的钥匙。事务开启那一刻你等于已经进了一间房子并且把门反锁了。DS 想做的是“帮我把房子换成隔壁那间”但你手里的钥匙根本开不了隔壁的门而 Spring 又告诉你“你已经在这间屋里了别的地方都别想去了”。3. 真实故障现场与排查经验3.1 故障一读写分离时查询跑到了主库我最早踩这个坑是在一个简单读写分离的业务里。主库负责写从库负责读代码大概是DS(slave) Transactional(readOnly true) public OrderDetail queryOrderDetail(String orderId) { Order order orderMapper.selectById(orderId); ListOrderItem items orderItemMapper.selectByOrderId(orderId); return assemble(order, items); }在这个事务里两个 Mapper 都是只读查询。看起来没什么问题但实际压测时发现从库的请求量很低主库的 CPU 却一直很高。排查方法很简单给连接池加了 SQL 日志把所有执行的 SQL 打印出来发现select语句全部落在主库连接上。原因就是我上面说的Transactional即使标记为readOnly true事务管理器也会在方法执行前获取一个连接并绑定到当前线程。此时 DS 还没来得及设置 slave 路由 key事务拿到的就是 master 连接。后续所有的查询哪怕 Mapper 上有 DS(slave)也全部被固定到了 master 连接上。3.2 故障二偶发性的数据源串库还有一个更隐蔽的问题当你用了 group 数据源比如配置了一主多从DS(slave) 会在多个从库之间负载均衡代码里如果有嵌套调用一旦上下文没有正确恢复就可能出现“上一次线程池里的连接被复用到另一次请求”的错觉。实际上连接池本身不会串串的是业务逻辑对 ThreadLocal 上下文的错误依赖。比如你写了这样的代码DS(slave) public void query() { masterService.doSomethingInMaster(); }如果 masterService 里的方法没有标注 DS但是被嵌套调用方式触发了 dynamic-datasource 的清理逻辑可能在这个方法执行完后把 slave key 给清理掉导致后面的链路走默认主库。这一类问题表现不稳定最大的特点是“换一台机器就复现不了同一个线程上下文复现概率高”。3.3 故障三事务一直不提交锁等待爆表有些项目会把 DS 放在一个方法上然后在方法内部手动 new 一个事务去操作另一个数据源比如用TransactionTemplate。刚开始我也是这么写的结果发现事务提交了但另一个数据源的操作一直不提交。看具体代码DS(order) public void createOrder(OrderDTO dto) { orderMapper.insert(dto); transactionTemplate.executeWithoutResult(status - userMapper.update(dto.getUserId())); }transactionTemplate 默认拿到的 Connection 同样来自 Spring 事务管理它发现当前线程已经绑定了一个连接就直接复用 order 库的连接。你以为执行的是 userMapper 里的 update实际上这个 SQL 还是发给了 order 库。如果 user 表在另一个库里直接报表不存在或者更诡异的是如果两个库有同名表数据写错了库这才是真正恐怖的事故现场。3.4 故障四异步线程里数据源切了等于没切还有一个很多人忽略的点。DS 用 ThreadLocal 存储路由 key而Async异步方法是在一个新线程里执行的父线程里的 ThreadLocal 根本传不进去。如果异步方法里没有重新标注 DS它执行的数据库操作就会落到默认数据源上。这不是并发问题而是 ThreadLocal 的天然特性别问问就是我在生产环境亲眼见过统计报表数据全部查到了主库最后让默认库扛了大半夜的读流量。3.5 故障五手动 try-catch 把异常吃了事务毫无感知事务回滚依赖异常抛出而且默认只对RuntimeException和Error回滚。有些人写业务代码习惯加 try-catch结果事务方法里把异常吞掉了对外看起来方法正常执行完毕实际上数据库操作已经失败。这个和 DS 没有直接关系但和多数据源一起出现时排查难度会翻倍你还要先判断当前连接的库是不是正确的。我当时排查那个事务不生效的问题花了很久看业务代码最后发现 dataSource 库里路由到了正确位置但异常被 catch 了Connection 一直没得到回滚指令。记住一条事务方法里能不做业务 try-catch 就不要做必须做的时候记得在 catch 里面TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。4. 可落地的解决路径4.1 思路一最简单的办法是拆方法别让两个注解叠在一个方法上如果你的场景是“同一个事务里只访问一个数据源”那问题很好解决。把数据源切换和事务拆分到不同层保证事务方法内部不改变路由 key。比如可以这样设计Service public class OrderServiceImpl { DS(order) public void createOrder(OrderDTO dto) { innerCreateOrder(dto); } Transactional public void innerCreateOrder(OrderDTO dto) { orderMapper.insert(dto); orderLogMapper.insertLog(dto); } }注意innerCreateOrder必须是从外部类调用不能是this.innerCreateOrder()这种自调用。否则 Spring 代理机制不生效Transactional 等于没有。可以在另一个 Service 类里放内部事务方法这里只是一个思路示例。原则很简单开启事务之前先把 DS 的路由 key 设置好事务内部不要再切换数据源。这样事务从开启到结束只绑定目标数据源连接不会出现切库失效。4.2 思路二同一个事务里访问多个库用 DSTransactional 但要认清局限dynamic-datasource 框架自己也意识到和 Spring 原生 Transactional 不兼容的问题所以提供了DSTransactional注解。它和 Spring 事务的差别在于它不会一开始就创建一个绑定到线程的固定连接而是维护一个事务上下文记录当前事务里到底访问了哪几个数据源。DS(order) DSTransactional public void createOrder(OrderDTO dto) { orderMapper.insert(dto); userMapper.update(dto.getUserId()); }这样执行时orderMapper 会从 order 库拿连接userMapper 会从 user 库拿连接最终框架会在事务结束时把所有参与的数据源连接一起处理。但是请你务必清醒一点DSTransactional 不是分布式事务它是“尽力而为”的本地事务调度不能保证多库之间的强一致性。如果提交过程中某个库提交失败其它库可能已经提交成功了数据不一致的风险依然存在。所以我的建议是临时方案可以用 DSTransactional因为它至少解决了 DS Transactional 直接失效的问题如果涉及资金、库存这类强一致场景别指望它能兜底该上分布式事务还是得上。4.3 思路三用 TransactionTemplate 做编程式事务如果同一个方法里就是需要先写一个库再写另一个库而且各库内部要保持事务性怎么办我目前最习惯的做法是用TransactionTemplate结合手动 push / poll 数据源上下文Service public class OrderServiceImpl { private final TransactionTemplate transactionTemplate; public OrderServiceImpl(PlatformTransactionManager transactionManager) { this.transactionTemplate new TransactionTemplate(transactionManager); } public void createOrder(OrderDTO dto) { DynamicDataSourceContextHolder.push(order); try { transactionTemplate.executeWithoutResult(status - { orderMapper.insert(dto); orderLogMapper.insertLog(dto); }); } finally { DynamicDataSourceContextHolder.poll(); } DynamicDataSourceContextHolder.push(user); try { transactionTemplate.executeWithoutResult(status - { userMapper.update(dto.getUserId()); }); } finally { DynamicDataSourceContextHolder.poll(); } } }这段代码的逻辑是每次在执行一个新事务前手动把路由 key 压入数据源上下文然后开启事务这个时候事务管理器获取连接的时机是在 push 之后所以拿到的是一定是目标数据源连接。一个事务跑完、提交、清理再 push 另一个 key开启下一个事务。注意这里两个executeWithoutResult是两个独立事务它们之间没有全局原子性。如果第二个事务失败第一个事务已经提交了这是业务上需要接受的。对于大多数“多库各自保证一致性、整体允许短暂不一致”的业务这个方案比塞在一个事务里强行实现要可靠得多。4.4 思路四真需要跨库强一致上 Seata AT 模式如果你面对的是“用户下单扣余额 写订单 写库存”这种硬性要求任一分项失败都要全部回滚的场景那不要浪费时间在本地事务和 DS 上纠结了。直接把 Seata 引入进来用 AT 模式接管分布式事务。Seata AT 模式的核心是数据源代理它会把你的业务数据源包一层记录 SQL 执行前后的数据镜像通过全局事务 ID 协调多个服务的提交与回滚。老实说配置这一步不是零成本的需要部署 TC 服务端业务数据库要加 undo_log 表本地事务注解要换成GlobalTransactional还要把 dynamic-datasource 和 Seata 的数据源代理顺序调整对。但相比自己手动维护跨库一致性Seata AT 已经算是最成熟的方案了。这里给一条贴合实际的经验如果公司没有统一的分布式事务基础设施不建议一上来就上 Seata。优先从业务设计上规避跨库强一致比如把订单、库存、余额放到同一个库里或者在业务上允许通过 MQ 做最终一致性。架构上的妥协带来的收益很多时候比技术方案的复杂度收益高得多。4.5 思路五项目里立规矩从根源避开冲突代码层面的问题配合团队约定往往效果最好。我这边总结了几条多数据源开发的“军规”你可以直接拿来当团队规范规则原因DS 和 Transactional 禁止出现在同一个方法上会产生连接绑定冲突切库失效或串库事务边界尽量控制在单一数据源内Spring 本地事务本来就不支持跨库查询场景不要加 Transactional读操作加事务会增加无谓的连接占用DS 优先放在 Service 方法上不要放在 Mapper 方法上Mapper 层切换时机太晚容易和已绑定连接冲突异步方法内必须单独标注 DSThreadLocal 不跨线程传递跨数据源的强一致操作明确使用分布式事务中间件不要用自定义方案反复踩坑这些规则不是拍脑袋定出来的都是从故障现场一条条反推出来的。合规到位的团队靠规则能避免绝大部分低级故障。5. 故障排查套路与经验速查5.1 排查步骤遇到多数据源 事务的问题我建议按下面的顺序排查不要上来就 debug第一步确认 SQL 到底跑在哪个库上。最简单的方式是在配置里打开 SQL 打印日志观察连接 id 或数据库名。dynamic-datasource 配合 p6spy 可以很直观地看到每条 SQL 使用的真实数据源。第二步确认当前线程的路由 key。可以临时在业务代码里加一行日志打印DynamicDataSourceContextHolder.peek()看方法执行到某个节点时 key 是什么。第三步确认是否已经有事务连接被绑定。可以用一个简单的判断方式在业务代码里手动调用TransactionSynchronizationManager.getResource(dataSource)看返回值是否为 null。不是 null说明当前线程已经绑定了连接。第四步看代理顺序。如果确认是 DS 和 Transactional 冲突再去看你加的这两个注解在切面链里的顺序。通过 IDEA 的断点调试在TransactionInterceptor和DynamicDataSourceAnnotationInterceptor里分别打上断点看谁先进入、谁后进入基本就能定位问题。5.2 常见问题速查表现象可能原因验证方式DS 切换无效查询全走默认库事务绑定了默认库连接确认方法上是否加了 TransactionalDS 有时生效有时不生效切面顺序不确定 / 嵌套调用导致上下文被清理打断点看 AOP 执行顺序Mapper 上的 DS 失效Service 层已经开启事务绑定连接去掉 Service 层事务后再测异步线程内查询走默认库ThreadLocal 无法跨线程传递在异步方法内打印 peek()出现表或列找不到的报错SQL 发到了错误的库打开 SQL 日志看实际库名事务方法 catch 异常后不回滚异常被吞掉去掉 try-catch 或手动 rollback-only5.3 一个排查小技巧最后分享一个非常实用的调试技巧多数据源问题里光看 StackTrace 往往不够你需要看的是“连接是不是同一个”。在业务代码里临时加一个日志Connection connection dataSource.getConnection(); System.out.println(current connection: connection , hashcode: System.identityHashCode(connection));如果两次操作的 connection hashcode 一样说明它们共用同一个物理连接如果不一样说明是两次独立获取连接。这个信息能帮你快速区分问题是出在“连接复用”还是出在“路由 key 没对上”。排查完成后别忘了把这段临时代码删掉。说到这我脑子里又浮现出那次折腾了两天的经历。最后发现问题不是出在配置也不是出在连接池而是我在一个 Service 方法上同时用了 DS 和 Transactional导致整个请求被固定到了默认数据源。修好之后我给自己定了一条规矩凡是加了 DS 的方法一律不用 Spring 原生事务要么拆开要么用编程式事务要么用 DSTransactional。直到现在这个规矩在项目里一直沿用踩坑的概率低了很多。你如果刚好被这个问题卡住先把这段代码里的注解分开试一次大概率立刻就能看到变化。