方法上标了 @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. 自测
- 事务边界的第一判据是什么?为什么远程调用不该放进去?
- 五类失效原因中,哪一类在代码 review 时最难看出来?
- MySQL RR 下"幻读"的准确表现是什么?
- 间隙锁在什么条件下出现,代价体现在哪?
- 先提交后发消息的丢失窗口在哪,为什么"重试"补不上?
- 补偿本身为什么必须幂等?靠什么保证?