3006 字
约 10 分钟
13
S06-A04 · 事务架构:边界、隔离级别与补偿

方法上标了 @Transactional,回滚日志也打了,数据依然不一致。这类问题的根因几乎从不在"注解写没写",而在三个更早的决定:事务边界划在哪、隔离级别承诺了什么、跨不过去的那一步用什么补。前两个决定正确性下界,第三个决定不一致的持续时间。


1. 边界怎么划:一次不变量变更 = 一个事务

判据 划进同一个事务 划出去
业务不变量 必须同时成立的多个写入(余额与流水) 只是"顺带做一下"的旁路动作
失败语义 一起失败可接受 一方失败一方必须成功
时间尺度 毫秒到百毫秒量级 秒级以上、含外部调用
参与资源 同一数据源 跨库、跨服务、跨中间件

推论很直接:事务里不放远程调用。一次 HTTP 调用把事务从毫秒级拉到秒级,持锁时间、连接占用、undo 增长全部随之放大,而它失败时你还得决定要不要回滚本地写入。

2. @Transactional 失效的五类原因

原因 机制 症状
自调用 代理未经过,注解不生效 抛异常但数据已写入
非 public 方法 部分代理实现不拦截 同上,且不明显
异常被吞 捕获后未抛出,事务不知情 部分成功、部分失败
rollbackFor 缺失 默认只回滚运行时异常 受检异常导致提交
多线程 事务绑定线程,子线程不继承 子线程写入独立提交
// 反例:同类自调用,注解形同虚设
public void outer() { inner(); }           // 走 this,不经过代理
@Transactional public void inner() { ... }

// 验证手段:不靠读代码,靠观察
// 1) 打开事务日志,确认 begin/commit 成对出现
logging.level.org.springframework.transaction.interceptor=TRACE
// 2) 在方法内打印 TransactionSynchronizationManager.isActualTransactionActive()
// 3) 故意抛受检异常,看数据是否留下

判断有没有事务,最可靠的信号是日志里的 begin/commit 与 isActualTransactionActive(),而不是注解是否存在。异常被吞的场景尤其隐蔽:try { ... } catch (Exception e) { log.warn(...) } 之后事务提交,回滚永远不会发生。

3. 隔离级别:标准语义与数据库实现的差距

级别 标准禁止 MySQL(InnoDB) 实际 常见误解
READ UNCOMMITTED 脏读 实际不会脏读 以为能读到未提交
READ COMMITTED 脏读、不可重复读 每条语句新快照 以为同一事务内读一致
REPEATABLE READ 上述 + 幻读 快照读一致,当前读仍可能见新行 以为 RR 就没有幻读
SERIALIZABLE 全部 退化加锁,冲突报错增多 以为可以随便用
-- RR 下的两种读,行为不同
START TRANSACTION;
SELECT count(*) FROM orders WHERE uid = 1;   -- 快照读:整事务内结果固定
UPDATE orders SET status='X' WHERE uid = 1;  -- 当前读:读最新版本并加锁
COMMIT;

"幻读"在 RR 下的准确说法是:快照读不会看到新插入的行,但当前读(UPDATE/DELETE/加锁读)会看到并加间隙锁。把 RR 当成"绝对一致"去设计业务,遇到当前读就会露出破绽。跨库、跨版本比较语义时,以目标数据库版本文档为准。

4. 锁与并发

现象 机制 排查入口
行锁升级为表锁 无索引条件导致全表扫描加锁 EXPLAIN 看是否走索引
间隙锁阻塞插入 RR 下范围条件锁住间隙 performance_schema.data_locks
死锁 两个事务加锁顺序相反 SHOW ENGINE INNODB STATUS 的 LATEST DETECTED DEADLOCK
锁等待超时 长事务持锁 data_lock_waits 找阻塞者
SELECT * FROM performance_schema.data_locks WHERE OBJECT_NAME = 'orders';
SELECT * FROM performance_schema.data_lock_waits;      -- 谁在等谁
SHOW ENGINE INNODB STATUS\G                            -- 最近一次死锁的两条 SQL

死锁处置的优先级是:统一加锁顺序(治本)→ 缩短持锁时间(缩小窗口)→ 重试(兜底)。只做重试等于把死锁变成常态噪音。间隙锁的代价常被低估:它锁的是"区间",并发插入会互相阻塞,范围更新在 RR 下的吞吐明显低于等值更新。

5. 长事务的代价

代价 机制 表现
锁持有 行锁/间隙锁不释放 并发写入排队,超时上升
undo 膨胀 旧版本需保留供快照读 磁盘增长、purge 滞后
连接占用 连接绑定事务 连接池耗尽,表现为"数据库连不上"
主从延迟 大事务一次性提交 从库追不上,读到旧数据
回滚成本 回滚同样要写 undo 失败时耗时可能超过执行本身

经验阈值:单事务耗时压到百毫秒量级,超过就该拆。拆分不是把逻辑切碎,而是把"必须原子"的部分缩到最小,其余改为异步或补偿。

6. 只读事务与传播行为

@Transactional(readOnly = true)                 // 提示,不是强制:仍可能写入
public List<OrderView> query(...) { ... }

