一个 Service 里,"下单"被拆成五个 @Transactional 方法:校验、扣库存、建订单、写明细、发积分。每个方法各自一个事务,看起来"职责清晰"。而实际结果是:库存扣了但订单没建——因为第三个方法失败时,前两个已经提交。
事务不是"方法属性",而是"一组操作要么全成功要么全失败的边界"。这个"留下什么"比"性能"更难补救:性能问题可以加机器,而"部分成功"要写对账、写补偿、还要有人处理——所以边界应当先按"正确性"划,再按"性能"调。
而这个边界的划法,决定了"失败时系统会留下什么状态"。
1. 三种粒度与它们的状态
| 粒度 | 失败后留下的状态 |
|---|---|
| 一方法一事务 | 部分成功(需人工/补偿) |
| 一用例一事务 | 无中间态(全部回滚) |
| 过大的事务 | 锁与连接占用(影响并发) |
第一项就是开头那个局面:"部分成功"是"设计出来的",不是"意外"。第一项的"隐蔽"在于它"看起来更清晰":每个方法一个事务,职责分明、代码短——于是它成了"整洁代码"的默认做法,而"部分成功"的代价在几个月后才显现。
而清理它需要"对账 + 补偿 + 监控"三件套。
第二项是"默认正确"的粒度:一个业务动作一个事务。它的边界由"业务"定义,而不是"类的方法"。
第三项的"成因"通常是"懒":"反正都在一个方法里,就都包上"——而它把"两个独立业务动作"的事务耦合在一起,于是"一个慢就都慢"。
第三项是"过度"的一面:把多个用例放进一个事务,于是"锁持有时间长、连接占用高",高并发下吞吐骤降。
2. "一个用例一个事务"怎么落地
| 做法 | 说明 |
|---|---|
| 应用服务方法上加事务 | 边界清晰 |
| 内部方法不加 | 避免"事务套事务" |
| 事务内只做数据库操作 | 外部调用移出 |
| 外部动作放"提交后" | 事务同步回调 |
第三项是硬规则:HTTP、MQ、缓存都不在事务里——它们回滚不掉,于是"事务回滚"只回滚了数据库。第三项的"边界"要说清:缓存可以"在事务内删除"(因为删缓存是幂等的、可重试的),但不能"在事务内写缓存"(因为回滚后缓存里是新值)。所以"删"与"写"要分开对待。
第四项是"发消息/发事件"的正解:在 afterCommit 里执行,于是"库成功才发"。
// 应用服务:一个用例一个事务,外部动作在提交后
@Transactional
public void placeOrder(Cmd cmd) {
order.validate(); // 无副作用
inventory.deduct(cmd); // 同库
orderRepo.insert(cmd); // 同库
detailRepo.batchInsert(cmd); // 同库
// 发积分:注册到"提交后"执行,不在事务内
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override public void afterCommit() { pointClient.add(cmd.userId()); }
});
}
3. 什么时候"必须拆"
| 场景 | 原因 |
|---|---|
| 长耗时操作(生成报表、外部对账) | 事务内不能等 |
| 调用第三方(支付、物流) | 不可回滚 |
| 大批量写入(十万行) | 锁与日志压力 |
| 跨服务(另一个库) | 无全局事务 |
第三项的"量化"很直观:十万行的批量更新会产生几倍于数据的 undo 与 binlog,而"锁"会覆盖所有被改的行——于是"并发写同一批数据"会直接阻塞。
第三项常被忽略:一个事务里写十万行,会把 undo 日志、锁、连接全部撑满——而它看起来只是"一个循环"。
拆的方式是"分批 + 每批一个事务 + 可续跑":于是"失败时只丢一批",且"重跑从断点开始"。
四个检查可以做成"评审清单",在 PR 里逐项过——因为"事务边界问题"在代码里看不出来:
// 事务评审清单(四问,两分钟)
1 事务内是否调用了 HTTP/RPC/MQ/缓存写? → 移出或改 afterCommit
2 事务内是否有 sleep / 长查询 / 文件 IO? → 移出
3 事务内是否有 > 1000 行的循环写? → 分批 + 幂等
4 拆成多事务后,失败能否"重跑不重复"? → 加幂等键与唯一约束
// 任一项不符 → 打回;这四项覆盖了大多数"部分成功"事故
4. 事务边界的四个检查
| 检查 | 判据 |
|---|---|
| 事务内有没有外部调用 | 有 → 移出 |
| 事务内有没有等待(sleep/长查询) | 有 → 拆 |
| 事务内有没有大循环 | 有 → 分批 |
| 失败后能否"重跑而不重复" | 不能 → 加幂等 |
第四项是"补偿"的前提:如果重跑会重复执行,那"拆事务"就变成了"制造数据问题"。第四项的"实现"通常是一个"幂等键 + 唯一约束":每批带一个批次号,重复提交时唯一约束拒绝——于是"重跑"安全。
"事务传播"是同一个问题的另一面:REQUIRED 会加入外层事务,于是"内层方法的失败"会把外层一起标记回滚。
// 一个常见事故:内层"记日志"失败,把外层业务一起回滚
@Transactional
public void placeOrder(...) {
orderRepo.insert(...);
auditService.log(...); // 内部 @Transactional(REQUIRED) → 加入外层
}
// 若 auditService 抛异常(如日志表满了)→ 整个下单回滚
// 修法:审计用 REQUIRES_NEW(独立事务),或移到事务外
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void log(...) { ... }
5. 事务与"锁"的相互作用
| 现象 | 原因 |
|---|---|
| 高并发下吞吐骤降 | 长事务持有行锁 |
| 偶发死锁 | 多事务交叉加锁 |
| 连接池耗尽 | 事务未提交,连接不还 |
| 从库延迟 | 大事务产生大量 binlog |
第三项的"判据"是"事务平均时长 × QPS ≈ 需要的连接数(Little 定律的简化)":事务 200ms、QPS 100,就需要至少 20 个连接同时持有——而连接池常配 10–20。
第三项很直观:事务期间连接不能归还,于是"事务越长,占用的连接越多"。这在"连接池 20、并发 30"的服务上就是瓶颈。
第四项在"批量更新"时出现:一个大事务产生一次巨大的 binlog 事件第四项的"缓解"是"分批提交 + 每批小一点":于是"binlog 事件"被拆成多个,从库可以"边重放边追",而不是"等一个大事件"。
,而从库重放要花很久——于是"主库很快、从库很慢"。
"事务时长"应当被度量,否则"长事务"只能靠故障发现:
// 三个可量化的数(按接口统计)
1 事务平均时长(P50/P99) > 200ms 需关注
2 单事务最大行数(写) > 1 万需分批
3 事务持有连接的平均时间 ≈ 事务时长 → 连接池按它算
// 用法:P99 远高于 P50 → 有"偶发长事务"(多半是批量或锁等待)
6. 排错:五种"事务相关问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 部分成功 | 一方法一事务 | 合并到一个用例 |
| 高并发吞吐低 | 长事务 | 拆分 + 移出外部调用 |
| 死锁偶发 | 交叉加锁顺序 | 统一加锁顺序 |
| 从库延迟暴涨 | 大事务 | 分批提交 |
| 重跑产生重复 | 无幂等 | 加幂等键 |
第四项(死锁偶发)的"防法"是"统一加锁顺序":所有事务都按"先 A 后 B"的顺序加锁——而它需要"跨模块的约定",所以常被漏。
第五项是"补偿"的必要条件:没有幂等,"重试"就是"重复写入"。
最后一个"编排"视角:跨服务的一致性不能用"事务"解决,只能用"扩展-收缩 + 补偿"。所以"下单要扣另一个服务的库存"时,正确的问题不是"怎么加事务",而是"怎么定义补偿动作"。
// 跨服务的三段式(替代"分布式事务")
1 本地事务:写订单(状态=待确认)
2 调用库存:成功 → 状态=已确认;失败 → 状态=已取消(补偿)
3 对账:定期扫"待确认超时"的订单,主动补偿
// 关键:每一步都"可重跑 + 可对账",而不是"要么全成功"
7. 边界:什么情况"一方法一事务"是对的
| 场景 | 为什么 | 做法 |
|---|---|---|
| 方法之间无依赖(独立操作) | 无"一起成功"要求 | 各自事务 |
| 审计/日志写入 | 独立失败可接受 | 各自事务 |
| 后台批处理按批提交 | 每批独立 | 每批一个事务 |
| 只读查询 | 不需要事务 | 不用 |
判断口径一句话:这些操作"是否必须一起成功"。最后一条与"重构"有关的边界:合并事务是"行为变更",不是"重构"——因为它改变了"失败后留下什么状态"。所以它需要"测试覆盖 + 灰度",而不能当"整理代码"直接改。
必须(下单的五个副作用),就一个事务;不必(写日志、更新统计),各自事务反而更稳。
8. 自测
- 三种粒度分别会留下什么状态?为什么"部分成功"是"设计出来的"?
- "一个用例一个事务"的边界由什么定义?为什么"外部调用"必须移出?
- 为什么"大批量写入"要拆?"分批 + 可续跑"解决了什么?
- 事务边界的第四个检查为什么是"能否重跑而不重复"?
- 长事务为什么会"耗尽连接池"与"让从库延迟"?
- 判断"该不该合并事务"的那个问题是什么?