ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring @Transactional 事务失效场景与传播行为深度解析

Spring @Transactional 事务失效场景与传播行为深度解析 1. 没加 Transactional 的时候线上到底发生过什么1.1 一个让我半夜爬起来修数据的真实事故先讲一件真事。前几年我维护过一个订单中心服务核心逻辑是用户在 App 上下单后端要完成三件事——扣减库存、生成订单记录、给用户增加积分。三个操作分布在不同的 Service 方法里当时代码写得比较随意createOrder()方法里按顺序调用了stockService.reduce()、orderMapper.insert()、pointsService.add()方法上干干净净一个事务注解都没有。上线初期流量小一切正常。等有一天大促压测一开问题就炸了库存扣了订单表里没有记录或者订单生成了积分没到账。用户投诉截图一张接一张运营那边对着 Excel 手工补单补了整整两天。我半夜爬起来查日志发现是pointsService.add()里调了一个外部接口那个接口超时抛了异常导致后面的逻辑中断但前面已经执行完的库存扣减和订单插入却都已经提交到数据库了。这就是没加事务的典型后果方法里的多个数据库操作各自为战每个操作执行完就自动提交任何一步出错都不会回滚其他步骤。你可能觉得这是初学者才会犯的错但说实话我在不少生产环境的旧代码里都见过类似写法很多还是当初从别的同事手里接过来的。1.2 Transactional 到底帮我们做了什么Transactional注解的核心价值在于它把 Spring 的声明式事务管理能力绑定到一个方法上。你只需要在方法上方加一行注解Spring 就会在这个方法执行前开启事务方法内所有的数据库操作都纳入同一个事务上下文全部成功才统一提交任何一个 RuntimeException 冒出来就整体回滚。这里要澄清一个最常见的误区很多人以为Transactional是让方法支持事务其实它的准确含义是让方法内的所有操作原子化。区别在于前者让你以为事务是方法自带的默认属性后者才说清楚了事务边界在哪里——方法的入口是事务的开始方法的出口是提交或回滚的决定点。数据库层面的原理也值得说一句。Spring 的事务管理是基于数据库连接Connection的默认情况下Connection的 autoCommit 是 true也就是每条 SQL 执行完马上提交。Spring 在开启事务时会把这个连接的 autoCommit 临时改成 false等业务方法执行完毕再统一执行 commit 或 rollback。理解这一点后面很多诡异问题你都能自己推导出来。2. 传播行为你以为很简单其实它就是事务边界的规则2.1 7 种传播行为真正常用的就那几个Transactional注解里有个propagation属性用来定义事务的传播行为。这个东西听起来抽象但你可以把它理解成当一个带事务的方法调用另一个带事务的方法时两者的事务边界如何合并。Spring 定义了 7 种传播行为我直接说结论日常开发中绝大多数情况只需要关注这几种传播行为含义使用频率REQUIRED如果当前有事务则加入没有则新建最常用REQUIRES_NEW无论如何都挂起当前事务新建一个独立事务偶尔用NESTED嵌套事务内层回滚不影响外层已提交的少用SUPPORTS有事务则加入没有就以非事务方式执行少用NOT_SUPPORTED以非事务方式执行挂起当前事务极少用MANDATORY必须有事务否则抛异常几乎不用NEVER必须没有事务否则抛异常几乎不用REQUIRED是默认值。绝大多数业务场景比如创建订单、更新用户资料、批量导入数据你只需要保持默认就行。两个方法 A 和 B 都是 REQUIREDA 调用 B那 B 会直接加入 A 的事务共用一个事务连接同生共死。这也是最容易理解的协作模式。2.2 REQUIRES_NEW 的典型场景日志不能跟着业务一起回滚REQUIRES_NEW是我在实战中第二个用得多的传播行为。它的语义是调用方的事务先挂起被调用方法自己新建一个独立事务两者互不干扰。被调用方法提交了即使调用方后面回滚了它也不受影响。最典型的场景是操作日志记录。我在一个支付系统里遇到过这么个需求支付成功后需要写一条审计日志不管后续业务流程怎么失败这条日志都必须留下来。如果日志方法和业务方法共用同一个 REQUIRED 事务业务一失败回滚日志也没了审计等于白做。把日志方法改成REQUIRES_NEW后各管各的日志稳稳落库。还有消息发送场景也类似订单状态更新失败要回滚但通知管理员的消息必须发出去。这类需求用REQUIRES_NEW是合理的但也要清楚代价——被挂起的事务持有数据库连接而连接池里的连接是有限的。如果外层事务很久不结束内层事务又频繁创建连接池可能被耗尽。我见过因为在高并发路径上滥用 REQUIRES_NEW导致连接池活跃连接数飙升到上限整个服务雪崩的案例。2.3 NESTED 和 REQUIRES_NEW 的区别90% 的人说不清很多文章把这两个混为一谈实际上它们完全不同。NESTED是嵌套事务底层利用数据库的 savepoint保存点机制内层事务回滚时只回滚到 savepoint 的位置不影响外层事务已经执行的操作。但内层事务并不是独立提交的它依然跟随外层事务统一提交。也就是说NESTED 是整体提交、局部回滚REQUIRES_NEW 是各自提交、互不影响。同样拿日志场景举例用 NESTED 的话如果外层后续真的抛异常回滚了内层那个 savepoint 之后的日志照样也会被回滚掉。所以如果你的需求是无论如何日志都要留下NESTED 不行只有 REQUIRES_NEW 能满足。如果你的需求是批量处理一批数据其中某一条失败时只回滚这一条不影响其他条那 NESTED 比 REQUIRES_NEW 更合适因为外层事务的公共服务比如更新批次状态不会跟着某一条数据的失败而回滚。3. 那些让你怀疑人生的 Transactional 失灵场景3.1 this 调用同一个类里的方法互调事务直接失效这应该是 Transactional 翻车率最高的一个坑。代码看起来没什么问题Service public class OrderService { public void createOrder(Order order) { // 业务逻辑 updateStock(order); insertOrder(order); } Transactional public void updateStock(Order order) { stockMapper.reduce(order.getProductId(), order.getCount()); } Transactional public void insertOrder(Order order) { orderMapper.insert(order); } }updateStock和insertOrder上都标注了Transactional但调用方是同一个类里的createOrder方法这段代码不会开启任何事务。原因在于 Spring 事务是通过 AOP 动态代理实现的。Spring 容器里实际放着的不是你的OrderService实例而是一个代理对象。当外部调用orderService.createOrder()时调用的是代理对象的方法代理逻辑里先开启事务再调用真实目标对象的方法。但当createOrder()方法内部通过this.updateStock()调用时这个this指向的是原始目标对象根本不是代理对象所以直接绕过了代理逻辑Transactional自然就不生效。解决方案有几种我按推荐程度排序拆分 Service把需要事务的方法放到另一个 Service 类里注入进来再调用这是最干净的做法。注入自身在类里注入OrderService自己用self.updateStock()调用让调用路径经过代理。用 AopContext 获取代理对象通过((OrderService) AopContext.currentProxy()).updateStock(order)调用需要额外配置exposeProxy true。用 TransactionTemplate在方法内部用编程式事务包住需要原子操作的代码这个我后面细说。3.2 方法不是 public事务同样不生效Transactional只对public 方法生效这是 Spring 官方文档写死的规则。放在 private、protected 或者包级可见的方法上时Spring 不会报错但会无提示地忽略掉。为什么因为 Spring AOP 的默认代理方式是 JDK 动态代理它只能代理接口方法即使你用 CGLIB 代理它也是通过生成子类来覆写方法private 方法无法被覆写自然无法拦截。所以你在 private 方法上加注解相当于写了一句注释。这个坑尤其容易出现在把公共逻辑抽到私有方法里再统一加事务这类重构中。我自己也踩过当时把批量保存的逻辑抽到一个 private 方法里看方法上没加注解总觉得不踏实就补了一个Transactional还跟同事说这块肯定是事务保护的。结果后来某条数据落库失败前面的数据却都在排查了半天才发现注解放在 private 方法上根本没起作用。3.3 吃掉了异常事务怎么可能回滚另一个高频问题事务方法内部自己 try-catch 了异常异常没抛出去Spring 根本不知道出错了于是事务就正常提交了。Transactional public void transfer(Account from, Account to, BigDecimal amount) { try { accountMapper.decrease(from.getId(), amount); accountMapper.increase(to.getId(), amount); } catch (Exception e) { log.error(转账失败, e); // 没有重新抛出异常——事务认为一切正常 } }这段代码的问题在 catch 块里异常被吞掉了Spring 事务监听器无法感知异常事务最终以 commit 收场。结果是钱扣了但没到账数据就脏了。正确的做法是如果确实需要捕获异常做额外处理处理完之后一定要重新抛出 RuntimeException。比如记录日志后throw new RuntimeException(e)。如果你既想拿到异常做业务处理又不想因此回滚整个事务可以考虑在 catch 里调用一个REQUIRES_NEW的方法去记录错误信息然后再重新抛出异常。3.4 checked 异常默认不回滚这件事要知道Spring 默认只对RuntimeException和Error进行回滚对于 checked 异常比如IOException、SQLException这种必须捕获或声明的异常默认不回滚。这个设计思路是Spring 认为 checked 异常通常代表可以恢复的业务预判错误不应该直接让事务回滚而运行时异常才代表不可预期的严重问题。但实际开发中很多业务异常就是自定义的 checked 异常。你需要在注解上显式声明Transactional(rollbackFor Exception.class)甚至更精确一点Transactional(rollbackFor BizException.class)rollbackFor Exception.class是很多公司规范里的标配因为它的语义是任何异常都回滚基本符合绝大多数业务诉求。不过也别无脑堆线程池里处理任务时如果异常被包装成 checked 异常而你声明的回滚规则不匹配照样不会回滚这点在写异步任务时要留意。4. 事务失效之外隔离级别和锁才是真正的隐雷4.1 隔离级别不是 SQL 课上的概念它会直接影响你的订单金额Transactional的isolation属性对应数据库的四种隔离级别。在 Spring 中默认用的是数据库自己的默认隔离级别MySQL InnoDB 默认是REPEATABLE_READPostgreSQL 和 Oracle 通常是READ_COMMITTED。如果你没改配置Spring 不会主动改这一点很多人存在误解。四种隔离级别从松到严排列如下READ_UNCOMMITTED能读到别的事务未提交的数据脏读风险最高几乎不用。READ_COMMITTED只能读到已提交的数据解决脏读但存在幻读问题。REPEATABLE_READ同一事务内多次读取结果一致解决脏读和不可重复读但依然可能幻读MySQL InnoDB 通过间隙锁基本解决了幻读。SERIALIZABLE事务串行执行性能代价最大基本不在高并发业务里用。你可能会问那我代码里该怎么选我的经验是绝大多数业务用数据库默认值就够了不要轻易往上调。事务隔离级别的调整意味着更多的锁竞争和更差的并发性能。如果是因为某个特定场景需要防止并发问题优先考虑用行锁、乐观锁、唯一索引等方式去解决而不是一刀切提升隔离级别。4.2 事务 行锁注意锁的释放时机事务和锁是紧密相关的。MySQL InnoDB 的行锁在事务提交或回滚时才释放所以一个事务如果持有某些行的锁它执行的时间越长其他事务等锁的时间就越长。在 Transactional 方法里做耗时操作比如调远程接口、睡几秒、循环大量计算都会长时间占用行锁进一步拖慢所有涉及同一行数据的请求。我记得有个项目用户在订单详情页点了确认收货后端在事务里发了一条短信验证码、调用了物流查询接口、还往消息队列里塞了一条消息请求平均耗时 3 秒。用户如果快速连着点两次第二个请求就会卡在行锁上直到第一个事务超时。后来把短信、物流查询、消息推送全部挪到事务外面事务里只剩两个数据库 UPDATE接口耗时就降到了 50 毫秒以内。这是一个很值得记住的优化原则事务越短越好事务里不要写任何和数据库无关的 IO 操作。4.3 自增主键回填和批量插入事务方法的隐藏边界问题看一个我实际处理过的场景。用 MyBatis Plus 做批量插入时如果是在事务方法里一批一批地 insert每批之间做了大量非数据库校验逻辑整批操作都会在事务提交时才真正写盘。有些人会在同一个事务里先 select count 再判断再 insert本意是防止并发重复插入但 REPEATABLE_READ 级别下同一个事务多次 select结果可能一致MVCC 快照读根本读不到其他事务新插入的数据。这时候你的查重逻辑其实是自欺欺人。正确的思路是用数据库唯一索引做最终防线让数据库在违反唯一约束时抛异常再由事务回滚。而不是自己先用 select 去判断再插入。这一点我在高并发下单场景里吃过亏写出来给各位避雷。5. 实操配置从注解参数到事务管理器5.1 最常用的几个注解参数我挑实战中最重要的几个参数说Transactional( propagation Propagation.REQUIRED, isolation Isolation.DEFAULT, timeout 10, rollbackFor Exception.class, readOnly false ) public void updateOrder(...) { // ... }timeout事务超时时间秒超过就抛异常并回滚。默认没有超时等于依赖数据库自己的锁等待超时。高并发交易类接口我建议至少设一个超时避免死锁长时间占用连接。readOnly true标记为只读事务。它并不强制数据库只读但有两点作用一是给数据库一个提示可以对其做只读优化二是在某些数据库比如 MySQL上会优化掉加锁逻辑降低锁竞争。注意如果你在只读事务里执行了 insert/update/deleteSpring 不一定会阻止你——实际上很多数据库照样执行成功别对它有太强的拦截期待。rollbackFor指定哪些异常触发回滚这个前面说过生产规范里直接用Exception.class最省心。noRollbackFor指定某些异常即使抛出也不回滚。我用得不多但有一种场景某个异常发生后你希望事务继续并且后续代码还有机会补数据。这种情况我会用 REQUIRES_NEW 的内层事务去补或者提前把数据状态调整好再抛出可忽略的异常。5.2 自定义事务管理器什么时候有必要Spring Boot 在DataSourceAutoConfiguration和TransactionAutoConfiguration里会自动配置好一个DataSourceTransactionManager针对单一数据源你基本不需要手动创建。但一旦涉及多数据源、JTA 分布式事务现在一般用 Seata 之类替代或者某些特殊的数据库连接池需要定制你才需要自己注册事务管理器。Bean public PlatformTransactionManager transactionManager(DataSource dataSource) { DataSourceTransactionManager manager new DataSourceTransactionManager(); manager.setDataSource(dataSource); manager.setDefaultTimeout(10); manager.setRollbackOnCommitFailure(true); return manager; }setRollbackOnCommitFailure(true)这个参数容易被忽略。提交阶段如果发生异常比如网络中断导致提交请求没到达数据库设置这个参数可以确保此时依然尝试回滚否则可能出现方法以为提交成功了实际数据库没收到的不一致。在交易链路里我建议开启。5.3 Spring Boot 4.x 的配置变化别被旧资料带偏标题热词里出现了 spring boot 4.x 找 DataSourceAutoConfiguration 的问题。Spring Boot 确实在持续调整自动配置类的包名和结构。在新版本里事务相关的自动配置类位置和默认行为可能有变化最稳妥的做法是遇到配置类找不到时先去对应版本的官方文档确认类名不要依赖旧文章里的全限定类名硬编码。Spring 对事务的核心理念没有变变的更多是自动装配的路径和组织方式。6. 事务方法不被外部调用怎么保证原子性6.1 如果事务只保护一个 Service 方法别忘了入口调用链有些项目里引入 Transactional 后发现事务像是没生效排查到最后发现事务方法是被 Controller 直接调用没错但 Controller 内部先调了一个不带事务的方法比如做参数组装那个方法里又调了另一个 Service 的事务方法。这时候事务边界其实只在被调用的 Service 方法内部Controller 里其他非事务操作不会跟它绑定。这种事务边界不清晰常常会带来一种错觉你以为整个请求都是原子的实际上只有那一小块是原子的。比如用户注册流程先插入用户表再插入账户表再存一个初始优惠券模板到用户模板表。如果你把三个操作分散在三个 Service 方法的调用链上只有其中一个是 Transactional那么第三个操作失败时前面两个已经提交了。为了让整个注册流程原子化你应该在入口方法上加 Transactional让所有内部操作都加入同一事务。6.2 编程式事务什么时候它比注解更靠谱注解方式方便但也有不好用的地方。比如你想在某个循环里每处理一条数据就开启一个新事务或者想事务内部根据业务结果动态决定提交还是回滚——这种场景用注解写着就拧巴。这时我推荐用TransactionTemplateService public class BatchImportService { private final TransactionTemplate transactionTemplate; public BatchImportService(PlatformTransactionManager transactionManager) { this.transactionTemplate new TransactionTemplate(transactionManager); this.transactionTemplate.setTimeout(30); this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); } public void importBatch(ListRow rows) { for (Row row : rows) { try { transactionTemplate.execute(status - { insert(row); return null; }); } catch (DataIntegrityViolationException e) { log.error(单条导入失败继续处理下一条: {}, row.getId(), e); } } } }这种每条记录一个独立事务的写法在批量导入场景非常实用。某一条出问题只会回滚这一条不影响其他记录。用注解做这个事会很别扭因为你需要写一个单独的方法再通过代理调用而且异常传播满了你很难在循环里精细控制。TransactionTemplate 还有一个好处它不依赖代理所以不存在 this 调用失效的问题。7. 从表象到本质事务日志、监控和验证手段7.1 如何确认事务真的生效了很多人加了注解心里没底不知道事务到底开没开。其实验证手段很简单分两步走第一步在配置文件里开启 Spring 事务的日志输出logging.level.org.springframework.transaction.interceptorDEBUG logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManagerDEBUG日志里会出现类似这样的内容Getting transaction for [com.example.service.OrderService.createOrder] Completing transaction for [com.example.service.OrderService.createOrder]如果只有方法进出栈的 Getting 和 Completing说明事务确实被拦截了。如果你发现这些日志根本没出现那基本可以断定注解没有生效优先检查是不是 this 调用或者方法可见性问题。第二步更粗暴也更有说服力的做法在事务方法里故意抛一个 RuntimeException看看之前 insert 的数据是否回滚。测试环境这么干最直接别在生产上试就行。7.2 事务结合 MyBatis最容易出现的执行顺序问题在 Spring Boot MyBatis 的项目里Transactional 和 MyBatis 的执行时机关联紧密。MyBatis 操作的每个 Mapper 方法都会从当前事务上下文中拿到连接多个 Mapper 方法在同一个事务里共享同一个连接。这个连接在哪里Spring 用 ThreadLocal 保存了事务资源所以你可以在一个事务方法里依次调用多个 mapper 方法它们拿到的都是同一个连接SQL 都排在同一个事务里。这里我踩过一个坑在事务方法里查询数据拿到的结果不是最新的。原因是同一个事务里第二次查询走的是快照读读不到其他事务在该事务启动后提交的数据。这在报表统计、计数类场景里尤其容易让人困惑。解决方法是如果确实要实时读最新数据在事务外先查一次或者把查询拆到独立事务REQUIRES_NEW里执行或者使用 SELECT ... FOR UPDATE 走当前读。7.3 线上排查事务超时的真实思路事务超时或者长时间未提交在线上怎么快速定位我的经验是结合数据库侧的状态来查。MySQL 里执行SELECT * FROM information_schema.innodb_trx;可以查到当前运行的事务、trx_started事务开始时间、trx_state、trx_mysql_thread_id 这些关键信息。如果某个事务的trx_started很早说明它已经存活了很久很可能是事务方法里的某个远程调用卡住了或者代码里出现了未提交但因为异常被吞而一直开着的事务。再配合SHOW PROCESSLIST看看对应线程当前执行的 SQL 是什么基本就能锁定是哪个业务方法的问题。我之前排查过一次就是一个加了 Transactional 的异步任务内部调了一个第三方报表接口对方接口 5 分钟没响应事务连接一直被占用连接池被拖垮进而影响了这个连接池上所有其他正常请求。后来那个异步任务整体改造成了线程池隔离数据库操作单独用独立事务包裹第三方调用全部放到事务之外才解决。7.4 事务方法内到底能不能做远程调用我的结论关于事务方法内部能不能做远程调用这个问题网上的说法两极分化。我的实际经验是不要做除非你有非常充分的理由并且做好了超时和降级。远程调用的不确定性网络超时、对方服务重启、消息堆积会直接拉长事务时间继而影响所有竞争同一把锁的请求。上面说的行锁就是这么被拖死的。如果业务上必须在同一个链路里完成更新和通知推荐的做法是先提交事务再发消息。比如用 Spring 的TransactionSynchronizationManager.registerSynchronization()在事务提交后通过afterCommit回调去发送 MQ 消息或调用远程接口。这样既保证了数据一致性又避免了长事务。Transactional public void updateOrderStatus(String orderId, String status) { orderMapper.updateStatus(orderId, status); TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { mqSender.sendOrderStatusChanged(orderId, status); } }); }这个模式在订单状态变更、支付回调、库存变动等场景都很实用核心思想是先保证本地数据对再通知别人比把远程调用塞进事务里健壮得多。8. 最后想聊的事务编码的三个思维层次写事务代码这么多年我最大的体会是Transactional 只是入口真正决定系统稳定性的是你对事务边界的理解和对并发模型的认识。第一层是语法层。知道注解怎么加、参数怎么配、哪些情况会失效这属于会写。第二层是规划层。设计一个业务接口时先想清楚哪些操作必须绑定成一个原子单元哪些可以独立提交哪些应该放在事务外面。规划得好很多线上问题根本不会出现。第三层是治理层。事务和连接池、线程池、消息队列、分布式锁配合使用时你能否判断瓶颈出在哪一环。比如连接池耗尽时是先查数据库连接数还是先看日志事务回滚后下次重试的幂等性怎么保证这些只有踩过坑才懂。我个人尤其推荐团队里立几条硬性规范所有 Transactional 方法的复杂度控制在方法体内不超过 5 条数据库操作且没有远程调用。开启事务的方法全部设置 rollbackFor Exception.class。批量导入、消息消费者的单条处理用 TransactionTemplate 而不是注解控制事务粒度。每次 code review 时重点关注事务方法的调用链上有没有 this 调用、有没有 try-catch 吞异常。最后再分享一个小技巧在你觉得事务行为很诡异、怎么调试都不对的时候先把 Spring 事务日志级别调到 DEBUG看一眼Getting transaction和Completing transaction的配对情况。大多数事务问题的答案其实都藏在这两行日志中间的代码里。
返回列表