Spring事务传播行为详解:7种策略与回滚机制实战避坑 先说个真实事故。某天晚上线上支付回调突然大量重试原因是订单状态被更新成了“已支付”但账户余额却一分没扣。查到最后业务代码里一个Service方法成功提交了订单状态另一个Service的扣款方法抛了异常却因为事务传播行为配置不当让异常被吞掉——扣款回滚了订单状态却提交了。事后复盘根子就在Transactional默认的传播行为上。这篇东西要解决的就是Spring事务传播行为这回事。什么场景下内外事务合并什么场景下必须各管各的什么场景下允许局部回滚如果你写过涉及订单、库存、账户、积分这些相互调用的业务迟早会撞上这些问题。本文会把这7种传播行为逐一拆解配上完整的代码示例再把我踩过的坑一并说清楚。1. 一个事故带出的核心问题事务边界到底谁说了算1.1 先搞懂什么是事务传播行为Java后端做数据一致性靠的是数据库事务。一个方法加了Transactional注解之后Spring会通过AOP在方法执行前开启事务、执行后提交或回滚。这是绝大多数人都知道的第一层。但业务不可能只有一个方法。很多时候是ServiceA调ServiceBServiceB又调ServiceC每个Service方法都可能带事务注解。这时候问题就来了B加入A的事务还是自己开一个新事务如果B回滚了A要不要跟着回滚如果A已经执行了一半B失败了A能不能只回滚到调用B之前的状态“一个事务方法调用另一个事务方法时事务该如何传递、如何界定边界”这就是事务传播行为要回答的问题。Propagation枚举里定义了7种策略REQUIRED、REQUIRES_NEW、SUPPORTS、NOT_SUPPORTED、MANDATORY、NEVER、NESTED。每一种都规定了方法被执行时当前线程上如果已经存在事务应该怎么办如果不存在事务又应该怎么办。1.2 为什么默认的REQUIRED不是万能的Spring默认用的是REQUIRED翻译过来就是“有事务就加入没事务就新建”。这个默认值对大多数单一写操作是合理的但它掩盖了很多边界问题。举个例子一个下单接口里主流程要写订单表、扣库存、加积分、发消息。这四个操作如果全是REQUIRED那它们会合并成一个物理事务。好处是只要任何一个环节失败全部回滚数据不会出现“订单存在但库存没扣”的中间状态。坏处也很明显事务粒度太大一个地方出问题整个事务标记成rollback-only就算外层代码捕获了异常最终也会抛UnexpectedRollbackException。更麻烦的是日志、通知这类操作。业务本身失败要回滚但日志希望留下来REQUIRED做不到这一点它只会跟着一起回滚。这时候就需要REQUIRES_NEW来开一个独立事务。理解了这一点就会发现7种传播行为不是理论概念每一个都有它存在的具体场景选错了线上就会出事故。2. 7种事务传播行为拆解每种都该在什么场景用2.1 REQUIRED默认行为绝大多数写操作的选择如果当前存在事务则加入当前事务 如果当前不存在事务则新建一个事务。这是最常用的一种定义在Propagation.REQUIRED。它保证同一个事务边界内多个方法共享同一个数据库事务。假设A方法调B方法两者都是REQUIREDA开启事务后B发现当前线程已经绑定了事务就直接加入不新开。这个传播行为的核心价值是“原子性”。订单保存、库存扣减、明细写入这些操作必须同时成功或同时失败就应该统一用REQUIRED。我见过很多项目把所有方法都加上Transactional(propagation Propagation.REQUIRED)这本身没错但如果所有业务都堆在一个大事务方法里后续拆分时会非常痛苦。实际使用中要注意的是加入当前事务的方法它的异常会污染整个事务。一旦有个子方法抛了RuntimeException整个事务会被标记为rollback-only即使外层方法用try-catch包住了异常最终提交时Spring也会强制回滚。这一点后面会专门展开。2.2 REQUIRES_NEW独立事务日志与审计的最优解无论当前是否存在事务都新建一个事务 如果当前存在事务则把当前事务挂起新开一个独立事务。REQUIRES_NEW是最容易被误解也最容易用错的一种。它的关键是“完全独立”新事务有自己的提交和回滚边界外层事务不会影响它它也不会影响外层事务。最适合的场景就是操作日志、审计记录、消息通知。比如订单创建失败要回滚但“谁在什么时间尝试创建过订单、因为什么失败”这条日志必须留下来。如果把日志方法设成REQUIRED它和主流程共享事务主流程一回滚日志也没了。要日志不受牵连就得用REQUIRES_NEW。但代价是主事务被挂起期间新事务独占一个数据库连接。如果这个独立事务逻辑耗时长连接会被长时间占用。在高并发场景下大量REQUIRES_NEW会让连接池很快耗尽。这一点我在第4章的坑里详细讲。2.3 SUPPORTS查询方法的宽松模式如果当前存在事务则加入当前事务 如果当前不存在事务则以非事务方式执行。SUPPORTS翻译过来就是“可有可无”。它不会主动创建事务别人有事务就搭个便车别人没事务自己也不折腾。查询、枚举、主数据加载这类只读操作用SUPPORTS很合适避免明明不需要事务还白白占用一个数据库事务连接。一个隐藏的好处是当查询方法被一个事务方法调用时它会加入当前事务这样查询到的数据在同一个事务边界内保持一致不会出现不同快照导致的数据不一致问题。当它被非事务方法调用时也能正常执行。2.4 NOT_SUPPORTED挂起事务跑非事务逻辑如果当前存在事务则把当前事务挂起以非事务方式运行 如果当前不存在事务也以非事务方式运行。NOT_SUPPORTED是“强制不要事务”。它会把外层事务挂起让自己以非事务模式跑完然后再恢复外层事务。适合场景是批量清理缓存、临时表写入、某些外部系统交互逻辑。这些操作不需要事务也怕事务长时间锁住数据库资源。我实际用过一次是在对接某外部文件导出功能时文件生成耗时很久如果放在事务里数据库连接要一直被占用别的请求等连接等到超时。改成NOT_SUPPORTED后连接在进入方法前就释放了文件生成过程中完全不留事务资源。要注意的是这种方法里如果自己抛了异常只会影响自己外层事务还挂在那边方法结束后由Spring恢复。两边互不干扰这点和REQUIRES_NEW类似但NOT_SUPPORTED自己不会开启任何新事务。2.5 MANDATORY强制要求事务环境如果当前存在事务则加入当前事务 如果当前不存在事务则抛出异常。MANDATORY的意思是“必须有事务否则直接报错”。它适合那些内部逻辑严重依赖事务环境的操作比如事务内的数据校验、前置检查。如果调用方没有开启事务在这里直接抛IllegalTransactionStateException让问题在入口处就暴露而不是等数据写乱了再排查。老实说这种传播行为我在业务代码里用得不多但在写一些框架性质的基础服务时很有价值。比如一个幂等校验组件它要求调用方必须在事务内执行否则校验和写入不在同一个事务边界内幂等就失去意义。这时候用MANDATORY等于用异常强迫调用方遵守约定。2.6 NEVER明确拒绝事务如果当前存在事务则抛出异常 如果当前不存在事务则以非事务方式运行。NEVER是MANDATORY的反面。它强制要求当前没有事务一旦发现有事务就抛异常。适用的场景是某些操作如果出现在事务上下文里说明调用关系已经错了应该尽早暴露。比如一个独立的定时任务执行器它内部自己维护一批业务操作本来就不应该被嵌入到其他事务里。如果某天代码重构后有人把这个执行器的方法包进了一个事务方法中NEVER就会通过异常提示调用方这个操作不该在事务里跑请重新设计调用链。2.7 NESTED基于保存点的局部回滚如果当前存在事务则在当前事务内部创建一个保存点Savepoint 后续代码可以只回滚到保存点而不影响外层事务 如果当前不存在事务则新建一个事务执行。NESTED是七种里最需要理解的。它不是一个独立物理事务而是在现有事务基础上利用数据库的保存点机制实现“部分回滚”。场景很典型批量导入Excel。一万条数据前五千条都插成功了第六千条出了错业务要求“出错的那条跳过其余继续”。如果用REQUIRED一条异常整个事务回滚如果用REQUIRES_NEW每条数据一个独立事务数据是躲过了回滚但频繁开关事务性能太差。NESTED的分寸刚好外层事务还在跑内层方法被标记为保存点内层异常只回滚到保存点外层还能继续提交。要注意的是NESTED能不能真正启用保存点取决于底层数据库是否支持SAVEPOINT。MySQL和PostgreSQL都支持用起来没问题。如果数据库不支持Spring会退化为REQUIRED的行为那就和预期不一致了。2.8 七种传播行为速查表传播行为当前有事务当前无事务典型场景REQUIRED加入当前事务新建事务订单、库存、账户等原子性写入REQUIRES_NEW挂起当前事务新建独立事务新建事务操作日志、审计、消息通知SUPPORTS加入当前事务非事务执行只读查询、主数据加载NOT_SUPPORTED挂起当前事务非事务执行非事务执行长时间外部交互、缓存清理MANDATORY加入当前事务抛异常必须依赖事务的校验组件NEVER抛异常非事务执行不允许出现在事务里的清理任务NESTED创建保存点局部回滚新建事务批量导入、逐条独立失败处理3. 代码实战7个场景完整演示3.1 实战环境与基础代码我用Spring Boot JdbcTemplate来做示例这样不引入额外的ORM复杂度把注意力全放在传播行为上。先准备两张表账户表和操作日志表。CREATE TABLE account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_code VARCHAR(32) NOT NULL UNIQUE, balance DECIMAL(12,2) NOT NULL DEFAULT 0.00 ); CREATE TABLE operation_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_type VARCHAR(32) NOT NULL, content VARCHAR(255) NOT NULL, create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP );项目的核心依赖就是spring-boot-starter-jdbc和spring-boot-starter-web配合一个MySQL数据源。配置我放在application.yml里spring: datasource: url: jdbc:mysql://localhost:3306/demo?useSSLfalse username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver数据访问层用两个简单的Dao一个是账户操作一个是日志插入。Repository public class AccountDao { Autowired private JdbcTemplate jdbcTemplate; public void deduct(String userCode, BigDecimal amount) { jdbcTemplate.update( UPDATE account SET balance balance - ? WHERE user_code ?, amount, userCode ); } public void add(String userCode, BigDecimal amount) { jdbcTemplate.update( UPDATE account SET balance balance ? WHERE user_code ?, amount, userCode ); } public BigDecimal getBalance(String userCode) { return jdbcTemplate.queryForObject( SELECT balance FROM account WHERE user_code ?, BigDecimal.class, userCode ); } }Repository public class OperationLogDao { Autowired private JdbcTemplate jdbcTemplate; public void insert(String bizType, String content) { jdbcTemplate.update( INSERT INTO operation_log(biz_type, content) VALUES (?, ?), bizType, content ); } }3.2 场景一REQUIRED——事务合并与回滚最典型的REQUIRED场景就是转账。从A账户扣钱到B账户加钱。我用TransferService包住这两个操作Service public class TransferService { Autowired private AccountDao accountDao; Transactional(propagation Propagation.REQUIRED) public void transfer(String from, String to, BigDecimal amount) { accountDao.deduct(from, amount); accountDao.add(to, amount); } }这是最简单的单物理事务扣钱和加钱在同一个事务里任何一步抛异常两步都回滚。用REQUIRED在这里没有任何争议。再看一个更贴近真实的场景创建订单时扣库存。OrderService调StockService两个方法都声明REQUIRED内部会合并成一个事务。Service public class OrderService { Autowired private OrderDao orderDao; Autowired private StockService stockService; Transactional(propagation Propagation.REQUIRED) public void createOrder(OrderCreateVO vo) { orderDao.insert(vo); stockService.deductStock(vo.getProductId(), vo.getCount()); } }Service public class StockService { Autowired private StockDao stockDao; Transactional(propagation Propagation.REQUIRED) public void deductStock(Long productId, int count) { stockDao.deduct(productId, count); } }这里的关键是StockService.deductStock发现自己执行时当前线程已经存在事务就加入进去不会新开事务。所以最终只有一个物理事务。createOrder成功订单和库存一起提交deductStock抛异常订单和库存一起回滚。这就是REQUIRED的合并效果。3.3 场景二REQUIRES_NEW——日志独立提交继续用下单场景。业务要求“无论订单创建成功还是失败操作日志必须保留。”如果用REQUIRED日志会跟主事务一起回滚这就不符合要求了。所以日志方法要改成REQUIRES_NEW。Service public class OrderService { Autowired private OrderDao orderDao; Autowired private OperationLogService logService; Transactional(propagation Propagation.REQUIRED) public void createOrder(OrderCreateVO vo) { try { orderDao.insert(vo); // 模拟核心业务失败 if (vo.getCount() 100) { throw new RuntimeException(库存不足); } logService.recordLog(ORDER, 订单创建成功: vo.getOrderNo()); } catch (RuntimeException e) { logService.recordLog(ORDER, 订单创建失败: e.getMessage()); throw e; } } }Service public class OperationLogService { Autowired private OperationLogDao operationLogDao; Transactional(propagation Propagation.REQUIRES_NEW) public void recordLog(String bizType, String content) { operationLogDao.insert(bizType, content); } }注意看执行过程主事务开启后订单写进去了然后模拟库存不足抛异常。在catch块里调用recordLog时当前线程是存在事务的但REQUIRES_NEW会让Spring先把主事务挂起新开一个日志事务插入成功后立刻提交然后恢复主事务。最后createOrder重新抛出异常主事务回滚订单数据没了但日志已经提交留了下来。这个场景在支付回调、异步对账中特别常见主数据要不要保留是一回事审计轨迹必须完整是另一回事。3.4 场景三NESTED——批量导入部分成功一张用户导入清单要逐条插入。业务规定单条失败不影响其他条失败的记录下来成功的照常保存。如果把每条插入声明成NESTED外层保存点就能发挥“局部分支回滚”的作用。先看数据访问层Repository public class UserDao { Autowired private JdbcTemplate jdbcTemplate; public void insert(UserImportVO user) { jdbcTemplate.update( INSERT INTO user_info(user_code, nickname, status) VALUES (?, ?, ?), user.getUserCode(), user.getNickname(), user.getStatus() ); } }批量导入服务Service public class BatchImportService { Autowired private UserImportHelper userImportHelper; Transactional(propagation Propagation.REQUIRED) public int importUsers(ListUserImportVO users) { int successCount 0; for (UserImportVO user : users) { try { userImportHelper.insertOne(user); successCount; } catch (Exception e) { // 单条失败只回滚这条的保存点不影响外层 System.out.println(导入失败: user.getUserCode() , e.getMessage()); } } return successCount; } }注意这里我把单条插入逻辑放到了另一个独立的Helper里Service public class UserImportHelper { Autowired private UserDao userDao; Transactional(propagation Propagation.NESTED) public void insertOne(UserImportVO user) { userDao.insert(user); } }为什么要拆成两个Bean因为Spring的Transactional走的是代理机制如果我在BatchImportService内部用this.insertOne(user)直接调用那只是普通方法调用NESTED注解根本不生效。拆开成BatchImportService调UserImportHelper.insertOne才真正走代理传播行为才会被Spring处理。在这个示例里外层开启一个事务。每次进入UserImportHelper.insertOne时当前已有事务Spring就在事务里创建一个保存点。如果插入成功保存点继续往下如果插入失败事务回滚到保存点位置外层已经执行过的成功记录不受影响。循环结束后外层事务统一提交。这就是NESTED和REQUIRES_NEW最本质的区别NESTED的回滚是局部的外层事务没有被分开最终数据仍在一个事务里提交而REQUIRES_NEW直接拆出了独立物理事务两边各自提交、各自回滚。3.5 场景四SUPPORTS、NOT_SUPPORTED、MANDATORY、NEVER——四种特殊场景验证SUPPORTS的代码最简单Service public class AccountQueryService { Autowired private AccountDao accountDao; Transactional(propagation Propagation.SUPPORTS) public BigDecimal getBalance(String userCode) { return accountDao.getBalance(userCode); } }单独调用getBalance时Spring发现当前没有事务就不新建事务直接作为非事务查询执行。如果这个方法被另一个事务方法调用它就会加入那个事务。这就是“有就搭车没有就自己走着去”。NOT_SUPPORTED更适合耗时操作Service public class ExternalSyncService { Transactional(propagation Propagation.NOT_SUPPORTED) public void syncToExternal() { // 模拟耗时的外部系统交互 // 当前没有事务数据库连接不会一直被占用 } }假设这个syncToExternal是从一个事务方法里调用的Spring会把外层事务挂起让它以无事务方式执行。执行期间不占用数据库连接资源结束后再恢复外层事务。好处很明显事务持有的连接不会因为慢逻辑而长时间不释放。MANDATORY和NEVER代码上更像“校验器”Service public class VerificationService { Transactional(propagation Propagation.MANDATORY) public void verifyInTransaction() { // 只有当前存在事务时能执行否则抛异常 } }Service public class CleanupService { Transactional(propagation Propagation.NEVER) public void cleanupTmpData() { // 只有当前没有事务时能执行否则抛异常 } }这两个方法平时很少单独出现在业务逻辑中但当你设计一些基础组件时它们能帮你把“调用契约”用异常直接固化下来。4. 你一定会踩的4个坑4.1 自调用导致事务失效我在第3章写NESTED时特别提到把Helper拆成独立Bean就是因为自调用问题。这个坑极其常见。Service public class BadService { Transactional public void outerMethod() { // 这里其实是this调用的普通方法注解不生效 this.innerMethod(); } Transactional(propagation Propagation.REQUIRES_NEW) public void innerMethod() { // 期望新开事务实际不会 } }Spring对Transactional的处理是通过AOP生成代理对象事务逻辑全在代理上。this.innerMethod()调用的是原始对象的方法根本没有经过代理所以事务控制全部失效。解决方式有三种第一种把内层方法拆到另一个Service中由外层注入后引用。这也是我代码实战里用的方案。第二种用Autowired注入自己然后通过代理调用。Service public class SomeService { Autowired private SomeService self; Transactional public void outerMethod() { self.innerMethod(); } Transactional(propagation Propagation.REQUIRES_NEW) public void innerMethod() { // 通过self调用走代理事务生效 } }第三种用ApplicationContext手动拿到代理或者直接使用TransactionTemplate做编程式事务。对于复杂场景编程式事务反而更直观。4.2 外层捕获内层异常却还是回滚rollback-only之谜这个坑是事务传播里最费解的一个线上排查极容易翻车。场景是这样的外层方法A用REQUIRED开启事务调用了也是REQUIRED的内层方法B。B抛了异常A用try-catch把异常接住了想着继续执行后面的逻辑。但最终A提交时Spring直接抛UnexpectedRollbackException所有数据全部回滚。原因要从事务状态说起。A和B共用一个物理事务。B抛了运行时异常Spring把当前事务标记成了rollback-only意思是“这个事务已经被判了死刑不需要再执行任何提交”。这个标记是全局的A接住异常根本没有用事务的最终结局早就定好了。解决思路有两个方向一是改内层传播行为把B改成REQUIRES_NEW。这样B回滚自己的事务时只是把独立事务标记为rollback-onlyA事务完全不知道接下来照常提交。二是别接异常让B的异常直接往上抛。这样A也进入回滚流程整体数据一致虽然业务上可能不满但起码没有脏数据。我个人的习惯是除非非常确信自己知道在做什么否则不要轻易在外层捕获内层事务方法抛出的异常。捕获前先问一句这个事务还要不要提交如果要就选REQUIRES_NEW或NESTED再配合异常处理。4.3 连接池被REQUIRES_NEW耗尽REQUIRES_NEW独立事务看起来很爽但代价是物理连接。假设连接池只有10个连接一个主事务占着1个进入REQUIRES_NEW方法时Spring挂起主事务库连接还锁在主事务上新事务需要再拿1个连接。如果一段业务里连续嵌套多个REQUIRES_NEW连接占用就会成倍增加。我在一个批处理任务里曾经写过三层嵌套主事务 - 处理任务 - 记录日志日志方法用了REQUIRES_NEW。一次跑了几千条记录每调一次日志方法就抢一个连接等其他方法需要连接时连接池已经空了整个任务卡死。这种场景最合适的做法是尽量缩小日志粒度或者用异步方式记录日志让日志提交不阻塞主流程。如果必须同步REQUIRES_NEW一定评估连接池最大连接数压测后再上线。4.4 受检异常默认不回滚Transactional默认只对RuntimeException和Error回滚受检异常Checked Exception抛出时不会回滚。这一点很容易被忽略尤其是初学阶段。比如一个导入方法里解析Excel抛出一个自定义的ImportFormatException这个异常如果继承的是Exception那Spring默认不会回滚。结果就是数据已经写了一半异常也抛了但前面写入的半截数据被提交了。解决办法是显式指定回滚条件Transactional(rollbackFor ImportFormatException.class) public void importExcel(...) { // ... }或者统一约定业务里使用的异常都继承RuntimeException让默认回滚策略覆盖所有异常。两种做法都行但团队里必须统一否则就会出现“有的人回滚了有的人没回滚”的混乱局面。5. 平时我是怎么选传播行为的写业务代码时我给自己总结了一套简单粗暴的选择逻辑。常规写操作默认REQUIRED绝不滥用。一个事务能涵盖的原子操作就让它涵盖别一个方法反复拆事务。真正需要独立提交的比如日志、审计、消息发送才用REQUIRES_NEW。批量处理中想实现单条失败不影响整体的优先考虑NESTED。只读查询用SUPPORTS或干脆不加事务。耗时外部交互用NOT_SUPPORTED。基础框架预置规则用MANDATORY和NEVER做强制约束。我在实际项目里被事务问题坑过很多次最惨的一次就是文章开头说的凌晨被叫起来核对订单状态。后来我给自己定了一条规矩凡是写入操作能用一个事务包住的绝不用两个凡是必须拆开的一定用REQUIRES_NEW并且提前评估连接池。这习惯帮我躲过了好几次事故。如果你正在排查事务相关的问题建议先把这7种传播行为的定义背下来再针对性看代码里实际调用的链路。把每个方法的传播行为、异常类型、调用关系画清楚问题基本就浮出水面了。