写在前面
上次写了个手写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_NEW | NESTED |
|---|---|---|
| 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次调用耗时 | 连接数 |
|---|---|---|
| REQUIRED | 150ms | 1个 |
| REQUIRES_NEW | 280ms | 2个 |
| NESTED | 180ms | 1个 |
原因:
- REQUIRES_NEW需要频繁创建/关闭连接
- 挂起/恢复也有开销
所以能用REQUIRED就用REQUIRED,除非真的需要独立提交。
七种传播行为的决策表
把7种传播行为的决策逻辑整理成表格,一目了然:
| 传播行为 | 外层有事务 | 外层无事务 | Connection数量 | 典型场景 |
|---|---|---|---|---|
| REQUIRED | 加入外层事务,复用conn1 | 创建新事务conn1 | 1个 | 订单+库存,必须一致 |
| SUPPORTS | 加入外层事务,复用conn1 | 非事务执行 | 0-1个 | 查询操作,可有可无 |
| REQUIRES_NEW | 挂起外层conn1,创建新的conn2 | 创建新事务conn1 | 2个 | 日志记录,必须保存 |
| NESTED | 在conn1上创建Savepoint | 降级为REQUIRED | 1个 | 批量导入,单条失败不影响其他 |
| NOT_SUPPORTED | 挂起外层conn1,非事务执行 | 非事务执行 | 0个 | 大批量查询,不需要事务 |
| MANDATORY | 加入外层事务,复用conn1 | 抛异常 | 1个 | 强制在事务中调用 |
| NEVER | 抛异常 | 非事务执行 | 0个 | 文件上传,不能有事务 |
快速决策:
- 需要多个独立事务? → REQUIRES_NEW
- 子操作允许失败? → NESTED
- 不确定外层有没有事务? → SUPPORTS
- 强制要求有事务? → MANDATORY
- 强制要求没事务? → NEVER
- 不需要事务? → NOT_SUPPORTED
- 其他情况? → REQUIRED(默认)
写到最后
整个过程写下来,我最大的感悟是:Spring事务传播行为,本质上就是Connection的管理策略。
- 需要1个Connection → REQUIRED / NESTED
- 需要2个Connection → REQUIRES_NEW
- 不需要Connection → NOT_SUPPORTED / NEVER
剩下的就是怎么用栈来管理这些Connection的顺序问题。
代码在这里: simple-spring-transaction
如果你也对Spring的底层实现感兴趣,可以自己跑一遍代码,看看日志输出,会有很多"原来是这样"的感觉!
你遇到过哪些事务的坑?
在实际项目中,你有没有遇到过:
- 以为会回滚,结果提交了?
- 以为会提交,结果回滚了?
- 连接池被打爆?
- 嵌套层级太深导致的问题?
欢迎在评论区分享你踩过的坑,大家一起交流学习!
也可以说说你对这7种传播行为的理解,或者提出你的疑问,我们一起讨论~
如果这篇文章对你有帮助,请点个赞支持一下!你的鼓励是我持续输出的动力~