分布式事务

本页目录

假设下单要同时创建订单、占用库存并调用支付服务。某一步失败或响应丢失时,系统可能只完成一部分。分布式事务与工作流设计要回答:哪些效果必须共同提交,哪些中间状态可以存在,如何可靠地把未完成的操作推进到可解释的结果。

分布式系统可以提供 ACID 事务,但需要相应协议与参与资源支持。不能说跨机器天然“没有全局 ACID”,也不能因为数据库支持跨分片事务,就把任意外部 HTTP 调用自动纳入其中。

先区分原子性、隔离性与业务恢复

原子提交解决多个参与者是否对同一事务作出一致的提交或中止决定。隔离性决定并发事务能看到什么、是否等价于某种串行执行。持久性涉及成功结果如何跨故障保存。这些需要不同机制配合。

例如两个分片都提交了转账,满足共同提交,并不单独证明并发余额检查是可串行化的。2PC 是原子提交协议,不会仅凭两轮消息自动提供所有隔离级别。锁、MVCC、时间戳或冲突验证仍需承担并发控制。

2PC:为什么需要一个“准备好了”的阶段

协调者不能只依次发送普通 COMMIT:第一个资源成功后,第二个可能因为约束冲突而无法提交。Prepare 阶段要求每个参与者先确认并持久保留“我能够按最终决定提交”的能力。

2PC:投票前持久准备,通知前持久决定 协调者请求 Prepare 参与者检查本地事务能否提交 1 参与者持久记录 Prepared 后投 YES 承诺保留提交能力,不能再随意自行中止 2 协调者形成并持久记录决定 所有 YES 才可提交,否则决定中止 3 通知参与者并重试未确认的通知 参与者按唯一决定提交或回滚并释放资源 4 Prepared 表示准备好服从最终决定,不表示已经提交。

参与者投 YES 前,要完成其协议要求的持久化和约束检查,并保留所需资源。锁式实现可能继续持有相关锁;这不意味着整个数据库都被锁住。协调者必须把提交决定可靠保存后再发出提交通知,否则它可能在恢复后无法解释已经提交的参与者。

阻塞来自哪里

考虑 A、B 都进入 Prepared。协调者已经决定 COMMIT,A 收到通知并提交,发往 B 的通知丢失。B 超时后不能自行回滚,因为这会与 A 的结果冲突;即使 B 没收到任何提交消息,也不能据此推断不存在提交决定。

如果参与者尚未作出 YES 承诺,协议可以允许其拒绝或中止;如果能够从恢复日志或其他参与者取得可靠决定,也可以继续完成。经典 2PC 的阻塞问题是:某些已准备参与者在无法取得决定时必须等待,不是“协调者一故障,所有参与者在任何状态都只能永久等待”。

协调者恢复后可以重发持久决定,参与者也可由事务管理器恢复。PostgreSQL 的 prepared transaction 就是供外部事务管理器使用的机制;长期悬置会保留资源,因此需要可靠清理与恢复管理。PREPARE TRANSACTION 文档

复制协调者状态可以减少单机故障带来的等待,但不能让不可达的必要资源凭空恢复。共识与事务提交的关系见 Gray 与 Lamport 的论文。管理员手工决定提交或回滚属于异常处置,不应成为正常恢复的默认步骤。

Prepared 参与者看到的同一段沉默,可以对应两种历史

从 B 的视角,两种执行完全一样:自己已持久准备、投了 YES,然后等不到最终通知。但系统其他部分可能处于不同状态:

B 无法观察到的部分执行一执行二
协调者所有 YES 后已持久决定 COMMIT尚未收齐投票,没有提交决定
参与者 A已按 COMMIT 提交尚未准备,仍可能投 NO
B 本地状态Prepared,等待通知Prepared,等待通知

如果 B 根据超时自行 ABORT,会破坏执行一的原子性;如果自行 COMMIT,会在执行二中越过尚未满足的提交条件。单靠 B 的本地沉默,无法选择一个对两种历史都安全的终态。

恢复因此要找可靠决定或协议允许的决定依据⁠,而不是把等待时间拉长到某个数就猜答案。已提交参与者能提供可靠提交证据时,可以帮助恢复;若所有能联系到的参与者也都只知道 Prepared,就仍可能缺少决定。保留准备态资源是这种不确定性带来的代价,不是一个普通 RPC 重试能消除的窗口。

同一段沉默,背后可能有两个决定

这里的等待不是动画缺少下一步,而是 B 的知识确实不够。把远端分别设为“COMMIT 已持久化”和“另一个参与者拒绝后 ABORT 已持久化”,B 在通知到来前仍只有同一份 Prepared 证据。切换远端历史是比较两次执行,不是允许同一事务修改已经作出的决定。

正在呈现知识画面
同一段沉默,背后可能有两个决定

Prepared 后,超时本身不能证明提交或中止。相同本地状态可对应远端已提交或尚可中止的执行,恢复需要可靠决定。

