分布式事务:方案选型与工程实践

目录

电商下单同时涉及库存、余额和订单记录。如果库存已经扣减,支付调用却超时,系统面对的首先不是“调用失败”,而是支付结果暂时未知。它可能没有执行,也可能已经成功,只是响应没有回来。

分布式事务的设计,就是在这类不确定性下明确业务状态、提交决定和恢复责任。本文沿用下单场景,比较 2PC、3PC、TCC、Saga、本地消息表和事务消息,并说明它们为何不能互相替代。

先定义要保持的业务约束

如果库存、账户与订单属于同一数据库事务,本地事务可以提供原子提交。但账户不透支、金额币种一致、重复请求不重复扣款,仍依赖业务约束和正确实现。ACID 中的一致性不能仅靠另外三个字母自动获得。

拆到多个服务后,要先回答:

  • 下单期间能否出现“资源已预留、订单待确认”的状态
  • 外部支付是否支持预授权、撤销、退款和幂等查询
  • 哪些步骤必须共同提交,哪些可以异步推进
  • 超时之后由谁查询实际结果,失败之后如何恢复

CAP 讨论的是允许网络分区的模型中,线性一致性与对所有非故障节点请求的可用性不能同时保证。它不是所有系统任意“三选二”,也不等同于多资源事务原子提交的定义。BASE 是一种处理可用性与最终收敛的工程思路;“最终一致”仍要求恢复、重试、冲突处理等条件成立,不是等待一段时间就自动一致。Gilbert 与 Lynch 的 CAP 论文

2PC:把全局提交决定持久化

两阶段提交让参与者先确认能否履行提交,再依据统一决定完成。数据库分支的操作可能早已执行,Prepare 不是业务执行的唯一开始时机。

2PC 的准备投票与统一提交决定

  1. Prepare:参与者确认可以提交,将必要状态持久化后投 Yes;不能满足条件则投 No
  2. 决定:协调者收齐所需 Yes 后形成提交决定,并可靠记录;否则决定中止
  3. 完成:参与者按决定提交或回滚,确认结果;通信恢复后可以重发与恢复

已进入 prepared 状态的参与者不能因为本地等待超时就随意回滚,否则可能与其他已提交分支分裂。经典 2PC 的问题是这种情况下可能阻塞;不能把“一个已提交、另一个等待决定”直接等同于协议允许永久相反结果。

协调者宕机后,恢复依赖持久日志和协议状态,并非新进程天生不知道以前的决定。日志丢失、启发式决策等超出正常故障假设的行为另当别论。Gray 与 Lamport:Consensus on Transaction Commit

XA 定义了事务管理器与资源管理器之间的接口。使用 Java/JTA 时,还要让连接和 XAResource 正确加入全局事务。给两个普通 DataSource 调用套上 UserTransaction.begin,并不会自动把它们变成正确的 XA 分支。

对跨库资金修改,需验证资源登记、恢复日志、提交结果不明时的处理和故障恢复。协调者发起 commit 后报错,不意味着调用方可以无条件再次 rollback 并宣称所有分支已经撤销。

3PC:多一个阶段,不是按成功概率提交

3PC 在投票与最终提交之间引入预提交状态,尝试在更强的同步和故障假设下改善阻塞行为。它的正确性来自状态及终止协议,不能解释为“多数时候会成功,所以超时默认提交”。

3PC 成功路径与故障假设

阶段 成功路径中的作用
CanCommit / 投票 参与者给出能否提交的承诺
PreCommit 协调者传播预提交决定,参与者确认相应状态
DoCommit 完成提交并释放相关资源

投 Yes 必须有足够约束保证承诺可履行,不能仅检查连接池和磁盘还有空位。任意网络分区、无界延迟下,仅凭一个超时就自主提交并不安全。本文保留这一机制对比,不给出缺少持久状态和终止协议的“可运行 3PC 实现”。Skeen:Nonblocking Commit Protocols

对工程选型,更有用的问题是现有事务管理器如何恢复 prepared 分支、协调者状态如何持久化,而不是把阶段数越多视为可靠性越高。

TCC:用业务预留代替长时间数据库持锁

TCC 将业务操作拆成三个阶段:

阶段 库存示例
Try 可用库存减 10,冻结库存加 10,记录本次预留
Confirm 根据预留记录确认扣减,减少冻结库存
Cancel 根据预留记录释放冻结库存,恢复可用库存

