☰
多数据源事务失效?@DS与@Transactional的底层原理与正确姿势
2026/10/2 1:19:45 网站建设 项目流程

如果你的项目用了dynamic-datasource-spring-boot-starter,并且同时用过@DS和@Transactional,那你大概率见过这种诡异现象:外层方法开了事务,内层方法明明标了@DS("另一个库"),可 SQL 仍然打在第一个库上。

有人说是多数据源和事务不能共存,有人说是注解顺序问题,还有人加了@Transactional(propagation = Propagation.REQUIRES_NEW)发现能用,就再也不敢动它。这篇文章想把这件事彻底讲明白,从底层时序到常见改法的利弊,再到我实际项目中最终采用的写法,一次性说清楚。

1. 一个典型翻车现场:事务方法里换库,换了个寂寞

1.1 业务长这样:主库订单加积分库积分

先说一个我真实遇到过的需求。系统里有主库master,存订单核心数据,另外有一个独立的积分库score,存用户积分流水。业务要求:创建订单成功后,顺带给用户加积分。

最初代码大概是这样的,我相信很多人一看就眼熟:

@Service public class OrderService { @Autowired private OrderMapper orderMapper; @Autowired private ScoreMapper scoreMapper; @Transactional public void createOrder(Order order) { orderMapper.insert(order); scoreMapper.addScore(order.getUserId(), 10); } }

ScoreMapper上标了@DS("score"):

@DS("score") public interface ScoreMapper { void addScore(@Param("userId") Long userId, @Param("points") Integer points); }

看起来没什么问题,一个事务方法,主库写订单,积分库写积分。但线上跑起来就不是那么回事了。

1.2 现象一:SQL 全打在默认数据源上

打开 SQL 日志,两条 SQL 都走了master:

==> Preparing: INSERT INTO orders (order_no, user_id, amount) VALUES (?, ?, ?) ==> Parameters: 20240510001(String), 10001(Long), 299.00(BigDecimal) ==> Preparing: INSERT INTO user_score (user_id, points, create_time) VALUES (?, ?, ?) ==> Parameters: 10001(Long), 10(Integer), 2024-05-10 12:00:00(Timestamp)

如果master库里恰好没有user_score表,就会直接报“表不存在”;如果凑巧两张表结构一样,数据就被静默写进了错误的分库,这种问题排查起来更难受。

1.3 现象二:回滚范围远超预期

另一个场景是:订单插入成功,但积分接口抛了异常。由于两个操作被同一个@Transactional包裹,订单插入被一起回滚了。

从业务上看,订单失败回滚,这是合理的。但关键是——如果你本意是想让积分失败不影响订单主流程,或者想单独补偿积分,那这套写法完全做不到。更麻烦的是,当同事尝试在createOrder方法内手动捕获异常“假装处理”时,因为 Spring 事务默认只在RuntimeException和Error时回滚,加上异常已经被捕获,事务机制直接失效,订单提交了,积分库没写。这种“一半成功一半失败”的状态,比整体失败更难收拾。

好,现象说完了。接下来进入正题:为什么@DS在事务方法内部不生效。

2. 连接先于路由产生:拆解 @DS 失效的底层时序

2.1 @DS 切换的只是一个 ThreadLocal 里的 Key

很多人对@DS的理解是“它帮我切换了数据库连接”,这个理解不能算错,但不够精确。

@DS的本质,是在方法执行前通过 AOP 拦截器,向DynamicDataSourceContextHolder这个类里 push 一个字符串 key。这个类内部是一个ThreadLocal<Deque<String>>,栈结构,支持嵌套。方法执行后,在 finally 里再 poll 掉。

也就是说,@DS改变的只是当前线程上的一个“路由标记”,并没有直接去创建或切换任何连接。真正的切库动作发生在DynamicRoutingDataSource.getConnection()被调用的时候:它从 ThreadLocal 里拿出当前 key,再根据 key 从内部维护的Map<String, DataSource>中取到具体的数据源,返回一个真实连接。

举个生活化的例子:你告诉快递员“去3号楼取件”,快递员只有真正走到3号楼门口那一刻,才会按门牌号拿件。你中途改口说“其实是5号楼”,如果快递员已经拿着3号楼的件在路上了,你说什么都晚了。