Saga:用一系列本地事务实现业务流程

Saga 将流程拆成可独立提交的步骤。以示例订单流程为例:创建待支付订单、申请扣款、安排发货。若扣款明确失败,可以把订单改为取消;若扣款成功但发货最终无法履行,可以申请退款并标记履约失败。

补偿是新的业务操作,不是把数据库恢复成过去的字节状态。退款可以抵消扣款的主要业务效果,但支付记录、通知和审计历史仍然存在。并发业务可能已经读取到中间状态,因此不能用旧快照覆盖整个对象来“撤销”。这是 Sagas 原论文所强调的语义补偿区别。

中间态必须成为业务模型的一部分

订单可以有“待支付”“支付结果待确认”“退款中”等状态,并限制哪些状态允许发货。只靠失败时反向调用几个接口,无法防止其他流程在补偿前根据中间结果继续执行。

补偿也会失败、超时或长期等待。需要持久记录步骤、请求标识、结果和补偿进度,按策略重试,必要时进入人工处理状态。网络超时首先意味着结果未知,应按稳定操作 ID 查询或重试确认,不能立即当作“从未扣款”再创建一次新支付。

编排方式流程如何推进需要关注
Orchestration编排器保存状态并发出步骤命令编排器恢复、命令去重、流程版本
Choreography服务根据事件继续后续步骤事件关联、循环、全局进度与失败追踪

两者都需要可靠状态和消息处理。事件驱动不会自动消除协调逻辑,只是将其分布到各服务。

TCC:先取得可兑现的资源预留

Try-Confirm-Cancel 为业务资源定义三个动作:Try 检查并预留,Confirm 消耗预留,Cancel 释放预留。比如库存 10 件,Try 预留 2 件后,可售库存变成 8 件;Try 已经修改了资源状态,并非“什么都没发生”。

TCC:预留后走向一个终态,终态不能互相覆盖 未开始 已预留 已确认 已取消 Try Confirm Cancel Cancel 先到:记下取消,再拒绝迟到的 Try 每次状态转换与资源操作原子提交;重复请求返回该分支已有结果。

为了让 Confirm 可兑现,Try 要把业务前置条件转化为可靠预留,避免其他请求又消耗同一份资源。Confirm 仍可能遇到临时技术失败,因此需要重试;如果资源在确认前会无条件过期,必须协调过期与最终决定,否则“Try 成功就一定可确认”的承诺不成立。

三个常见边界可以用同一个持久状态机解释:

  • 幂等⁠:同一分支的 Try、Confirm、Cancel 重复到达,不重复占用、消耗或释放资源。
  • 空回滚⁠:Cancel 先于 Try 到达,也记录已取消,资源无需释放。
  • 防悬挂⁠:已取消后迟到的 Try 必须被拒绝,不能重新建立无人清理的预留。

Confirm 与 Cancel 必须服从一致的最终决定,并通过原子状态转换避免两者都生效。已确认之后若需撤销业务,应进入新的退款或退货流程,不能说 TCC 在任何时刻都可以直接 Cancel。

用“可售+预留+已消耗=初始库存”检查 TCC 分支

下面把一次分支调用视为原子的本地状态转换,初始库存为 10。分支身份绑定数量,所有动作都检查参数一致;Confirm 在没有成功 Try 时返回失败,Cancel 可以先记录空取消。返回 True 表示该动作的语义已经满足,重复调用不代表再次修改了资源。

class InventoryTCC:
    def __init__(self, stock=10):
        self.initial = self.available = stock
        self.consumed = 0
        self.branches = {}  # branch_id -> (state, quantity)

    def call(self, action, branch_id, quantity):
        if action not in {"try", "confirm", "cancel"}:
            raise ValueError("未知动作")
        if type(quantity) is not int or quantity <= 0:
            raise ValueError("数量必须为正整数")
        previous = self.branches.get(branch_id)
        if previous is not None and previous[1] != quantity:
            raise ValueError("同一分支不能更换数量")
        state = previous[0] if previous else "new"
        if action == "try":
            if state == "cancelled":
                return False
            if state in {"reserved", "confirmed"}:
                return True
            if self.available < quantity:
                return False
            self.available -= quantity
            self.branches[branch_id] = ("reserved", quantity)
        elif action == "confirm":
            if state == "confirmed":
                return True
            if state != "reserved":
                return False
            self.consumed += quantity
            self.branches[branch_id] = ("confirmed", quantity)
        else:
            if state == "confirmed":
                return False
            if state == "cancelled":
                return True
            if state == "reserved":
                self.available += quantity
            self.branches[branch_id] = ("cancelled", quantity)
        return True

    def check(self):
        reserved = sum(q for state, q in self.branches.values() if state == "reserved")
        assert self.available >= 0 and self.consumed >= 0
        assert self.available + reserved + self.consumed == self.initial
        return self.available, reserved, self.consumed