TCC 预留、确认和释放资源的分支

Try 本身会提交本地业务状态,“预留”并非“不执行”。Confirm 和 Cancel 也各自执行本地事务;业务需要接受中间预留状态,并防止其他路径绕过它。

可靠实现的关键是一条受数据库约束保护的分支状态记录,而不是先 exists 再更新库存。下列是状态转换伪代码,省略 DAO,事务键包含全局事务与分支标识:

Try(tx, sku, quantity):
  在本地事务内创建或锁定唯一分支记录
  若已 CANCELED,拒绝迟到的 Try
  若已 TRIED/CONFIRMED,校验请求参数相同后返回已处理
  校验 quantity > 0,并以 available >= quantity 条件冻结库存
  库存变更与分支 TRIED 一起提交

Confirm(tx):
  在本地事务内锁定分支记录
  CONFIRMED 返回已处理;CANCELED 不得改为成功
  仅允许 TRIED -> CONFIRMED
  使用记录中的 sku/quantity 完成冻结资源扣减并核对影响行数

Cancel(tx):
  在本地事务内创建或锁定唯一分支记录
  不存在 Try 时也持久化 CANCELED 墓碑,防止迟到 Try
  TRIED -> CANCELED 时释放本分支冻结资源
  CANCELED 返回已处理;CONFIRMED 不能再执行普通 Cancel

并发创建同一记录要依靠唯一键和冲突后重读等协议处理;不能只靠进程内判断。数量以已持久化分支为准,避免重试参数变化扣到其他事务的冻结余额。三个接口的幂等、空回滚和防悬挂,最终要在同一套状态机中成立。

Saga:将恢复写成业务流程

Saga 把长流程拆为多个本地事务,并为可补偿的步骤设计反向业务动作。已创建订单可以标记取消,已支付资金可能发起退款,但已发送短信无法像数据库行一样物理撤销。

Saga 事件协作与中心编排的两种组织方式

本文统一使用“事件协作(Choreography)”和“中心编排(Orchestration)”,避免将两个英文术语反向翻译。

组织方式 谁推进流程 主要工程责任
事件协作 服务消费事件,再发布后续事件 追踪整体状态、避免事件循环、可靠传播
中心编排 编排器安排执行与补偿 持久化状态机、恢复调度、幂等命令

“调用成功后写一条 COMPLETED 日志”仍有空隙:远端成功、日志写入前进程崩溃,恢复时不能仅按本地 completedSteps 列表决定补偿。应先持久化步骤意图与幂等键,再发起调用;结果未知时查询或以同一幂等键重试,确认状态后推进。

补偿顺序根据业务依赖决定,简单线性流程常反向补偿已完成步骤,但不总是机械地 C3、C2、C1。补偿失败需要持久化为待处理状态,不能一边记录失败一边向调用方返回“已完全回滚”。

Saga 不提供跨全部步骤的数据库隔离,中间状态可能被观察到。可以通过待确认状态、语义锁和资源预留等手段约束业务可见性。Saga 模式

本地消息表:将业务变更与发送意图一起提交

本地消息表,也称 Transactional Outbox,解决数据库提交与发消息之间的空隙。它可以为 Saga 服务,也可单独服务于订单通知;只有可靠发消息而没有业务补偿流程,并不自动构成 Saga。

本地事务写入业务与 Outbox,异步投递并接收处理回执

上游把订单和事件写入同一个数据库事务。事件有稳定的 event_id;发送进程读到它后,将这个 ID 一起放入消息。Broker 已收到、发送进程还没记为 SENT 就崩溃时会重发,因此下游必须按稳定事件 ID 去重,而不是依赖每次发送都可能变化的 Broker 消息 ID。Transactional Outbox

上游本地事务:
  写入订单
  写入 Outbox(event_id, order_id, payload, PENDING)
  一起提交

投递器:
  有界领取待发送事件
  使用相同 event_id 重试投递
  Broker 确认接收后记录 SENT

下游本地事务:
  根据 event_id 创建唯一 Inbox 记录
  若已成功处理,返回已有结果
  将本地业务更新与 Inbox 成功状态一起提交
  再确认消费

