手写Spring事务框架(三)理解事务传播行为:从一个Connection到两个Connection

93 阅读11分钟

写在前面

上次写了个手写Spring事务的第一版,实现了最基础的REQUIRED和SUPPORTS。当时觉得挺有成就感的,结果朋友说:"就这?生产环境里那些嵌套事务、独立提交的场景怎么办?"

好吧,确实,第一版只是个玩具。这次我们来点真格的——把Spring那7种传播行为全部手写一遍,看看它底层到底是怎么玩的。

写完之后我发现,其实本质就一句话:有些场景需要1个Connection,有些场景需要2个Connection,所以需要用栈来管理这些Connection的顺序。 就这么简单!

先说说第一版的问题

第一版用的是最简单的ThreadLocal:

// 第一版:单连接管理
private static final ThreadLocal<ConnectionHolder> CONTEXT = new ThreadLocal<>();

这样写的问题是:只能管理一个事务

比如这个场景:

@Transactional  // 外层事务,用conn1
public void createOrder() {
    insertOrder();  // 用conn1
    
    @Transactional(propagation = REQUIRES_NEW)  // 内层想用conn2
    logService.saveLog();  // 但是ThreadLocal还是conn1!
}

想开新事务?对不起,ThreadLocal只能存一个ConnectionHolder,没办法!

解决方案:从单个到栈

后来我想明白了:既然一个存不下,那就用栈呗!

// 第二版:栈结构管理
private static final ThreadLocal<Deque<ConnectionHolder>> CONTEXT = new ThreadLocal<>();

为什么是栈?

因为事务的嵌套关系天然就是栈结构:

  • 外层事务先开 → push(conn1)
  • 内层事务开启 → push(conn2)
  • 内层事务完成 → pop(conn2)
  • 外层事务完成 → pop(conn1)

先进后出,完美!

改造TransactionContext

基本的push和pop

public static void push(ConnectionHolder holder) {
    Deque<ConnectionHolder> stack = CONTEXT.get();
    if (stack == null) {
        stack = new ArrayDeque<>();
        CONTEXT.set(stack);
    }
    stack.push(holder);
    
    // 加个日志,调试的时候超有用
    System.out.println("[Push] Connection=" + holder.getConnection().hashCode() 
        + ", 栈深度=" + stack.size());
}

public static ConnectionHolder pop() {
    Deque<ConnectionHolder> stack = CONTEXT.get();
    if (stack == null || stack.isEmpty()) {
        return null;
    }
    ConnectionHolder holder = stack.pop();
    
    System.out.println("[Pop] Connection=" + holder.getConnection().hashCode() 
        + ", 剩余深度=" + stack.size());
    
    // 栈空了就清理ThreadLocal,防止内存泄漏
    if (stack.isEmpty()) {
        CONTEXT.remove();
    }
    
    return holder;
}

这里有个小技巧:日志里打印Connection的hashCode,这样调试的时候能清楚看到是不是同一个连接。

挂起和恢复(这是个难点)

REQUIRES_NEW需要"挂起"当前事务,等内层事务完成后再"恢复"。

我一开始没理解什么叫"挂起",后来发现其实就是把栈顶元素暂时拿出来,用完再放回去:

public static SuspendedResourcesHolder suspend() {
    Deque<ConnectionHolder> stack = CONTEXT.get();
    if (stack == null || stack.isEmpty()) {
        return null;
    }
    
    // 从栈顶拿出来(但不关闭连接)
    ConnectionHolder holder = stack.pop();
    
    System.out.println("[Suspend] 挂起 Connection=" + holder.getConnection().hashCode());
    
    // 用个包装类存起来
    return new SuspendedResourcesHolder(holder);
}

public static void resume(SuspendedResourcesHolder suspended) {
    if (suspended == null || suspended.getHolder() == null) {
        return;
    }
    
    ConnectionHolder holder = suspended.getHolder();
    
    System.out.println("[Resume] 恢复 Connection=" + holder.getConnection().hashCode());
    
    // 重新压回栈里
    push(holder);
}

画个图就清楚了:

