2641 字
约 8 分钟
17
S08-A21 · 事务的粒度:"一个方法一个事务"为什么有问题

一个 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. 自测

  1. 三种粒度分别会留下什么状态?为什么"部分成功"是"设计出来的"?
  2. "一个用例一个事务"的边界由什么定义?为什么"外部调用"必须移出?
  3. 为什么"大批量写入"要拆?"分批 + 可续跑"解决了什么?
  4. 事务边界的第四个检查为什么是"能否重跑而不重复"?
  5. 长事务为什么会"耗尽连接池"与"让从库延迟"?
  6. 判断"该不该合并事务"的那个问题是什么?
S08-A21 · 事务的粒度:"一个方法一个事务"为什么有问题
https://www.clxhxhhr.top/posts/6585/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。