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的实现,涉及到事务挂起和恢复,应该会更有意思。


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

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

下篇见!

相关推荐
踏浪无痕1 小时前
手写Spring事务框架:200行代码揭开@Transactional的神秘面纱( 附完整源代码)
spring boot·spring·spring cloud
R***62311 小时前
Spring Boot 整合 log4j2 日志配置教程
spring boot·单元测试·log4j
0***h9421 小时前
使用 java -jar 命令启动 Spring Boot 应用时,指定特定的配置文件的几种实现方式
java·spring boot·jar
刘一说1 小时前
Nacos 与 Spring Cloud Alibaba 集成详解:依赖、配置、实战与避坑指南
spring boot·spring cloud·微服务·架构
i***48611 小时前
微服务生态组件之Spring Cloud LoadBalancer详解和源码分析
java·spring cloud·微服务
一 乐2 小时前
购物|明星周边商城|基于springboot的明星周边商城系统设计与实现(源码+数据库+文档)
java·数据库·spring boot·后端·spring
y1y1z2 小时前
Spring框架教程
java·后端·spring
q***51892 小时前
Spring Boot中Tomcat配置
spring boot·tomcat·firefox
V***u4532 小时前
【学术会议论文投稿】Spring Boot实战:零基础打造你的Web应用新纪元
前端·spring boot·后端