@Transactional(propagation = Propagation.REQUIRES_NEW)  // 挂起外层,独立提交
public void writeAudit(...) { ... }             // 注意:外层回滚不影响它
传播行为 语义 典型误用
REQUIRED 加入现有事务,无则新建 以为内层失败会独立回滚
REQUIRES_NEW 挂起外层,独立事务 用它"记录失败日志",结果连接翻倍
NESTED 保存点,可局部回滚 以为等价于独立事务
NOT_SUPPORTED 挂起事务 内部写入自动提交,破坏原子性
SUPPORTS 有则加入,无则不用 在事务外调用时静默无事务

readOnly 只影响部分优化(如跳过脏检查),不阻止写入。把它当成"只读保护"是常见误解。

7. 跨服务的四条路

方案 一致性 成本 适用
本地事务 + 消息 最终一致 低(需可靠投递) 绝大多数跨服务场景
TCC 最终一致(业务可见中间态) 中高(三个接口 + 幂等) 资金、库存等需显式预留
Saga 最终一致 中(每步补偿) 长流程、多参与方
两阶段提交/XA 强一致 高(同步阻塞、运维复杂) 极少,多为遗留系统

原则一句话:能本地就不分布式。把跨服务的一致性需求压缩成"一次本地事务 + 一条消息",剩下的用对账兜底,比引入协调器便宜一个数量级。

8. 补偿的三种形态与补偿自身的幂等

形态 做法 适用
正向补偿 再执行一次相反业务动作(扣了再退) 有明确反向操作
冲正 记一笔反向流水,不改原始记录 账务、审计要求留痕
对账修复 定时比对双方,差异生成修复任务 无法实时补偿、量大
// 补偿必须幂等:以业务唯一键去重,而不是靠"应该只调一次"
@Transactional
public void compensate(String bizNo) {
    if (compensationRepo.existsByBizNo(bizNo)) return;   // 已补过
    compensationRepo.insert(bizNo, "REFUND");            // 唯一键兜底
    account.refund(bizNo);                               // 反向动作也要幂等
}

补偿失败的处置要预先定义:重试上限、进入人工队列的条件、告警对象。没有这三项,补偿代码只是把不一致推迟了几天。

9. 事务与消息的顺序

// 危险写法:先提交后发消息,之间崩溃 → 消息永久丢失
orderRepo.save(order);
mq.send(new OrderCreated(order.id()));

// 可行写法:本地消息表与业务同事务,再由投递器发出(至少一次)
@Transactional
public void create(Order o) {
    orderRepo.save(o);
    outboxRepo.save(new Outbox(o.id(), payload(o)));   // 同库同事务
}
// 独立投递器扫 outbox → 发送 → 标记已发(消费端按业务键去重)

顺序只有两种正确形态:同事务内写"待发"记录,或消费端可容忍丢失并通过对账补齐。"先提交再发、失败就重试"在进程崩溃时无法保证,而这个窗口恰好是最容易发生的时刻。

10. 分布式事务框架的成本

成本项 表现
性能 同步阻塞、多轮网络往返,TPS 明显低于本地事务
复杂度 每个参与方都要实现分支逻辑与幂等
运维 协调器成为关键路径,需独立部署与监控
排障 悬挂、空回滚、幂等失败三类问题排查链长

引入前的判据:一致性要求的窗口是否真的需要毫秒级。多数业务要的是"最终不错",不是"永远同时可见"。

11. 可观测

指标 采集方式 异常信号
事务耗时分布 事务拦截器打点 P99 上升 → 长事务变多
回滚率 提交/回滚计数比 突增 → 上游校验或异常策略变化
死锁次数 InnoDB 状态或监控项 持续非零 → 加锁顺序问题
锁等待时长 data_lock_waits 采样 上升 → 持锁时间变长
补偿任务积压 补偿表待处理数 上升 → 下游长期失败

回滚率是最被忽略的指标:它同时反映异常设计与业务校验,突增往往先于数据不一致出现。

12. 排错表

现象 原因 处理
抛异常但数据已写入 自调用、非 public、异常被吞 走代理调用、改 public、不吞异常
受检异常未回滚 默认 rollbackFor 不含受检 显式指定 rollbackFor = Exception.class
子线程写入未回滚 事务不跨线程 改为同线程或独立补偿
死锁频发 加锁顺序不一致 统一顺序 + 缩短事务 + 重试兜底
从库读到旧数据 大事务 + 复制延迟 拆小事务,关键读走主库

13. 边界

场景 结论 理由
单库单表 不需要分布式事务 本地事务已足够
允许最终一致 不必强一致 强一致的成本换不到业务价值
只读接口 不需要事务(或仅 readOnly) 无写入即无原子性需求
纯日志、埋点写入 不需要事务 丢失可容忍,回滚无意义

14. 自测

  1. 事务边界的第一判据是什么?为什么远程调用不该放进去?
  2. 五类失效原因中,哪一类在代码 review 时最难看出来?
  3. MySQL RR 下"幻读"的准确表现是什么?
  4. 间隙锁在什么条件下出现,代价体现在哪?
  5. 先提交后发消息的丢失窗口在哪,为什么"重试"补不上?
  6. 补偿本身为什么必须幂等?靠什么保证?
S06-A04 · 事务架构:边界、隔离级别与补偿
https://www.clxhxhhr.top/posts/6095/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。