这是我在线上事故复盘 里,最常被问、也最容易被误解的问题之一。
因为大多数人学的是"事务怎么用",
而事故发生时,考验的是:你是否真的知道事务是在什么时候、以什么方式生效的。
一、用一个真实例子,先把"事务失效 + 脏数据"跑出来(必须具体)
1️⃣ 业务场景:用户余额扣减 + 订单创建
这是一个非常真实的业务:
- 用户下单
- 扣减用户余额
- 创建订单
- 任意一步失败,必须全部回滚
这个服务在 Spring 中是一个单例 Bean。
2️⃣ 最小可理解示例代码(明确共享状态 + 事务位置)
java
@Service
public class OrderService {
// ⚠️ 单例 Bean 的成员变量(共享状态)
private BigDecimal currentBalance;
@Autowired
private UserMapper userMapper;
@Autowired
private OrderMapper orderMapper;
// 事务加在这里
@Transactional
public void createOrder(Long userId, BigDecimal amount) {
// 1. 查询余额
currentBalance = userMapper.queryBalance(userId);
// 2. 扣减余额
currentBalance = currentBalance.subtract(amount);
userMapper.updateBalance(userId, currentBalance);
// 3. 创建订单
saveOrder(userId, amount);
}
// ⚠️ 注意:内部方法调用
public void saveOrder(Long userId, BigDecimal amount) {
// 模拟异常
if (amount.compareTo(new BigDecimal("100")) > 0) {
throw new RuntimeException("金额过大,创建订单失败");
}
orderMapper.insert(userId, amount);
}
}
先别急着挑刺。
这段代码在大量真实项目中都出现过"变体"。
3️⃣ 并发 + 事务时间线:问题是如何一步一步发生的
现在有两个并发请求:
- 请求 A:用户 1,下单 80 元(正常)
- 请求 B:用户 1,下单 200 元(会抛异常)
⏱ 时间线(关键在"你以为事务生效了")
T1:线程 A 进入 createOrder()
Spring 开启事务 Tx-A
T2:线程 B 进入 createOrder()
Spring 开启事务 Tx-B
T3:线程 A 查询余额
currentBalance = 500
T4:线程 B 查询余额
currentBalance = 500 ← 覆盖 A 的中间态
T5:线程 A 扣减余额
currentBalance = 500 - 80 = 420
updateBalance(420)
T6:线程 B 扣减余额
currentBalance = 420 - 200 = 220
updateBalance(220)
T7:线程 A 调用 saveOrder()
正常插入订单
Tx-A 提交
T8:线程 B 调用 saveOrder()
抛出 RuntimeException
❗ 但注意:事务并没有回滚用户余额
4️⃣ ❗关键点:事务为什么"没兜住"?
读到这里你必须明确三件事:
@Transactional确实生效了- 异常 确实抛出了
- 但余额还是被改脏了
👉 这不是并发"概率问题",而是执行路径决定的"必然结果"。
为什么?
-
@Transactional只对通过代理调用的方法生效 -
saveOrder()是 类内部调用 -
事务边界没有覆盖你以为的执行范围
-
再叠加:
- 单例 Bean
- 成员变量保存中间态
- 并发请求
结果一定是:
数据被改了,事务没帮你兜底。
到这里,你应该已经能回答:
"原来 Spring 事务是这样一步一步失效的。"
二、1--2 万工程师如何看这个问题(功能交付视角)
这个阶段的人,看完上面的代码,反应通常是:
1️⃣ 第一反应:"事务不是加了吗?"
- 有
@Transactional - 抛的是
RuntimeException - 按文档:应该回滚
于是他们会开始:
- 怀疑数据库
- 怀疑隔离级别
- 怀疑 Spring 版本
2️⃣ 常见修法
- 把
@Transactional挪到别的方法 - 把异常改成
RuntimeException - 加
rollbackFor = Exception.class
3️⃣ 为什么他们会"以为问题解决了"
- 单线程测试 OK
- 本地跑流程 OK
- 事务日志显示"rollback"
但他们忽略了一件事:
他们从来没用"并发 + 执行路径"去推演真实过程。
三、3--5 万工程师如何看这个问题(系统责任视角)
这个层级的人,看问题的方式已经完全不同。
1️⃣ 他们会立刻注意到的风险信号
- 单例 Service
- 成员变量保存请求态
- 内部方法调用 + 事务
- 把"业务正确性"寄托在事务注解上
2️⃣ 为什么他们不急着改注解
因为他们知道:
事务是兜底机制
不是业务隔离机制
3️⃣ 他们真正害怕的系统后果
- 数据"部分正确、部分错误"
- 财务类数据不可追溯
- 问题无法复现
4️⃣ 为什么他们会否定"表面正确"的修法
在他们眼里:
- 调整注解位置
- 改异常类型
- 加 rollbackFor
都只是:
让系统"看起来更安全",而不是"真的安全"
四、100 万级工程师 / 架构负责人怎么看(系统定价视角)
到这个层级,关注点已经彻底变了。
1️⃣ 他们还会讨论"事务怎么配吗"?
几乎不会。
他们问的是:
- 为什么允许在 Service 里存请求态?
- 为什么团队默认"事务 = 数据安全"?
- 为什么没有并发执行推演?
2️⃣ 如果真出了事故,追责哪一层?
不是:
- 写代码的人
而是:
- 技术负责人
- 代码评审机制
- 架构约束是否失效
3️⃣ 在他们眼里,这是什么问题?
这暴露的不是 Spring 技术问题
而是:
团队是否理解"事务能力的边界"
五、为什么这是工程师身价差异的分水岭?
我们直接给结论:
| 层级 | 关注点 |
|---|---|
| 1--2 万 | 事务为什么没回滚 |
| 3--5 万 | 执行路径是否被事务覆盖 |
| 100 万 | 为什么业务正确性依赖事务 |
差距不在"会不会用 @Transactional",
而在于:
是否理解系统是如何在"看似安全"的情况下悄悄失控的。
六、给读者的认知校准
1️⃣ 最常见的认知陷阱
- "事务能保证一致性"
- "异常抛了就会回滚"
- "Spring 都帮我处理好了"
这些话,
在事故复盘会上,几乎都是前奏。
2️⃣ 哪些能力不是靠多背规则获得的
- 并发执行路径推演能力
- 事务生效边界的直觉
- 对"兜底机制"的不信任感
3️⃣ 一个自检问题(非常重要)
当你看到最开始那段代码时,你的第一反应是:
- ❌「事务怎么没生效?」
- ✅「为什么这个设计需要事务兜底?」
这个差异,
决定了你未来能不能继续往上走。
如果你愿意,下一步我可以继续拆:
- 为什么"自调用"是事务失效的根本原因
- 为什么很多人把事务当成"原子性保险"
- 真实系统里,事务真正该负责什么、不该负责什么
你一句话,我继续往下拆。