@DS就是在改口,而getConnection()才是快递员真正去取件的那一步。

2.2 Spring 事务绑定连接的时机

@Transactional的底层是DataSourceTransactionManager。它在事务开启的时候,会做这样几件事:

  1. 调用dataSource.getConnection()获取一个连接;
  2. 通过TransactionSynchronizationManager把连接绑定到当前线程的 ThreadLocal 资源中;
  3. 设置连接自动提交为 false;
  4. 后续同一个事务内的所有数据库操作,都复用这个绑定好的连接。

关键在于第 1 步:getConnection()的调用时机,是事务开始的那一刻,而不是 SQL 执行的那一刻。

对于dynamic-datasource来说,传入DataSourceTransactionManager的是DynamicRoutingDataSource这个代理对象。事务开始时,它调用代理对象的getConnection(),代理对象根据当前线程的 key,决定从哪个真实数据源取出连接。取出之后,连接通过bindResource()绑定到当前线程。

这一步一旦完成,路由就已经“定死”了。之后不管你 ThreadLocal 里的 key 怎么变,事务管理器都只认之前绑定好的那个连接。

2.3 把两个时序叠在一起,问题就清楚了

现在把两个时序叠到一起看:

  1. 进入OrderService.createOrder(),此时如果没有额外的@DS,路由 key 为空,默认走配置的primary数据源,也就是master;
  2. @Transactional开始,getConnection()被调用,根据 key 拿到master连接,并绑定到线程;
  3. 执行orderMapper.insert(),走的是master连接,正确;
  4. 进入scoreMapper.addScore(),@DS("score")拦截器执行,push("score"),ThreadLocal 里的 key 变成score;
  5. 但 MyBatis 的 SqlSession 在事务模式下拿到的还是事务管理器绑定的那个master连接;
  6. INSERT INTO user_score实际上还是打在master连接上;
  7. 方法返回,@DS拦截器执行 poll,key 恢复。

所以问题的根子不在@DS,而在@Transactional把“取连接”的动作提前到了事务创建时。连接已经定死,后面再怎么改路由标记都只是改了个寂寞。

2.4 什么时候 @DS 和 @Transactional 可以共存

这里要纠正一个流传很广的说法:“@DS和@Transactional不能同时使用”。严格说,不能同时使用是错的。

如果@DS和@Transactional放在同一个方法上,且@DS的 AOP 拦截器先于事务拦截器执行,那么事务在getConnection()时,ThreadLocal 里已经设置好了正确的 key,连接会路由到正确数据源。这不仅能共存,而且是正常的写法。

dynamic-datasource默认把DynamicDataSourceAnnotationAdvisor的 order 设为最高优先级,目的就是为了让它先于事务拦截器执行。所以下面这种方法是没问题的:

@DS("slave") @Transactional(readOnly = true) public User getUserById(Long id) { return userMapper.selectById(id); }

真正的问题是“外层事务已开启,内层再换库”。换句话说,事务边界决定了连接归属,而不是@DS出现在哪个方法上。

搞清楚这个,接下来就可以逐个拆解网上流行的各种“解决方案”了。

3. 网上流传的几种改法,我挨个试了一遍

3.1 给内层方法加 REQUIRES_NEW:能通,但别滥用

很多人发现内层@DS不生效以后,第一反应是给内层方法加上@Transactional(propagation = Propagation.REQUIRES_NEW),比如这样:

@DS("score") @Transactional(propagation = Propagation.REQUIRES_NEW) public void addScore(Long userId, Integer points) { scoreMapper.addScore(userId, points); }

我自己也这么改过,确实能生效。原因是REQUIRES_NEW会挂起外层事务,重新开启一个新事务,新事务在doBegin时会再次调用getConnection()。此时 ThreadLocal 里的 key 已经是score,所以新连接会正确路由到score库。

但这里有几个很大的坑:

第一,内层方法变成了独立事务,它提交成功以后,如果外层方法后续执行失败,内层已经提交的积分不会被回滚。比如订单插入成功、积分也加成功了,但外层接下来发送 MQ 消息时抛了异常,外层回滚了订单和积分?不会,外层回滚只影响 master 连接上的操作,积分已经独立提交了。最终结果就是:没有订单,但有积分。这种数据不一致,比当初连接路由错误还难发现。