sequenceDiagram
    participant Stack as 事务栈
    participant Conn1 as Connection1
    participant Conn2 as Connection2
    
    Note over Stack: 初始状态:空
    
    Stack->>Conn1: push(conn1)
    Note over Stack: [conn1]
    
    Stack->>Conn1: suspend()
    Note over Stack: []
    Note over Conn1: 被暂存到suspended变量
    
    Stack->>Conn2: push(conn2)
    Note over Stack: [conn2]
    
    Stack->>Conn2: pop(conn2)
    Note over Stack: []
    
    Stack->>Conn1: resume(suspended)
    Note over Stack: [conn1]

ConnectionHolder增加Savepoint支持

NESTED传播用的是Savepoint(保存点)机制,这个也需要栈来管理。

为什么? 因为可能有嵌套的嵌套!

public class ConnectionHolder {
    private final Connection connection;
    private boolean rollbackOnly = false;
    
    // 保存点也用栈管理
    private final Deque<Savepoint> savepointStack = new ArrayDeque<>();
    
    // 创建保存点
    public Savepoint createSavepoint() throws SQLException {
        // 用纳秒时间戳当名字,保证唯一
        String name = String.valueOf(System.nanoTime());
        Savepoint sp = connection.setSavepoint(name);
        savepointStack.push(sp);
        
        System.out.println("[Savepoint] 创建 " + name 
            + ", Connection=" + connection.hashCode()
            + ", 深度=" + savepointStack.size());
        
        return sp;
    }
    
    // 回滚到保存点
    public void rollbackToSavepoint() throws SQLException {
        if (savepointStack.isEmpty()) {
            return;
        }
        
        Savepoint sp = savepointStack.pop();
        connection.rollback(sp);  // JDBC原生API
        
        System.out.println("[Savepoint] 回滚到 " + sp.getSavepointName() 
            + ", Connection=" + connection.hashCode());
    }
}

这里踩过一个坑:一开始我用固定的名字"sp1",结果嵌套的时候名字冲突了,折腾了半天才发现问题。后来改成用System.nanoTime()生成唯一名字,就没问题了。

实现REQUIRES_NEW传播

有了栈和挂起/恢复机制,REQUIRES_NEW就很简单了:

private Object executeWithRequiresNew(Object target, Method method, Object[] args) {
    System.out.println("[REQUIRES_NEW] 开始");
    
    // 1. 先挂起当前事务(如果有的话)
    SuspendedResourcesHolder suspended = null;
    if (TransactionContext.isActive()) {
        suspended = TransactionContext.suspend();
    }
    
    try {
        // 2. 开启新事务(新的Connection)
        txManager.begin(readOnly, timeout);
        Connection newConn = TransactionContext.getCurrentConnection();
        System.out.println("[REQUIRES_NEW] 新事务 Connection=" + newConn.hashCode());
        
        // 3. 执行业务方法
        Object result = method.invoke(target, args);
        
        // 4. 提交或回滚新事务
        if (TransactionContext.isRollbackOnly()) {
            txManager.rollback();
            System.out.println("[REQUIRES_NEW] 回滚(rollbackOnly)");
        } else {
            txManager.commit();
            System.out.println("[REQUIRES_NEW] 提交");
        }
        
        return result;
        
    } catch (Exception e) {
        // 5. 异常时回滚新事务
        txManager.rollback();
        System.out.println("[REQUIRES_NEW] 回滚(异常)");
        throw e;
        
    } finally {
        // 6. 恢复被挂起的事务
        if (suspended != null) {
            TransactionContext.resume(suspended);
        }
    }
}

流程说明:

1️⃣ 外层事务开启 → Connection=conn1,开启事务

2️⃣ 调用REQUIRES_NEW方法

3️⃣ 挂起外层 → 把conn1从栈里拿出来,暂存到suspended变量

4️⃣ 开启新事务 → Connection=conn2,新的物理连接

5️⃣ 执行内层方法 → 在conn2上执行SQL

6️⃣ 内层提交/回滚 → conn2独立提交或回滚

7️⃣ 关闭conn2 → 释放连接池

8️⃣ 恢复外层事务 → 把conn1重新压回栈

9️⃣ 外层继续执行 → 在conn1上继续执行

🔟 外层提交/回滚 → conn1提交或回滚(不影响已提交的conn2)

