分布式事务:原子提交、业务补偿与恢复状态机
面试标准回答与精确原理跳转:分布式事务面试题。
一、学完本章必须会什么
分布式事务不是“让多个数据库神奇地同时提交”,也不等于在方法上加 @GlobalTransactional。它解决的是:一个业务不变量跨越多个独立提交边界时,如何在超时、宕机、重复和部分成功下,让结果保持正确、可解释并最终可恢复。
学完本章,你应该能够:
- 区分本地事务、全局事务、分支事务和业务工作流。
- 解释为什么远程调用成功不受当前
@Transactional自动回滚。 - 画出协调器、发起者和参与者之间的状态变化。
- 解释 XA/2PC 的 Prepare、in-doubt、日志恢复和阻塞来源。
- 解释 TCC 的资源预留、幂等、空回滚、悬挂和防悬挂表。
- 解释 Saga 补偿为什么不是数据库物理回滚。
- 解释 Outbox、事务消息、CDC 各自关闭哪个双写窗口。
- 比较 Seata AT、XA、TCC、Saga 的边界。
- 根据业务不变量、可逆性、时长、吞吐和资源能力选型。
- 按 XID、事务日志、分支状态、业务流水、消息状态和对账证据排查。
二、先定义问题:要维护什么业务不变量
事务的目标不是让所有表“看起来一起变”,而是维护业务不变量。例如:
- 同一个订单最多成功扣款一次。
- 库存可售量不能小于零。
- 支付成功后,订单最终必须进入已支付或人工确认状态。
- 取消订单后,已预留库存最终必须释放。
- 同一张优惠券不能被两个订单最终使用。
如果不先定义不变量,就无法判断需要强一致、资源预留还是最终一致。
flowchart TD
A["用户提交订单"] --> B["订单库创建订单"]
B --> C["库存库预留库存"]
C --> D["优惠券库锁定优惠券"]
D --> E["支付系统创建支付单"]
E --> F{"中间任一步超时或宕机"}
F --> G["必须根据业务不变量决定提交、补偿、重试或人工处理"]三、本地事务、全局事务和分支事务
| 概念 | 边界 | 典型控制者 | 能保证什么 |
|---|---|---|---|
| 本地事务 | 一个事务资源/连接 | 数据库事务管理器 | 当前资源的提交或回滚 |
| 分支事务 | 全局事务中的一个参与步骤 | RM、XAResource或业务 TCC/Saga 参与者 | 向全局协调器汇报一个步骤状态 |
| 全局事务 | 多个分支的协调范围 | TC/事务协调器、工作流引擎 | 根据协议推动全部分支决议或补偿 |
| 业务状态机 | 订单等业务对象生命周期 | 业务服务 | 限制合法状态迁移并保存业务事实 |
Spring 本地事务通常把数据库连接绑定到当前线程:
@Transactional
public void createOrder(CreateOrderCommand command) {
orderRepository.insert(command);
orderItemRepository.batchInsert(command.getItems());
}这两个操作使用同一个本地事务管理器时可以共同提交或回滚。但下面的远程调用属于库存服务自己的进程和数据库:
@Transactional
public void createOrder(CreateOrderCommand command) {
orderRepository.insert(command);
inventoryClient.reserve(command.getOrderNo(), command.getItems());
// 后面抛异常,只会让当前订单库事务回滚
}即使库存调用返回成功,订单事务后来回滚,库存服务也不会自动感知。要么使用协议协调其分支,要么通过业务补偿释放库存。
四、为什么无法简单“同时提交”
不同节点不能共享一条数据库连接,也没有一个瞬间能让所有机器物理上同时执行 commit。协调协议只能通过消息和持久化状态逐步逼近一个全局决议。
flowchart TD
A["协调器决定 COMMIT"] --> B["发送提交给参与者 A"]
A --> C["发送提交给参与者 B"]
B --> D["A 提交并返回"]
C --> E["B 已提交但响应丢失"]
E --> F["协调器必须保留决议并重试查询或提交"]核心难点包括:
- 消息可能丢失、重复、乱序或延迟。
- 参与者可在提交前后宕机。
- 协调器也会宕机和重启。
- 调用方超时不代表事务最终回滚。
- 补偿操作也可能失败或重复。
- 外部支付、短信等资源不一定支持 XA 或回滚。
因此,事务方案必须包含持久化状态机和恢复过程,不能只画正常调用时序。
五、协调器为什么必须保存状态机
一个抽象的全局事务可以经历:
flowchart TD
A["BEGIN"] --> B["ACTIVE"]
B --> C["COMMITTING"]
B --> D["ROLLING_BACK"]
C --> E["COMMITTED"]
D --> F["ROLLED_BACK"]
C --> G["COMMIT_RETRYING"]
D --> H["ROLLBACK_RETRYING"]
G --> E
H --> F
G --> I["MANUAL_INTERVENTION"]
H --> I协调器必须持久化:
- 全局事务 ID。
- 发起时间、超时和当前状态。
- 每个参与者/分支 ID、资源和状态。
- 最终提交或回滚决议。
- 重试次数、下次重试时间和最后错误。
- 协议特有信息,如 XA Xid、TCC 分支键、Saga 步骤和补偿位置。
为什么决议必须持久化:协调器向 A 发出 Commit 后宕机,重启后不能因为内存丢失而改成 Rollback。已经形成的最终决议要可重放;参与者的 Commit/Confirm/Cancel 也必须幂等。
六、任何方案都要逐个检查失败窗口
| 失败点 | 调用方看到什么 | 事实可能是什么 | 必须具备 |
|---|---|---|---|
| 请求发送前失败 | 明确未发送或本地异常 | 下游未执行 | 可安全重试 |
| 请求已达但响应超时 | 结果未知 | 未执行、已执行未提交、已提交 | 幂等键和事实查询 |
| 本地事务提交后进程宕机 | 上游可能超时 | 数据已生效,后续状态没写 | 事务日志/Outbox/恢复扫描 |
| 协调器发出部分提交后宕机 | 全局状态暂不明确 | 部分参与者已收到决议 | 持久化决议和重试 |
| 补偿执行后响应丢失 | 协调器认为补偿失败 | 补偿可能已成功 | 补偿幂等和查询 |
| 人工处理与自动重试并发 | 状态竞争 | 两条修复路径同时操作 | owner token、状态条件更新 |
七、方案全景:不是强一致和最终一致两个标签就够了
flowchart TD
A["跨资源业务不变量"] --> B{"资源是否支持原子提交协议"}
B -- "支持且事务很短" --> C["XA/JTA/2PC 或 Seata XA"]
B -- "不适合" --> D{"资源能否显式预留"}
D -- "能" --> E["TCC"]
D -- "不能或流程很长" --> F{"已提交步骤是否可业务补偿"}
F -- "能" --> G["Saga"]
F -- "事件传播即可" --> H["Outbox、事务消息或CDC"]
F -- "外部通知" --> I["最大努力通知与对账"]框架和模式也要区分:Seata 是框架,AT/XA/TCC/Saga 是其支持的事务模式;JTA 是 Java API 规范,XA 是资源和事务管理器交互的标准接口,2PC 是协议阶段。
八、XA/JTA/2PC:原子提交协议
8.1 正常流程
flowchart TD
A["TM 开启全局事务"] --> B["各 XAResource 执行业务分支"]
B --> C["阶段一:协调器发送 PREPARE"]
C --> D{"所有参与者是否 Vote YES"}
D -- "是" --> E["协调器持久化 COMMIT 决议"]
E --> F["阶段二:发送 COMMIT,失败可重试"]
D -- "否" --> G["持久化 ROLLBACK 决议"]
G --> H["发送 ROLLBACK"]参与者返回 Prepared,表示它已将恢复所需信息持久化,并承诺以后能按协调器决议提交或回滚。Prepared 后不能自行随意决定,否则多个参与者可能产生不同最终结果。
8.2 in-doubt 是什么
参与者已 Prepared,但暂时联系不到协调器,不知道最终 Commit 还是 Rollback,这就是 in-doubt。相关锁和资源可能继续占用,直到协调器恢复并重发决议,或管理员依据事务日志正确处理。
2PC 的高成本来自:
- Prepare/Commit 多轮网络交互。
- Prepared 资源持锁或保留事务上下文。
- 最慢参与者决定全局时延。
- 协调器和参与者都要写恢复日志。
- 故障恢复期间可能阻塞业务资源。
深入学习:XA、JTA 与 2PC
九、TCC:业务资源预留协议
| 阶段 | 业务含义 | 库存示例 |
|---|---|---|
| Try | 校验并预留资源 | available -= n, frozen += n |
| Confirm | 将预留转成最终结果 | frozen -= n, sold += n |
| Cancel | 释放预留 | frozen -= n, available += n |
flowchart TD
A["Try:按 branchKey 原子预留"] --> B{"全局决议"}
B -- "提交" --> C["Confirm:只处理 TRY_SUCCEEDED"]
B -- "回滚" --> D["Cancel:释放或记录空回滚"]
C --> E["CONFIRMED"]
D --> F["CANCELLED"]TCC 的三个经典问题:
- 幂等:Confirm/Cancel 可能多次调用,只能产生一次效果。
- 空回滚:Try 未执行或未成功,Cancel 已先到达,不能错误释放资源。
- 悬挂:Cancel 已记录全局回滚后,迟到的 Try 不能再成功预留。
通常使用唯一 (xid, branch_id) 的事务栅栏记录状态,在 Try 前检查是否已经 Cancelled;Confirm/Cancel 使用条件更新推进状态。
深入学习:TCC 资源预留、空回滚与悬挂
十、Saga:本地提交加业务补偿
Saga 每个正向步骤先本地提交,后续失败时逆序执行补偿:
flowchart TD
A["T1 创建订单"] --> B["T2 预订库存"]
B --> C["T3 创建物流单"]
C --> D{"T4 失败"}
D --> E["C3 取消物流单"]
E --> F["C2 释放库存"]
F --> G["C1 取消订单"]补偿不是数据库物理回滚。例如“退款”会新增退款流水,原支付流水仍然存在;“取消物流单”可能因已出库而无法完全恢复原状。这要求:
- 明确定义每一步补偿语义。
- 补偿可重复执行。
- 补偿失败可重试和人工接管。
- 读侧能解释
COMPENSATING等中间状态。 - 某些不可逆步骤尽量后置。
深入学习:Saga 长事务与补偿
十一、Outbox/本地消息表:关闭数据库与消息的双写窗口
错误方案一:先写数据库再发 MQ。数据库提交后进程宕机,消息丢失。
错误方案二:先发 MQ 再写数据库。消息已被消费但数据库回滚,下游处理了不存在的业务事实。
Outbox 把业务记录和待发事件放进同一本地事务:
flowchart TD
A["本地事务写订单"] --> B["同一事务写 Outbox PENDING"]
B --> C["提交后投递器扫描或CDC捕获"]
C --> D["发送 MQ"]
D --> E{"Broker已收但状态回写是否成功"}
E -- "成功" --> F["Outbox标记 SENT"]
E -- "失败或超时" --> G["再次发送,消费者必须幂等"]Outbox 保证“业务事实存在时,待发送事件也存在”,但不能保证只发送一次。发送成功与更新 Outbox 状态仍跨两个资源,崩溃后会重复发送。
深入学习:可靠消息、Outbox 与事务消息
十二、MQ 事务消息:半消息和本地事务回查
以支持事务消息的 Broker 为例,抽象流程是:
flowchart TD
A["Producer发送暂不可消费消息"] --> B["Broker持久化半消息"]
B --> C["Producer执行本地事务"]
C --> D{"本地事务结果"}
D -- "提交" --> E["通知Broker提交,消息可消费"]
D -- "回滚" --> F["通知Broker回滚"]
D -- "结果未知" --> G["Broker回查本地事务事实"]
G --> E
G --> F本地事务回查必须查询可靠业务事实,例如订单表和事务流水,不能只读取进程内变量。事务消息协调的是“生产者本地事务与消息是否可见”,消费者仍可能重复收到,消费者自己的数据库事务、HTTP、短信等副作用仍需幂等。
十三、CDC 与 Outbox 的关系
CDC 从数据库日志读取已提交变化,避免业务线程同步发送消息。两种常见方式:
- 直接捕获业务表:侵入低,但事件语义、字段变更和一次事务多行合并更难。
- 捕获 Outbox 表:业务明确写入领域事件,CDC 只负责可靠搬运。
CDC 仍要处理:位点恢复、重复、事务顺序、Schema 演进、消息大小、回放和下游幂等。它是可靠传播工具,不是跨服务强一致协议。
十四、Seata 在方案地图中的位置
| Seata 模式 | 第一阶段 | 第二阶段/失败处理 | 关键边界 |
|---|---|---|---|
| AT | 本地提交业务数据和 undo_log | 提交清日志;回滚按镜像补偿 | SQL代理、全局锁、热点和脏写校验 |
| XA | XA分支执行并 Prepare | 协调 XA Commit/Rollback | 数据源/驱动支持、持锁与恢复 |
| TCC | 调用业务 Try | Confirm/Cancel | 幂等、空回滚、悬挂、业务侵入 |
| Saga | 每步本地提交 | 状态机执行补偿 | 中间状态、不可逆步骤、补偿失败 |
Seata 提供协调框架,不会自动让外部支付、短信、任意 NoSQL 都具备回滚能力。框架事务外仍要有业务状态机、幂等、对账和人工处理。
深入学习:Seata TM、TC、RM 与四种模式
十五、选型必须回答的九个问题
- 能否调整服务边界,把强一致写入放回一个本地事务?
- 真正需要维护的业务不变量是什么?
- 用户能否看到短暂中间状态,能接受多久?
- 资源是否支持 XA,事务能否保持很短?
- 资源能否预留,Try/Confirm/Cancel 是否自然?
- 已完成步骤是否可补偿,补偿是不是合法业务动作?
- 峰值吞吐、热点行和锁等待能否接受?
- 是否具备幂等、重试、死信、对账、告警和人工处置?
- 团队能否运维协调器、事务日志、恢复任务和版本兼容?
十六、方案对比矩阵
| 维度 | XA/2PC | TCC | Saga | Outbox/事务消息 | Seata AT |
|---|---|---|---|---|---|
| 一致性 | 原子提交倾向强 | 预留资源、较强控制 | 最终一致 | 最终一致 | 全局提交前存在中间可见性边界 |
| 业务侵入 | 中低 | 高 | 高 | 中 | 较低但依赖SQL代理 |
| 事务时长 | 必须短 | 短到中 | 可长 | 异步长 | 应保持短 |
| 资源要求 | XA支持 | 可实现预留接口 | 可实现补偿 | DB + MQ/CDC | 关系库与支持的SQL |
| 热点吞吐 | Prepared持锁敏感 | 预留行可能热点 | 中间状态但少长锁 | 通常较高 | 全局锁热点敏感 |
| 失败恢复 | 协调器日志重放 | Confirm/Cancel重试 | 补偿重试 | 投递、消费、对账 | TC/RM、undo_log恢复 |
| 常见场景 | 核心短事务 | 库存、余额、券 | 履约、审批、物流 | 积分、通知、ES同步 | 可控数据库短事务 |
十七、商业场景:下单、支付和关单怎样拆
17.1 创建订单
推荐先把订单创建成明确中间状态,并用业务订单号幂等:
INIT → STOCK_RESERVING → WAIT_PAY → PAID
↘ CANCELING → CLOSED库存可用 TCC 预留或 Saga 预订;通知、积分、ES 不应阻塞下单主事务,可由 Outbox 事件驱动。
17.2 支付成功
支付回调先验签、校验商户、订单号、金额和币种,再使用渠道流水唯一约束和状态条件更新:
update t_order
set status = 'PAID', paid_at = now(), version = version + 1
where order_no = ?
and status = 'WAIT_PAY';更新 0 行不等于直接报错:可能是重复回调、订单已关闭后的竞态或数据异常,要读取事实状态分类处理。
17.3 支付与关单竞态
关单和支付都用条件状态迁移。若关单先成功,迟到支付要进入退款或人工处理;若支付先成功,关单任务更新不到 WAIT_PAY 就跳过。不能读取状态后在 Java 中判断再无条件更新,因为两个线程可同时读取旧状态。
十八、恢复设计:自动重试不是无限循环
每个恢复任务至少需要:
- 稳定事务/业务 ID。
- 当前状态和期望状态。
- 锁版本或 owner token,防止多个恢复器并发接管。
- 重试次数和指数退避。
next_retry_at,避免全表高频扫描。- 最后错误分类和错误摘要。
- 首次失败和最后失败时间。
- 最大自动重试阈值。
- 人工状态和操作审计。
失败分类:
| 类型 | 示例 | 处理 |
|---|---|---|
| 瞬时技术失败 | 网络抖动、短暂超时、锁冲突 | 有界退避重试 |
| 结果未知 | 提交后断连、回调响应丢失 | 先查事实源 |
| 永久业务拒绝 | 余额不足、状态不允许 | 不重试,推进失败/补偿 |
| 数据冲突 | AT后镜像不一致、人工改数 | 停止自动覆盖,人工审核 |
| 依赖长期不可用 | 渠道停机、Schema不兼容 | 熔断、告警、延迟恢复 |
十九、事务可观测性要保存哪些证据
| 证据 | 用途 |
|---|---|
| globalTxId/XID | 串联协调器、TM、RM日志 |
| branchId/stepId | 定位具体参与者或Saga步骤 |
| businessKey | 回到订单、支付、库存事实 |
| global/branch status | 判断在提交、回滚还是重试 |
| idempotency key | 判断是否同一次业务意图 |
| retry count/next retry | 识别卡死和重试风暴 |
| MQ messageId/eventId | 串联投递和消费 |
| traceId | 定位一次调用尝试,但不替代业务键 |
日志不能记录密码、Token、完整身份证、银行卡和密钥。业务键按安全要求脱敏。
二十、线上事务异常排查 Runbook
20.1 先找事实源
先确定订单、支付、库存谁是各自事实源,不要只看调用方异常。超时后查询业务流水、数据库状态、协调器状态和消息状态。
20.2 再看协议阶段
flowchart TD
A["按业务键找到全局事务/XID"] --> B["读取全局状态"]
B --> C["列出全部分支和最后更新时间"]
C --> D{"卡在哪个阶段"}
D -- "准备/预留" --> E["查锁、资源、Try与Prepare日志"]
D -- "提交" --> F["查Commit/Confirm重试与参与者事实"]
D -- "回滚" --> G["查Cancel、补偿、undo_log与数据冲突"]
D -- "消息" --> H["查Outbox、Broker、消费日志和死信"]20.3 常见症状
| 症状 | 优先检查 | 禁止草率操作 |
|---|---|---|
| XA 长时间 Prepared | 协调器恢复日志、数据库 in-doubt、锁等待 | 未确认全局决议就手工提交 |
| TCC 库存一直冻结 | Try记录、全局状态、Confirm/Cancel重试 | 直接修改可用库存 |
| Saga 补偿卡住 | 当前步骤、补偿幂等记录、外部事实 | 从头重跑整个流程 |
| Outbox PENDING堆积 | 扫描器、索引、锁、Broker发送结果 | 批量改 SENT |
| AT 回滚失败 | XID/branchId、undo_log、后镜像、全局锁 | 删除undo_log或覆盖业务数据 |
| 全局事务超时但业务已成功 | TM超时、各分支事实、最终决议 | 重新生成业务号执行一次 |
二十一、JDK 8 Demo:可恢复的 Saga 状态机
下面用纯 JDK 8 展示正向步骤失败后,补偿可以重入且从已完成步骤逆序恢复:
import java.util.ArrayList;
import java.util.List;
public class RecoverableSagaDemo {
enum Status { RUNNING, COMPENSATING, COMPENSATED, SUCCEEDED }
static final class Saga {
private final List<String> completed = new ArrayList<String>();
private Status status = Status.RUNNING;
void execute() {
complete("CREATE_ORDER");
complete("RESERVE_STOCK");
status = Status.COMPENSATING;
}
void compensate() {
if (status == Status.COMPENSATED) {
return;
}
for (int i = completed.size() - 1; i >= 0; i--) {
System.out.println("compensate=" + completed.get(i));
}
status = Status.COMPENSATED;
}
private void complete(String step) {
if (!completed.contains(step)) {
completed.add(step);
}
}
}
public static void main(String[] args) {
Saga saga = new Saga();
saga.execute();
saga.compensate();
saga.compensate();
System.out.println("status=" + saga.status);
}
}输出:
compensate=RESERVE_STOCK
compensate=CREATE_ORDER
status=COMPENSATED真实 Saga 必须把已完成步骤、补偿状态和每步业务键持久化;Demo 的内存 List 只用于解释状态机,不具备崩溃恢复能力。
二十二、常见错误与后果
| 错误 | 后果 | 正确方向 |
|---|---|---|
| 把所有远程调用放本地事务里 | 长时间占连接和锁,远程成功无法自动回滚 | 缩小本地事务,选协调或消息方案 |
| 认为超时就是回滚 | 已提交业务被再次执行 | UNKNOWN状态、事实查询、同一幂等键 |
| TCC没有事务栅栏 | 空回滚、悬挂、重复释放 | 唯一分支记录和条件状态迁移 |
| Saga补偿无幂等 | 重试造成重复退款、重复释放 | 补偿业务键、结果复用、状态机 |
| Outbox标SENT后再发 | 进程宕机导致消息永久漏发 | 允许重复发送,消费者幂等 |
| Outbox发成功就假设只消费一次 | ACK丢失、Rebalance仍会重复 | 消费日志与业务同事务 |
| AT用于秒杀热点行 | 全局锁竞争,吞吐和延迟恶化 | 原子库存模型、队列、TCC或专用方案 |
| 自动补偿无限重试 | 重试风暴、掩盖永久错误 | 错误分类、退避、上限、人工处置 |
| 直接删除协调/undo记录 | 丢失恢复依据,数据无法证明 | 先取证、按协议恢复和审计 |
二十三、学习路线
- 先学分布式系统硬事实、CAP/BASE和幂等。
- 学XA/JTA/2PC,理解原子提交和恢复日志。
- 学TCC,掌握资源预留、空回滚和悬挂。
- 学Saga,理解长事务和业务补偿。
- 学可靠消息与Outbox,掌握最终一致主线。
- 学Seata,把框架角色映射到前面的模式原理。
- 最后用选型与排查和面试页复习。
二十四、面试标准回答
分布式事务解决一个业务不变量跨多个独立提交边界时的正确性问题。没有任何方案能让多台机器物理上同时提交,工程上只能通过原子提交协议、资源预留、业务补偿或可靠事件传播来协调。XA/2PC 依赖参与者 Prepare 和协调器持久化决议,适合支持 XA 的短事务;TCC 通过 Try/Confirm/Cancel 预留资源;Saga 通过本地提交和补偿处理长流程;Outbox或事务消息解决数据库与事件的双写窗口;Seata对 AT、XA、TCC、Saga提供框架集成。无论哪种方案,都必须有幂等、状态机、超时、恢复日志、补偿、对账、监控和人工兜底。
本章小结
真正的分布式事务设计从业务不变量开始,以持久化状态机和失败恢复结束。只会画正常流程、不会解释协调器宕机和结果未知,就还没有理解分布式事务。
