第 4 章 大事务处理方法论:Saga、补偿与最终一致性
大事务不是把多个数据库强行变成一个数据库,而是把一个跨系统的关键业务动作拆成可提交、可重试、可补偿、可审计、可最终收敛的状态机。本章从电商创单出发,讨论大事务的难点、决策点、异常处理和治理方法。
很多工程师第一次听到“大事务”时,会自然联想到数据库事务、分布式事务,甚至 2PC / XA。这些概念当然重要,但如果把它们直接套到互联网业务里,往往会把问题理解偏。
互联网系统里的大事务,真正要解决的不是“如何让多个数据库像一个数据库那样一起提交”,而是:
当一个关键业务动作必须跨库存、营销、订单、支付、账务等多个系统共同完成时,系统如何在失败必然发生的现实里,仍然把结果收敛到可解释、可恢复、可补偿的状态。
这里有一个必须先划清的边界:
- 数据库事务解决一个数据库内部的原子性、隔离性和持久性。
- 分布式事务协议试图在多个资源之间提供更强的提交语义。
- 业务大事务管理一个业务目标跨多个本地事务和外部副作用后的最终结果。
- 长生命周期业务流程管理业务对象跨支付、履约、审核、售后等多个阶段的持续推进。
例如,订单从创单到支付、履约、售后,是一个长生命周期业务流程;而“创单”这个关键节点内部的锁库存、占优惠、落订单,则是一个典型的大事务。本章只聚焦“创单这一刻如何做对”,第 5 章再讨论订单或商品如何跨阶段持续运行。
4.1 问题定义:大事务到底在解决什么问题
先把三个经常混在一起的概念拆开:
| 概念 | 解决的问题 | 典型手段 | 主要边界 |
|---|---|---|---|
| 数据库事务 | 单库内多条读写是否作为一个原子单元提交 | 本地事务、锁、隔离级别、MVCC | 只能直接保护同一资源管理器内的数据 |
| 分布式事务 | 多个资源管理器如何达成统一提交或回滚决定 | 2PC、XA、TCC | 依赖参与者支持协议,并可能放大锁、等待和可用性成本 |
| 业务大事务 | 跨系统业务目标如何推进、失败、补偿和收敛 | Saga、状态机、Workflow、Outbox、对账 | 结果通常是业务等价的最终一致,而非瞬时全局原子 |
数据库事务关心的是“同一个数据库里的几条 SQL 要不要一起成功或失败”。分布式事务关心的是“多个资源管理器能不能形成统一提交语义”。业务大事务关心的则是“一个业务流程跨多个系统推进时,如何保证结果最终正确,且过程可解释、可恢复、可审计”。
这三者不是相互替代的关系,而是不同层次的问题。大事务内部仍然需要本地事务;Saga 的每一个步骤通常都应该在参与方内部原子提交;Outbox 也必须依赖本地事务把业务事实和待发送事件一起写入。只是,跨系统的整体目标不能再假设由一次本地 commit 自动完成。
4.1.1 为什么不能把所有事情都塞进 2PC / XA
传统两阶段提交的价值在于提供较强的统一提交语义,但它要求参与者支持协议、在准备阶段保持资源状态,并共同实现故障恢复。它并非“错误方案”,而是有严格的适用边界。
真实互联网业务经常不满足这些前提:支付网关、短信平台和物流供应商不会加入你的数据库事务;用户支付和人工审批不能长期持有数据库锁;短信、支付受理和供应商订单也不一定存在真正的回滚。业务上更常见的动作是取消占用、冲正流水、标记失败和等待对账。
Helland 在讨论大规模系统时,把这类现实概括为:应用程序往往不能假设存在覆盖所有业务对象和外部系统的全局分布式事务。[2] 它提醒设计者:一致性语义必须和参与者能力、业务时限以及恢复路径一起定义。
《凤凰架构》把它归纳为多服务、多数据源的事务问题,强调按业务一致性与隔离性需求选型。[7]
因此,本章的核心问题不是“有没有一种更强的事务框架”,而是:业务动作的最小边界是什么,哪些步骤会留下副作用,每种结果和补偿分别意味着什么,请求中断后能否从持久化事实恢复,以及谁有权判断最终应为成功、已补偿、待对账还是待人工处理。
这把“大事务”从接口编排转成状态收敛。
4.2 约束与指标:为什么创单节点天然是大事务
电商创单看起来只是用户点击一次“提交订单”,但在服务端通常至少包含以下动作:
- 校验购物车、商品、价格、税费和运费快照。
- 判断库存是否仍然可售,并预占目标数量。
- 校验优惠券、满减、积分或会员权益,并占用额度。
- 创建订单主记录与订单行,保存价格和优惠快照。
- 创建支付单或支付上下文,为后续支付做准备。
- 生成后续履约、通知、搜索和统计所需的事件。
真正的难点不在步骤数量,而在于每一步都可能拥有独立的本地事务和失败语义:
- 库存预占成功,但订单服务写库失败。
- 订单已写入,但营销服务超时,无法确定优惠是否已经占用。
- 订单和库存都成功,但支付上下文创建结果未知。
- 用户重复点击提交,两个请求同时通过库存检查。
- 第一次调用已经在下游提交,响应却在返回前丢失,客户端于是再次发起请求。
- 订单超时取消后,库存释放和优惠释放只完成了一部分。
所以创单节点天然具有四个特征:跨系统、多副作用、可失败、需收敛。可以用下面的约束表把它从“接口链路”转换成“系统设计问题”。
| 约束维度 | 创单中的具体问题 | 设计时必须回答的问题 |
|---|---|---|
| 参与方与故障域 | 订单、库存、营销、支付、风控可能独立部署 | 谁拥有每个事实?哪个故障域可以独立恢复? |
| 资源类型 | 库存和优惠可预占,通知和支付受理可能不可逆 | 资源是释放、冲正、重试还是只能记录状态? |
| 时效 | 用户希望较快得到“已创建 / 被拒绝”,外部回执可能很慢 | 哪些结果必须同步确认?哪些可以转为异步收敛? |
| 并发 | 重复点击、并发下单、取消与支付回调可能同时发生 | 如何防止重复占用和旧请求覆盖新事实? |
| 失败代价 | 超卖、重复扣款、优惠资损、孤儿占用 | 哪些失败要立即阻断?哪些允许进入待处理? |
| 可审计性 | 事后需要解释订单为何存在、库存为何释放 | 是否保存步骤、幂等键、外部请求号和操作原因? |
| 恢复目标 | 线上重试不能解决所有未知结果 | 多久内自动补偿?多久告警?何时转人工? |
4.2.1 先定义成功,不要只定义返回码
“HTTP 200”不是业务成功的充分条件。一个创单动作至少要区分五种结果:
- 明确成功:订单已经创建,关键资源已确认,后续动作可以继续。
- 明确拒绝:库存不足、优惠失效、风控拒绝等业务条件不满足,不应该盲目重试。
- 技术失败且确认未执行:例如参数校验失败,或下游在受理前返回明确错误。
- 结果未知:请求超时、连接断开、进程在提交后崩溃,无法从响应判断下游是否已经执行。
- 进入收敛流程:在线请求无法继续,但系统已经记录事务单,后续由重试、补偿、对账或人工操作推进。
如果把最后三类全部压成“失败”,系统就会在结果未知时重复执行副作用;如果把它们全部压成“成功”,系统又会把未确认的业务事实暴露给用户。成功判据必须由业务状态和下游权威事实共同决定。
4.2.2 指标应该围绕收敛,而不是只看 RPC 成功率
大事务的关键指标不应只有平均延迟和接口错误率,还应包括:
- 事务单从创建到终态的停留时长。
UNKNOWN步骤数量及其最大存留时间。- 补偿任务成功率、重试次数和补偿失败积压。
- 对账差异数量、差异金额或资源量、差异存留时间。
- 人工处理队列长度、逾期任务数量和人工收敛耗时。
- 重复请求被正确吸收的比例,以及重复副作用数量。
这些指标表达的是系统能否把“不确定”变成“确定”;只看在线接口成功率是不够的。
4.3 核心模型:状态机、事务单与步骤账本
大事务设计的起点不是“先选 Saga 框架”,而是先定义一个可以被持久化、查询、重试和审计的业务动作模型。
4.3.1 业务动作、事务单和步骤账本
以创单为例,可以把模型拆成三层:
| 对象 | 作用 | 典型字段 |
|---|---|---|
| 业务动作 | 定义本次必须完成的目标 | action_type、业务主键、请求来源、输入快照 |
| 事务单 | 描述整体推进状态和期限 | transaction_id、status、deadline、version |
| 步骤账本 | 记录每个副作用的执行事实 | step_name、幂等键、尝试次数、外部请求号、结果、补偿状态 |
业务动作回答“我们要完成什么”;事务单回答“这一次动作整体走到哪里”;步骤账本回答“每一个可能产生副作用的动作究竟发生过什么”。
如果只保存一个订单状态,就无法区分“订单没有创建”和“库存已经预占但订单写入失败”。如果只保存日志,又无法让恢复程序安全判断下一步。事务单与步骤账本不是为了增加表数量,而是把恢复所需要的事实从日志文本中提取成可查询数据。
4.3.2 权威事实源与不变量
每一种资源都必须有明确的权威事实源:库存是否被占用由库存系统确认,优惠是否占用由营销系统确认,订单是否存在由订单系统确认,支付是否受理由支付渠道或支付系统确认。事务协调者可以保存副本和执行记录,但不应在没有查询依据时凭自己的状态替代参与方事实。
至少需要建立以下不变量:
- 一个可能产生副作用的步骤必须拥有稳定、可重放的幂等键。
- 同一幂等键最多产生一份业务副作用,并且重复请求返回第一次执行的语义结果。
- 事务单状态只能通过受控状态迁移变化,不能由多个服务直接覆盖同一个字段。
- 补偿动作必须检查当前权威状态,不能把已经确认的资源再次释放或把已冲正的流水重复冲正。
- 结果未知不能直接跳过,也不能直接重做,必须先查询、重放或进入可运营的待处理状态。
- 每条事件都应带有业务主键、事件 ID、版本或序列信息,使消费者能够识别重复和过期消息。
4.3.3 状态不是“成功 / 失败”二选一
事务单可以使用下面的最小状态集合:
PENDING
-> RUNNING
-> SUCCEEDED
RUNNING
-> UNKNOWN
-> COMPENSATING
-> SUCCEEDED
UNKNOWN
-> RUNNING
-> COMPENSATING
-> MANUAL_REVIEW
COMPENSATING
-> COMPENSATED
-> MANUAL_REVIEW
这里的 UNKNOWN 不是错误日志里的临时标记,而是一个需要持久化的业务状态。COMPENSATED 表示已确认的副作用已经被业务等价地回收,不代表系统回到了完全没有发生过的历史;MANUAL_REVIEW 表示自动机制无法在安全边界内继续推进,需要带着完整上下文交给有权限的人处理。
步骤状态还应区分“未开始、执行中、已成功、结果未知、补偿中、已补偿”。只有把这些状态保存下来,进程重启后协调者才知道应该查询、继续、补偿还是停止。
4.3.4 最小数据模型
编排式 Saga 不是在一个 Service 中写一串接口调用。进入生产后,最重要的是持久化执行事实:进程重启、网络超时、消息重复和人工介入发生后,系统仍然知道这次动作走到了哪里。
可以从四类记录开始:
| 记录 | 关键字段 | 要回答的问题 |
|---|---|---|
transaction | transaction_id、业务主键、status、deadline、version | 整体是否成功、失败、补偿中或待人工处理? |
transaction_step | 步骤名、幂等键、状态、尝试次数、外部请求号、错误 | 每个副作用是否执行过、结果是否可查询? |
outbox_event | 事件 ID、业务主键、版本、投递状态、重试时间 | 本地事实提交后应该发出的事件是否可重放? |
operation_audit | 操作者、动作、前后状态、原因、时间 | 谁在什么依据下推进或修复了状态? |
事务单和步骤账本必须有明确的所有者。协调器可以负责事务单和步骤推进,库存服务负责库存事实,营销服务负责权益事实,订单服务负责订单事实。不要让协调器为了“方便查询”直接修改库存服务的状态字段。
4.3.5 幂等键的作用域
幂等键不能只写成一个全局字符串,而要回答“谁在什么语义下去重”。推荐把它拆成:
业务意图键 = client_request_id + user_id + action_type
步骤执行键 = transaction_id + step_name + resource_id
补偿执行键 = transaction_id + compensate_step_name + resource_id
外部请求号 = service_name + transaction_id + step_attempt_scope
同一交易单对两个不同 SKU 的库存预占不能共享一个会误吞请求的键;同一 SKU 的同一业务意图也不能因为重试生成新键。幂等记录应保存第一次结果或可查询的结果引用,避免重复请求只能得到“已处理”却拿不到订单号、reservation ID 或支付单号。
4.3.6 乐观并发控制
协调器可能被 HTTP 重试、消息重投和定时器同时唤醒。事务单状态更新应带版本或状态条件:
UPDATE transaction
SET status = 'COMPENSATING', version = version + 1
WHERE transaction_id = :transaction_id
AND status = 'RUNNING'
AND version = :expected_version;
受影响行数为零时,不要简单报错或再次写入;应重新读取权威事务状态,判断是重复唤醒、正常竞态还是版本冲突。版本控制保护的是协调记录,不能替代库存或余额系统自己的并发控制。
4.4 参考架构:同步边界、Saga/TCC、Outbox 与消息语义
围绕创单这类关键节点,常见实现路线可以分为同步短流程、协同式 Saga、编排式 Saga 和 TCC。Outbox 通常不是独立的终局方案,而是其中某些路线的可靠事件基座。
Saga 最初就是为长时间事务提出的分解思路:把一个长事务拆成多个本地事务,并为已经完成的步骤定义补偿路径。[1]
4.4.1 同步 RPC + 本地事务
做法是由一个入口服务串行调用库存、营销和订单服务,尽量在一次请求中完成全部步骤;每个服务用本地事务保护自己的数据。
它适合以下前提:步骤有限、没有长等待、参与方响应稳定、失败后可以直接向调用方返回拒绝、补偿动作很少且容易查询。它的优点是链路直观、用户反馈快、排障入口集中。
但同步并不等于原子。假设入口服务先调用库存预占,再调用订单写入:库存成功后,入口进程在第二次调用前崩溃,数据库事务不会替你释放库存。同步只降低了流程延迟,并没有消除跨系统副作用。
因此,使用同步 RPC 时仍然需要:
- 为每个可重试请求定义幂等键。
- 把已完成的步骤持久化,而不是只放在调用栈里。
- 为中间失败提供释放或补偿入口。
- 在超时后查询下游,而不是直接假设“没有执行”。
4.4.2 协同式 Saga
协同式 Saga 由参与方通过事件彼此驱动:订单服务发出 OrderCreated,库存服务消费后预占库存并发出 InventoryReserved,营销服务再处理优惠;失败时由参与方发布补偿事件。
它的优点是领域自治强,服务之间不依赖中央协调者;当事件本来就是领域边界时,协同式设计可以自然扩展。代价是全局路径分散,补偿逻辑散落在多个服务中,参与方增加后依赖关系容易变成隐形拓扑,排障者必须从事件链拼出完整历史。AWS 的 Saga 指南也提示,编舞式方案的参与方增加后依赖追踪会变难。[3]
Seata 中文文档将 Saga 定义为先提交本地事务,失败后补偿前序参与者,并支持状态机编排正向与补偿节点。[8]
它适合多系统自治、主控方不强且事件边界存在的场景;若主链路需要判断成功或补偿,需建设事务视图或审计投影。
4.4.3 编排式 Saga
编排式 Saga 引入一个协调者,由它持久化流程状态并显式推进步骤:先预占库存,再占用优惠,再创建订单,失败时根据已完成步骤触发补偿。协调者不应把自己设计成一个持有全局锁的“超级事务管理器”,而应是一个可重入、可恢复的状态推进器。
它适合:
- 业务动作有明确主控方。
- 步骤顺序和补偿顺序需要集中解释。
- 需要统一审计、重试、超时和人工接管。
- 参与方可以提供幂等执行和结果查询接口。
它的代价是协调者本身会成为复杂系统:需要高可用、状态持久化、调度、并发控制、版本升级、补偿队列和可观测性。只有内存状态会丢失“哪些步骤已经成功”的事实,盲目重试又会把不确定性变成重复副作用。
4.4.4 TCC / 预留、确认、取消
TCC 把资源操作拆成 Try / Confirm / Cancel:Try 检查并预留资源,Confirm 确认业务操作,Cancel 释放预留资源。Oracle 的 MicroTx 文档把 TCC 描述为让资源处于预留状态,随后对全部参与者确认或取消;这种模型尤其适合库存、座位、额度和余额等可以被明确预留的资源。[6]
TCC 的能力边界比普通补偿更严格:参与方必须理解预留状态,能够处理重复的 Confirm / Cancel,还要处理空回滚、悬挂和超时。它不是把普通接口改名为三个接口:
Try需要在本地事务中记录预留事实,不能只在内存中扣减。Confirm必须在Try成功后可重入,重复确认不能重复扣减。Cancel必须能安全释放预留,重复取消应该返回同一语义结果。Try迟到时,必须识别此前已经发生的取消,避免出现“全局已经取消、迟到的 Try 又把资源冻结”的悬挂。
因此,TCC 适合资源预留语义稳定、失败代价高且参与方能共同改造的系统,不适合把普通 CRUD 都包装成三阶段接口。
4.4.5 Outbox:可靠事件基座,不是全局事务
当服务需要在一次本地事务中同时更新业务数据并发布事件时,直接“先写库、再发消息”会产生双写断裂:数据库提交成功但进程在发消息前崩溃,或者消息已经发出但数据库最终回滚。Transactional Outbox 的做法是把业务记录和待发送事件写进同一个本地事务,再由独立投递器发送事件。[4]
Outbox 解决的是“本地事实如何可靠产生事件”,没有解决:
- 下游如何避免重复副作用、查询未知结果并补偿资源变化。
- 外部系统已经受理后,如何得到最终回执。
所以 Outbox 通常和状态机、Saga 或对账体系组合使用。投递器也可能在“消息已经发出、发送记录还没标记”时崩溃,导致重复投递;消费者必须幂等,不能把“可靠投递”误读成“端到端恰好一次”。
4.4.6 一张能力对比表
| 方案 | 适用前提 | 获得的能力 | 无法保证的能力 | 失败恢复 | 主要成本 |
|---|---|---|---|---|---|
| 同步 RPC + 本地事务 | 步骤少、短闭环、参与方稳定 | 低延迟、链路直观 | 跨系统原子性、自动收敛 | 有限补偿、查询后重试 | 早期简单,复杂后容易散落 |
| 协同式 Saga | 领域自治强、事件天然存在 | 松耦合、局部自治 | 全局可见性、集中审计 | 事件驱动、各方补偿 | 拓扑和排障复杂 |
| 编排式 Saga | 主控方明确、需要统一治理 | 路径可见、补偿集中、易审计 | 参与方本地语义仍需自己保证 | 协调器重试、查询、补偿、人工接管 | 协调器高可用与状态治理 |
| TCC | 资源可预留、参与方可改造 | 预留 / 确认 / 取消语义清晰 | 不可逆副作用的真正回滚 | Confirm / Cancel 重试和超时释放 | 侵入性、开发和测试成本高 |
| Outbox | 本地写入需要可靠发事件 | 避免数据库与消息双写丢失 | 全局事务、下游幂等、外部回执 | 投递重试、消费者去重 | 额外存储、投递和清理治理 |
4.4.7 同步边界
有消息队列不等于应该把所有步骤都异步化;一次 HTTP 请求也不等于应该把所有步骤都同步阻塞。更务实的划分方式是:这个结果是否改变当前对用户的承诺?
| 类型 | 适合放入的动作 | 返回给调用方的语义 |
|---|---|---|
| 同步确认 | 决定能否创建订单、是否占用核心资源、是否拒绝请求 | 成功、明确拒绝或处理中 |
| 异步推进 | 搜索索引、通知、画像、统计、非关键投影 | 已记录,后续处理 |
| 异步收敛 | 供应商回执、支付渠道最终状态、补偿和对账 | 已受理 / 待确认,不伪造最终成功 |
同步边界的目标不是让所有结果立即完成,而是让调用方明确知道“现在可以依赖什么”。例如,订单主记录和库存预占已确认,可以返回一个可支付订单;支付渠道的最终扣款则可以作为后续生命周期事件,不应该被隐藏在创单请求中。
4.4.8 Outbox 的本地原子性
典型的本地事务可以是:
BEGIN
update order status
insert transaction_step
insert outbox_event
COMMIT
dispatcher reads committed outbox_event
-> publish event
-> record delivery attempt
如果本地事务回滚,事件不应发送;如果本地事务提交,投递器最终应能读到事件。Outbox 解决的是双写断裂,不保证消息只发送一次。投递器在消息已发出、投递记录尚未更新时崩溃,下一次仍可能再次发送;消费者必须以事件 ID、业务版本和幂等键吸收重复。
4.4.9 消息顺序和版本
消息队列的分区顺序只在有限范围内成立,不能替代业务版本。事件中至少应带有:
- 事件 ID 和事件类型。
- 业务主键与事务 ID。
- 聚合版本或序列号。
- 发生时间与生产者服务。
- 必要的价格、数量或状态快照。
消费者处理前要判断事件是否重复、是否早于当前版本、是否与当前状态允许的迁移匹配。对于不能按任意顺序处理的事件,应使用版本条件更新、重排队列或进入异常事件表,而不是让最后到达的消息覆盖当前事实。
4.5 方案选型:先写 ADR,再决定机制重量
选型不应从“团队熟悉哪个框架”开始,而应从约束和失败语义开始。下面用几个 ADR 说明常见决策。
ADR-001:不把创单设计成跨系统 2PC / XA
**背景与问题:**创单需要同时影响库存、优惠、订单和支付上下文,参与方跨多个服务,且支付或外部系统不能统一加入本地事务。
**候选方案:**跨服务 2PC / XA、同步 RPC + 补偿、编排式 Saga、TCC。
**决策:**以本地事务 + 持久化事务单 + 编排式 Saga 为主,库存和优惠等可预留资源在必要时提供 TCC 风格接口;Outbox 用于可靠传播本地事实。
**获得的能力:**可在参与方自治的前提下推进业务;每一步可以独立扩容和恢复;能够把超时、结果未知、补偿失败和人工接管建模成正式状态;对外部不可加入事务的系统保留适配空间。
**主动牺牲的能力:**不再承诺所有参与方在同一瞬间拥有全局 ACID 隔离;用户可能短暂看到“创建中”或“待确认”;补偿可能产生业务等价结果,而不是物理回到原始状态。
**已接受风险:**协调器故障、消息重复、补偿失败和并发 Saga 可能造成中间状态,需要依赖状态账本、幂等、语义锁、对账和人工收敛治理。
验证指标:UNKNOWN 步骤存留时间、补偿成功率、孤儿库存数量、订单与库存对账差异、协调器恢复时间。
**重新评估条件:**如果新增的参与方普遍支持同一事务协议,且业务要求强隔离的时间窗口很短,可以重新评估更强的提交协议;否则不能仅因为“看起来更一致”就引入全局锁和更复杂的恢复协议。
ADR-002:交易主链路选择编排式 Saga,而不是纯协同式 Saga
**背景与问题:**创单需要在一个用户请求中给出可解释结果,且库存、优惠和订单有明确顺序;失败后必须按照已执行步骤补偿。
**决策驱动因素:**主控方清晰、失败代价高、需要统一审计、参与方数量可能增长、运维人员需要从一个入口查看完整事务。
**决策:**由订单域或交易域维护协调状态,参与方只负责本地执行、幂等和结果查询;事件用于异步推进和通知,不把全局成功判断分散给任意消费者。
**获得 / 牺牲:**获得全局可见性、集中补偿和清晰的人工入口;牺牲部分领域自治,并承担协调器的高可用、版本演进和容量治理成本。
**验证指标:**单个事务从创建到终态的最大停留时间、步骤重试次数、协调器重启后的恢复成功率、人工介入率。
ADR-003:Outbox 只承担可靠事件桥接
**背景与问题:**订单状态更新后必须发布 OrderCreated 或 OrderCancelled,直接双写会有丢消息和脏消息风险。
**决策:**订单本地事务同时写订单事实、步骤账本和 Outbox;投递器至少一次发送;消费者以事件 ID、业务版本和幂等键去重。
**获得 / 牺牲:**获得本地事实与事件的原子关联;牺牲投递延迟、额外存储和清理成本,接受消息重复和消费者需要幂等。
**验证指标:**Outbox 积压、投递延迟、重复投递比例、消费者去重命中率、事件与业务事实不一致数量。
**重新评估条件:**如果事件量、顺序约束或多数据源写入方式改变,需要重新评估轮询投递、CDC 或事件存储;不能把 Outbox 表永久当作没有生命周期的垃圾桶。
4.5.1 一条实用的决策顺序
可以按下面的顺序缩小方案空间:
- 先确认是否真的存在跨系统副作用;如果只是查询聚合,不要过度引入事务协调。
- 再确认失败后是否需要释放资源、冲正流水或更新多个系统;如果没有,普通同步流程可能足够。
- 明确每个参与方是否提供幂等执行和结果查询;没有查询能力时,结果未知无法安全重试。
- 判断主控方是否明确;明确则优先考虑编排,自治则考虑事件协作,但要补全全局可见性。
- 判断资源是否能够预留;能够稳定实现
Try / Confirm / Cancel时才考虑 TCC。 - 最后才选择框架、消息系统或 Workflow 产品,且先定义业务状态,再让技术平台承载这些状态。
4.6 完整案例:创单动作如何从请求走向收敛
下面用一个简化的创单案例贯穿正常链路和异常分支。假设参与方包括:交易服务、库存服务、营销服务、订单服务和支付上下文服务。支付本身属于后续生命周期,创单只负责建立支付入口,不等待用户完成支付。
4.6.1 先创建事务单,而不是先调用库存
客户端提交请求时携带 request_id。交易服务先以用户、购物车版本、请求 ID 和业务动作类型组成幂等作用域,检查是否已经存在同一请求的事务单:
- 如果已经成功,返回第一次成功结果。
- 如果仍在执行,返回处理中状态和
transaction_id。 - 如果已明确失败,返回原失败原因。
- 如果处于
UNKNOWN或MANUAL_REVIEW,返回可查询的待处理状态,不能自动创建另一笔订单。
随后,交易服务在本地事务中写入事务单、输入快照和初始步骤账本。输入快照至少包括商品、数量、价格版本、优惠版本和配送约束;它不是为了替代权威服务,而是为了让后续补偿和审计知道当时系统试图完成的目标。
4.6.2 正常链路
正常链路可以表示为:
客户端提交 request_id
-> 创建或恢复 transaction
-> 校验价格、库存快照和业务资格
-> ReserveInventory(transaction_id, idempotency_key)
-> ReservePromotion(transaction_id, idempotency_key)
-> CreateOrder(transaction_id, idempotency_key)
-> CreatePaymentContext(transaction_id, idempotency_key)
-> 事务单 SUCCEEDED,发布 OrderCreated
每一步都有本地成功判据,而不是仅凭 HTTP 响应码。例如库存步骤的成功判据应包含库存服务返回的预占记录 ID、数量、过期时间和版本;营销步骤应包含优惠占用流水号;订单步骤应包含订单号和订单状态;支付上下文步骤应包含支付单号和允许支付的截止时间。
同步请求是否等待所有步骤完成,要由用户承诺决定。如果产品要求点击后立即看到可支付订单,交易服务至少要同步确认库存、优惠和订单主记录;通知、搜索索引和画像可以异步。支付渠道最终受理则属于后续流程,不应为了“返回一个完整结果”而让创单请求等待用户支付。
4.6.3 步骤账本示例
| 顺序 | 步骤 | 幂等键 | 成功判据 | 补偿动作 | 失败后的去向 |
|---|---|---|---|---|---|
| 1 | 校验价格与资格 | tx:validate | 快照版本有效 | 无副作用 | 明确拒绝 |
| 2 | 预占库存 | tx:inventory:sku | 返回 reservation ID | 释放预占 | 重试或 UNKNOWN |
| 3 | 占用优惠 | tx:promotion:id | 返回权益流水 | 回补或冲正 | 重试或补偿 |
| 4 | 创建订单 | tx:order | 返回订单号 | 取消订单 | 重试查询或补偿 |
| 5 | 创建支付上下文 | tx:payment-context | 返回支付单号 | 关闭支付入口 | 查询或人工处理 |
步骤账本应保存“最后一次尝试”和“下游可查询的外部请求号”,而不是只存一个布尔值。这样,恢复程序才可以判断:某步骤从未发出、已经发出但未知、已经成功,还是已经执行补偿。
4.6.4 五类异常分支
创单案例可以用下面的矩阵统一表达:
| 异常 | 已知事实 | 首要动作 | 禁止动作 | 收敛状态 |
|---|---|---|---|---|
| 库存成功、订单写入失败 | 库存已预占,订单结果可能未知 | 先查询订单;确认未创建后释放库存 | 直接返回失败并忘记占用 | COMPENSATED 或人工 |
| 优惠占用超时 | 库存成功,营销结果未知 | 按幂等键查询;必要时冻结价格承诺 | 再占用一份优惠 | 已确认、待确认或人工 |
| 支付上下文超时 | 支付单可能已创建 | 查询外部请求号,未创建才重试 | 创建第二个支付上下文 | 成功或 UNKNOWN |
| 重复请求 | 可能是同一业务意图 | 按事务单和幂等键返回第一次结果 | 创建第二笔订单 | 吸收重复 |
| 迟到回调 | 当前状态可能已取消或补偿 | 校验版本和权威状态,必要时生成退款 | 用最后消息覆盖当前状态 | 维持现状或人工 |
库存成功、订单失败时,事务单转为 COMPENSATING,先按 tx:order 查询订单;确认未创建后,以补偿派生键释放库存,释放未知则进入补偿队列。若订单其实已创建,协调者必须恢复订单事实,不能释放库存后再把订单标成失败。
优惠或支付结果未知时,都遵循“记录未知—查询—同键重试—对账 / 人工”的顺序。是否允许订单继续,取决于价格承诺和资损风险,而不是技术框架。支付上下文若只有创建接口、没有按幂等键查询接口,就不满足关键参与方的最低能力。
重复请求需要事务单唯一约束、订单接口幂等和资源服务自己的幂等记录三层保护。两个不同业务意图争抢最后一件库存,则是并发控制问题,仍需原子扣减、版本校验、语义锁或预占记录,不能误以为幂等已经解决竞争。
迟到回调不能按接收时间覆盖状态。已取消订单收到支付成功时,应查询渠道权威状态并生成退款或待退款任务,保留回调、取消和退款之间的审计关系。
4.7 故障处理与收敛治理
最危险的工程错误,是把所有失败都理解成“再试一次”。不同失败对应完全不同的动作。
| 失败类型 | 识别条件 | 首选动作 | 禁止动作 | 最终收敛 |
|---|---|---|---|---|
| 明确拒绝 | 库存不足、优惠不可用、参数不合法 | 结束正向流程,补偿已成功步骤 | 盲目重试同一业务请求 | 已拒绝或已补偿 |
| 瞬时失败 | 限流、短暂网络故障、连接池耗尽 | 有上限的退避重试 | 无限同步阻塞调用方 | 成功、失败或待处理 |
| 结果未知 | 超时、连接断开、提交后进程崩溃 | 按幂等键查询或对账 | 直接再次执行副作用 | 已确认、可重试或人工 |
| 重复请求 | 同一业务意图再次提交 | 返回第一次结果或处理中状态 | 创建第二笔资源 | 吸收重复 |
| 乱序 / 迟到 | 旧版本事件晚于新状态到达 | 校验版本、忽略或生成补偿 | 让最后消息覆盖事实 | 维持权威状态 |
| 部分成功 | 前序步骤成功,后序步骤明确失败 | 按账本反向补偿或继续收敛 | 只回滚内存变量 | 已补偿或待人工 |
| 补偿失败 | 释放、冲正或取消再次失败 | 独立补偿队列、延迟重试、告警 | 标记失败后丢弃 | 补偿完成或人工 |
| 长期不可用 | 依赖超过恢复窗口仍不可达 | 限制新增流量、转待处理、对账 | 无限扩大重试风暴 | 待恢复或人工 |
| 版本冲突 | 状态条件更新影响行数为零 | 重新读取权威状态 | 强行覆盖新状态 | 接受现状或业务冲突 |
4.7.1 明确拒绝与技术失败必须分开
库存不足是业务拒绝,重试不会让库存凭空增加;数据库连接短暂失败是技术失败,可能重试成功。错误码、异常类型和下游契约必须能让协调器区分这两类结果。
重试还要受到截止时间和预算约束。创单有自己的 deadline,各步骤只能消耗部分预算;不能让库存已经过期后,协调器仍然无限重试营销。指数退避和抖动能缓解拥塞,但不能替代幂等。AWS 对幂等 API 的说明强调,安全重试的关键是让服务识别重复请求并返回与第一次处理一致的结果。[5]
4.7.2 结果未知必须先查询
“请求超时”只说明调用方没有在截止时间内得到响应,不说明下游没有执行。一个正确的未知结果处理过程是:
- 将步骤持久化为
UNKNOWN,记录请求发送时间、超时点和外部请求号。 - 调用下游的幂等查询接口,查询同一个执行键。
- 查询到成功则补写执行事实并继续;查询到未执行才允许使用同一键重试。
- 查询接口也不可用时,进入延迟查询、对账或人工队列。
如果下游没有查询接口,系统只能降低承诺:例如把该步骤设计成可补偿的预留,或要求外部系统提供对账文件。没有查询能力时,不能靠“再发一次通常没问题”来冒险。
4.7.3 补偿不是机械地反向调用
补偿动作至少分为四类:
- 资源释放:释放库存、座位、额度或优惠占用;要求校验占用归属和当前状态。
- 反向流水:账务、积分和权益使用冲正或反向流水,保留原始事实,不删除历史。
- 不可逆通知:短信、邮件、推送无法撤回,只能发送更正或记录通知结果。
- 外部受理动作:支付退款、供应商取消可能异步完成,需要查询和等待最终回执。
补偿顺序也必须服从业务依赖。订单取消通常应先关闭支付入口或进入取消中,再释放库存和优惠;如果先释放资源,迟到的支付回调可能把一笔已经失去资源的订单推进到错误状态。补偿顺序应成为状态机的一部分,而不是开发者凭直觉在 catch 块里倒序调用。
4.7.4 并发 Saga 与语义锁
两个创单动作可能同时读取到“还有一件库存”,或者一个取消动作与支付确认同时推进。Saga 没有自动提供跨服务隔离,因此需要使用资源版本、语义锁、预占状态或可串行化的本地更新来保护关键竞争。
所谓语义锁,不一定是数据库长锁,而是把“正在被某个事务单处理”写入资源状态或独立占用记录,让其他业务动作知道应该等待、拒绝或重新读取。这样既能避免长时间持有数据库锁,也能让并发冲突成为可解释的业务状态。
4.7.5 对账、可观测与人工收敛
在线重试和自动补偿仍可能失败,因此关键链路必须有独立于在线流程的对账能力。对账不是上线后的补丁,而是大事务设计的一部分:在线链路负责尽快推进,对账链路负责发现遗漏、识别歧义并提供受控修复入口。
4.7.5.1 对账的四组事实
| 核对对象 | 常见差异 | 权威来源 | 修复动作 |
|---|---|---|---|
| 订单与库存 | 已取消订单仍占库存;已支付订单没有有效预占 | 库存占用流水 + 订单状态 | 查询、释放孤儿占用或冻结订单 |
| 订单与优惠 | 订单失败但券未返还;权益已核销但没有订单 | 权益流水 + 订单快照 | 回补、冲正或人工审核 |
| 订单与支付 / 账务 | 支付成功但订单未更新;退款已受理但账务未记账 | 渠道回执 + 账务流水 | 补记、退款、冲正或人工核验 |
| 事务单与步骤账本 | 事务显示成功但步骤未知;步骤成功但事务卡住 | 事务状态 + 下游查询 | 重放状态迁移或进入人工队列 |
每组对账都要先定义口径:按订单号、交易单号、外部请求号还是资源流水号关联;时间窗口多长;差异是否允许短暂存在;哪些动作可以自动修复;哪些动作必须双人复核。没有口径的“对账脚本”往往只是另一种不可审计的人工改库。
4.7.5.2 可观测性要关联业务事实
日志、指标和链路追踪至少贯穿以下关联键:
transaction_id
business_id / order_id
idempotency_key
external_request_id
event_id
指标应能回答哪些事务卡住、卡在哪个参与方、是否产生副作用、补偿是否成功、谁需要处理。重点关注各步骤未知和补偿比例、UNKNOWN 存留时间、事务终态延迟、补偿队列年龄、对账差异及人工队列逾期量;单看某个 RPC 成功率无法判断大事务是否收敛。
4.7.5.3 人工接管必须是受控状态迁移
人工操作台至少应显示:事务单、业务主键、已执行步骤、外部请求号、最近错误、当前权威状态、建议动作、风险提示和截止时间。操作者应选择“重新查询”“重试补偿”“确认外部成功”“终止并冻结”等有限动作,并填写原因;系统记录操作者、时间、前后状态和审批依据。
对资金、库存和高价值权益动作,应增加权限隔离、双人复核或风险阈值。人工不能直接把订单字段改成“已支付”,而应执行一个被授权的业务命令,让系统校验渠道事实并产生相应流水。直接改库可能临时解决一笔订单,却会破坏状态机、审计链和后续对账。
4.8 演进与迁移:从简单方案到其他关键业务
大事务能力应渐进建设:
4.8.1 阶段一:同步短流程
适用于步骤少、依赖稳定、失败可以立即返回的动作;仍要建立请求幂等键和本地状态记录。
4.8.2 阶段二:状态机 + 补偿任务
出现“库存成功、订单失败”时,先建立事务单和步骤账本;补偿可以由定时任务或队列驱动,但必须使用同一幂等键并记录尝试。
4.8.3 阶段三:编排式 Saga
当步骤顺序、补偿关系、超时和审计已经跨多个模块时,把协调逻辑集中成可恢复的编排器,为复杂度提供可观察、可治理的边界。
4.8.4 阶段四:关键资源引入 TCC
当库存、余额、座位或授信额度的失败代价高,且参与方能按预留模型改造时,引入 TCC;上线前先覆盖空回滚、幂等、悬挂、超时和重复确认。
4.8.5 阶段五:平台化能力
只有多个领域反复出现状态账本、补偿队列、人工操作台、对账和审计需求时,才值得抽象共享平台。平台提供持久化、幂等、调度、观测和审计,不把所有业务补偿规则硬编码成不可扩展的通用引擎。
4.8.6 从创单迁移到其他关键业务动作
创单只是最典型的样本。其他场景仍然要先划定关键动作、权威事实和副作用,再决定补偿方式:
- 注册与认证:账号是权威事实,权益可以补发,短信和邮件不可逆。账号已创建但权益失败时进入“权益待发放”;实名结果未知时按身份证或外部请求号查询,不能创建第二个账号。
- 退款与售后:退款单、可退金额、支付渠道、优惠回补和库存恢复可能分属不同系统。渠道“受理成功”不等于资金到账,补偿通常是查询、重试、冲正或人工核验,而不是撤销一条退款记录。
- 商品审核与发布:商品主数据是权威事实,索引和缓存是可重建投影。发布失败时可以保持“发布中 / 待同步”,通过事件重放收敛,不必强行回滚所有投影。
这三个场景的共同判断是:关键动作完成的标准不是所有通知都已发送,而是权威事实已经确定,必要资源已经确认或释放,不可逆副作用已经被记录,剩余投影有可追踪的异步收敛路径。
4.9 方法论总结、评审清单与常见反模式
4.9.1 方法论总结
大事务设计可以归纳为一条推理链:先定义关键业务动作和参与方约束,再明确权威事实源、事务单、步骤账本、状态和不变量;随后按同步确认、异步推进和异步收敛划分边界,依据参与方能力组合选择同步流程、Saga、TCC 和 Outbox;最后用完整案例验证明确拒绝、结果未知、部分成功、补偿失败和迟到回调,并以幂等、查询、对账、观测和人工接管把失败变成可治理状态。
最关键的判断始终不变:不要把“接口调用成功”误当成“业务已经完成”。只有所有必要副作用被确认、被补偿,或进入明确的待处理状态时,这次业务动作才真正收敛。
系统的价值不是承诺不会失败,而是让失败后的事实、责任和修复路径可见:发生了什么、哪些动作已生效、哪些结果未知、谁拥有权威事实、下一步由谁推进,以及何时回到成功、已补偿、待对账或待人工处理。
4.9.2 评审清单
在方案评审时,至少逐项回答:
- 这个关键业务动作的最小边界是什么?哪些动作属于后续长流程?
- 哪些参与方会产生真实副作用?每个副作用的权威事实源是什么?
- 每个步骤的幂等键如何构造?重复请求返回什么结果?
- 超时后如何区分“未执行”和“结果未知”?是否存在真实查询接口?
- 哪些结果必须同步确认,哪些可以异步推进,哪些只能异步收敛?
- 事务单、步骤账本、Outbox 和消费者处理记录分别由谁维护?
- 补偿是释放资源、反向流水、状态更正还是外部异步取消?
- 补偿失败会停在哪里?告警阈值是什么?谁可以人工接管?
- 并发下单、取消、支付回调和重复消息如何避免旧状态覆盖新事实?
- 方案选择牺牲了什么能力?验证指标是什么?什么条件下重新评估?
4.9.3 常见反模式
- 把异常处理当补偿设计:只写
try/catch,没有持久化步骤状态和可重入补偿。 - 把重试当幂等:同一请求重放时重复扣减资源,事后再靠人工修复。
- 把超时当未执行:响应丢失后直接重做,导致重复订单、重复扣款或重复占用。
- 把 Outbox 当全局事务:可靠发消息不能替代下游幂等、补偿、查询和对账。
- 把 Saga 当回滚:补偿得到的是业务等价结果,不一定能恢复所有外部世界的历史状态。
- 把人工改库当恢复机制:短期绕过流程,长期破坏权威状态、审计和版本控制。
- 为了“绝对一致”持有长锁:把等待支付、供应商回调或人工审批塞进数据库事务,最终牺牲可用性且仍无法覆盖外部副作用。
- 先选框架再定义状态:让框架的任务模型反过来决定业务语义,导致状态不可解释、升级难以迁移。
参考文献与延伸阅读
本节汇总正文引用源,供延伸阅读;正文编号用于就近说明,链接优先指向原始论文或官方页面。
[1] Hector Garcia-Molina, Kenneth Salem, “Sagas”, ACM SIGMOD, 1987。
[2] Pat Helland, “Life beyond Distributed Transactions: an Apostate’s Opinion”, CIDR 2007, 2007。
[3] AWS Prescriptive Guidance, “Saga patterns”。
[4] AWS Prescriptive Guidance, “Transactional outbox pattern”。
[5] Malcolm Featonby, “Making retries safe with idempotent APIs”, Amazon Builders’ Library, 2021。
[6] Apache Seata,“Seata TCC 模式”。
[7] 周志明,《凤凰架构》“分布式事务”章节,在线阅读。
[8] Apache Seata,“Seata Saga 模式”。