资讯详情

Spring事务完整解析:@Transactional失效场景与排查技巧

📅 2026/10/16 13:04:08 | 华诺云谱 👁 阅读
Spring事务完整解析:@Transactional失效场景与排查技巧
做Java后端开发的Spring事务可以说是绕不开的一道坎。平时加个Transactional注解好像很轻松但真到了线上出问题事务不生效、回滚没触发、死锁频繁这些坑能让你排查到怀疑人生。我自己就踩过大坑某转账服务里一个方法内部调用了同类中另一个被事务修饰的方法结果自调用绕过了代理整个事务静默失效一次批量操作跑到一半数据全部错乱最后花了一整个下午才定位到根因。那次之后我把Spring事务的原理和所有失效场景系统过了一遍这篇就把整块内容完整拆开讲。不管你是刚入行的新手还是已经写过几年业务的开发者这里应该都有值得看的东西。1. 为什么需要事务从一次转账事故说起1.1 没有事务时数据是怎么悄悄变乱的先想想最简单也最经典的场景转账。A账户要给B账户转100块在数据库里对应的就是两条UPDATE语句UPDATE account SET balance balance - 100 WHERE id A; UPDATE account SET balance balance 100 WHERE id B;如果第一条执行成功、第二条执行失败账面上A少了100B却没多出这100钱凭空消失了。没有事务的情况下这种数据不一致就是常态。有了事务这两条操作就被绑定在同一个原子操作里要么全部成功要么全部失败。失败时数据库会回滚到执行前的状态A账户的钱原封不动。这听起来很基础但很多人在实际写代码时恰恰会把这件事忽略掉。比如有些同事习惯在Service方法里一条条地操作数据库中间表某个步骤失败就抛个异常但前面已经UPDATE或者INSERT的数据并不会自动撤销——因为Spring不会因为你抛了个异常就神奇地帮你回滚除非你正确地使用了事务。从我接触过的项目来看事务没生效很多时候根本不是事务框架的问题而是代码从一开始就没把事务边界设计对。边界设置得太宽无关操作全被裹进事务性能变差边界设置得太窄真正该绑定的操作被拆成了两次独立提交数据照样会乱。理解事务的第一步是想清楚哪些操作必须绑定在一起。1.2 ACID四个特性到底保护了什么事务的四大特性大家应该都背过原子性Atomicity、一致性Consistency、隔离性Isolation、持久性Durability合起来就是ACID。这里不照着教科书念定义而是用实际场景说清楚每个特性在保护什么。原子性保护的是要么全做要么全不做一致性保护的是业务规则不被破坏比如余额不能为负数、总账必须守恒隔离性保护的是多个事务同时跑时互不干扰避免读到脏数据持久性保护的是事务一旦提交数据就永久落盘不会因为进程崩溃丢数据。从开发者的角度看Spring事务帮我们解决的核心是原子性和隔离性这两块的编排问题。持久性是数据库底层存储引擎负责的一致性很大程度要靠业务代码自己保证比如转账金额不能为负的校验逻辑。理解这一点很重要很多人以为加了Transactional就万事大吉其实事务只能保证操作要么全成功要么全失败以及并发隔离你的业务校验逻辑该写还是得写该加唯一约束还是得加代码里的bug不会因为事务而消失。我见过一个典型的反面案例某新人以为有了事务就不再需要SQL层面的唯一索引结果并发下单时同一商品被卖了两次。事务保证的是两条SQL要么都成功要么都失败但两个并发事务各自成功并不会互相感知唯一约束和锁才是防住这种问题的关键。事务不是万能药它只是数据一致性拼图里的一块。2. 编程式事务与声明式事务到底怎么选Spring事务管理提供了两种风格编程式事务和声明式事务。很多人一上来就直接用Transactional但有时候编程式才是更合适的解法有必要把两者的差异讲清楚。2.1 编程式事务TransactionTemplate的正确用法编程式事务的核心是TransactionTemplate这个类把事务管理的模板方法封装好了你只需要在回调里写业务逻辑Service public class TransferService { private final TransactionTemplate transactionTemplate; public TransferService(PlatformTransactionManager transactionManager) { this.transactionTemplate new TransactionTemplate(transactionManager); } public void transfer(String from, String to, BigDecimal amount) { transactionTemplate.execute(status - { accountDao.decreaseBalance(from, amount); accountDao.increaseBalance(to, amount); return null; }); } }TransactionTemplate最有价值的地方在于可以精确控制事务边界。事务从execute方法开始到回调执行完提交或抛出异常回滚边界非常明确。你可以在一个循环里对每一批数据分别开启事务也可以只把真正需要事务的两行代码包进去而不是把整个方法都裹在事务里。我在处理批量任务时经常遇到一种情况一个方法要处理一万条数据总不能一万条包在一个大事务里跑吧要么频繁提交导致性能差要么一个大事务占着数据库连接和锁其他请求全被卡住。这时候用TransactionTemplate就特别灵活每处理一条单独提交一次或者每100条提交一次完全由自己掌握。2.2 声明式事务Transactional背后的代理原理声明式事务就是最常见到的Transactional注解。Spring通过AOP在目标方法执行前开启事务方法正常返回时提交事务方法抛出异常时回滚事务。开发者不需要关心事务开启和关闭的代码只需要在方法或类上标注注解即可。Service public class OrderService { Transactional public void createOrder(Order order) { orderMapper.insert(order); inventoryMapper.reduceStock(order.getProductId(), order.getCount()); } }声明式事务的最大优点是侵入性低、代码简洁业务代码里看不到任何事务管理的痕迹可读性比编程式好太多。日常业务开发中90%以上的场景用Transactional就够了这也是它成为主流选择的原因。但理解声明式事务必须要理解它背后的代理机制。Spring容器启动时会为带有Transactional的Bean生成一个代理对象外部调用这个Bean的方法时实际调的是代理对象的方法。代理对象先开启事务再调用你写的真实方法最后根据结果提交或回滚。这个机制解释了后面要讲的一系列失效场景为什么私有方法不行、为什么自调用不行、为什么final方法不行——因为它们都无法让代理对象介入。Spring的代理有两种实现方式。JDK动态代理要求目标类实现接口只代理接口中声明的方法CGLIB是通过生成目标类的子类来实现代理不要求实现接口。Spring Boot 2.x之后默认使用CGLIB方式。理解了代理方式你就明白了为什么被Spring管理的类如果用了final事务就可能失效——CGLIB生成的子类无法覆盖final方法。2.3 一张表看懂两种方式的适用边界我把两者的差异整理成一张表方便选型时对照维度编程式事务声明式事务使用方式TransactionTemplate.execute()Transactional注解事务边界精确可控方法级颗粒度较粗代码侵入业务代码包在回调里有一定侵入无侵入加注解即可动态控制可在逻辑中动态决定提交点行为固定适用场景批量任务、循环内事务、复杂边界常规单体接口、简单业务方法这里有一个容易被忽略的点Transactional的事务边界是整个方法。如果方法里有循环、远程调用、文件IO这些耗时的内容全都会被包在同一个事务里。而编程式事务可以把边界精确缩到最小这正是大事务优化时的一个重要手段。所以别把Transactional当成唯一的选择该用TransactionTemplate的时候就要果断用。3. 事务核心参数详解传播、隔离、回滚Transactional注解有一堆属性很多人从来没用过但遇到并发和嵌套场景时这些属性才是解决问题的关键。3.1 传播行为嵌套调用时的事务组织方式事务传播行为解决的核心问题是两个被Transactional修饰的方法互相调用时它们的事务该怎么组织。Spring在Propagation枚举里定义了7种行为我挑最常用的三种重点讲。REQUIRED是默认值也是最常用的。规则是如果当前已经有事务就加入当前事务如果没有就新建一个事务。简单说就是有就蹭没有就开。大部分业务场景下想要的就是这个效果外层方法和内层方法在同一个事务里任何一个环节出错整个一起回滚。REQUIRES_NEW的含义是无论如何都开一个新事务。即使外层已经有一个事务在运行内层方法也会挂起外层事务、开启一个新事务新事务独立提交或回滚。这个行为适合什么场景比如下单成功后要写操作日志日志写失败了不应该导致下单回滚这时候就可以把写日志的方法标记为REQUIRES_NEW让日志提交独立于主事务之外。NESTED是嵌套事务它利用数据库的savepoint保存点机制在当前事务内部创建一个子事务。子事务回滚时只会回滚到保存点而不影响外层事务的其余操作。效果有点像部分回滚外层事务里A操作成功、B操作失败只回滚BA的结果可以保留最终整个事务再决定是否提交。不过要注意NESTED依赖数据库的savepoint支持并不是所有数据库和所有隔离级别下行为都一致。其他几个行为一句话带过SUPPORTS是有事务就加入、没有就无所谓MANDATORY是必须要有事务没有就抛异常NOT_SUPPORTED是当前方法以非事务方式执行有事务就先挂起NEVER是当前方法必须不在事务里执行有事务就抛异常。这几个用得少知道含义就行。3.2 隔离级别并发读写下的可见性选择隔离级别解决的是多个事务同时读写同一条数据时能看到什么、不能看到什么的问题。SQL标准定义了四个级别隔离级别脏读不可重复读幻读READ_UNCOMMITTED可能可能可能READ_COMMITTED不会可能可能REPEATABLE_READ不会不会可能SERIALIZABLE不会不会不会先解释三个概念。脏读是读到另一个事务未提交的数据别人回滚了你就读了个假的不可重复读是同一个事务里两次读同一行结果不一样因为中间被别的事务改了幻读是同一个事务里两次查询同一个范围结果行数不一样因为中间被别的事务插入或删除了行。Spring的Transactional里有一个isolation属性可以指定隔离级别Transactional(isolation Isolation.REPEATABLE_READ) public void someMethod() { // ... }但有一个关键点很多人都不知道Spring默认的隔离级别是DEFAULT也就是不主动设置完全由底层数据库决定。比如MySQL默认是REPEATABLE_READOracle默认是READ_COMMITTED。这意味着你在MySQL上写的代码如果假设了某种隔离级别换到Oracle上行为就可能不一样。所以线上要追求严格的行为一致性最好显式指定isolation属性。从实际开发角度我建议除非有明确需求否则不要轻易上调隔离级别。SERIALIZABLE虽然最安全但性能代价非常大事务基本串行执行并发量一大就是灾难。真正该做的是通过业务设计减少锁冲突通过索引减少锁范围通过乐观锁版本号替代数据库悲观锁。举个例子库存扣减如果每次都锁行高并发下必然排队改成UPDATE stock SET count count - ? WHERE id ? AND count ?这种条件更新配合受影响行数判断往往比单纯抬高隔离级别更高效。3.3 回滚规则与readOnly、timeout等属性Transactional最容易被误解的地方就是回滚规则。默认情况下Spring只对RuntimeException和Error进行回滚受检异常不会触发回滚。Transactional public void createAccount(Account account) throws Exception { accountDao.insert(account); throw new Exception(something wrong); }上面这段代码里insert会正常执行并提交异常不会导致回滚。很多新手在这里踩坑踩得莫名其妙明明方法抛异常了为什么数据还是写进去了原因就是默认只回滚RuntimeException。这个设计的原因在于Spring认为受检异常通常是业务上可以预期并处理的情况调用方捕获后可能还能继续完成业务流程所以默认不回滚而运行时异常通常表示程序出现了未预期的错误继续提交数据很危险所以默认回滚。解决办法是显式指定rollbackForTransactional(rollbackFor Exception.class) public void createAccount(Account account) throws Exception { accountDao.insert(account); throw new Exception(something wrong); }还有一种反向场景某个运行时异常你希望它不影响事务提交比如业务上有失败了也要把状态更新进去的需求这时候可以用noRollbackFor来排除特定异常。另外还有两个常用属性readOnly和timeout。readOnly true表示当前事务为只读事务。它并不是严格禁止写入而是一个优化提示Spring和某些数据库比如MySQL在特定配置下可以根据这个提示做优化。比如Hibernate在只读事务下可以跳过脏检查提升性能。但如果你的方法确实有写操作却标了readOnly可能不会报错只是事务表现和预期不一致所以标注时要谨慎。timeout属性用于设置事务的超时秒数超过时间还没提交事务会被强制回滚并抛出异常。这个参数可以在极端情况下避免事务长期悬挂比如某个方法被慢SQL拖住时。我给超时设置的一个参考值普通接口5到10秒批量任务可以适当放宽但一定不能不设。关于回滚规则我的建议是所有写事务注解的地方都要形成条件反射一样地问自己一句——这个方法会抛受检异常吗会不会需要显式指定rollbackFor养成这个习惯可以帮你避开一大半的事务不回滚问题。4. 从配置到代码完整实操与失效场景清单理论讲完了接下来是实战。从Spring Boot下的基础配置开始到一个完整的转账业务Demo再给你一份排查事务失效的完整清单。4.1 Spring Boot环境下的基础配置在Spring Boot里事务的基本配置几乎是零成本的。只要你引入了spring-boot-starter-data-jpa或者spring-boot-starter-jdbc并且正确配置了数据源Spring Boot的自动配置机制会自动帮你创建PlatformTransactionManager。对于单数据源场景默认就是DataSourceTransactionManager。如果你用的是Spring Boot之外的传统Spring MVC项目需要显式配置事务管理器和注解驱动bean idtransactionManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean tx:annotation-driven transaction-managertransactionManager/而Spring Boot下你只需要在启动类所在的配置里让EnableTransactionManagement生效——其实Spring Boot的自动配置已经帮你开启了声明式事务管理一般情况下连这个注解都不用写直接用Transactional即可。多数据源的情况要复杂一些。需要为每个数据源配各自的PlatformTransactionManager然后在Transactional注解里通过transactionManager属性指定用哪个事务管理器Transactional(transactionManager orderTransactionManager) public void createOrder(Order order) { // ... }这个在多数据源项目里特别重要。我之前在某个项目里配了两个数据源结果所有事务都默认用了第一个数据源的事务管理器第二个库的操作根本不参与事务排查了很久才发现是transactionManager没有指定。如果你接手一个老项目看到一个方法里操作了两个库先别急着怀疑事务框架先看看事务管理器是不是配对了。4.2 一个完整的转账业务实现我们来实现一个完整的转账方法把前面讲到的知识点都串起来。先看Account对应的Mapper层这里以MyBatis为例Mapper public interface AccountMapper { Update(UPDATE account SET balance balance - #{amount} WHERE id #{fromId} AND balance #{amount}) int decreaseBalance(Param(fromId) Long fromId, Param(amount) BigDecimal amount); Update(UPDATE account SET balance balance #{amount} WHERE id #{toId}) int increaseBalance(Param(toId) Long toId, Param(amount) BigDecimal amount); }注意到decreaseBalance的SQL里带了AND balance #{amount}这个条件这是防止余额不足扣成负数的最朴素做法。如果条件不满足int返回值就是0业务层根据返回值判断是否抛出余额不足的异常。Service层的实现Service public class TransferService { private final AccountMapper accountMapper; public TransferService(AccountMapper accountMapper) { this.accountMapper accountMapper; } Transactional(rollbackFor Exception.class) public void transfer(Long fromId, Long toId, BigDecimal amount) { if (amount.compareTo(BigDecimal.ZERO) 0) { throw new IllegalArgumentException(转账金额必须大于0); } int affected accountMapper.decreaseBalance(fromId, amount); if (affected ! 1) { throw new IllegalStateException(扣款失败余额可能不足); } accountMapper.increaseBalance(toId, amount); } }几点说明。第一rollbackFor Exception.class在这里是必要的因为如果我把受检异常往上抛默认情况下不会触发回滚。虽然这个例子里抛的都是运行时异常但显式写出来是一种更好的防御习惯。第二扣款和入账中间没有远程调用事务范围很小这是理想的场景。如果真实业务里需要通知、发消息、调用外部接口尽量不要把这些操作放在事务里否则事务时间会被拉得很长连接池和锁资源都会被拖垮。第三balance #{amount}的SQL条件加上受影响行数的判断这既是并发安全的第一道防线也是业务校验的一部分。事务能保证这两条SQL要么都成功要么都失败但不能代替业务规则校验。4.3 事务失效的九大典型场景我把平时最容易踩的事务失效场景整理成一份清单每条都标注了原因和解决方案。场景原因解决方案方法不是publicSpring代理不会拦截非public方法改为public类内部自调用this.method()绕过代理对象注入自身代理或拆分Beantry-catch吞掉异常异常没传到代理层无法触发回滚别吞异常必要时手动标记回滚受检异常默认只回滚RuntimeException指定rollbackFor Exception.classfinal类或final方法CGLIB无法代理final成员去掉final跨线程调用事务与线程绑定子线程不继承不在子线程里做事务逻辑传播行为设置错误内层用了不支持事务的行为按业务场景选择传播行为数据表不支持事务比如MySQL的MyISAM引擎改成InnoDB引擎事务管理器指定错误多数据源时默认指向错误的库显式指定transactionManager这九条里最容易犯的是前三条。自调用问题我见过太多次了后面专门有一节讲解决方案try-catch吞异常也是高发问题很多人以为只在方法里打了日志就算处理了实际上异常被自己catch住Spring根本感知不到。4.4 吞掉异常等于宣告放弃回滚这里单独说一下try-catch吞异常的问题因为它隐蔽性极强。看这段代码Transactional public void batchUpdate() { try { dao.updateA(); dao.updateB(); } catch (Exception e) { log.error(更新失败, e); } }表面上看updateA和updateB只要有异常就会被捕获并记录日志方法正常返回事务正常提交。问题在于updateA成功、updateB失败时updateA的修改已经被提交了——这恰恰是事务最不应该出现的结果。正确的做法是Transactional public void batchUpdate() { dao.updateA(); dao.updateB(); }让异常自然抛出去交给Spring触发回滚。如果确实需要在catch里做一些清理工作记得在catch块里调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();把当前事务标记为需要回滚这样方法即使正常返回事务最终也会回滚。这种方式可以作为兜底手段但最好的做法还是别吞异常。5. 常见问题排查与避坑实录这一节把我自己在实际项目中反复处理过的问题和排查思路完整记录下来很多内容在官方文档里是看不到的。5.1 自调用事务失效的三种解决方案先复现一下这个经典问题Service public class OrderService { public void outerMethod() { this.innerMethod(); // 自调用 } Transactional public void innerMethod() { // 期望在事务中执行实际没有 } }这里的innerMethod加上了Transactional但通过this调用的方式是直接调用目标对象的方法根本没有经过Spring生成的代理对象所以事务注解完全失效。Spring文档里把这种情况叫self-invocation。我见过三种靠谱的解决方案。方案一把内部方法拆分到另一个Spring管理的Bean中。比如新建一个OrderInnerService把innerMethod放到里面然后在OrderService里注入这个新Bean并调用。这是最推荐的做法干净、清晰、易测试。方案二通过AopContext获取当前代理对象。需要在配置里暴露代理EnableAspectJAutoProxy(exposeProxy true)然后代码里这样调用public void outerMethod() { OrderService proxy (OrderService) AopContext.currentProxy(); proxy.innerMethod(); }这个方案能用但AopContext.currentProxy()在异步线程里会取不到代理对象存在一定限制。方案三注入ApplicationContext从容器里重新获取Bean再调用。这个方法可行但比方案一繁琐不推荐。还有一个变通的思路把事务边界统一放在最外层方法上内部方法不加Transactional。如果你确定整个调用链就是一个事务那直接把Transactional加在outerMethod上就完了内部方法根本不需要注解。很多时候自调用问题的出现恰恰是外部方法没加事务、内部方法又期望单独事务这时候才需要拆Bean。5.2 事务没回滚时的标准排查路径线上反馈事务没回滚的时候我的排查路径基本是固定的。第一步确认异常类型。先定位抛出来的到底是不是受检异常。如果是受检异常且没有指定rollbackFor那大概率就是它了。第二步确认异常有没有被catch。在业务代码里找一下方法调用链上有没有try-catch把异常吞掉。这一步最容易忽略因为吞掉的异常在日志里可能只有一行warn甚至什么都没有。第三步确认调用方式。看看事务方法是不是被同类内部调用的、是通过this还是通过代理对象调的。最快的方式是在事务方法第一行打个断点或者加日志观察方法调用栈看有没有经过Spring AOP相关的拦截器。第四步确认事务管理器。多数据源项目里看看当前数据源是不是配置了对应的事务管理器。排查方向整理成表格会更直观现象优先怀疑方向验证手段抛了异常但没回滚受检异常、回滚规则确认异常类型检查rollbackFor方法没进事务自调用、非public、代理失败断点看调用栈打印代理类名数据部分更新try-catch吞异常检查调用链上的catch块多库操作部分生效事务管理器指向错误核对transactionManager配置5.3 大事务和长事务的治理方法大事务是性能问题的重灾区。一个事务里塞了太多操作意味着数据库连接被长时间占用连接池容易被打满行锁长时间不释放其他事务全部阻塞事务日志要记录大量变更提交时压力巨大如果中途失败回滚回滚时间也长得可怕。治理思路通常从这几个方向入手。方向一缩小事务范围。能用编程式事务的不用声明式特别是循环内操作用TransactionTemplate逐批提交。我自己写批处理代码时一般每100条或每1000条提交一次根据单行操作耗时和事务日志大小来调整。方向二把非DB操作移出事务。远程调用、消息发送、文件处理、HTTP请求这些IO操作统统不要在事务里做。常见的做法是事务内只做DB操作事务提交后再发消息、调远程。这样既保证数据落库又不让IO拖住事务。方向三使用批量SQL。大量数据的更新尽量走批量语句不要一条条循环更新。每条更新都要拿一次连接、开一次事务性能和锁竞争的差别是数量级的。方向四设置合理的超时时间。Transactional(timeout 5)可以设置事务超时秒数超时会抛出异常并回滚。这个参数可以在极端情况下避免事务长期悬挂。5.4 调试事务的日志配置与判断技巧最后分享一套我常用的排查配置。在Spring Boot的application.yml里logging: level: org.springframework.transaction: debug org.springframework.jdbc.datasource.DataSourceTransactionManager: debug com.example.mapper: trace其中org.springframework.transaction这个日志级别设为debug后Spring会打印大量事务生命周期信息比如Creating new transaction with name、Initiating transaction commit、Rolling back transaction。这些日志是排查事务是否生效的最有力证据。com.example.mapper你的Mapper包路径设为trace可以打印MyBatis执行的完整SQL用来看实际执行的语句和事务提交时机。还有一个技巧在事务方法里用TransactionSynchronizationManager.isActualTransactionActive()判断当前线程是否真的处于事务中。boolean txActive TransactionSynchronizationManager.isActualTransactionActive(); log.info(Current transaction active: {}, txActive);这个判断点在你调试自调用问题时尤其好用一段代码就能确认事务到底开没开。返回false说明你的事务注解压根没起作用继续往代理方向排查返回true但数据还是没回滚再往异常逻辑和事务边界方向排查。6. 写在最后的一些经验最后再分享一点我的感受。Spring事务这套东西看着就是一个个注解和配置但真正用好的关键是理解它背后那条代理链你的业务方法不是直接被调用的而是被Spring生成的代理对象包了一层事务的开启、提交、回滚全在这一层完成。理解了这条链你就能解释自调用为什么会失效、异常为什么要抛出去、private方法为什么没用也能在遇到奇怪问题时快速定位问题出在哪一环。另一个体会是事务不是越多越好也不是越少越好。它保护的是数据一致性但每一层保护都有代价。日常开发里我会先想清楚哪些操作必须在同一个事务里哪些操作根本不该在事务里再用声明式或编程式的方式把边界画准确。很多时候排查事务问题排查到天亮回头一看其实是当初画边界的时候太随意。如果你在实际项目里遇到过更奇怪的事务问题建议把当时的日志和代码保留下来用上面提到的排查路径一步步走一遍。这类问题往往是越追越清楚的希望这篇Spring事务的完整拆解能帮你少走一些弯路。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