5个测试用例带你彻底理解Spring事务传播行为( 附完整源代码)

5个测试用例带你彻底理解Spring事务传播行为( 附完整源代码)

写在前面

说实话,Spring事务传播行为这块,我之前一直是半懂不懂的状态。

面试的时候能背出来7种传播行为,REQUIRED、REQUIRES_NEW、NESTED啥的,但要问我"内层方法抛异常,外层会不会回滚",我就开始心虚了。看网上的文章,要么太理论讲不清楚,要么直接贴Spring源码让人更晕。

后来我干脆自己手写了个简化版的事务框架(上篇文章写过),把核心逻辑剥出来,然后写了5个测试用例,一个个跑一遍。现在可以拍着胸脯说:事务传播这块,我是真懂了

今天这篇文章,就带你通过这5个测试用例,把事务传播行为彻底搞明白。

为什么传播行为这么难理解?

我觉得主要是两个原因:

  1. 场景太抽象:文档里说"有事务就加入,没有就新建",但"加入"到底是啥意思?共享一个连接?还是开个子事务?

  2. 异常处理复杂:内层方法抛异常,外层要不要回滚?内层回滚了,外层还能提交吗?这些边界情况很容易搞混。

所以我的思路是:用实际代码跑一遍,看看数据库里到底有没有数据,比看一万字的文档都管用

测试环境准备

为了让大家能直接复现,我先说说环境:

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条记录

原理解析

这个最简单,执行流程是这样的:

sequenceDiagram participant Client participant Proxy participant Interceptor participant TxManager participant DB Client->>Proxy: placeOrder() Proxy->>Interceptor: 拦截调用 Interceptor->>TxManager: begin() 开启事务 TxManager->>DB: setAutoCommit(false) Interceptor->>DB: INSERT orders Interceptor->>DB: INSERT order_items Interceptor->>TxManager: commit() TxManager->>DB: conn.commit() TxManager->>DB: close()

关键点:

  1. 拦截器在方法执行前调用begin(),拿到数据库连接并关闭自动提交
  2. 业务代码执行,在同一个连接上执行两条INSERT
  3. 方法正常返回,拦截器调用commit()
  4. 最后清理资源,恢复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会导致回滚。

sequenceDiagram participant Client participant Interceptor participant TxManager participant DB Client->>Interceptor: placeOrder() Interceptor->>TxManager: begin() TxManager->>DB: setAutoCommit(false) Interceptor->>DB: INSERT orders (成功) Interceptor->>DB: INSERT order_items (成功) Interceptor->>Interceptor: 抛RuntimeException Interceptor->>TxManager: rollback() TxManager->>DB: conn.rollback() Note over DB: orders和order_items都被回滚

关键点:

  1. 虽然两条INSERT都执行成功了,但还没commit
  2. 抛出RuntimeException后,拦截器捕获异常
  3. 判断是运行时异常,调用rollback()
  4. 数据库回滚,两条记录都不会保存

这个很好理解,我之前踩过坑的是下一个场景。

测试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传播的核心:有事务就加入,没有就新建

sequenceDiagram participant Outer participant OuterInterceptor participant Inner participant InnerInterceptor participant TxContext participant DB Outer->>OuterInterceptor: placeOrderWithInner() OuterInterceptor->>TxContext: begin() 开启事务 TxContext->>DB: 获取连接,setAutoCommit(false) OuterInterceptor->>DB: INSERT orders OuterInterceptor->>Inner: 调用createOrderItem() Inner->>InnerInterceptor: 拦截 InnerInterceptor->>TxContext: 检查是否已有事务 TxContext-->>InnerInterceptor: 有事务,直接加入 InnerInterceptor->>DB: INSERT order_items (用同一个conn) InnerInterceptor-->>OuterInterceptor: 返回 OuterInterceptor->>TxContext: commit() TxContext->>DB: conn.commit() 一次性提交所有操作

关键点:

  1. 外层开启事务,把Connection放到ThreadLocal
  2. 内层发现ThreadLocal里已经有事务了,直接复用这个连接
  3. 内外层的SQL都在同一个Connection上执行
  4. 最后外层统一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)

我们来看具体流程:

sequenceDiagram participant Outer participant OuterInterceptor participant Inner participant InnerInterceptor participant TxContext Outer->>OuterInterceptor: placeOrderWithInner() OuterInterceptor->>TxContext: begin() 外层开启 OuterInterceptor->>Outer: INSERT orders OuterInterceptor->>Inner: 调用内层 Inner->>InnerInterceptor: 拦截 InnerInterceptor->>TxContext: 检查,已有事务,加入 InnerInterceptor->>Inner: INSERT order_items Inner->>Inner: 抛RuntimeException InnerInterceptor->>InnerInterceptor: catch到异常 InnerInterceptor->>TxContext: setRollbackOnly() 打标记 InnerInterceptor-->>OuterInterceptor: 异常向上抛 OuterInterceptor->>OuterInterceptor: catch到异常 OuterInterceptor->>TxContext: commit() 尝试提交 TxContext->>TxContext: 检查rollbackOnly=true TxContext->>TxContext: 执行rollback() 整体回滚 Note over TxContext: orders和order_items都回滚

关键点:

  1. 内层抛异常后,不能直接rollback(因为事务是外层开的)
  2. 内层只能打个标记:setRollbackOnly(true)
  3. 异常继续向上抛,外层捕获
  4. 外层尝试commit时,发现有rollbackOnly标记
  5. 最终执行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个测试跑下来,我总结了几条生产经验:

  1. 统一加rollbackFor = Exception.class
    • 避免受检异常不回滚的坑
  2. 理解REQUIRED的"统一进退"
    • 内层失败 = 整体回滚
    • 不要企图catch住内层异常让外层继续提交
  3. 拆分服务要小心
    • 不同类的方法调用才会走代理
    • 同类内部调用事务不生效
  4. 数据库连接要复用
    • REQUIRED传播下,内外层共享一个Connection
    • 所以可以看到彼此未提交的数据
  5. ThreadLocal一定要清理
    • finally块里统一清理资源
    • 否则线程池复用会出问题

下一步:REQUIRES_NEW

REQUIRED搞清楚后,下一个要研究的是REQUIRES_NEW。它跟REQUIRED最大的区别:

  • REQUIRED:有事务就加入(共享Connection)
  • REQUIRES_NEW:总是新建事务(挂起外层,新开Connection)

这个涉及到事务挂起和恢复,需要维护一个事务栈,实现起来更复杂。我准备下一篇文章专门写这个。

写在最后

事务传播行为这块,说实话光看文档真的很难理解。我这次通过手写框架+测试用例,算是彻底搞明白了。

最大的收获是:不要怕动手实践。自己写200行代码,比看2000行源码理解得更深。

代码我放Gitee了(链接:gitee.com/sh_wangwanb... ) 有兴趣的同学可以clone下来跑跑看。下一篇我打算写REQUIRES_NEW的实现,涉及到事务挂起和恢复,应该会更有意思。


你们在用事务的时候,有没有遇到过"内层失败外层却提交了"的坑?或者还有哪些传播行为的问题搞不清楚?欢迎评论区聊聊,大家一起交流。

如果文章对您有帮助,辛苦点个赞支持下呗。您的支持,是我创作的最大动力,谢谢。

下篇见!

相关推荐
用户8307196840823 小时前
Spring Boot 集成 RabbitMQ :8 个最佳实践,杜绝消息丢失与队列阻塞
spring boot·后端·rabbitmq
Java水解4 小时前
Spring Boot 视图层与模板引擎
spring boot·后端
Java水解4 小时前
一文搞懂 Spring Boot 默认数据库连接池 HikariCP
spring boot·后端
洋洋技术笔记8 小时前
Spring Boot Web MVC配置详解
spring boot·后端
初次攀爬者1 天前
Kafka 基础介绍
spring boot·kafka·消息队列
用户8307196840821 天前
spring ai alibaba + nacos +mcp 实现mcp服务负载均衡调用实战
spring boot·spring·mcp
Java水解1 天前
SpringBoot3全栈开发实战:从入门到精通的完整指南
spring boot·后端
初次攀爬者2 天前
RocketMQ在Spring Boot上的基础使用
java·spring boot·rocketmq
花花无缺2 天前
搞懂@Autowired 与@Resuorce
java·spring boot·后端