5个测试用例带你彻底理解Spring事务传播行为( 附完整源代码)
写在前面
说实话,Spring事务传播行为这块,我之前一直是半懂不懂的状态。
面试的时候能背出来7种传播行为,REQUIRED、REQUIRES_NEW、NESTED啥的,但要问我"内层方法抛异常,外层会不会回滚",我就开始心虚了。看网上的文章,要么太理论讲不清楚,要么直接贴Spring源码让人更晕。
后来我干脆自己手写了个简化版的事务框架(上篇文章写过),把核心逻辑剥出来,然后写了5个测试用例,一个个跑一遍。现在可以拍着胸脯说:事务传播这块,我是真懂了。
今天这篇文章,就带你通过这5个测试用例,把事务传播行为彻底搞明白。
为什么传播行为这么难理解?
我觉得主要是两个原因:
-
场景太抽象:文档里说"有事务就加入,没有就新建",但"加入"到底是啥意思?共享一个连接?还是开个子事务?
-
异常处理复杂:内层方法抛异常,外层要不要回滚?内层回滚了,外层还能提交吗?这些边界情况很容易搞混。
所以我的思路是:用实际代码跑一遍,看看数据库里到底有没有数据,比看一万字的文档都管用。
测试环境准备
为了让大家能直接复现,我先说说环境:
sql
-- 数据库:MySQL 8.x
CREATE DATABASE sst_demo;
-- 订单表
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(64) NOT NULL UNIQUE,
user_id VARCHAR(64) NOT NULL,
total_amount DECIMAL(10,2) NOT NULL,
status TINYINT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 订单明细表
CREATE TABLE order_items (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_id BIGINT NOT NULL,
sku VARCHAR(64) NOT NULL,
quantity INT NOT NULL,
price DECIMAL(10,2) NOT NULL
);
业务场景就是下单:插入订单主表 + 插入明细表,这两步要么都成功,要么都失败。
测试1:正常提交 - 最基础的场景
代码
java
@Transactional(rollbackFor = Exception.class)
public void placeOrder(String userId, String sku, int quantity) throws Exception {
Connection conn = TransactionContext.getCurrentConnection();
// 1. 插入订单
String orderNo = "ORD-" + UUID.randomUUID();
PreparedStatement ps = conn.prepareStatement(
"INSERT INTO orders(order_no, user_id, total_amount) VALUES(?, ?, ?)"
);
ps.setString(1, orderNo);
ps.setString(2, userId);
ps.setBigDecimal(3, new BigDecimal("99.99"));
ps.executeUpdate();
// 2. 插入明细
long orderId = getGeneratedId(ps);
PreparedStatement ps2 = conn.prepareStatement(
"INSERT INTO order_items(order_id, sku, quantity, price) VALUES(?, ?, ?, ?)"
);
ps2.setLong(1, orderId);
ps2.setString(2, sku);
ps2.setInt(3, quantity);
ps2.setBigDecimal(4, new BigDecimal("99.99"));
ps2.executeUpdate();
System.out.println("订单创建成功:" + orderNo);
}
运行结果
diff
========== 测试1:正常提交 ==========
订单创建成功:ORD-xxx
订单创建成功
验证
sql
SELECT * FROM orders WHERE user_id='U1001';
-- 结果:有1条记录
SELECT * FROM order_items WHERE order_id=...;
-- 结果:有1条记录
原理解析
这个最简单,执行流程是这样的:
关键点:
- 拦截器在方法执行前调用
begin(),拿到数据库连接并关闭自动提交 - 业务代码执行,在同一个连接上执行两条INSERT
- 方法正常返回,拦截器调用
commit() - 最后清理资源,恢复autoCommit并关闭连接
这就是事务的基本流程,没啥特别的。
测试2:运行时异常回滚 - Spring的默认行为
代码
java
// 业务代码里模拟库存不足
if ("OUT_OF_STOCK".equals(sku)) {
throw new RuntimeException("库存不足,订单回滚");
}
运行结果
diff
========== 测试2:运行时异常回滚 ==========
捕获运行时异常:库存不足,订单回滚
验证
sql
SELECT * FROM orders WHERE user_id='U1002';
-- 结果:无记录
SELECT * FROM order_items WHERE sku='OUT_OF_STOCK';
-- 结果:无记录
原理解析
这个是Spring的默认回滚规则:RuntimeException和Error会导致回滚。
关键点:
- 虽然两条INSERT都执行成功了,但还没commit
- 抛出RuntimeException后,拦截器捕获异常
- 判断是运行时异常,调用
rollback() - 数据库回滚,两条记录都不会保存
这个很好理解,我之前踩过坑的是下一个场景。
测试3:受检异常回滚 - 容易被忽略的坑
代码
java
// 业务代码里抛受检异常
if ("INVALID_SKU".equals(sku)) {
throw new Exception("无效的SKU,订单回滚"); // 注意:这是Exception,不是RuntimeException
}
运行结果(如果没加rollbackFor)
假设我们的@Transactional没有配置rollbackFor:
java
@Transactional // 没有rollbackFor
public void placeOrder(...) throws Exception {
// 抛Exception
}
悲剧发生了:数据库里有数据!
sql
SELECT * FROM orders WHERE user_id='U1003';
-- 结果:有1条记录!!!
为什么?
因为Spring的默认规则:受检异常(Exception)不回滚,只提交。
我当时第一次遇到这个问题,在生产环境差点出事故。业务代码抛了个自定义的BusinessException(继承自Exception),我以为会回滚,结果数据都入库了。
正确做法
必须显式指定rollbackFor:
java
@Transactional(rollbackFor = Exception.class) // 所有异常都回滚
public void placeOrder(...) throws Exception {
// ...
}
现在再跑:
diff
========== 测试3:受检异常回滚 ==========
捕获受检异常:无效的SKU,订单回滚
验证:
sql
SELECT * FROM orders WHERE user_id='U1003';
-- 结果:无记录
异常回滚规则总结
这个表建议保存下来,面试常考:
| 异常类型 | 默认行为 | rollbackFor配置后 |
|---|---|---|
| RuntimeException | 回滚 | 回滚 |
| Error | 回滚 | 回滚 |
| Exception(受检异常) | 提交 | 回滚 |
| 自定义异常extends Exception | 提交 | 需显式配置 |
我的建议:生产环境统一加上rollbackFor = Exception.class,避免漏掉受检异常。
测试4:REQUIRED传播 - 外内层统一提交
好,前面3个测试都是单方法的,现在进入重点:方法嵌套调用时的传播行为。
场景设计
java
// 外层服务
@Transactional
public void placeOrderWithInner(String userId, String sku, int quantity) {
// 1. 插入外层订单
insertOuterOrder(userId, sku);
// 2. 调用内层服务
innerService.createOrderItem(orderId, sku, quantity);
System.out.println("外层方法执行完成");
}
// 内层服务
@Transactional // 默认REQUIRED
public void createOrderItem(long orderId, String sku, int quantity) {
// 插入订单明细
insertOrderItem(orderId, sku, quantity);
System.out.println("内层方法执行完成");
}
关键问题
外层和内层各有一个@Transactional,这会开几个事务?
很多人会说"两个事务",错了!
运行结果
diff
========== 传播:统一提交 ==========
外层订单创建成功:ORD-xxx
内层明细创建成功
内层方法执行完成
外层方法执行完成
外内层均成功,统一提交
验证:
sql
SELECT * FROM orders WHERE user_id='U2001';
-- 有记录
SELECT * FROM order_items WHERE order_id=...;
-- 有记录
原理:REQUIRED只有一个事务
这是REQUIRED传播的核心:有事务就加入,没有就新建。
关键点:
- 外层开启事务,把Connection放到ThreadLocal
- 内层发现ThreadLocal里已经有事务了,直接复用这个连接
- 内外层的SQL都在同一个Connection上执行
- 最后外层统一commit,一次性提交所有操作
所以:REQUIRED传播下,嵌套调用共享一个事务。
测试5:REQUIRED传播 - 内层失败整体回滚(难点)
这个是最容易搞混的场景,面试经常问。
代码
java
// 内层服务模拟失败
public void createOrderItem(long orderId, String sku, int quantity) {
insertOrderItem(orderId, sku, quantity);
if ("FAIL_INNER".equals(sku)) {
throw new RuntimeException("内层失败"); // 抛异常
}
}
运行结果
diff
========== 传播:内层失败整体回滚 ==========
捕获内层异常:内层失败
验证(重点)
sql
SELECT * FROM orders WHERE user_id='U2002';
-- 结果:无记录
SELECT * FROM order_items WHERE sku='FAIL_INNER';
-- 结果:无记录
注意:外层的订单也回滚了!虽然外层代码没抛异常。
为什么外层也回滚了?
很多人会想:内层抛异常,内层回滚就好了,外层继续提交不行吗?
不行!因为它们共享同一个事务(同一个Connection)。
我们来看具体流程:
关键点:
- 内层抛异常后,不能直接rollback(因为事务是外层开的)
- 内层只能打个标记:
setRollbackOnly(true) - 异常继续向上抛,外层捕获
- 外层尝试commit时,发现有rollbackOnly标记
- 最终执行rollback,整个事务回滚
这就是REQUIRED的"统一进退":要么都成功,要么都失败。
实际代码是怎么实现的?
我手写框架里的代码是这样的:
java
// 拦截器逻辑
try {
Object result = method.invoke(target, args);
if (started) { // 如果是我开启的事务
if (TransactionContext.isRollbackOnly()) {
txManager.rollback(); // 有人标记了回滚
} else {
txManager.commit();
}
}
return result;
} catch (InvocationTargetException e) {
Throwable ex = e.getTargetException();
if (shouldRollback(ex)) {
if (started) {
txManager.rollback(); // 我开的事务,我负责回滚
} else {
TransactionContext.setRollbackOnly(); // 不是我开的,打标记
}
}
throw ex;
}
这个started标志很关键:
- 外层:started=true(我开的事务)
- 内层:started=false(加入的事务)
所以:
- 内层抛异常:只打标记,不直接回滚
- 外层拿到标记:执行回滚
几个容易踩的坑
坑1:以为内层可以独立回滚
错误认知:
java
try {
innerService.createOrderItem(); // 内层失败
} catch (Exception e) {
// 以为catch住了就没事
}
// 以为外层可以继续提交
真相:catch住也没用,事务已经被标记为rollbackOnly。
坑2:同类方法调用不生效
这个是经典问题:
java
@Service
public class OrderService {
@Transactional
public void methodA() {
methodB(); // 直接调用
}
@Transactional
public void methodB() {
// 这个@Transactional不生效!
}
}
为什么?因为代理只能拦截外部调用,内部调用走的是this,不经过代理。
解决办法:拆到不同的类,或者通过注入拿到代理对象。
坑3:忘记配置rollbackFor
生产环境建议:
java
@Transactional(rollbackFor = Exception.class) // 统一配置
不要依赖默认行为,否则受检异常会让你抓狂。
传播行为对比表
虽然我这个简化版只实现了REQUIRED,但我把7种传播行为总结了下:
| 传播行为 | 外层有事务 | 外层无事务 | 使用场景 |
|---|---|---|---|
| REQUIRED | 加入外层事务 | 新建事务 | 默认,最常用 |
| REQUIRES_NEW | 挂起外层,新建事务 | 新建事务 | 日志记录(独立提交) |
| NESTED | 嵌套事务(SavePoint) | 新建事务 | 部分回滚场景 |
| SUPPORTS | 加入外层事务 | 非事务执行 | 查询方法 |
| NOT_SUPPORTED | 挂起外层事务 | 非事务执行 | 不需要事务的操作 |
| MANDATORY | 加入外层事务 | 抛异常 | 必须在事务中执行 |
| NEVER | 抛异常 | 非事务执行 | 禁止在事务中执行 |
生产环境99%的场景用REQUIRED就够了,REQUIRES_NEW用在日志记录(即使业务失败也要记日志),其他的基本用不上。
生产经验总结
这5个测试跑下来,我总结了几条生产经验:
- 统一加rollbackFor = Exception.class
- 避免受检异常不回滚的坑
- 理解REQUIRED的"统一进退"
- 内层失败 = 整体回滚
- 不要企图catch住内层异常让外层继续提交
- 拆分服务要小心
- 不同类的方法调用才会走代理
- 同类内部调用事务不生效
- 数据库连接要复用
- REQUIRED传播下,内外层共享一个Connection
- 所以可以看到彼此未提交的数据
- ThreadLocal一定要清理
- finally块里统一清理资源
- 否则线程池复用会出问题
下一步:REQUIRES_NEW
REQUIRED搞清楚后,下一个要研究的是REQUIRES_NEW。它跟REQUIRED最大的区别:
- REQUIRED:有事务就加入(共享Connection)
- REQUIRES_NEW:总是新建事务(挂起外层,新开Connection)
这个涉及到事务挂起和恢复,需要维护一个事务栈,实现起来更复杂。我准备下一篇文章专门写这个。
写在最后
事务传播行为这块,说实话光看文档真的很难理解。我这次通过手写框架+测试用例,算是彻底搞明白了。
最大的收获是:不要怕动手实践。自己写200行代码,比看2000行源码理解得更深。
代码我放Gitee了(链接:gitee.com/sh_wangwanb... ) 有兴趣的同学可以clone下来跑跑看。下一篇我打算写REQUIRES_NEW的实现,涉及到事务挂起和恢复,应该会更有意思。
你们在用事务的时候,有没有遇到过"内层失败外层却提交了"的坑?或者还有哪些传播行为的问题搞不清楚?欢迎评论区聊聊,大家一起交流。
如果文章对您有帮助,辛苦点个赞支持下呗。您的支持,是我创作的最大动力,谢谢。
下篇见!