第二,连接占用翻倍。外层事务占着一个 master 连接,内层 REQUIRES_NEW 又占着一个 score 连接。如果系统并发稍微高一点,连接池很容易被打满。我曾经在一个压测环境里见过 HikariCP 连接数直线飙到上限,最后所有请求都在等连接,原因就是某个服务里嵌套了三层 REQUIRES_NEW,每层一个连接。

第三,死锁风险。两个库的连接分别持有,如果在业务逻辑里出现了相互依赖的更新顺序,DB 层的死锁检测很难发现跨库的死锁,最终只能等超时。

所以我的结论是:REQUIRES_NEW 可以作为一种“临时绕过”的手段,但绝不能当作常规解决方案。

3.2 调整 AOP 拦截顺序:解决一半问题

还有一种思路是手动调整 AOP 顺序,确保@DS的拦截器在事务拦截器之前运行。

dynamic-datasource的DynamicDataSourceAnnotationAdvisor默认 order 是Ordered.HIGHEST_PRECEDENCE,也就是最高优先级。而 Spring 事务的BeanFactoryTransactionAttributeSourceAdvisor默认 order 是Ordered.LOWEST_PRECEDENCE,也就是最低优先级。

如果你是通过自定义切面来实现@DS的,或者项目里引入了别的 AOP 组件,那么确实要注意顺序。做法是在定义 Advisor 时手动设置 order:

@Bean public Advisor dynamicDataSourceAdvisor(DynamicDataSourceAnnotationInterceptor interceptor) { DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor( new AnnotationMatchingPointcut(DS.class, true), interceptor); advisor.setOrder(Ordered.HIGHEST_PRECEDENCE); return advisor; }

这样做能保证同一个方法上@DS先 push key,然后事务才开始取连接。但我要说清楚:这只能解决“同一个方法上标注了两个注解”的场景,解决不了“外层事务已开启、内层换库”的根本矛盾。

因为当执行到内层方法时,外层事务的连接已经绑定在线程上了。内层@DS就算先执行,也只是改了 ThreadLocal 里的 key,事务管理器绑定在线程上的连接不会变。除非内层事务传播行为是 REQUIRES_NEW,强制新开事务重新取连接。

所以“调顺序”是个正确的细节,但别指望它一劳永逸。

3.3 自调用导致的 @DS 失效,经常被忽略

还有一个很隐蔽的坑:在同一个类内部调用带@DS的方法。