stock = InventoryTCC()
assert stock.call("try", "order-1", 2)
assert stock.call("try", "order-1", 2)
assert stock.check() == (8, 2, 0)
assert stock.call("confirm", "order-1", 2)
assert stock.call("confirm", "order-1", 2)
assert not stock.call("cancel", "order-1", 2)
assert stock.check() == (8, 0, 2)

assert stock.call("cancel", "order-2", 3)  # 空回滚,也留下终态
assert not stock.call("try", "order-2", 3)  # 拒绝迟到 Try
assert stock.check() == (8, 0, 2)
assert stock.call("try", "order-3", 4)
assert stock.call("cancel", "order-3", 4)
assert stock.call("cancel", "order-3", 4)
assert stock.check() == (8, 0, 2)
assert not stock.call("try", "too-large", 9)
assert stock.check() == (8, 0, 2)

Try 把数量从可售移动到预留;Confirm 从预留移动到已消耗;Cancel 从预留移回可售。重复 Cancel 如果再次增加 available,会凭空造出库存,守恒式能直接抓住这种错误。同样,空取消不能增加库存,因为之前根本没有预留。

这是单线程内存模型,没有实现磁盘持久化、进程崩溃或并发锁。真实实现要让分支状态、数量和库存修改共同原子提交,并验证操作确实来自有权推进该事务的协调者。多个事务共享库存时,还必须原子检查可售量,不能先读库存再无条件扣减。

更重要的是:⁠本地终态互斥不等于全局决定一致⁠。若协调者错误地给 A 发 Confirm、给 B 发 Cancel,两边都可以合法进入各自终态,系统仍然不原子;终态检查只能阻止同一分支反复改口。协调者的持久决定、恢复与重试是另一层必需机制,Seata 的 TCC 模式也将自定义分支行为纳入全局事务管理。TCC 分支与全局事务

已取消分支的记录也不能立即删除,否则迟到 Try 又会被当作新请求。保留期限必须覆盖允许的重试与重放窗口,或由协议证明旧分支再也无法出现;这与消息消费去重的保留问题相同。

重试一次,会不会凭空增加库存?

守恒式把重试错误变成可检查的数量变化:任何合法动作之后,可售、预留和已消耗的和都应保持 10。先 Cancel 再 Try 可以检查防悬挂;重复 Cancel 可以检查是否错误返还;Confirm 后再 Cancel 则必须拒绝。本地分支满足这些规则,仍不能替代全局协调者的一致决定。

正在呈现知识画面
重试一次,会不会凭空增加库存?

Try 将可售移到预留,Confirm 将预留移到已消耗,Cancel 将预留返还。空取消只记终态,不增加库存;迟到 Try 不能复活取消分支。

幂等:同一次业务请求只产生一次效果

幂等接口需要识别“这是同一次请求”,而不只是让 SQL 看起来可重复。可用 (业务作用域, idempotency_key) 唯一标识操作,把请求参数摘要、业务效果和结果一起可靠保存。

去重记录与业务修改需要同一个原子边界。先标记已处理再执行,崩溃可能漏执行;先执行再标记,崩溃可能重复执行。对于外部服务,需使用对方支持的稳定操作标识与结果查询机制。Stripe 的接口展示了按幂等键保存结果、校验参数以及保留期限等具体契约。Stripe 幂等请求文档

乐观锁 WHERE version = expected 用于发现竞争,不等于完整去重:更新影响 0 行,可能因为原请求已成功,也可能因为别的请求改过记录。客户端仍需辨认自己的操作结果。把相同幂等键用于不同请求参数也应报错,而不是静默返回无关结果。

Outbox:把业务状态与待发送事件一起提交

若订单数据库和消息系统无法共同事务提交,可以在一个本地事务里同时修改订单并插入 Outbox 事件,再由转发器发送。这样,成功的本地业务变更有一条持久的待发送记录。

转发器在消息已发出、发送状态尚未更新时崩溃,会再次发送。因此 Outbox 通常需要消费者幂等处理,不提供外部副作用天然的“恰好一次”。完整示例见消息语义。

方案速查

机制主要解决什么不自动提供什么
2PC支持协议的资源共同作出提交决定任意外部调用、所有隔离级别、无条件可用性
Saga多个已提交步骤的持续执行与业务补偿隐藏全部中间态、物理回滚、永不阻塞
TCC业务资源预留后的确认或取消无成本预留、确认后任意撤销
幂等同一操作重试不重复产生效果多个独立资源的共同提交
Outbox本地变更与事件记录原子提交消费端与任意外部效果恰好一次

选择应从约束出发:能把强耦合数据放进同一事务边界时,优先评估这种简化;必须跨资源时,再明确参与能力、允许的中间状态、失败恢复与业务补偿,而不是仅按“强一致”或“高可用”两个标签选方案。

参考

Keywords: distributed transaction, atomic commit, 2PC, Saga, TCC, compensation, idempotency, outbox