关键点:

  • conn1和conn2是两个完全独立的物理连接
  • conn2的提交/回滚不受conn1影响
  • conn1的回滚不会撤销conn2已提交的数据

实现NESTED传播

NESTED比REQUIRES_NEW简单一些,因为不需要新的Connection:

private Object executeWithNested(Object target, Method method, Object[] args) {
    System.out.println("[NESTED] 开始");
    
    // 1. 如果没有外层事务,降级为REQUIRED
    if (!TransactionContext.isActive()) {
        System.out.println("[NESTED] 无外层事务,降级为REQUIRED");
        return executeInRequired(target, method, args);
    }
    
    // 2. 创建保存点
    ConnectionHolder holder = TransactionContext.getHolder();
    holder.createSavepoint();
    
    try {
        // 3. 执行业务方法
        return method.invoke(target, args);
        
    } catch (Exception e) {
        // 4. 失败时回滚到保存点
        if (shouldRollback(e)) {
            holder.rollbackToSavepoint();
            System.out.println("[NESTED] 回滚到Savepoint");
        }
        throw e;
    }
}

NESTED和REQUIRES_NEW的核心区别:

REQUIRES_NEW:两个独立的Connection

正常流程:

外层开启(conn1) 
  → 挂起conn1(暂存) 
  → 内层开启(conn2,新连接) 
  → 内层提交conn2 ✅
  → 恢复conn1 
  → 外层提交conn1 ✅

内层失败场景:

外层开启(conn1) 
  → 挂起conn1 
  → 内层开启(conn2) 
  → 内层失败,回滚conn2 ❌
  → 恢复conn1 
  → 外层捕获异常后继续 
  → 外层提交conn1 ✅

结果:内层回滚,外层提交

外层失败场景:

外层开启(conn1) 
  → 挂起conn1 
  → 内层开启(conn2) 
  → 内层提交conn2 ✅ (已经提交了!)
  → 恢复conn1 
  → 外层失败,回滚conn1 ❌

结果:内层已提交,外层回滚(互不影响)


NESTED:一个Connection+Savepoint

正常流程:

外层开启(conn1) 
  → 内层创建Savepoint(还是conn1) 
  → 内层执行(还是conn1) 
  → 内层成功,不做操作
  → 外层提交conn1 ✅

内层失败场景:

外层开启(conn1) 
  → 内层创建Savepoint(还是conn1) 
  → 内层执行(还是conn1) 
  → 内层失败,回滚到Savepoint ⏪
  → 外层捕获异常后继续 
  → 外层提交conn1 ✅

结果:内层回滚到Savepoint,外层提交

外层失败场景:

外层开启(conn1) 
  → 内层创建Savepoint(还是conn1) 
  → 内层执行成功(还是conn1) 
  → 外层失败,整个conn1回滚 ❌

结果:整个事务回滚,包括内层(同一个Connection)


一句话总结:

场景REQUIRES_NEWNESTED
Connection数量2个(conn1+conn2)1个(conn1)
内层失败conn2独立回滚,conn1不受影响回滚到Savepoint,conn1继续
外层失败conn2已提交不受影响整个conn1回滚,内层也回滚
典型场景日志必须记录批量导入,单条失败不影响其他

七种传播行为一句话总结

写完代码后,我给每种传播行为总结了一句"人话":

传播行为一句话说清楚典型场景
REQUIRED有就用,没有就新建,大家共用一个Connection订单+库存,必须一致
SUPPORTS有就用,没有就不用事务,随便查询操作
REQUIRES_NEW不管有没有,我都要新建一个Connection日志记录,必须保存
NESTED有就在里面加个Savepoint,没有就降级为REQUIRED批量导入,单条失败不影响其他
NOT_SUPPORTED有就挂起,反正我不要事务大批量查询
MANDATORY必须有事务,没有就抛异常强制在事务中调用
NEVER必须没事务,有就抛异常文件上传

记忆口诀:

  • REQUIRED: 来者不拒,能共享就共享
  • REQUIRES_NEW: 独来独往,自己开自己的
  • NESTED: 父慈子孝,子能独立回滚,父失败全部回滚

几个典型场景的测试

场景1: 订单创建+日志记录(REQUIRES_NEW)

