资讯动态

Spring声明式事务失效与传播行为深度解析

发布时间:2026/10/14 12:45:10 来源:尧图企业网站定制
1. 从一次线上事故说起为什么声明式事务不是加个注解就完事去年帮一个团队排查过一个挺典型的线上问题用户下单后扣了库存订单却查不到库存也回滚不回去两边数据对不上。翻代码一看Transactional注解加得好好的方法上明晃晃写着可问题就出在这个加得好好的上——方法内部调用了同类里的另一个方法事务根本没生效。这种坑我见过太多次了几乎每个用 Spring 的团队都踩过。Spring 的声明式事务说白了就是让你不用手写begin、commit、rollback那一套模板代码只在方法或类上标个Transactional框架帮你把事务的开启、提交、回滚全包了。它解决的核心问题是把业务逻辑和事务管理解耦让写业务的人专注写业务别在每段数据库操作前后都塞一堆样板代码。听起来很美但它背后是一整套 AOP 代理、事务传播、连接绑定、异常回滚规则的组合拳任何一环理解不到位就会出现注解加了但没生效该回滚的没回滚事务范围比想象中大得多这类问题。这篇内容适合谁看如果你已经会用Transactional但说不清楚它到底怎么生效的、为什么有时候失效、传播行为那几个值到底啥区别那这篇就是写给你的。我会从设计思路讲到源码级别的执行链路再落到实操配置和排查技巧尽量把每个为什么都讲透。全程按一个踩过坑的从业者视角来聊不搞教科书那套。2. 声明式事务的整体设计与思路拆解2.1 为什么是 AOP而不是别的方案要理解声明式事务先得理解它为什么长成现在这个样子。事务管理的本质需求是在一段业务逻辑的前后插入固定动作开启事务、提交或回滚而这段业务逻辑本身不应该感知到这些动作。这不就是典型的横切关注点吗所以 Spring 选择用 AOP 来实现是顺理成章的。具体来说Spring 在容器启动时会扫描所有带Transactional的 Bean为它们生成代理对象。你从容器里拿到的其实不是原始对象而是代理。当你调用代理对象的方法时代理会先判断这个方法需不需要事务需要的话就开启事务然后调用真正的目标方法最后根据执行结果决定提交还是回滚。整个过程对调用方完全透明。这里有个关键点很多人忽略代理是 Spring 事务生效的前提。这就解释了为什么同类内部方法调用会导致事务失效——你调用的是this.xxx()this是原始对象不是代理对象压根没经过代理那一层事务自然无从谈起。这个坑我在第 4 节会详细展开。那为什么不用字节码增强比如编译期织入其实 Spring 也支持但默认用的是运行时代理因为运行时代理对代码零侵入不需要特殊的编译器或类加载器兼容性最好。代价就是必须有代理这一层也就带来了自调用失效、private 方法失效这些限制。这是典型的用一点限制换极大便利的取舍。2.2 两种代理方式的选择逻辑Spring 生成代理有两种方式JDK 动态代理和 CGLIB 代理。这个选择不是随便定的背后有明确的逻辑。JDK 动态代理要求目标类必须实现至少一个接口它生成的代理对象和目标对象实现同一个接口通过接口来调用。CGLIB 则是通过继承目标类、生成子类来实现代理所以它不要求接口但要求目标类和方法不能是final的。Spring Boot 从 2.x 开始默认把spring.aop.proxy-target-class设成了true也就是默认走 CGLIB。为什么改这个默认值因为实际项目里很多 Service 类并没有实现接口用 JDK 代理会直接报错开发者还得手动去配。改成 CGLIB 默认后绝大多数场景开箱即用。但这也带来一个副作用如果你的类被final修饰或者方法被final修饰CGLIB 没法覆写事务就会静默失效。这个点后面排查章节会重点讲。提示如果你在维护一个老项目发现事务时灵时不灵先确认一下代理方式。JDK 代理下只有接口里声明的方法才会被代理类里额外加的 public 方法即使标了Transactional也不会生效。2.3 事务属性一组需要协同工作的参数声明式事务不是只有开和关两个状态它有一组属性需要你按业务场景配置。这些属性包括传播行为Propagation、隔离级别Isolation、超时时间Timeout、只读标志ReadOnly和回滚规则RollbackFor 等。它们不是孤立的而是协同决定事务的最终行为。传播行为是最容易搞混的一个。它回答的问题是当前方法已经在一个事务里了现在又调用了一个需要事务的方法该怎么办是共用同一个事务还是挂起当前事务开个新的还是直接报错Spring 定义了七种传播行为常用的其实就三四种。隔离级别则决定了事务之间的可见性比如脏读、不可重复读、幻读这些并发问题靠它来控制。超时是防止事务长时间不释放连接只读标志是个优化提示回滚规则决定了哪些异常触发回滚。这些属性的默认值都挺保守的传播行为默认REQUIRED隔离级别默认跟数据库一致超时默认不限只读默认 false回滚默认只对RuntimeException和Error生效。默认值能用但很多场景下你需要主动调整尤其是回滚规则——受检异常默认不回滚这是无数人踩过的坑。3. 核心细节解析与实操要点3.1 事务的开启、提交与回滚到底发生在哪很多人以为Transactional是 Spring 在方法执行时顺便处理的其实它有一套完整的拦截器链路。核心是TransactionInterceptor这个类它实现了MethodInterceptor是 AOP 通知的一种。当代理对象的方法被调用时拦截器链会执行到这个TransactionInterceptor。它的处理逻辑大致分三步。第一步通过TransactionAttributeSource解析出当前方法的事务属性也就是你注解里配的那些值。第二步通过PlatformTransactionManager开启事务拿到一个TransactionStatus对象这个对象记录了当前事务的状态。第三步执行目标方法然后根据执行结果决定是提交还是回滚。这里有个细节值得说事务的开启时机。REQUIRED传播行为下如果当前没有事务才会真正开启一个新事务如果已经有事务了就直接加入不会新开。这个判断当前有没有事务靠的是TransactionSynchronizationManager它内部用ThreadLocal维护了当前线程的事务资源和连接。这也是为什么事务和线程是绑定的——跨线程调用事务不会自动传递。回滚的判断逻辑在completeTransactionAfterThrowing方法里。它会拿抛出的异常去匹配回滚规则匹配上了就回滚没匹配上就照样提交。默认规则是RuntimeException及其子类、Error及其子类回滚其他也就是受检异常不回滚。这个默认规则是很多人踩坑的根源因为业务里抛的自定义异常如果继承了Exception而不是RuntimeException事务是不会回滚的。3.2 传播行为七种值到底怎么选传播行为是声明式事务里最需要理解清楚的部分。我用一个表格把七种值列出来再逐个说使用场景。传播行为含义典型场景REQUIRED有事务就加入没有就新建绝大多数业务方法默认值REQUIRES_NEW总是新建事务当前事务挂起日志记录、独立提交的操作SUPPORTS有事务就加入没有就非事务执行查询方法可有可无NOT_SUPPORTED非事务执行当前事务挂起不需要事务的耗时操作MANDATORY必须在事务中否则报错强制要求上层开启事务NEVER必须不在事务中否则报错强制要求非事务环境NESTED嵌套事务可独立回滚部分失败不影响整体的场景REQUIRED是默认值也是最常用的。它的语义是我需要事务有就共用没有就新建。这里要注意共用的含义如果外层方法已经开了事务内层方法用REQUIRED加入那么内层方法抛异常导致回滚时整个外层事务都会被标记为回滚。这就是为什么有时候你只想回滚一小段结果整个大事务全挂了。REQUIRES_NEW解决的就是上面这个问题。它会挂起当前事务开一个全新的独立事务。新事务的提交和回滚跟外层完全隔离。典型场景是记操作日志不管主业务成功还是失败日志都得记下来那就用REQUIRES_NEW。但要注意挂起和恢复事务是有成本的而且它需要额外的数据库连接高并发下要留意连接池够不够。NESTED比较特殊它依赖数据库的保存点Savepoint机制。它在外层事务里打一个保存点内层失败时回滚到保存点外层可以选择继续或者也回滚。注意它和REQUIRES_NEW的本质区别NESTED是同一个物理事务REQUIRES_NEW是两个物理事务。所以NESTED的内层回滚不影响外层已做的操作但外层回滚会连带内层一起回滚。注意NESTED需要底层数据库支持保存点而且只在DataSourceTransactionManager下有效。如果你用的是 JTA 事务管理器NESTED是不支持的会直接抛异常。3.3 回滚规则受检异常这个坑回滚规则是声明式事务里最容易被忽视、又最容易出问题的地方。默认情况下只有RuntimeException和Error触发回滚受检异常继承自Exception但不是RuntimeException不触发回滚。为什么这么设计因为 Spring 的设计者认为受检异常代表的是可预期的、调用方应该处理的情况比如文件找不到、网络超时这些不一定要回滚事务。而运行时异常代表的是程序 bug 或不可恢复的错误应该回滚。这个设计理念有它的道理但实际业务里很多团队的自定义业务异常都继承自Exception结果就是抛了异常事务却没回滚数据出现不一致。解决办法很直接在注解里显式指定rollbackFor。Transactional(rollbackFor Exception.class) public void createOrder(OrderDTO dto) { // 业务逻辑 }我个人的习惯是只要方法涉及写操作一律加上rollbackFor Exception.class宁可多回滚也不要漏回滚。有人担心这样会把不该回滚的也回滚了但实际上一个写操作方法里抛出了异常绝大多数情况下你都希望它回滚而不是留下半截数据。还有一个反向的配置noRollbackFor用来指定某些异常不回滚。这个用得少典型场景是抛异常但业务上算成功的情况比如重复提交检测抛个异常提示用户但不想回滚已做的记录。这种场景我建议还是用返回值来表达别用异常异常语义太重了。3.4 只读事务和超时容易被忽略的优化点readOnly true这个属性很多人觉得可有可无其实它在特定场景下是有实际价值的。它告诉底层驱动和数据库这个事务只读数据库可以据此做一些优化比如不分配回滚段、不加写锁。对于纯查询方法加上它是有好处的。但要注意readOnly只是个提示不是强制约束。你在只读事务里执行写操作数据库不一定拦你取决于具体实现但语义上是错的。另外某些 ORM 框架会根据readOnly决定是否开启一级缓存刷新配错了可能导致预期外的行为。超时属性timeout单位是秒默认是 -1 表示不限。它的作用是防止事务长时间占用连接。设置超时后如果事务执行超过指定时间会抛TransactionTimedOutException并回滚。这个属性在高并发场景下很有用能防止慢查询拖垮整个连接池。但要注意超时的计时是从事务开启算起不包括等待获取连接的时间而且它依赖底层实现不是所有数据库驱动都精确支持。4. 实操过程与核心环节实现4.1 从零搭一个可验证的事务环境光讲理论没意思我们搭个能跑的环境把各种情况都验证一遍。我用 Spring Boot 加内存数据库的方式这样不用装数据库跑起来就能测。先看依赖核心就是 Spring Boot 的 starter 和数据库驱动dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdcom.h2database/groupId artifactIdh2/artifactId scoperuntime/scope /dependency配置文件里开启事务管理Spring Boot 会自动装配DataSourceTransactionManagerspring: datasource: url: jdbc:h2:mem:testdb driver-class-name: org.h2.Driver h2: console: enabled: true然后建两张表一张账户表一张流水表用来验证事务的原子性CREATE TABLE account ( id BIGINT PRIMARY KEY, balance DECIMAL(10,2) ); CREATE TABLE account_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, account_id BIGINT, amount DECIMAL(10,2), create_time TIMESTAMP );环境搭好后我们写一个转账方法扣款和记流水在同一个事务里验证要么都成功要么都失败。4.2 一个完整的转账事务实现先看 Service 层的实现这是事务的核心载体Service public class TransferService { Autowired private JdbcTemplate jdbcTemplate; Transactional(rollbackFor Exception.class) public void transfer(Long fromId, Long toId, BigDecimal amount) { // 扣款 jdbcTemplate.update( UPDATE account SET balance balance - ? WHERE id ?, amount, fromId); // 记流水 jdbcTemplate.update( INSERT INTO account_log(account_id, amount, create_time) VALUES(?,?,?), fromId, amount.negate(), new Timestamp(System.currentTimeMillis())); // 模拟异常 if (amount.compareTo(new BigDecimal(1000)) 0) { throw new RuntimeException(超过限额); } // 入账 jdbcTemplate.update( UPDATE account SET balance balance ? WHERE id ?, amount, toId); } }这段代码里扣款、记流水、入账三个操作在同一个事务里。如果金额超过 1000 抛异常前面扣的款和记的流水都会回滚。你可以把rollbackFor去掉再把异常改成受检异常就能亲眼看到抛了异常但没回滚的现象。这里有个实操细节用JdbcTemplate而不是直接拿Connection。因为JdbcTemplate会通过DataSourceUtils获取连接而这个工具类会优先从TransactionSynchronizationManager里拿当前事务绑定的连接。如果你自己dataSource.getConnection()拿到的可能是另一个连接事务就串了。这是很多人手写 JDBC 时踩的坑。4.3 验证传播行为的实验设计光看文档记不住传播行为得动手验证。我设计一个实验外层方法开事务内层方法用不同传播行为观察回滚结果。Service public class PropagationService { Autowired private JdbcTemplate jdbcTemplate; Autowired private InnerService innerService; Transactional(rollbackFor Exception.class) public void outer() { jdbcTemplate.update(INSERT INTO account_log(account_id, amount) VALUES(1, 100)); try { innerService.innerRequired(); } catch (Exception e) { // 吞掉异常观察外层是否回滚 } } } Service public class InnerService { Transactional(propagation Propagation.REQUIRED, rollbackFor Exception.class) public void innerRequired() { jdbcTemplate.update(INSERT INTO account_log(account_id, amount) VALUES(2, 200)); throw new RuntimeException(内层异常); } }跑这个实验你会发现内层抛异常后即使外层把异常吞了整个事务还是回滚了两条记录都没了。因为REQUIRED下内外是同一个事务内层把事务标记成了 rollback-only外层提交时会发现这个标记直接回滚。把innerRequired改成REQUIRES_NEW再跑一遍结果就变了内层的记录回滚了外层的记录还在。因为内层是独立事务它的回滚不影响外层。这个对比实验做一遍传播行为就再也不会记混了。提示做这个实验时innerRequired必须放在另一个 Bean 里不能和outer在同一个类。否则就是自调用事务根本不生效实验结论会完全错误。这个坑我在下一节详细说。4.4 事务与连接池的配合事务和连接池的关系是实操中必须搞清楚的。一个事务从开启到结束会独占一个数据库连接。事务不结束连接不归还。所以如果事务里包含了耗时操作比如远程调用、文件处理连接就会被长时间占用高并发下连接池很快耗尽。我见过一个案例某接口在事务里调了一个第三方 HTTP 接口对方响应慢的时候要好几秒结果高峰期连接池被打满整个服务雪崩。解决办法是把远程调用挪到事务外面先调远程拿到结果再开事务写库。事务的范围要尽可能小只包住真正需要原子性的数据库操作。连接池的配置也要和事务配合。以 HikariCP 为例maximumPoolSize决定了最大连接数如果事务平均耗时 50msQPS 是 200那理论上需要 10 个连接。但实际要考虑峰值和事务耗时波动一般会留足余量。另外connectionTimeout是获取连接的等待超时设太短会导致高峰期大量获取连接失败设太长又会让请求堆积。这个值需要根据实际压测来调。5. 常见问题与排查技巧实录5.1 事务失效的六大原因速查事务失效是最高频的问题我把常见原因整理成一张表方便对照排查。失效原因现象解决办法自调用同类内部方法调用注解无效拆到另一个 Bean或注入自身代理方法非 publicprivate/protected 方法注解无效改为 public类或方法 finalCGLIB 无法代理去掉 final异常类型不匹配抛受检异常不回滚加 rollbackFor异常被吞catch 后没重新抛出重新抛出或手动回滚多数据源未指定用错事务管理器指定 transactionManager自调用是最隐蔽的一个。因为代码看起来完全正常注解也在就是不生效。判断方法很简单看调用是不是this.method()或者直接method()。如果是那基本就是自调用问题。解决办法有两个一是把方法拆到另一个 Bean 里通过注入调用二是注入自身Autowired private XxxService self;用self.method()调用。第二种方式在 Spring 4.3 之后支持得比较好但要注意循环依赖问题。异常被吞也很常见。有些开发者习惯在方法里try-catch所有异常catch 完记个日志就完事了异常没往外抛事务拦截器根本不知道出错了自然就提交了。这种情况要么把异常重新抛出要么在 catch 里手动调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()标记回滚。5.2 事务不回滚的排查思路遇到该回滚没回滚我一般按这个顺序排查。第一步确认异常类型。打印一下抛出的异常全类名看它是不是RuntimeException的子类。如果不是检查注解有没有配rollbackFor。第二步确认异常有没有被吞。在事务方法的最外层加个日志看异常有没有传播到拦截器。如果方法内部有 try-catch重点看 catch 块。第三步确认事务有没有真正开启。可以打开 Spring 的事务调试日志logging: level: org.springframework.transaction: DEBUG org.springframework.jdbc: DEBUG日志里会打印Creating new transactionInitiating transaction rollback这类信息能清楚看到事务的开启和回滚动作。如果压根没有Creating new transaction说明事务根本没开回到 5.1 去查失效原因。第四步确认是不是多数据源问题。如果项目配了多个数据源Transactional默认用的是主事务管理器。操作从库时如果没指定transactionManager事务管的是主库从库的操作自然不受控。5.3 事务与异步、缓存的冲突处理事务和异步一起用是个高频的坑。Async方法会在新线程里执行而事务是绑定线程的新线程里没有事务上下文。所以如果你在一个事务方法里调用Async方法异步方法里的事务是独立的跟外层没关系。更麻烦的是如果异步方法里操作了外层事务还没提交的数据可能读到旧值或者根本读不到。正确的做法是把异步调用放到事务提交之后。可以用TransactionSynchronizationManager.registerSynchronization注册一个回调在afterCommit里触发异步操作。这样能保证异步任务执行时数据已经落库了。TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { Override public void afterCommit() { asyncService.doSomething(); } });事务和缓存的冲突也类似。如果在事务里更新了数据库又更新了缓存事务回滚时缓存不会跟着回滚就会出现缓存和数据库不一致。稳妥的做法是事务提交后再删缓存同样用afterCommit回调。或者干脆用先更新数据库再删缓存的策略把删缓存放到事务外。5.4 大事务的拆分与优化大事务是性能杀手它带来三个问题锁持有时间长、连接占用久、回滚代价大。我处理过大事务的拆分核心思路是能不在事务里做的都挪出去。具体来说事务里只保留必须原子性的数据库写操作。远程调用、消息发送、文件处理、复杂计算全部挪到事务外。如果业务上确实需要写库成功后再发消息用afterCommit回调或者本地消息表来保证最终一致性。对于批量操作如果一次要插入几万条数据别放在一个事务里。可以分批提交每批几百到一千条用REQUIRES_NEW或者干脆在循环外手动控制事务。这样即使中途失败也只回滚当前批次不用把前面成功的全部推翻。还有一个技巧是调整事务的隔离级别。如果业务能容忍用READ_COMMITTED而不是默认的REPEATABLE_READ能减少锁的持有时间。当然这个要看具体数据库和业务语义不能乱改。6. 一些配置层面的经验补充6.1 注解该加在类上还是方法上Transactional可以加在类上也可以加在方法上。加在类上表示该类所有 public 方法都开启事务加在方法上只对该方法生效。方法上的配置会覆盖类上的配置。我的习惯是默认加在方法上。因为类级别的事务范围太大容易把不该开事务的查询方法也包进去而且一旦有人加了个新方法可能无意中就带上了事务。方法级别虽然啰嗦一点但意图明确每个方法的事务行为一目了然。如果某个类里几乎所有方法都需要同样的事务配置那加在类上也无妨但要在类注释里写清楚。6.2 事务管理器的选择大多数单数据源项目Spring Boot 会自动装配DataSourceTransactionManager你不用管。但如果是多数据源就得手动配置多个事务管理器并在Transactional里用transactionManager属性指定。Transactional(transactionManager orderTransactionManager, rollbackFor Exception.class) public void createOrder() { // 操作订单库 }这里有个容易忽略的点多数据源下一个方法里操作两个库是没法用声明式事务保证原子性的因为一个事务管理器只管一个数据源。这种情况要么用分布式事务方案要么通过业务设计避免跨库写。别指望一个Transactional能管两个库那是做不到的。6.3 事务的测试策略事务代码怎么测我的经验是集成测试比单元测试更有价值。因为事务行为依赖 Spring 容器和数据库纯 Mock 测不出真实行为。用SpringBootTest加Transactional注解测试类可以让每个测试方法跑完自动回滚不污染数据库。但要注意这个测试类上的Transactional会让测试方法本身也在事务里可能掩盖一些传播行为的问题。如果要测传播行为得用Rollback(false)或者手动清理数据。我一般会针对关键的事务方法写专门的集成测试覆盖正常提交、异常回滚、传播行为这几个场景。测试里用内存数据库跑得快也不依赖外部环境。断言的时候不光看返回值还要查数据库确认数据状态这样才能真正验证事务有没有按预期工作。7. 我个人在实际操作中的几点体会聊了这么多最后说几个我踩坑踩出来的经验。第一别迷信注解要理解代理。声明式事务的便利来自代理限制也来自代理。每次写Transactional的时候脑子里过一遍这个方法会被代理调用吗是 public 吗是 final 吗调用方是外部 Bean 还是 this这几个问题想清楚了大部分失效问题都能提前避免。第二回滚规则宁严勿松。我现在写业务方法只要涉及写操作rollbackFor Exception.class是标配。多回滚一次顶多是业务重试漏回滚一次可能就是数据不一致后者排查成本高得多。第三事务范围能小则小。事务里只放必须原子性的数据库操作其他一律挪出去。这个原则看起来简单但实际写代码时很容易顺手就把远程调用、循环处理塞进去了。每次 review 事务代码我都会专门看事务边界划得对不对。第四调试日志是你的朋友。事务出问题的时候别靠猜打开org.springframework.transaction的 DEBUG 日志事务的开启、提交、回滚、挂起、恢复每一步都有记录。看一遍日志比读十遍文档都管用。第五传播行为要动手验证。文档上写的REQUIRED 会加入当前事务看着明白真到用的时候还是容易错。找个下午把七种传播行为各写个 demo 跑一遍观察数据库的最终状态这个投入绝对值得。我当年就是这么把传播行为彻底搞明白的之后再也没在这上面栽过跟头。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