
最近又有同学来问我 MyBatis-Plus 多数据源事务问题Transactional明明加了结果一条数据落在 A 库一条落在 B 库A 库更新成功B 库报错数据就对不上了。这种现场我在项目里见过太多次每次都要从头解释一遍 Spring 事务跟多数据源的路由之间到底是怎么打架的。这篇就把它拆干净顺便把手上的几个可用方案和取舍一起说说。如果你用的是 MyBatis-Plus 搭配dynamic-datasource-spring-boot-starter做多数据源或者正准备在读写分离、分库、接老系统的项目里上这一套这篇文章能帮你少走一大段弯路。我会先还原一个典型的现场再讲清楚底层的绑定逻辑最后给出几种可落地的解决方案和排错方法。1. 先还原现场一个典型的多数据源事务失效案例1.1 订单写主库、流水写日志库到底哪里出错了假设你维护的是一个电商老系统订单表在master库操作流水表在log库。用 MyBatis-Plus 的DS注解切换数据源代码大概是这个意思Service public class OrderService { Autowired private OrderMapper orderMapper; Autowired private OperationLogService operationLogService; Transactional public void createOrder(Order order) { orderMapper.insert(order); OperationLog log new OperationLog(); log.setOrderId(order.getId()); log.setContent(创建订单); operationLogService.addLog(log); } }OperationLogService里这样写Service public class OperationLogService { Autowired private OperationLogMapper operationLogMapper; DS(log) public void addLog(OperationLog log) { operationLogMapper.insert(log); } }看着很合理但你一跑就会发现要么订单和日志都写进了默认库master要么订单已经提交通成功了日志服务抛出异常后订单也回滚了但日志却诡异地留在了log库里。这两种现象我都遇到过背后原因不一样但都指向同一个核心问题Spring 的声明式事务默认只绑定一个数据源它感知不到你在事务执行过程中“另起炉灶”切换了路由。1.2 为什么Transactional管不到DS的切换很多人把Transactional当成一把全局大锁以为只要方法上标了它里面无论执行几个库的 SQL都能原子性提交或回滚。实际上Transactional对应的DataSourceTransactionManager在开启事务的那一刻就会从当前DataSource里拿一个连接把这个连接绑定到当前线程。后面事务内的所有数据库操作走的都是这一条被绑定的连接。dynamic-datasource的DS注解本质是通过 AOP 在方法执行前切换当前线程的数据源上下文。但它切换的是“路由上下文”不是事务管理器已经绑定的那条物理连接。如果事务已经开启了事务管理器里的连接已经固定切上下文只不过让后续连接获取的逻辑变了个方向底层拿到那条连接却仍然是原来的。这就是为什么很多人发现Transactional跟DS放在一起行为会变得不可捉摸。更具体一点假如createOrder开启事务时OrderService还没有经过DS切库事务管理器拿到了master的连接。进入OperationLogService.addLog后DS(log)尝试切换数据源但事务内获取连接时会优先去当前线程里找已绑定的连接于是拿到的是master连接。日志 SQL 被发到master库执行不报错但数据落错了地方。还有一种情况如果配置和框架版本让DS在事务内真的获取了一个新的log连接那这个连接不在DataSourceTransactionManager的管理范围内它可能被自动提交也可能在事务回滚时完全不参与——于是订单回滚了日志却提交固化了。两边数据永远对不上。1.3 多数据源事务问题的本质本地事务边界问题所以你在 MyBatis-Plus 多数据源项目里遇到的所谓“事务问题”绝大多数不是 MyBatis-Plus 本身的问题而是本地事务跨多个数据源时的边界问题。Spring 的事务管理模型是基于单资源Single Resource设计的它把事务边界牢牢压在一条数据库连接上。一旦出现第二个数据源你就得自己决定是把它们并进同一个全局事务还是通过消息、补偿、最终一致性等方式把它们拆开。这个认知先建立起来后面所有的解决方案才好理解。2. 拆开揉了揉Spring 事务、动态数据源和 MyBatis-Plus 的协作逻辑2.1 Spring 事务是怎么绑定到线程的要彻底搞清楚得看DataSourceTransactionManager做的事。它开启事务时会调用DataSourceUtils.getConnection把连接放到TransactionSynchronizationManager持有的一组 ThreadLocal 资源里同时记录当前数据源名、隔离级别、是否新开连接等信息。之后同一个线程里后续的JdbcTemplate、MyBatis 的SqlSession拿连接时都会先去TransactionSynchronizationManager里查有没有跟当前数据源匹配的绑定连接。有就直接用没有才从数据源里新拿一个。这套机制保证了在一个数据源内事务的提交和回滚是可靠的。问题在于多数据源场景下TransactionSynchronizationManager里到底绑定的是哪个数据源的连接Spring 默认它只认一个“当前事务同步资源”。当你切换数据源路由时旧的数据源连接还留在 ThreadLocal 里新的数据源连接如果直接创建它游离在事务同步之外。这就是第二个问题的根源每个数据源各自的连接事务边界是独立的它们之间没有任何协调者。2.2 dynamic-datasource 的路由机制到底做了什么dynamic-datasource-spring-boot-starter这个工具的核心是一个DynamicRoutingDataSource它实现了 Spring 的AbstractDataSource内部维护一个MapString, DataSource。你使用DS(log)时切面会把log这个 key 存到当前线程的上下文中真正获取连接时动态数据源根据 key 从 map 里找到对应的物理数据源再调用它的getConnection。如果当前线程已经处于 Spring 事务中而且是默认单数据源事务管理器那么动态数据源即使找到了log数据源也未必能产生一个受管理的连接。因为连接获取过程往往会走到“事务同步优先”的逻辑它看到线程里已经绑定了master的连接就直接返回给 SQL 执行器了。这样DS看起来“失效”真正原因不是你注解写错了而是事务优先级高于路由优先级。2.3 多数据源事务失效的三个典型症状结合我实际接收过的提问和现场总结出最常见三种表现可以对照自己的情况快速判断症状实际现象根因数据源切换失效DS(log)的 SQL 写到了默认库事务已开启连接绑定优先于路由切换部分提交A 库提交成功B 库运行时报错最终 B 库没数据A 库有数据两个数据源各自本地事务无全局协调回滚后仍有残留主库回滚了另一个库却已经提交新连接不受当前事务管理器控制自动提交完成这些症状不是互斥的同一个故障现场可能同时出现一两个。比如订单成功、日志抛异常后订单回滚了但日志库里先插入的半条操作记录可能因为事务传播行为不同而残留下来。3. 解决方案与实操从业务规避到全局事务既然知道了问题出在哪里下面这几条路都是工程上验证过的。按推荐程度从“最好不用分布式事务”到“必须强一致时选分布式事务”依次排开。3.1 方案一业务侧拆分把多数据源事务变成单库事务这是我最推荐的思路能在设计上解决的问题就不要引入复杂度。既然集成了多个数据源一个典型的做法是按业务边界把操作拆到不同服务或不同接口里。比如创建订单主流程只写master库日志模块通过 MQ 或本地消息表异步写入log库。单个接口保持一致的事务边界多库之间的数据最终通过消息或者定时任务对齐。只要业务可以接受“秒级甚至分钟级延迟”这个方案成本和风险都最低。落地时可以参考本地消息表模式在master库里建一张消息表和订单业务放在同一个本地事务里。本地事务提交后由后台任务读取未发送的消息投递到 MQ消费端再写log库。这样订单数据和“待发消息”始终同生同死另一个库的写入即使失败也能靠重试补偿。3.2 方案二使用 dynamic-datasource 自带的DSTransactional如果你的多数据源场景比较简单不是核心资金链路又确实不想引入额外中间件可以试试dynamic-datasource提供的DSTransactional注解。它跟Transactional的区别是它不绑定单一数据源而是在方法执行期间把我们上面提到的多个数据源都纳入一个本地事务上下文里。简单说它的原理是方法启动时记录一个空的事务集合每次遇到新的数据源就给该数据源创建一条独立事务并加入集合方法结束后依次提交集合里所有事务一旦某个事务提交失败会尝试做反向回滚。但这里必须给你泼一盆冷水DSTransactional不是分布式事务它不保证原子性。它把多个本地事务放在一个调用链里提交阶段没有协调者如果提交到一半进程崩溃已经提交的那部分数据不会自动回滚。所以它比较适合“允许极小概率不一致并且有对账兜底”的场景。使用示例Service public class OrderService { Autowired private OrderMapper orderMapper; Autowired private OperationLogService operationLogService; DSTransactional public void createOrder(Order order) { orderMapper.insert(order); // 内部有 DS 切换也会被纳入事务上下文 operationLogService.addLog(...); } }注意不要把它和Transactional叠在一起用我在项目里见过同时标两个最后行为完全不可控。如果确定走DSTransactional就把Transactional去掉并且保证切库逻辑都发生在被这个注解覆盖的方法调用链内。3.3 方案三引入 Seata AT 模式做分布式事务一旦业务要求强一致比如订单创建后库存库和订单库必须同时成功或同时失败那就得引入真正的分布式事务框架了。目前 Java 生态里相对成熟、文档也比较多的是 Seata 的 AT 模式。AT 模式的核心思路是事务开始时在全局事务管理器里登记一个全局事务 ID每个参与事务的分支数据源在执行 SQL 前后自动把数据快照记录在undo_log表中。提交阶段做两阶段提交全局事务管理器通知各分支提交各自事务提交完成后清理快照如果发生异常则根据undo_log里的快照数据反向补偿。使用 Seata AT 时框架会代理你的数据源。项目里的事务注解要改成GlobalTransactional数据源配置通常要加上 Seata 的代理。对你的 MyBatis-Plus 工程来说改动量不算大但需要额外维护 Seata Server并且在每个参与事务的库里新建undo_log表。网络上相关的集成资料也比较多这里只给核心步骤!-- 引入依赖 -- dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-seata/artifactId /dependency配置application.yml时开启 Seata 代理seata: enabled: true application-id: order-service tx-service-group: my_test_tx_group enable-auto-data-source-proxy: true业务方法上这样写Service public class OrderService { Autowired private OrderMapper orderMapper; Autowired private OperationLogService operationLogService; GlobalTransactional public void createOrder(Order order) { orderMapper.insert(order); operationLogService.addLog(...); } }注意Seata AT 模式解决的是跨库的最终一一致性吗不AT 模式在标准情况下是满足事务 ACID 的它通过全局锁和 undo_log 实现了读已提交隔离基础下的强一致提交但也要求两个库不能有特别的分布式事务冲突。实际使用中还是会有性能开销和全局锁等待问题尤其并发冲突大时要警惕。3.4 方案四编程式事务手动补偿不推荐还有一种很多老项目里出现过的手写方式开启两个DataSourceTransactionManager在业务代码里分别手动commit或rollback然后再用一个循环或 try/catch 去做补偿。比如先提交 A再提交 B如果 B 提交失败手动反写 SQL 把 A 回滚。我不推荐这种方案。它能处理“运行时异常时回滚”的简单情况但处理不了提交过程中宕机、网络分区等极端问题。手写的补偿逻辑如果漏一条路径就是生产事故。如果项目还没有精力上 Seata宁可走方案一的消息或对账也别用手写补偿。4. 避坑指南跟多数据源事务死磕半年后的经验4.1 自调用导致的事务和切面双双失效这是所有Transactional相关问题里最容易忽略的在同一个类内部调用另一个标了事务注解的方法事务注解根本不会经过 Spring 代理。因为 Spring 的 AOP 代理只在外部调用时生效内部this调用绕过了代理对象。DS同理你在OrderService内部直接this.addLog()即使addLog上写了DS(log)也不会触发路由切换。解决方法是把需要切库或需要事务的方法拆到另一个被 Spring 管理的 Bean 里通过注入的方式调用。比如上面代码里的OperationLogService就是独立 Bean这样DS才能生效。4.2Transactional和DS不要混在同一个方法栈上这是我反复强调的点。如果你要在同一个业务方法里既开事务又切库多半会踩中前文说的连接绑定问题。除非你清楚自己在干什么否则主力业务方法上尽量只保留Transactional单库逻辑切库操作放在更内层的方法上并且内层方法不要和主事务混用同一个事务同步资源。一个比较稳的写法是主库事务方法内部通过独立 Bean 调用DS(other)的附加逻辑并让这个附加逻辑自己管理自己的事务可以用REQUIRES_NEW传播级别这样切库连接会从新事务里获取不受主事务绑定连接影响。真要做到这一步最好代码注释里写清楚每个数据源的事务边界。4.3 排查事务问题时的三个抓手日志、数据源名、连接地址遇到多数据源事务诡异问题先不要急着改代码。打开以下三样东西第一打印DynamicRoutingDataSource当前路由的数据源 key。可以在切面里加一行日志也可以直接在代码里调用DynamicContextHolder.peek()。第二查看数据库连接地址。在事务方法进入后使用DataSourceUtils.getConnection打印实际连接的元数据 URL。很多“切库失效”问题一看到 URL 就真相大白了。第三开启 SQL 日志。配置 MyBatis-Plus 的log-impl可以看到每条 SQL 执行时用的是哪个连接再对照事务边界判断连接归属。排查伪代码可以这样写log.info(当前数据源: {}, DynamicContextHolder.peek()); Connection connection DataSourceUtils.getConnection(dataSource); log.info(实际连接: {}, connection.getMetaData().getURL());4.4 到底该选哪种方案一张实用判断表我在团队内部做过一个决策表直接拿去用场景要求推荐方案理由允许异步最终一致能接受短暂不一致方案一消息表 / MQ 重试简单、可靠、无额外中间件简单两三个库能容忍极小概率提交中断不一致方案二DSTransactional改动小适合非核心链路核心资金、强一致不能有任何数据错乱方案三Seata AT 或其他分布式事务有标准协议补偿机制完整临时救火纯手写补偿不推荐容易漏补偿风险高如果项目还在早期我建议优先把“强一致”的需求一个一个砍掉能用最终一致性解决就用最终一致性。事务范围越大系统越脆弱多数据源事务更是一块需要谨慎处理的复杂地带。我自己导过十几个项目上线真正最后必须上 Seata 的也就两三个。更多的业务场景其实通过合理的库表设计、幂等消费和定时对账就把“跨库一致性”问题消化了。这个认知比任何技术方案都值钱。最后再分享一个小技巧如果某个多数据源场景你逃不掉那就在上线前做一次“故障演练”。强行让其中一个数据源在某条 SQL 上抛异常观察另一个库的数据是否残留。我见过太多项目自以为加了GlobalTransactional就高枕无忧结果一演练就发现还有自调用问题、切面顺序问题、数据源代理顺序问题。这些坑练过一遍比读十篇文章都强。