先说一个我自己的真实经历。前几年做订单中心重构,数据库拆了主库和统计库,为了压测报表接口,我在一个Service方法上同时加了@DS("statDb")和@Transactional,结果诡异的事情出现了:有时候数据能查到,有时候查不到;更离谱的是,明明标了走统计库,日志里打印的SQL却打在主库上。当时排查了大半天,最后差点把责任推到DBA头上。后来把两个注解拆开,问题瞬间消失。也就是从那次开始,我把“多数据源+事务”这个组合的底层逻辑彻底扒了一遍,才发现这里面的坑比想象的深得多。
如果你也正在用Spring Boot做多数据源,并且代码里同时出现@DS和@Transactional,这篇文章就是给你写的。我会把这个组合的底层原理、各种组合场景的实测结果、生产环境的稳妥写法,以及问题排查套路全部讲清楚,争取让你看完之后不再踩我踩过的坑。
1. 为什么@DS和@Transactional放在一起就“打架”:问题现场与根因分析
1.1 典型现象:不是报错,而是“悄悄走错库”
多数据源+事务的问题最难排查的地方在于:它往往不抛异常,而是在你毫无察觉的情况下把SQL发到了错误的数据库上。
我遇到过的典型现象大概有这么几类:
- 方法上标了
@DS("slave"),但实际执行的SQL出现在主库的慢查询日志里。 - 接口偶发性报错“Table 'xxx' doesn't exist”,因为同一个方法有时走A库,有时走B库。
- 事务明明配置了
rollbackFor = Exception.class,但异常抛出后数据没有回滚,或者回滚到了错误的库上。 - 一个方法里先查主库再查从库,结果两次查询用的是同一个连接,第二次查询的数据源切换完全没有生效。
这些问题有一个共同特点:代码没有编译错误,运行也不一定报错,但数据就是不对。这种问题比直接抛异常恶心得多,尤其在订单、支付这类对数据准确性要求极高的系统里,一次“悄悄走错库”就足够引发线上事故。
1.2 根因:事务在数据源切换之前就“锁死”了连接
要理解这个问题,得抓一个核心矛盾:Spring事务一旦开启,Connection就被固定了;而@DS要切换数据源,本质上是切换获取Connection时的路由规则。这两个动作如果发生顺序不对,结果必然错乱。
我画一条时间线你就明白了。假设一个方法内部执行了以下操作:
- 方法被Spring AOP拦截,先看到
@Transactional注解,事务拦截器开始工作。 - 事务管理器调用
DataSource.getConnection(),从当前数据源拿一个数据库连接,并把它绑定到当前线程的TransactionSynchronizationManager资源里。 - 事务管理器把这个连接设置为手动提交模式,后续所有SQL都用这个连接执行。
- 终于轮到
@DS的切面执行,它往线程上下文里写入了目标数据源的key。 - 方法里的SQL执行时,虽然数据源路由规则已经变了,但事务管理器根本不会再调用一次
getConnection(),它只会从TransactionSynchronizationManager里取出第2步已经绑定的那个连接。
看到了吗?第4步做的事情太晚了。连接在第2步就已经被拿到并绑定了,后面切换路由key对当前事务来说毫无意义。
反过来,如果@DS切面先执行、事务切面后执行,事务管理器在拿连接的时候路由key已经设置好了,数据源切换就是成功的。所以这个问题的本质,是两个AOP拦截器的执行顺序决定的。
1.3 两个AOP拦截器的时序博弈
这里插一个基础概念。@Transactional和@DS本质上都是通过Spring AOP代理来工作的。
@Transactional由Spring的TransactionInterceptor处理,它被注册为BeanFactoryTransactionAttributeSourceAdvisor,事务管理器通过它来开启、提交、回滚事务。
@DS来自dynamic-datasource-spring-boot-starter框架,由DynamicDataSourceAnnotationAdvisor处理,核心逻辑是在方法执行前把注解里指定的数据源key写入DynamicDataSourceContextHolder这个ThreadLocal,方法执行完后清掉。
两个Advisor都在代理链上,但谁先执行谁后执行,取决于它们各自的Order值。Spring的规则是Order值越小,优先级越高,越先执行。
TransactionInterceptor的Order默认是Ordered.LOWEST_PRECEDENCE,也就是优先级最低。而DynamicDataSourceAnnotationInterceptor实现了Ordered接口,Order值非常靠前。从理论上说,在同一个方法上同时标注@DS和@Transactional时,@DS会先执行,先设置路由key,然后事务拦截器再开启事务,这种情况下事务拿到的连接确实是指定数据源的。
但实际情况远比这个复杂。比如:
- 如果外层方法已经开启了事务,内层方法再标
@DS,由于传播机制,内层方法直接加入外层事务,连接还是外层那个,内层@DS根本不会重新获取连接。 - 如果项目里有人自定义了事务相关的AOP切面,把Order改乱了,顺序就不可控。
- 如果你在自己实现的动态数据源里,切面顺序没有像dynamic-datasource那样处理得很靠前,同样会出问题。
所以,不是“一定不能用”,而是“能用但前提条件很苛刻,且坑特别多”。我在后面的实测部分会具体展示哪些场景能用、哪些场景必炸。
2. 两个注解的底层机制逐层拆解
2.1 @Transactional:事务管理器的“连接绑定”逻辑
先说@Transactional。它背后的核心组件是DataSourceTransactionManager,它的职责有三个:获取连接、绑定资源、管理事务状态。
关键代码在DataSourceTransactionManager.doBegin()里,大致逻辑如下:
@Override protected void doBegin(Object transaction, TransactionDefinition definition) { DataSourceTransactionManager.DataSourceTransactionObject txObject = (DataSourceTransactionManager.DataSourceTransactionObject) transaction; Connection con = null; try { // 1. 获取数据库连接 con = txObject.getConnectionHolder().getConnection(); // 2. 关闭自动提交 con.setAutoCommit(false); // 3. 把连接绑定到当前线程 TransactionSynchronizationManager.bindResource(getDataSource(), txObject.getConnectionHolder()); } catch (SQLException ex) { // ... } }注意第3步,这里绑定的是一个ConnectionHolder,里面封装了Connection。一旦绑定,后续在这个线程、这个事务范围内的所有数据库操作,都会从这个ConnectionHolder里拿同一个连接。
这里面还有个细节容易被忽略:事务管理器是通过DataSourceUtils.getConnection(dataSource)来拿连接的。如果这个dataSource是动态路由数据源,调用getConnection()时,动态路由数据源会先执行determineCurrentLookupKey()来决定到底从哪个物理数据源拿连接。
也就是说,决定走哪个数据库的时机,就在doBegin()里调用getConnection()的那一刻。这一刻之前,路由key必须已经设置好;这一刻之后,再改路由key,对当前事务已经无效了。
这就是为什么很多人在分析代码时觉得逻辑没问题,但实际运行结果就是错。因为分析代码的时候看的是“方法执行顺序”,而真正决定结果的其实是“连接获取时机”。
2.2 @DS:动态路由数据源的核心实现原理
@DS注解背后的核心是AbstractRoutingDataSource。这是Spring提供的一个抽象类,它本身不是一个真实的数据库连接池,而是一个路由器。
它的路由逻辑可以简化理解为:
public class DynamicRoutingDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { // 从线程上下文中取出当前方法上@DS指定的数据源key return DynamicDataSourceContextHolder.getDataSourceKey(); } }它的工作原理是这样的:
- 项目启动时,把多个真实数据源(主库、从库、统计库)注册到一个
Map<Object, Object>里,key是数据源名称,value是真实的DataSource。 - 每次调用
getConnection()时,AbstractRoutingDataSource会先执行determineCurrentLookupKey(),拿到一个key。 - 用这个key去Map里找到真实的数据源,然后返回真实数据源的连接。
而DynamicDataSourceContextHolder内部就是一个ThreadLocal:
public class DynamicDataSourceContextHolder { private static final ThreadLocal<String> LOOKUP_KEY_HOLDER = new ThreadLocal<>(); public static void push(String ds) { LOOKUP_KEY_HOLDER.set(ds); } public static String peek() { return LOOKUP_KEY_HOLDER.get(); } public static void poll() { LOOKUP_KEY_HOLDER.remove(); } public static String getDataSourceKey() { return LOOKUP_KEY_HOLDER.get(); } }还有配套的AOP切面,在方法执行前把注解里的key推入ThreadLocal,方法执行后移除。整体逻辑并不复杂,但它和事务的互动机制才是关键。
2.3 顺序错位导致的三种典型后果
把两个机制放到一起,我总结了顺序错位会导致的三种后果,对应三种常见的代码写法:
后果一:路由key设置太晚,连接已经绑定为默认数据源。
外层方法有@Transactional,内部方法调用了带@DS的另一个方法。由于事务传播机制,内部方法加入外层事务,连接在外层事务开启时已经拿到,@DS改了key也没用。这种代码在日志里看起来像是“切换了但没完全切换”,最迷惑人。
后果二:线程上下文被污染,影响后续请求。
如果@DS切面的清理逻辑没有执行,ThreadLocal里的key会残留。线程池复用时,下一个请求拿到的key可能是上一个请求的,导致数据源指向错误。这个在查询量大的接口上最容易出现,时好时坏,很难复现。
后果三:事务管理器拿到的是路由数据源,但不同数据源之间的关联已经丢失。
比如先往主库插入一条数据,然后切换到从库查询这条记录。由于主从之间可能有同步延迟,即使数据源切换成功,也查不到刚插入的数据。这本质上不是@DS的问题,而是事务和一致性模型的问题,但在多数据源场景里特别容易被误诊为@DS的Bug。
3. 四组实测场景:哪些组合能用,哪些组合是雷
为了搞清楚到底哪些写法能跑、哪些写法会翻车,我在本地搭了一套最小复现环境:Spring Boot 2.7 + dynamic-datasource-spring-boot-starter 3.5.2 + MyBatis-Plus + MySQL主从实例,模拟了4组典型场景,每组都反复验证了多次。
3.1 场景一:同一个Service方法上同时标@DS和@Transactional
代码示例:
@Override @DS("slave") @Transactional(rollbackFor = Exception.class) public void insertAndQuery() { // 插入一条数据 orderMapper.insert(new Order()); // 查询同一张表 Order order = orderMapper.selectById(1L); }实测结果:大多数情况下是能正常工作的,但我不建议你依赖这个行为。
为什么可以正常工作?因为dynamic-datasource的@DS拦截器Order值非常高,它会先于事务拦截器执行。所以先设置了路由key,事务拦截器再开启事务时,拿到的连接确实是从库连接。
为什么又不建议?原因有三点:
- 依赖拦截器顺序,一旦项目里有人加了自定义切面,把顺序打乱,问题就来了。
- 如果这个Service方法被另一个带
@Transactional的Service方法调用,外层事务已经绑定主库连接,这里的@DS("slave")就直接失效,而且没有任何报错。 - 可读性太差,后来人看不懂你到底是想让整个方法走从库,还是想让某个子逻辑走从库。
3.2 场景二:入口方法有事务,内部方法@DS切数据源
这是我在生产环境踩过的坑,也是最常见的错误写法。
代码示例:
@Override @Transactional(rollbackFor = Exception.class) public void processOrder() { // 主库写入订单 orderMapper.insert(order); // 调用统计服务,标记为从库查询 orderStatService.statOrder(); }orderStatService.statOrder()方法上标了@DS("statDb")。按直觉理解,processOrder是事务方法,走主库;内部statOrder是统计查询,应该走统计库。
实测结果:这个预期完全是错的。statOrder()里的SQL最终还是走了主库,因为内部方法触发了事务传播,直接加入了外层事务,使用的是外层事务已经绑定的连接。@DS("statDb")的拦截器虽然执行了,也往ThreadLocal里写了key,但这个key根本没有机会再去触发一次getConnection()。
这种写法的迷惑性最强:代码看起来逻辑清晰,实际执行却完全不是想象的样子。
3.3 场景三:@DS在入口,内部方法带@Transactional
代码示例:
@Override @DS("slave") public void queryWithTx() { userService.doSomethingInTx(); }userService.doSomethingInTx()方法上标了@Transactional(rollbackFor = Exception.class)。
实测结果:这个能正常工作。流程是:外层@DS("slave")先把路由key设置为slave,然后内部@Transactional开启事务时,事务管理器调用getConnection(),动态数据源根据ThreadLocal里的key选择了从库连接,事务绑定的是从库连接。
但这里有一个隐藏条件:外层的方法本身不能有事务。如果外层也被事务包裹,那就退化成场景二了。
3.4 场景四:事务方法内手动切换路由Key
有些人不用@DS注解,而是直接手动操作DynamicDataSourceContextHolder。
代码示例:
@Override @Transactional(rollbackFor = Exception.class) public void manualSwitch() { // 先走主库插入 orderMapper.insert(order); // 手动切到统计库 DynamicDataSourceContextHolder.push("statDb"); try { OrderStat stat = statMapper.selectOne(...); // ... } finally { DynamicDataSourceContextHolder.poll(); } }实测结果:这是踩得最惨的一版。第一个orderMapper.insert()确实走了主库,因为事务开启时路由key是null或默认主库。但是事务提交的时候出问题了:事务管理器要提交的是主库连接,而当前ThreadLocal里的key已经被手动切到了statDb,动态数据源在某些清理逻辑里会额外处理当前数据源,这时候会出现CannotGetJdbcConnectionException或者根本找不到主库连接的错误。
手工切库和事务混用的行为非常不可控,我强烈不建议在事务方法内部手动改ThreadLocal。
3.5 实测结论:什么情况会翻车
我把四组场景的实测结果整理成了一个表,方便你对照:
| 场景 | 写法 | 实测结果 | 风险等级 |
|---|---|---|---|
| 同一方法同时标@DS和@Transactional | @DS("slave")+@Transactional | 多数可用,依赖拦截器顺序 | 中 |
| 外层事务,内部方法@DS | @Transactional外层 +@DS内层 | 内层切库失效,走主库 | 高 |
| 外层@DS,内部方法事务 | @DS外层 +@Transactional内层 | 正常走目标库 | 低 |
| 事务方法内手动切key | @Transactional+ThreadLocal手动改 | 极易抛异常 | 很高 |
先记住这个结论,下面我给你一套在生产环境里不会翻车的使用规范。
4. 生产环境里怎么用才稳妥:方案与最佳实践
这一章是整篇文章的核心。前面讲了原理和翻车场景,这部分给出我在生产项目里经过验证的几种稳定方案,以及对应的代码模板,你可以直接抄。
4.1 方案一:明确事务边界,事务方法里不切库
这是我目前最推荐的做法:事务边界和数据源边界分开,不要重叠。
具体来说分两条规则:
- 一个事务方法只对应一个数据源。哪个库需要事务,就把这个方法放到对应数据源的Service里,不要在方法内部再切数据源。
- 查询类操作不强加
@Transactional,直接用@DS指定数据源即可。
代码模板:
// 主库写操作:只标事务,不标@DS @Service public class OrderWriteService { @Transactional(rollbackFor = Exception.class) public void createOrder(Order order) { orderMapper.insert(order); orderLogMapper.insert(new OrderLog(...)); } } // 统计库只读操作:只标@DS,不标事务 @Service public class OrderStatQueryService { @DS("statDb") public OrderStat getStat(Long orderId) { return statMapper.selectByOrderId(orderId); } } // 调用方:先写后读,不包事务 @Service public class OrderAppService { public void createOrderAndStat(Order order) { orderWriteService.createOrder(order); orderStatQueryService.getStat(order.getId()); } }这样做的好处是心智负担最小:看到@Transactional就知道这是个写库事务方法,看到@DS就知道是读方法,两者互不干扰。牺牲的是“写后立即读同一事务内一致性”,但从业务角度看,绝大多数读写分离场景都能接受这个延迟。
4.2 方案二:需要读“刚写的数据”时,把事务提交放在前面
有些业务确实有“写完立即查”的需求,比如创建订单后立刻查询订单详情返回给前端。如果主从之间有延迟,走@DS("slave")读从库可能读不到。
这时候我的做法是先让事务提交,再执行读操作,而不是把读操作塞进同一个事务里。
代码模板:
// 1. 先提交事务 orderWriteService.createOrder(order); // 2. 事务提交后再读,必要时强制走主库 OrderDetail detail = orderDetailService.getDetailFromMaster(order.getId());如果你的主从同步延迟很低,也可以直接走从库。但如果是那种延迟有可能到秒级的场景,更稳妥的做法是让“刚写完的查询”走主库,接受一部分读压力。这类业务通常频次不高,主库多扛几次查询根本没有压力。
4.3 方案三:用编程式事务代替声明式事务
如果你确实无法拆方法,必须在同一个方法里先写库再跨库查询,可以用TransactionTemplate,把事务边界控制得更精细。
代码示例:
@Service public class OrderService { private final TransactionTemplate transactionTemplate; public OrderService(PlatformTransactionManager transactionManager) { this.transactionTemplate = new TransactionTemplate(transactionManager); } public void processOrder(Order order) { // 阶段一:主库事务,只包写操作 Long orderId = transactionTemplate.execute(status -> { orderMapper.insert(order); return order.getId(); }); // 阶段二:事务已提交,再切到统计库查询 DynamicDataSourceContextHolder.push("statDb"); try { statMapper.selectByOrderId(orderId); } finally { DynamicDataSourceContextHolder.poll(); } } }这里的关键点是:transactionTemplate.execute()执行完,事务已经提交,连接已经释放归还。之后再去切数据源,没有任何历史包袱。这是我在“无法拆类”的场景下最顺手的方式。
4.4 方案四:真的需要跨数据源强一致事务时,引入分布式事务
如果业务强要求一个事务里同时操作两个库,并且要么都成功要么都回滚,那就不是本地事务能解决的事了。这时候得引入分布式事务框架,最常见的是Seata。
dynamic-datasource官方也提供了一个@DSTransactional注解,它和@Transactional的使用位置一样,但内部通过Seata接管事务,支持在同一个事务里切换多个数据源。大致配置方式:
<dependency> <groupId>com.baomidou</groupId> <artifactId>dynamic-datasource-spring-boot-starter</artifactId> <version>3.5.2</version> </dependency> <dependency> <groupId>io.seata</groupId> <artifactId>seata-spring-boot-starter</artifactId> <version>1.6.1</version> </dependency>使用方式:
@DSTransactional @DS("slave") public void crossDsTx() { orderMapper.insert(order); statMapper.updateStat(...); }@DSTransactional牺牲了部分性能和可用性,换取的是跨数据源的一致性。如果系统并发量不大,业务又强一致,可以接受这种方案。但我不建议一上来就上Seata,它带来的运维复杂度、参数调优成本都不低。先试试拆方法能不能满足业务,不行再上分布式事务。
4.5 我现在的固定写法
最后分享一下我在实际项目中总结的固定写法,已经稳定跑了两三年:
第一,写操作类方法上只放@Transactional,数据源通过类级别@DS("master")或者默认数据源来定,绝对不在事务方法内部再用@DS。
第二,读操作类方法上只放@DS("slave")或@DS("statDb"),绝不放@Transactional。
第三,一个Service类只操作一个数据源,如果类里既有写又有读,拆成两个类。这会让代码结构清晰很多。
第四,跨主库和统计库的组装逻辑放到上层Service,上层不带任何数据源注解,也不带事务注解,只负责编排。
这套写法的核心原则其实是:让每个方法只有一个维度要做的事,要么管数据源,要么管事务,绝不让孩子同时解两道题。代码清晰度提升之后,出问题的概率会大幅下降。
5. 排查套路与常见问题速查
5.1 从现象快速定位原因的速查表
我把多数据源场景里最常见的现象、原因和解决方式整理成了一个表,遇到问题可以先对着查:
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
标了@DS("slave")但SQL打到主库 | 外层已有事务,连接在事务开启时已被绑定为默认库 | 拆方法;把@DS放到无事务的外层方法;或者用编程式事务 |
同方法@DS+@Transactional,时好时坏 | AOP执行顺序不稳定,或者方法被其他事务方法调用 | 不要在同一方法混用;必要时用@DSTransactional |
| 方法抛异常但事务没回滚 | @Transactional所在方法被自调用(内部this调用),代理未生效 | 通过注入自身代理,或者把事务方法拆到另一个类 |
| 切换后的库报“表不存在” | 路由key被上一个请求的ThreadLocal残留污染 | 检查afterCompletion清理逻辑;手动调用poll() |
| 偶发性连接池无法获取连接 | 事务内多次切换数据源,导致Connection和路由key错乱 | 避免在事务内切库;清理ThreadLocal |
| 生产环境偶发、本地无法复现 | 线程池复用导致ThreadLocal脏数据跨请求 | 重点检查有没有遗漏finally清理;临时加大清理日志验证 |
5.2 排查流程:从日志、断点到关键类
如果你遇到的不是上面表格里的简单情况,我建议按下面的流程排查。
第一步,打开Spring的debug日志,重点看数据源切换部分的日志。dynamic-datasource通常会打印类似dynamic-datasource switch datasource to [slave]这样的日志,确认路由key有没有被设置、设置的时间点是什么。
第二步,在AbstractRoutingDataSource.determineCurrentLookupKey()方法处打断点,看执行时机——是在事务开启之前还是之后。这一步能直接定位到核心问题。
第三步,检查TransactionSynchronizationManager中绑定的资源。在事务方法执行过程中,手工调用:
Map<Object, Object> resourceMap = TransactionSynchronizationManager.getResourceMap();看里面绑定的是哪个DataSource,连接是从哪个库拿的。这个Maps里key是DataSource对象,value是ConnectionHolder。如果key显示的是主库DataSource,但ThreadLocal里的路由key已经改成slave,那基本可以断定是连接绑定时机的问题。
第四步,也是最容易忽略的一步:查调用链。别只看当前方法,往上查一查方法是被谁调用的,调用方有没有开事务。很多时候问题不在当前方法,而是外层方法已经把事务开好了。
5.3 几个容易忽略的细节
最后分享几个我在实战中容易踩到、但很多人不知道的细节。
第一个是@DS的类级注解。@DS可以标在类上,也可以标在方法上。方法上的优先级高于类上。但要注意的是,标在类上时,只对类里的public方法生效,如果类内部方法之间互相调用,AOP不会触发。
第二个是自调用问题。this.doXxx()这种写法,调用的是当前对象的方法,不是代理对象的方法,@DS和@Transactional都会失效。解决办法是注入自身代理:
@Autowired @Lazy private OrderService self; public void outer() { self.inner(); // 通过代理调用,AOP生效 }第三个是DynamicDataSourceContextHolder的清理时机。dynamic-datasource框架在AOP的afterCompletion阶段会做清理,但如果你的代码里手动push了key,一定要在finally里及时poll,否则高并发下ThreadLocal会串数据。
第四个是“事务传播行为”对数据源的影响。默认的REQUIRED传播会导致内部方法直接加入外部事务,连接共用。如果内部方法必须独立走另一个数据源,可以试试propagation = Propagation.REQUIRES_NEW,让内部方法挂起外层事务、新开一个事务。这时它拿到的连接才是根据当前路由key重新获取的。但REQUIRES_NEW有副作用:内部方法异常不会导致外部回滚(除非再抛出),而且会额外占用一个连接,不可滥用。
5.4 一个可以救命的排查“小工具”
线上问题不好打断点,也不好开日志,这时候我习惯在配置里临时加一个简单的切面,把每次数据源路由的结果打印出来:
@Aspect @Component @Order(Ordered.HIGHEST_PRECEDENCE) public class DataSourceRouteLogAspect { @Around("@annotation(com.baomidou.dynamic.datasource.annotation.DS)") public Object logRoute(ProceedingJoinPoint pjp) throws Throwable { String dsKey = DynamicDataSourceContextHolder.peek(); System.out.println("[DataSourceRoute] " + pjp.getSignature() + " -> " + dsKey); return pjp.proceed(); } }这个切面只做日志输出,不打乱原有拦截器顺序。配合SQL日志,能看到每次方法调用时的数据源路由情况,再和实际SQL执行库对比,问题定位效率能提升一大截。
多数据源本身不是一个复杂的技术,但加上事务之后,组合出来的行为就变得非常微妙。从我的经验看,90%的问题不是框架Bug,而是使用姿势不对。只要坚持“事务方法不切库、切库方法不包事务”这条原则,再配合编程式事务做兜底,多数据源就能稳到你几乎感觉不到它的存在。