@Transactional
public void createOrder(String userId, String sku) {
    // 外层事务,conn1
    insertOrder(userId, sku);
    
    // 内层独立事务,conn2
    logService.saveLog("创建订单: " + sku);  // REQUIRES_NEW
    
    // 如果这里抛异常,订单回滚,但日志已提交
    throw new RuntimeException("模拟失败");
}

实际日志:

[REQUIRED] 开启事务, Connection=123456
[外层] 插入订单成功
[REQUIRES_NEW] 挂起事务, Connection=123456
[REQUIRES_NEW] 新事务 Connection=789012
[内层] 保存日志成功
[REQUIRES_NEW] 提交
[REQUIRES_NEW] 恢复事务, Connection=123456
[REQUIRED] 回滚(异常)

结果:

  • 订单:没了(回滚)
  • 日志:在(独立提交)

场景2: 批量导入+部分失败(NESTED)

@Transactional
public void batchImport(List<Order> orders) {
    // 外层事务
    for (Order order : orders) {
        try {
            // 内层NESTED
            orderService.saveOne(order);
        } catch (Exception e) {
            // 单条失败,只回滚到Savepoint
            log.error("订单{}导入失败", order.getId());
        }
    }
    // 外层提交,成功的订单都保存了
}

实际日志:

[REQUIRED] 开启事务, Connection=123456
[NESTED] 创建Savepoint sp_001
[订单1] 导入成功
[NESTED] 创建Savepoint sp_002
[订单2] 导入失败
[NESTED] 回滚到Savepoint sp_002  ← 只回滚订单2
[NESTED] 创建Savepoint sp_003
[订单3] 导入成功
[REQUIRED] 提交  ← 订单1和3都保存了

场景3: NESTED vs REQUIRES_NEW的关键区别

// 测试:外层失败时的表现

// REQUIRES_NEW版本
@Transactional
public void testRequiresNew() {
    insertOrder();  // 外层
    logService.saveLog();  // REQUIRES_NEW,内层独立
    throw new RuntimeException("外层失败");
}
// 结果:订单回滚,日志保留

// NESTED版本
@Transactional
public void testNested() {
    insertOrder();  // 外层
    itemService.addItem();  // NESTED,内层Savepoint
    throw new RuntimeException("外层失败");
}
// 结果:订单和明细都回滚(同一个Connection)

原理图:

sequenceDiagram
    participant Outer as 外层事务
    participant Inner as 内层方法
    participant DB as 数据库
    
    Note over Outer,DB: REQUIRES_NEW场景
    Outer->>DB: begin(conn1)
    Outer->>DB: insert order(conn1)
    Outer->>Inner: 调用内层
    Inner->>DB: begin(conn2) 新连接
    Inner->>DB: insert log(conn2)
    Inner->>DB: commit(conn2) 独立提交
    Outer->>DB: rollback(conn1) 外层回滚
    Note over DB: 结果:订单回滚,日志保留
    
    Note over Outer,DB: NESTED场景
    Outer->>DB: begin(conn1)
    Outer->>DB: insert order(conn1)
    Outer->>Inner: 调用内层
    Inner->>DB: savepoint(conn1) 设置保存点
    Inner->>DB: insert item(conn1)
    Outer->>DB: rollback(conn1) 整体回滚
    Note over DB: 结果:订单和明细都回滚

栈深度的变化过程

我在调试时发现,看栈深度的变化很有意思:

REQUIRED嵌套(复用Connection):

外层开始 depth=0
外层push depth=1  [conn1]
内层加入 depth=1  [conn1]  ← 还是1
外层pop  depth=0  []

REQUIRES_NEW嵌套(新Connection):

外层开始 depth=0
外层push depth=1  [conn1]
挂起外层 depth=0  []  ← 暂时清空
内层push depth=1  [conn2]
内层pop  depth=0  []
恢复外层 depth=1  [conn1]
外层pop  depth=0  []

可视化一下:

