1. 从一个转账失败的深夜说起
上周五晚上十一点,我正打算关电脑,突然收到测试同事发来的消息:“哥,那个批量转账的接口又出问题了,A账户扣了钱,B账户没收到,但日志里显示两个操作都成功了。” 我心里咯噔一下,这已经是本月第三次了。打开代码一看,果然,又是一个典型的事务传播问题:在一个标记了@Transactional的“主”方法里,调用了另一个也标记了@Transactional的“子”方法,当子方法内部发生异常时,预期是整个操作回滚,但实际只有子方法自己的数据库操作回滚了,主方法里之前的扣款操作却被提交了。钱,就这么凭空消失了。
这个场景,我相信很多后端开发朋友都遇到过,或者即将遇到。Spring 事务传播机制,这个听起来有点学术的名词,实际上是我们日常开发中处理数据库一致性的“交通规则”。它定义了当一个事务方法被另一个事务方法调用时,事务应该如何传播。规则没搞懂,代码就会像没有交通灯的十字路口,事故频发。网上很多文章一上来就罗列七种传播行为,配上官方文档的翻译,看完依然云里雾里。今天,我就结合自己踩过的坑和修复过的线上问题,用最直白的语言和场景,帮你彻底搞懂 Spring 事务传播机制,让你写的代码不再“丢钱”。
2. 事务传播:到底在“传播”什么?
在深入七种行为之前,我们必须先统一认知:Spring 事务传播机制,传播的不是事务对象本身,而是“对事务的参与方式”。
想象一下,你(主方法)要去银行办业务,你朋友(子方法)也正好要办。事务传播机制就是你们俩商量好的“排队策略”:
- 策略一(REQUIRED):你看大厅里已经有一个队伍(存在事务),你就直接排到那个队伍后面,和你朋友共用同一个窗口(同一个物理事务)。如果大厅里没队伍(没有事务),你就新开一个窗口(新启事务),然后让你朋友排到你后面。
- 策略二(REQUIRES_NEW):不管大厅里有没有队伍,你都必须为你朋友单独开一个新窗口(新启一个独立的事务)。两个窗口业务完全独立,一个窗口业务失败关门(回滚),不影响另一个窗口。
所以,核心在于:当方法B被方法A调用时,B是加入A的事务(成为其一部分),还是自立门户(开启新事务),或是干脆不参与事务(以非事务方式运行)。这个“加入/不加入/新开”的规则,就是传播行为。
这里有一个至关重要的底层知识:Spring 的事务管理是基于ThreadLocal的。它会把当前线程关联的数据库连接(Connection)和事务状态信息绑定到当前线程上。当你在方法上标注@Transactional时,Spring 会检查当前线程是否已经绑定了一个事务(即ThreadLocal里有没有)。根据传播行为的不同,它决定是复用这个已有的连接和事务状态,还是从连接池拿一个新的连接,绑定新的状态到线程上。理解这一点,就能明白为什么事务能“传播”——因为大家都在同一个线程里操作,可以共享线程上下文中的资源。
3. 七种传播行为逐个数:用场景代替定义
Spring 定义了七种传播行为,定义很枯燥,我们直接看它们各自最适合的“战场”。
3.1 REQUIRED(默认):最常用的“随大流”策略
- 官方解释:如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新的事务。
- 大白话:有福同享,有难同当。有现成的事务就跟着一起干,没有就自己当组长。
- 典型场景:绝大多数业务方法。比如用户下单,需要扣库存、生成订单、扣减优惠券。这三个子操作(对应三个Service方法)都应该使用
REQUIRED。它们被一个上游的placeOrder方法调用,共同组成一个事务单元。任何一个子操作失败,整个订单操作全部回滚。 - 代码与数据库视角:
在数据库层面,@Service public class OrderService { @Transactional(propagation = Propagation.REQUIRED) // 主事务 public void placeOrder(OrderDTO dto) { inventoryService.deductStock(dto.getSkuId(), dto.getQuantity()); // 子方法 REQUIRED orderMapper.insert(dto); // 本方法操作 couponService.useCoupon(dto.getUserId(), dto.getCouponId()); // 子方法 REQUIRED // 如果这里抛出异常,所有三个数据库操作都会回滚 } } @Service public class InventoryService { @Transactional(propagation = Propagation.REQUIRED) // 默认就是REQUIRED public void deductStock(Long skuId, Integer quantity) { // 操作库存表 } }placeOrder方法开始时,Spring 会从连接池获取一个Connection,设置autoCommit=false,并绑定到当前线程。deductStock方法被调用时,发现当前线程已绑定事务,就不再获取新连接,而是直接使用这个连接执行SQL。所以,所有SQL都在同一个数据库会话中,受同一个事务控制。
3.2 REQUIRES_NEW:必须开新店的“独立承包商”
- 官方解释:创建一个新的事务,如果当前存在事务,则把当前事务挂起。
- 大白话:别管你现在在干嘛,我必须要一个新的事务,你的事先放一边。
- 典型场景:日志记录、异步消息预处理、需要绝对独立成功的操作。比如,无论用户支付成功与否,都需要记录一条不可丢失的操作日志到数据库。如果日志记录和支付在同一个事务里,支付失败回滚,日志也会被回滚,这就丢失了重要的审计信息。
关键点与坑:@Transactional(propagation = Propagation.REQUIRED) public void processPayment(PaymentInfo info) { // 1. 核心支付逻辑,可能失败 paymentCoreService.process(info); // 2. 记录日志,必须成功,即使支付回滚 logService.saveOperationLog(buildLog(info)); } @Service public class LogService { @Transactional(propagation = Propagation.REQUIRES_NEW) // 独立事务 public void saveOperationLog(OperationLog log) { logMapper.insert(log); // 这个插入会立即提交,不受外部事务影响 } }REQUIRES_NEW会挂起外部事务。这意味着:- 内部方法(
saveOperationLog)会从连接池获取一个全新的Connection,开启独立事务并执行提交。 - 外部事务的
Connection会被暂时搁置,等内部事务完成后,再恢复。 - 数据库连接翻倍:这需要数据库连接池有足够的连接支撑,在高并发下可能成为瓶颈。
- 死锁风险:如果内外事务操作同一条数据,且加锁顺序不当,极易引发死锁。
- 内部方法(
3.3 NESTED:基于保存点的“后悔药”
- 官方解释:如果当前存在事务,则在嵌套事务内执行。嵌套事务可以独立于外部事务进行提交或回滚。如果当前没有事务,则行为同
REQUIRED。 - 大白话:在现有事务里划出一个“子任务区”。这个子任务可以单独回滚,而不影响主任务;但主任务回滚,子任务一定回滚。
- 实现原理:它依赖于数据库的保存点(Savepoint)功能。外部事务开始时,内部方法 (
NESTED) 被调用时,会在当前事务中创建一个保存点。如果内部方法失败,事务只会回滚到该保存点,外部方法之前的操作依然有效。如果外部方法失败,则整个事务(包括所有保存点)回滚。 - 典型场景:可部分回滚的复杂业务。例如,一个批量导入用户的任务,每处理100条记录作为一个批次。我们希望单个批次失败时,只回滚该批次的记录,而不影响已成功的前序批次。
重要限制:@Transactional public void batchImportUsers(List<User> users) { for (int i = 0; i < users.size(); i += 100) { List<User> batch = users.subList(i, Math.min(i + 100, users.size())); try { importSingleBatch(batch); // 嵌套事务 } catch (BatchImportException e) { // 仅该批次回滚,记录日志,继续下一批 log.error("批次 {} 导入失败", i/100, e); } } // 所有批次完成,最终提交 } @Transactional(propagation = Propagation.NESTED) public void importSingleBatch(List<User> batch) { for (User user : batch) { userMapper.insert(user); // 可能触发业务校验异常 } }NESTED必须在一个已存在的事务中才有效,且并非所有数据库都支持保存点。MySQL 的 InnoDB 引擎支持,但某些数据库可能不支持。使用时需确认数据库兼容性。
3.4 SUPPORTS:佛系的“跟随者”
- 官方解释:如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务方式执行。
- 大白话:你们有组织我就跟着组织,你们是散兵游勇我也单干。
- 典型场景:查询方法,或对事务无强制要求的辅助方法。比如一个查询用户详情的方法,如果它在某个更新事务中被调用,就跟着一起在事务里读(保证读到最新数据);如果它被单独调用,就直接执行(避免开启不必要的事务开销)。
注意:对于纯查询,现在更推荐使用@Transactional(propagation = Propagation.SUPPORTS) public User getUserDetail(Long userId) { // 纯查询,不修改数据 return userMapper.selectById(userId); }@Transactional(readOnly = true),这能给数据库和框架更明确的优化提示。
3.5 NOT_SUPPORTED:坚定的“非事务执行者”
- 官方解释:以非事务方式执行操作。如果当前存在事务,则将该事务挂起。
- 大白话:我不管你们在不在搞事务,反正我不用事务。你们的事先停一下。
- 典型场景:需要避免事务影响的操作。例如,执行一个非常耗时的统计计算,或者调用一个不支持事务的第三方存储(如某些 NoSQL 的写操作)。你不希望这个长耗时操作占用着数据库连接,导致整个事务锁持有时间过长。
与public void generateReport() { // 一些非DB操作... heavyCalculationService.calculate(); // 这个方法不希望受事务管理 // ... } @Service public class HeavyCalculationService { @Transactional(propagation = Propagation.NOT_SUPPORTED) public void calculate() { // 复杂的、与事务无关的计算或非事务性存储操作 // 即使外部有事务,这里也会被挂起,此方法无事务 } }REQUIRES_NEW的区别:NOT_SUPPORTED是完全不开启事务,而REQUIRES_NEW是开启一个独立的新事务。前者不涉及事务提交回滚,后者涉及。
3.6 MANDATORY:强制的“事务依赖者”
- 官方解释:如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常。
- 大白话:我必须在一个事务里干活,没有事务就别叫我!
- 典型场景:用于强制某些方法必须在事务上下文中被调用,是一种设计上的约束和防御性编程。可以避免在非事务环境下调用本应在事务中执行的方法,导致数据不一致。
@Service public class CriticalService { @Transactional(propagation = Propagation.MANDATORY) public void updateCriticalData(Data data) { // 这个操作非常重要,必须在事务保护下进行 criticalDataMapper.update(data); } } @Service public class OuterService { @Transactional // 有这个,调用 updateCriticalData 正常 public void businessProcess() { // ... 其他操作 criticalService.updateCriticalData(data); // OK } public void anotherProcess() { // 没有 @Transactional criticalService.updateCriticalData(data); // 抛出 IllegalTransactionStateException! } }
3.7 NEVER:决绝的“事务排斥者”
- 官方解释:以非事务方式执行。如果当前存在事务,则抛出异常。
- 大白话:我坚决不在事务里干活,你们要是正在搞事务,就别来烦我!
- 典型场景:明确要求不能在事务中执行的方法。比如,发送消息到消息队列。消息队列的生产者调用通常不希望被包裹在数据库事务里,因为事务提交可能很慢,或者我们希望消息发送和数据库操作解耦。使用
NEVER可以确保调用方不会错误地在一个事务方法里调用它。
注意:在现代架构中,更常见的做法是使用事务消息(如 RocketMQ 的事务消息)或本地消息表来保证数据库操作与消息发送的最终一致性,而不是简单用@Service public class MessageService { @Transactional(propagation = Propagation.NEVER) public void sendOrderCreatedMessage(Order order) { // 发送消息到MQ rocketMQTemplate.send(order); } }NEVER。NEVER更多是一种严格的约束。
4. 核心原理与底层机制拆解
理解了“是什么”和“怎么用”,我们再来深挖一层“为什么”。Spring 是如何实现这套传播机制的呢?关键在于两个核心接口:PlatformTransactionManager和TransactionStatus,以及背后的线程绑定机制。
1.PlatformTransactionManager(平台事务管理器)这是 Spring 事务抽象的核心。无论是 JDBC、JPA、Hibernate 还是 JTA,Spring 都通过这个接口的统一方法来管理事务(getTransaction,commit,rollback)。我们配置的DataSourceTransactionManager或JpaTransactionManager就是它的实现。
2.TransactionStatus(事务状态)它代表一个事务的当前状态,包含了诸如“是否是新事务”、“是否有保存点”、“是否已完成”等信息。传播行为的决策,就体现在getTransaction(TransactionDefinition definition)这个方法里,根据传入的定义(包含传播行为)和当前线程状态,决定是返回一个已有的TransactionStatus(对应加入事务),还是创建一个新的。
3. 线程绑定与TransactionSynchronizationManager这是实现传播的“粘合剂”。它是一个基于ThreadLocal的工具类,关键属性有:
resources: 一个 Map,用来将数据源(DataSource)映射到实际的数据库连接(Connection)或其他资源。synchronizations: 一个TransactionSynchronization回调列表,用于在事务完成前后执行自定义逻辑(如清理缓存、发送事件)。currentTransactionName: 当前事务的名称。currentTransactionReadOnly: 当前事务是否只读。currentTransactionIsolationLevel: 当前事务的隔离级别。actualTransactionActive: 当前是否真的有事务活动。
传播决策流程(以REQUIRED和REQUIRES_NEW为例):
假设方法A (REQUIRED) 调用方法B (REQUIRES_NEW)。
- 进入方法A,Spring 拦截器调用
TransactionManager.getTransaction(),传入Propagation.REQUIRED。 TransactionManager通过TransactionSynchronizationManager检查当前线程是否已绑定资源(连接)和事务状态。发现没有,于是:- 从数据源获取一个新的
Connection。 - 设置
autoCommit=false。 - 将这个
Connection绑定到当前线程的resources中。 - 创建一个新的
TransactionStatus对象(标记为新事务)并返回。
- 从数据源获取一个新的
- 方法A执行,调用方法B。
- 进入方法B,Spring 拦截器再次调用
TransactionManager.getTransaction(),但这次传入Propagation.REQUIRES_NEW。 TransactionManager检查当前线程,发现已绑定了一个活跃的事务(来自方法A)。由于传播行为是REQUIRES_NEW,它需要挂起当前事务:- 挂起:将当前线程绑定的所有事务资源(主要是那个
Connection和TransactionStatus)解绑,并保存在一个SuspendedResourcesHolder对象中。 - 新建:从数据源再获取一个新的
Connection,绑定到线程,开启新事务,创建新的TransactionStatus。
- 挂起:将当前线程绑定的所有事务资源(主要是那个
- 方法B在这个全新的连接和事务中执行。提交或回滚只影响这个新连接上的操作。
- 方法B执行完毕,Spring 会:
- 提交/回滚方法B的事务。
- 将方法B的连接归还连接池或关闭。
- 恢复:将之前挂起的
SuspendedResourcesHolder中的资源(方法A的连接和事务状态)重新绑定回当前线程。
- 控制权回到方法A,它继续使用自己原来的连接执行。最终方法A提交时,提交的是它自己连接上的所有操作。
这个过程清晰地展示了“挂起”和“独立连接”的含义。NESTED的实现则不同,它不会获取新连接,而是在当前连接上执行SAVEPOINT savepoint_name的SQL语句来创建保存点。
5. 实战中的高频“深坑”与避坑指南
理论懂了,代码还是会写错。下面是我总结的几个最容易出问题的地方。
坑一:REQUIRES_NEW不生效,内外事务一起回滚
- 现象:在方法A中调用方法B,B配置了
@Transactional(propagation = Propagation.REQUIRES_NEW),期望B独立提交。但当B执行完,A随后抛出异常时,B的操作也被回滚了。 - 根因:异常被吃掉了或传播错了。Spring 事务的回滚规则是:默认只对
RuntimeException和Error回滚。如果方法B内部抛出的异常被自己catch了但没有重新抛出,或者抛出的是受检异常(如Exception)且A方法没有配置@Transactional(rollbackFor = Exception.class),那么B方法的事务可能不会回滚,但A方法抛出的运行时异常会导致A的事务回滚。关键在于,如果B方法的事务已经提交了,那么A的事务回滚是无法影响B的。出现一起回滚,说明B的事务根本没提交成功。 - 排查:
- 检查B方法内部是否捕获了异常。
- 检查B方法抛出的异常类型。确保是
RuntimeException或已在@Transactional中声明回滚的异常。 - 最关键的:检查B方法是否被正确代理。如果B方法是在同一个类内部被调用的(即
this.methodB()),那么@Transactional注解会失效,因为Spring基于AOP的代理无法拦截内部调用。此时B方法根本没有独立事务,它的操作属于A事务的一部分。
- 解决方案:
- 确保异常正确抛出。
- 解决自调用问题:将B方法抽取到另一个
@ServiceBean中,通过注入的Bean来调用;或者使用AopContext.currentProxy()(不推荐,侵入性强)。
坑二:NESTED报错No existing transaction found for transaction marked with propagation 'nested'
- 现象:配置了
NESTED的方法在调用时抛出上述异常。 - 根因:
NESTED行为要求当前必须存在一个事务。如果调用它的方法没有@Transactional,或者事务传播行为是NOT_SUPPORTED、NEVER、SUPPORTS(且当前无事务),就会触发此错误。 - 解决方案:确保
NESTED方法被一个具有有效事务(REQUIRED,REQUIRES_NEW,MANDATORY等)的上下文所调用。
坑三:事务与异步方法(@Async)的冲突
- 现象:在事务方法中,调用一个异步方法去执行数据库更新,发现更新不生效或报错。
- 根因:Spring 的
@Async默认使用不同的线程池执行任务。事务资源(Connection)是绑定在调用线程的ThreadLocal上的。当任务切换到另一个线程时,原线程的事务上下文无法传递过去,导致异步方法内要么没有事务,要么开启的是另一个完全独立的事务。 - 解决方案:
- 方案A(简单但需注意):在异步方法内部自己管理事务,即在其方法上添加
@Transactional。但这意味着它是一个独立事务,与调用方事务无关联。 - 方案B(复杂但严谨):使用编程式事务管理(
TransactionTemplate)在异步任务的Runnable或Callable内部显式控制事务边界。 - 方案C(架构层面):重新考虑业务设计,避免在事务边界内进行真正的异步数据库写操作。通常的做法是,在主事务中记录一个“待处理”状态到数据库,然后提交。再由异步任务去轮询或监听消息,处理这个“待处理”的记录。这保证了主事务的快速提交和最终一致性。
- 方案A(简单但需注意):在异步方法内部自己管理事务,即在其方法上添加
坑四:事务传播与锁的协同问题
- 场景:方法A (
REQUIRED) 先查询并锁定某行数据(SELECT ... FOR UPDATE),然后调用方法B (REQUIRES_NEW)。方法B也需要更新这行数据。 - 问题:在MySQL中,行锁是基于连接的。方法A和方法B使用了不同的数据库连接(因为
REQUIRES_NEW)。方法B的连接无法获取被方法A的连接持有的行锁,会导致锁等待超时。如果设计不当,甚至可能形成跨连接死锁。 - 避坑指南:在设计使用
REQUIRES_NEW或NOT_SUPPORTED的方法时,要高度警惕它们是否可能与外部事务操作相同的数据库资源。如果可能,需要仔细评估锁的粒度和持有时间,或者考虑改变设计,避免跨事务的锁竞争。
6. 如何为你的方法选择正确的传播行为?
面对七种选择,不必慌张。遵循以下决策路径,可以帮你做出90%以上的正确选择:
第一步:这个方法要不要事务?
- 纯查询,且不需要“可重复读”等事务隔离级别保证-> 考虑
SUPPORTS或readOnly=true。甚至可以不加@Transactional,让数据库autoCommit。 - 需要原子性的增删改-> 进入第二步。
- 纯查询,且不需要“可重复读”等事务隔离级别保证-> 考虑
第二步:它通常被谁调用?
- 几乎总是作为其他业务方法的一部分被调用(如扣库存、生成订单项) ->首选
REQUIRED(默认值)。这是最安全、最常用的选择,保证大家在同一艘船上。 - 需要强制在事务中被调用(如关键状态更新) -> 使用
MANDATORY。这是一种防御性编程,在编译期就能暴露设计缺陷。 - 需要强制不在事务中被调用(如发送消息) -> 使用
NEVER。
- 几乎总是作为其他业务方法的一部分被调用(如扣库存、生成订单项) ->首选
第三步:它是否需要独立于调用方事务?
- 需要独立,且失败不影响调用方,成功也必须持久化(如审计日志) -> 使用
REQUIRES_NEW。务必评估连接池压力和死锁风险。 - 需要部分独立,即自己失败不影响调用方,但调用方失败自己也要回滚(如批量处理中的子批次) -> 使用
NESTED。务必确认数据库支持保存点。 - 希望不参与事务,避免长事务持有锁(如耗时计算) -> 使用
NOT_SUPPORTED。
- 需要独立,且失败不影响调用方,成功也必须持久化(如审计日志) -> 使用
一个简单的选择矩阵:
| 方法特性 | 推荐传播行为 | 原因 |
|---|---|---|
| 标准的增删改业务单元 | REQUIRED | 保证原子性,与调用方同进退 |
| 必须记录成功的日志 | REQUIRES_NEW | 独立提交,防止被主业务回滚 |
| 批量处理中的可失败子任务 | NESTED | 支持部分回滚,依赖主事务 |
| 查询方法 | SUPPORTS或readOnly=true | 灵活,避免不必要的开启事务 |
| 必须在事务中调用的关键方法 | MANDATORY | 强制约束,提前暴露错误调用 |
| 绝对不能在有事务时调用的方法 | NEVER | 强制约束,防止错误嵌套 |
| 避免受事务影响的耗时操作 | NOT_SUPPORTED | 挂起当前事务,非事务运行 |
最后,记住两个黄金实践:
- 保持简单:除非有明确且强烈的理由,否则坚持使用默认的
REQUIRED。过度设计传播行为会增加系统的复杂性和理解成本。 - 充分测试:尤其是使用了
REQUIRES_NEW、NESTED等行为时,必须编写集成测试,模拟正常、异常情况,验证事务的提交和回滚是否符合预期。可以结合SpringBootTest和内存数据库(如 H2)进行快速验证。
事务传播不是魔法,它是一套清晰的契约。理解每个行为背后的“合约条款”,就能在复杂的业务流中,精准地控制好每一笔数据操作的生死与共。