尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Spring Boot多数据源与事务冲突:@DS和@Transactional怎么用才不踩坑

Spring Boot多数据源与事务冲突:@DS和@Transactional怎么用才不踩坑 先说一个我自己的真实经历。前几年做订单中心重构数据库拆了主库和统计库为了压测报表接口我在一个Service方法上同时加了DS(statDb)和Transactional结果诡异的事情出现了有时候数据能查到有时候查不到更离谱的是明明标了走统计库日志里打印的SQL却打在主库上。当时排查了大半天最后差点把责任推到DBA头上。后来把两个注解拆开问题瞬间消失。也就是从那次开始我把“多数据源事务”这个组合的底层逻辑彻底扒了一遍才发现这里面的坑比想象的深得多。如果你也正在用Spring Boot做多数据源并且代码里同时出现DS和Transactional这篇文章就是给你写的。我会把这个组合的底层原理、各种组合场景的实测结果、生产环境的稳妥写法以及问题排查套路全部讲清楚争取让你看完之后不再踩我踩过的坑。1. 为什么DS和Transactional放在一起就“打架”问题现场与根因分析1.1 典型现象不是报错而是“悄悄走错库”多数据源事务的问题最难排查的地方在于它往往不抛异常而是在你毫无察觉的情况下把SQL发到了错误的数据库上。我遇到过的典型现象大概有这么几类方法上标了DS(slave)但实际执行的SQL出现在主库的慢查询日志里。接口偶发性报错“Table xxx doesnt 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(); } }它的工作原理是这样的项目启动时把多个真实数据源主库、从库、统计库注册到一个MapObject, Object里key是数据源名称value是真实的DataSource。每次调用getConnection()时AbstractRoutingDataSource会先执行determineCurrentLookupKey()拿到一个key。用这个key去Map里找到真实的数据源然后返回真实数据源的连接。而DynamicDataSourceContextHolder内部就是一个ThreadLocalpublic class DynamicDataSourceContextHolder { private static final ThreadLocalString 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和TransactionalDS(slave)Transactional多数可用依赖拦截器顺序中外层事务内部方法DSTransactional外层 DS内层内层切库失效走主库高外层DS内部方法事务DS外层 Transactional内层正常走目标库低事务方法内手动切keyTransactionalThreadLocal手动改极易抛异常很高先记住这个结论下面我给你一套在生产环境里不会翻车的使用规范。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 groupIdcom.baomidou/groupId artifactIddynamic-datasource-spring-boot-starter/artifactId version3.5.2/version /dependency dependency groupIdio.seata/groupId artifactIdseata-spring-boot-starter/artifactId version1.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放到无事务的外层方法或者用编程式事务同方法DSTransactional时好时坏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中绑定的资源。在事务方法执行过程中手工调用MapObject, 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而是使用姿势不对。只要坚持“事务方法不切库、切库方法不包事务”这条原则再配合编程式事务做兜底多数据源就能稳到你几乎感觉不到它的存在。
返回列表