@Service public class OrderService { public void createOrder(Order order) { orderMapper.insert(order); this.addScore(order.getUserId()); // 自调用 } @DS("score") public void addScore(Long userId) { scoreMapper.addScore(userId, 10); } }

这种写法,addScore方法上的@DS完全不会生效。原因是 Spring AOP 基于代理,createOrder方法里通过this调用的不是代理对象,而是原始对象,AOP 拦截器压根不会执行。这和@Transactional自调用失效是同一个原理。

解决办法也很朴素:把需要切换数据源的方法放到另一个独立的 Bean 里,注入进来调用。或者如果有必要,注入自身代理:

@Autowired private OrderService self; public void createOrder(Order order) { orderMapper.insert(order); self.addScore(order.getUserId()); }

这个方法不复杂,但很容易被忽视。排查的时候如果只盯着数据源配置,很难发现是代理问题。

3.4 错上加错:手动 getConnection 写 JDBC

我之前见过同事为了绕开@DS失效,直接在事务方法里手动拿数据源连接写 JDBC:

DataSource ds = DynamicRoutingDataSource.getInstance().getDataSource("score"); try (Connection conn = ds.getConnection()) { // 手动执行 SQL }

这种写法确实能连到score库,但问题更大:手动拿到的连接完全脱离 Spring 事务管理,外层事务回滚的时候它不会跟着回滚,外层提交的时候它也不会跟着提交。结果就是事务边界彻底失效,只能用“碰运气”来形容最终的数据一致性。

甚至还有更极端的做法,直接修改DynamicDataSourceContextHolder的 ThreadLocal 值,然后在 finally 里恢复,相当于手写了一遍 @DS 的逻辑。一旦方法中间抛出异常忘记恢复,这个 key 就会残留在线程上,后续所有 SQL 全部走错库,而且是偶发性、随机性的错库,排查看日志能把人看崩溃。

4. 最稳的姿势:按数据源边界拆散事务

4.1 一次事务只绑定一个数据源,原则就一条

踩完上面这些坑以后,我在项目中定了一条硬性原则:一个事务方法内,只允许操作一个物理数据源。

原因很简单:@Transactional本身不具备跨库事务能力。Spring 事务管理器眼中的 DataSource 只有一个动态代理,它没法感知内部路由到了哪个库,更没法在多个物理连接之间做统一提交或回滚。在“一个事务方法 + 多个物理库”这个模型下,无论你用什么姿势改代码,都是在跟框架的基本设计较劲。

正确的方向不是“让框架支持跨库事务”,而是“从 Design 上避免跨库事务”。

4.2 按库拆服务后的代码长什么样

回到之前那个订单加积分的例子。重构以后,代码结构是这样:

@Service public class OrderService { @Autowired private OrderCommandService orderCommandService; @Autowired private ScoreService scoreService; public void createOrder(Order order) { // 先写主库订单 orderCommandService.createOrder(order); // 再写积分库,失败走重试或补偿 scoreService.addScore(order.getUserId(), 10); } } @Service public class OrderCommandService { @Autowired private OrderMapper orderMapper; @Transactional @DS("master") public void createOrder(Order order) { orderMapper.insert(order); } } @Service public class ScoreService { @Autowired private ScoreMapper scoreMapper; @Transactional @DS("score") public void addScore(Long userId, Integer points) { scoreMapper.addScore(userId, points); } }

重构之后,订单和积分两个操作各自由独立的事务管理,@DS也回到了自己应该待的地方。即使积分加发失败,也不会把订单事务拖下水,可以做补偿、重试或者记录失败日志人工介入。

有人可能会问:那如果订单写成功、积分加失败,订单已经提交了怎么办?这个时候就不应该用事务去解决,而是引入重试或补偿机制,比如先把“待加积分”的消息发到 MQ,由消费端异步去更新积分库,失败自动重试。这就回到了最终一致性的范畴。

4.3 拆散之后的一致性怎么补

拆散事务后,业务上会从“强一致”变成“最终一致”,这个思维方式要转变。比如:

  • 积分服务失败后,通过 Spring Retry 或者手动重试补偿;
  • 把积分加发任务持久化到本地消息表,定时任务扫描重发;
  • 更简单的做法:失败后记录一条失败日志,控制台打印告警,人工或者脚本扫描补单。

大多数非金融类业务,最终一致完全够用。如果业务确实需要强一致,比如订单金额和账户余额必须严格同步变动,那@Transactional更是无能为力,你需要看第 5 章和第 4 节的后续部分。

5. @DSTransactional 能做什么,不能做什么

5.1 @DSTransactional 的用法很简单

dynamic-datasource框架在 3.4.0 之后提供了@DSTransactional注解。从名字就能看出来,这是专门为多数据源事务设计的。

用法非常简单,就是把@Transactional替换成@DSTransactional:

@DSTransactional public void createOrderAndScore(Order order) { orderMapper.insert(order); scoreMapper.addScore(order.getUserId(), 10); }

同时两个 Mapper 的方法上依然保留各自的@DS,方法内部的操作会分别走不同的数据源。@DSTransactional会拦截整个方法,在方法执行过程中为多个数据源分别管理连接,最后统一提交或统一回滚。

我当时在测试环境验证过:主库订单插入成功、积分库故意抛异常,最终两个连接上的操作都被回滚了。从效果上看,确实解决了@DS和@Transactional冲突的问题。

5.2 重点说清楚:它不是分布式事务

这里必须泼一盆冷水:@DSTransactional不是一个真正的分布式事务方案。

它的核心思路是在同一个线程内,为多个数据源分别获取连接,分别开启本地事务,最终一次性提交或回滚。这种模式业界叫“Best Efforts 1PC”——尽力而为的一次提交。正常场景下它能工作,但它无法保证 100% 的原子性。

举一个极端的例子:如果数据库连接 1 提交成功了,数据库连接 2 提交的时候恰好数据库宕机了,那就会出现一部分库提交成功、另一部分库提交失败,而且没有机制自动回滚已经成功提交的那一部分。两阶段提交协议存在的意义,正是为了处理这种极端情况,但那已经不在@DSTransactional的能力范围了。

所以,如果你的业务一致性要求极高,比如涉及资金交易、库存扣减这类不能忍受任意一分钱偏差的场景,建议考虑 Seata 的 AT 模式或 TCC 模式。如果只是普通的业务数据,能接受极小概率的不一致,并且有补偿机制兜底,那么@DSTransactional可以省掉大量拆分代码的麻烦。

5.3 什么项目适合用 @DSTransactional

我的建议是这样:

  • 作为临时方案:项目正在重构,来不及拆分服务,先用@DSTransactional顶住,把问题隔离在局部;
  • 中小项目业务量不大:跨库操作并发低、网络相对稳定、业务允许人工补偿或重跑,用起来成本低;
  • 多数据源数量不多:比如就两个库,用起来可控;如果每个方法涉及四五个数据源,这种方案的风险会成倍上升。

我目前的主力项目里,已经不再主动使用@DSTransactional,因为跨库强一致的地方全部迁移到了 Seata,跨库最终一致的地方全部拆成了独立事务 + 消息补偿。但不得不承认,在很多历史老项目里,@DSTransactional是当时性价比比较高的一个选择。

6. 生产环境里几个容易踩的细节坑

6.1 ThreadLocal 里的 Key 残留,SQL 静悄悄走错库

@DS的拦截器在正常逻辑里是有 finally 保证的,无论方法正常返回还是抛异常,都会执行 poll。但问题往往出在特殊路径上,比如:

  • 在代码里手动调用DynamicDataSourceContextHolder.push(),却没有在 finally 里 poll;
  • 自定义了切面,在@DS拦截器之前抛了异常;
  • 用了线程池,任务执行过程中线程没有正常走完销毁流程,ThreadLocal 被带到下一个任务复用。

Key 一旦残留,当前线程复用时后续 SQL 就可能走到错误的数据源。这种错误非常难排查,因为它是“间歇性”的:只有触发到那条线程池里的线程,才会出现问题;每次出问题的时间点和接口还不一样。

我的做法是:在核心服务的入口处,用一个拦截器强制把路由 key 重置为默认值,再往下走业务逻辑。这样即使上游某个地方 key 残留,到了这里也会被洗干净,不会污染后续调用链。

6.2 怎么快速确认一条 SQL 到底打的哪个库

排查多数据源问题,第一件事就是确认 SQL 实际走了哪个库。我常用的方法有三个:

第一个是看dynamic-datasource的日志。在application.yml里打开 debug 日志,动态数据源在路由时会输出当前使用的 key,配合 SQL 日志就能快速定位。

第二个是写一个简单的 MyBatis 拦截器,在 SQL 执行前输出当前线程的路由 key 和 SQL 语句。这个拦截器很小,网上一搜就有,实测对排查问题非常有帮助。

第三个是在业务代码里临时加一行输出,直接把DynamicDataSourceContextHolder.peek()的值打出来。这个方法最粗暴,但往往也最快。

最不建议做的是“盯着数据库看连接来自哪个 IP”,特别是在应用和数据库之间还隔着中间件的时候,误差会非常大。

6.3 代码评审中我盯的几个点

现在每次 code review,凡是涉及到数据源和事务的代码,我都会重点关注这几个位置:

  • 有没有跨数据源的 service 调用,有的话问清楚事务边界;
  • @DS是不是写在了 private 方法上,或者是不是被同类this调用;
  • @Transactional和@DS是否同时出现在一个方法上,若出现,判断 AOP 顺序;
  • 有没有在事务方法里做 RPC 调用、消息发送这类远程操作;
  • 有没有手动操作DynamicDataSourceContextHolder的代码,有的话必须有 try-finally 包住。

这些点大多不是框架级别的错误,而是写代码时不注意 AOP 机制和事务边界造成的隐患。表面上看是“数据源切换不生效”的小 bug,背后其实是事务设计和系统边界的问题。

最后聊一点体会:多数据源本身并不复杂,复杂的是多个数据源一旦和“事务”两个字扯上关系,整个设计难度就会上一个台阶。遇到这类问题,别急着找“让 @DS 和 @Transactional 共存”的奇技淫巧,先跳出来看一眼事务边界和数据源边界是否匹配。大多数情况下,把跨库操作拆开、用最终一致性补齐,才是真正省心的方向。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询