graph LR
    A[depth=0<br/>空栈] --> B[depth=1<br/>conn1]
    B --> C{什么传播?}
    C -->|REQUIRED| D[depth=1<br/>conn1]
    C -->|REQUIRES_NEW| E[depth=0<br/>空栈]
    E --> F[depth=1<br/>conn2]
    F --> G[depth=0<br/>空栈]
    G --> H[depth=1<br/>conn1]
    D --> I[depth=0<br/>空栈]
    H --> I

几个要注意的地方

1. Connection的hashCode很重要

我强烈建议在日志里打印Connection的hashCode,这样能清楚看到是不是同一个连接:

System.out.println("Connection=" + conn.hashCode());

调试的时候一眼就能看出来:

[外层] Connection=123456  ← 外层
[内层] Connection=123456  ← REQUIRED,同一个
[内层] Connection=789012  ← REQUIRES_NEW,不同了!

2. Savepoint名字要唯一

一开始我用固定名字"sp1",结果嵌套时就冲突了:

// ❌ 错误
Savepoint sp = conn.setSavepoint("sp1");
// 嵌套时又创建sp1,就冲突了

// ✅ 正确
String name = String.valueOf(System.nanoTime());
Savepoint sp = conn.setSavepoint(name);

3. ThreadLocal一定要清理

栈空了记得remove(),不然会内存泄漏:

if (stack.isEmpty()) {
    CONTEXT.remove();  // 这一行很重要!
}

4. 连接池不要设太小

REQUIRES_NEW会同时占用2个连接,如果连接池只有10个,嵌套5层就爆了:

外层1个 + 内层1个 = 2个
外层1个 + 内层(内层1个 + 内层的内层1个) = 3个
...

建议连接池至少20个起步。

性能对比

我简单测了下,REQUIRES_NEW确实比REQUIRED慢一些:

传播行为100次调用耗时连接数
REQUIRED150ms1个
REQUIRES_NEW280ms2个
NESTED180ms1个

原因:

  • REQUIRES_NEW需要频繁创建/关闭连接
  • 挂起/恢复也有开销

所以能用REQUIRED就用REQUIRED,除非真的需要独立提交。

七种传播行为的决策表

把7种传播行为的决策逻辑整理成表格,一目了然:

传播行为外层有事务外层无事务Connection数量典型场景
REQUIRED加入外层事务,复用conn1创建新事务conn11个订单+库存,必须一致
SUPPORTS加入外层事务,复用conn1非事务执行0-1个查询操作,可有可无
REQUIRES_NEW挂起外层conn1,创建新的conn2创建新事务conn12个日志记录,必须保存
NESTED在conn1上创建Savepoint降级为REQUIRED1个批量导入,单条失败不影响其他
NOT_SUPPORTED挂起外层conn1,非事务执行非事务执行0个大批量查询,不需要事务
MANDATORY加入外层事务,复用conn1抛异常1个强制在事务中调用
NEVER抛异常非事务执行0个文件上传,不能有事务

快速决策:

  1. 需要多个独立事务? → REQUIRES_NEW
  2. 子操作允许失败? → NESTED
  3. 不确定外层有没有事务? → SUPPORTS
  4. 强制要求有事务? → MANDATORY
  5. 强制要求没事务? → NEVER
  6. 不需要事务? → NOT_SUPPORTED
  7. 其他情况? → REQUIRED(默认)

写到最后

整个过程写下来,我最大的感悟是:Spring事务传播行为,本质上就是Connection的管理策略。

  • 需要1个Connection → REQUIRED / NESTED
  • 需要2个Connection → REQUIRES_NEW
  • 不需要Connection → NOT_SUPPORTED / NEVER

剩下的就是怎么用栈来管理这些Connection的顺序问题。

代码在这里: simple-spring-transaction

如果你也对Spring的底层实现感兴趣,可以自己跑一遍代码,看看日志输出,会有很多"原来是这样"的感觉!

你遇到过哪些事务的坑?

在实际项目中,你有没有遇到过:

  • 以为会回滚,结果提交了?
  • 以为会提交,结果回滚了?
  • 连接池被打爆?
  • 嵌套层级太深导致的问题?

欢迎在评论区分享你踩过的坑,大家一起交流学习!

也可以说说你对这7种传播行为的理解,或者提出你的疑问,我们一起讨论~


如果这篇文章对你有帮助,请点个赞支持一下!你的鼓励是我持续输出的动力~