唯一记录竞争、失败回滚和重复读取都要由具体数据库事务实现,不能把 exists 检查当成原子去重。如果下游还要调用外部仓储或付款接口,该外部副作用不在这个本地事务内,仍需幂等调用或再一个 Outbox。

图中额外画了业务回执:SENT 只表示 Broker 确认,DONE 表示收到下游业务确认。回执自身也要可靠重试、去重。这一步是否需要取决于上游是否必须跟踪下游完成,不能把 Broker ACK 与业务完成混成同一个状态。

事务消息:Broker 回查仍依赖业务证据

RocketMQ 半消息先保持对消费者不可见,Producer 执行本地事务后报告 Commit/Rollback;未得到确定结果时,Broker 发起回查。

RocketMQ 半消息与本地事务回查

回查必须根据消息中稳定的业务键查询持久化事务结果,不能依赖发送方法闭包里那次 request 对象。Producer 可能重启,另一个实例也可能接收回查。

业务记录不存在时,也不能在所有情况下立刻判定失败:原事务可能仍在执行。设计可查询的成功、失败和未知状态,并理解版本配置中的回查次数、超时和最终处置。一个内存异常也不一定证明本地提交没有发生。RocketMQ 事务消息

在经典 Java 客户端中,TransactionListener 注册到 TransactionMQProducer,再调用 sendMessageInTransaction(msg, arg);不应给发送方法传入一个并不存在的 listener 三参数重载。SDK 代际之间接口也不同,示例应随目标版本编译验证。

事务消息协调的是本地事务与消息生产结果,不自动保证下游业务执行成功。Kafka/Pulsar 的消息事务也有各自的读写范围,不能把消息系统内的事务直接扩展成外部数据库和支付的端到端 exactly-once。

选择时先看要协调哪些资源,以及团队已经拥有的可靠投递与状态查询能力,不需要制造“唯一能保证原子性”的中间件排行。

最大努力通知需要查询闭环

跨企业支付回调通常采用重试通知,具体间隔和停止条件以对应渠道契约为准,不把示例重试序列说成所有平台的固定配置。

接收方应校验来源、按业务键幂等处理,并通过主动查询与账单核对补齐未收到的通知。“通知已发送”和“商户状态已更新”是不同事实;有限次重试不等于最终一定送达。

对账修复的是已确认差异

订单、支付与渠道对账的证据汇聚和差异处理

对账首先统一对象和口径:一笔订单可以有多次支付尝试、部分支付与退款;业务日期和渠道清算日期也可能不同。不能简单用 order_id 去收集为单值 Map,遇到重复记录就报错或覆盖,再宣称完成资金对账。

检查 需要区分的情形
缺少支付记录 未付款订单、仍在处理中、回调丢失、记录损坏
金额不同 币种、部分支付、退款、手续费及确实错账
渠道侧单边记录 内部未入账、关联键错误、日期窗口错位
事件暂未到齐 传输延迟、消息丢失、服务异常

对外查询超时或返回未知时,应保留待确认状态,不能用 isPaid()==false 统一触发关单。关单和补录还要按业务状态机做条件更新,避免与晚到的支付结果竞争。

实时对账可以用窗口汇聚事件,但进程内缓存不能承担唯一记录责任。缓存过期回调不一定准时,重启也可能丢窗口;持久化原始事件、截止时间和处理状态后,才能扫描恢复。修复动作必须有来源、幂等键和审计记录,人工确认也属于恢复协议的一部分。

选型按约束,不按“强弱一致”一列决定

需求 候选机制 必须验证的前提
多个支持 XA 的数据库需要原子决定 2PC/XA 正确登记、日志恢复与 prepared 状态处置
库存、余额等可显式预留 TCC 分支状态机、幂等、空回滚、防悬挂
长流程存在可接受的补偿动作 Saga 不确定结果查询、恢复调度、补偿失败处置
本地提交后可靠发布事件 Outbox 或事务消息 稳定事件键、重试、消费幂等、积压治理
外部通知不保证到达 重试通知与查询对账 渠道契约、最终状态查询与差异恢复

先判断能否保留在单个本地事务中,再决定是否需要预留、补偿或异步收敛。最终验证也应围绕故障窗口展开:提交前后崩溃、响应丢失、重复调用、补偿失败和恢复重放。协议名说明设计方向,状态记录与恢复行为才决定这条业务链能否可靠完成。

评论