第 7 章 高准确性与强一致性系统设计方法论:支付、库存与账务场景
当系统面对支付、库存、账务、钱包、积分等“结果必须正确”的业务时,设计重点不再只是流程能不能跑通,而是事实能不能被严格定义、状态能不能被安全推进、错误能不能被可审计地恢复。
前面的章节讨论了大事务、长流程和任务处理。本章进一步收紧问题范围:有些业务流程即使暂时延迟几秒,最终也可以收敛;但有些业务一旦发生重复扣款、库存超卖、账务不平或金额丢失,事后再补偿也无法完全消除影响。
因此,本章不讨论“所有系统都应该强一致”,而是讨论一个更准确的问题:
在高并发、网络不可靠、依赖不确定的条件下,如何识别不可出错的业务事实,如何把这些事实保护在正确的事务边界内,又如何让跨系统流程在失败后继续可解释、可恢复、可对账。
本章采用八步方法:先定义问题,再确定约束和指标;然后建立业务不变量与状态模型;在此基础上设计参考架构,用 ADR 记录方案取舍,最后通过完整交易案例和故障治理验证设计是否真的可落地。
7.1 问题定义:高准确性不等于整条链路都强一致
7.1.1 先区分三个容易混淆的概念
高准确性关注业务结果是否正确。例如:支付金额是否正确、库存是否被重复扣减、账本借贷是否平衡、优惠券是否被重复核销。
强一致性关注多个副本或多个并发操作对外呈现的状态是否满足严格的可见性语义。线性一致性要求一个操作看起来像在某个时间点瞬间生效,并且不能违背真实时间顺序;它描述的是并发对象对外呈现的行为,不等于“数据库里有事务”这句泛化结论。[2]
可串行化关注一组并发事务的结果是否等价于某个串行执行顺序。它主要描述事务之间的隔离关系,而不是每个 API 的实时响应语义;数据库实现了某种隔离级别,也不代表跨服务的支付、库存和订单已经形成一个全局原子事务。[3]
业务一致性关注跨多个业务对象或多个服务的整体目标是否最终达成。例如,订单、库存和支付不一定由一个全局事务同时提交,但系统必须最终收敛到“已支付、库存已确认、订单可履约”,或者“支付失败、订单取消、库存已释放”。
这三个概念有交集,但不能互相替代:
- 一个本地数据库事务可能具备很强的隔离性,但如果金额计算规则错误,业务结果仍然不准确。
- 一个跨系统流程可能不是全局强一致,但只要每个核心事实有唯一权威源、每个状态迁移可验证、每个异常都有补偿和对账,业务仍然可以正确收敛。
- 一个系统如果只说“最终一致”,却没有定义最终要一致到什么状态、由谁修复、多久必须收敛,那么这不是设计,只是把问题延后。
7.1.2 典型场景和核心风险
| 场景 | 不可接受的结果 | 可以接受的延迟或中间态 | 必须保护的事实 |
|---|---|---|---|
| 支付 | 重复扣款、金额错误、支付成功但无法识别 | 渠道回调延迟,短时间处于处理中或待查单 | 支付意图、渠道流水、支付金额和最终结果 |
| 库存 | 超卖、重复释放、库存变负、已付款却无货 | 库存预占后等待支付,外围库存视图短暂延迟 | 可用量、预占量、已售量和资源版本 |
| 账务 | 借贷不平、流水丢失、余额凭空增加或减少 | 统计报表延迟,余额快照异步重算 | 记账凭证、借贷分录、币种和业务流水号 |
| 钱包与额度 | 重复扣减、冻结金额丢失、额度穿透 | 非核心展示延迟,异步通知延迟 | 额度变更、冻结记录、解冻记录和账本 |
| 积分与优惠券 | 重复发放、重复核销、撤销后无法回收 | 发放通知延迟,营销展示短暂不一致 | 用户权益状态、领取资格和核销流水 |
设计的第一步不是选择数据库或消息队列,而是把“不能错”和“可以晚一点”分别写出来。如果一个字段只是用于搜索排序或营销展示,它不应因为“看起来重要”而被强行放进支付主事务;如果一个字段会直接决定扣款、发货或记账,它也不能因为“之后会同步”就被放到一个没有约束的异步队列里。
7.1.3 三个反例:为什么“最终一致”不能成为口号
第一个反例是支付成功但订单仍显示未支付。展示延迟几秒本身不一定是事故,真正危险的是订单服务把“未收到回调”解释成“支付失败”,于是允许用户再次创建支付单;或者支付回调与用户取消并发到达,两个处理器都直接覆盖状态。这里要保护的不是页面刷新速度,而是同一支付意图不能出现两个有效成功结果。
第二个反例是库存异步扣减。系统先接受订单,再把扣库存消息放入队列,消费者稍后才扣减。当消息积压或消费者暂时不可用时,多个请求都可能看到相同的可售数量并成功下单。后续把部分订单改成缺货虽然能让库存账恢复,却无法恢复用户已经收到的购买承诺。因此,稀缺资源的“允许延迟”必须发生在预占之后,而不能发生在决定是否出售之前。
第三个反例是钱包余额依赖多个异步任务拼接。如果余额不是由账本严格推导,而是通过充值、消费、退款、冻结和解冻任务慢慢修正,那么消息乱序、重复消费或部分失败都会让余额暂时失去解释能力。对于钱包一类对象,可以让余额查询延迟,但不能让每一笔资金变化没有唯一流水和可重放依据。
7.1.4 范围与边界:只保护 CP 核心段
CAP 讨论的是网络分区发生时,一组共享数据如何在一致性和可用性之间取舍;它不是“数据库分类表”,也不能直接推出“整个业务系统必须选择 CP”。Brewer 后续对 CAP 的说明强调,设计者应明确处理分区并按场景优化一致性和可用性。[1] Spanner 的研究则展示了在特定复制、时钟和基础设施假设下,系统可以提供外部一致的分布式事务,但这并不意味着普通业务可以无成本复制同样的前提。[4]
更务实的做法是把一条交易链路拆成两层:
- 强一致核心段:价格确认、库存预占、订单事实、支付事实和账务分录。
- 最终一致外围段:站内信、积分展示、推荐、营销统计、报表、搜索索引和用户通知。
核心段内部需要严格约束并发、状态和金额;外围段则需要可靠事件、重试、幂等和对账。这样做不是降低质量,而是把最昂贵的正确性能力集中到真正无法出错的地方。
7.1.5 本章的范围与非目标
本章聚焦以下问题:
- 如何识别不可出错的业务事实和不变量。
- 如何选择本地事务、Outbox、预占、TCC、Saga 或对账补偿的组合。
- 如何处理请求重试、回调重复、状态未知、消息乱序和跨系统失败。
- 如何建立审计、对账、人工接管和恢复机制。
本章不试图展开完整的数据库实现、支付渠道接入协议或财务会计制度。数据库隔离级别、Redis 细节、Kafka 消费模型和订单系统的完整实现,应分别结合本书后续基础设施和电商实战章节阅读。
关于大事务、Saga 和 Outbox 的通用流程可参见第 4 章《大事务处理方法论》;购物车、结算、订单和支付的完整领域实现可参见第 14 章《电商用户 C 端搜索、交易、履约与售后全生命周期设计》。本章只保留跨场景的判断框架和关键取舍。
7.2 约束与指标:先把正确性写成可以验证的条件
高准确性系统不能只用“成功率”和“接口延迟”衡量。它必须同时有业务正确性指标、状态收敛指标和恢复能力指标。更重要的是,指标必须和约束绑定:没有时间窗口的收敛率、没有金额口径的差异数、没有资源范围的库存准确率,都不足以指导设计。
7.2.1 四类约束
规模约束包括峰值请求量、并发事务数、单资源竞争度、单账户写入频率、单分片热点和跨地域复制距离。示例数字必须标记为假设或测量结果。例如,假设一个活动库存只有 10,000 件、峰值每秒 20,000 次抢购请求,那么重点不只是总吞吐,而是同一库存键的写冲突、队列积压和失败后的释放压力。
时间约束包括支付确认时限、库存预占 TTL、回调等待时间、事件最大延迟、对账周期和人工接管时限。TTL 不是一个随意的缓存过期时间,它决定资源会被锁多久、用户等待多久以及超时任务有多大的扫描压力。
正确性约束包括金额不能错、库存不能超卖、状态不能逆向跳转、同一幂等键不能产生两个副作用、补偿不能超过原始动作的影响范围。约束应优先写成可验证的关系,而不是“尽量保证”“通常不会”。
治理约束包括每条事实必须可查询、可追踪、可对账、可重放;出现差异时必须能定位责任边界;人工修复必须有审批、审计和可撤销的补偿记录。
7.2.2 用指标描述系统是否真的收敛
| 指标类别 | 示例指标 | 设计用途 | 需要附带的维度 |
|---|---|---|---|
| 业务不变量 | 借贷差额、库存差额、重复扣款数 | 判断事实是否被破坏 | 业务日、币种、商品、账户、渠道 |
| 状态收敛 | 待支付、处理中、待查单、待补偿数量 | 判断中间态是否堆积 | 状态年龄、来源、最老任务 |
| 幂等与重复 | 重复请求率、重复回调率、重复消费率 | 判断重试和客户端行为 | 幂等键、事件 ID、调用方 |
| 恢复能力 | 补偿成功率、补偿延迟、人工接管量 | 判断异常闭环质量 | 重试次数、失败原因、责任方 |
| 对账质量 | 对账差异数、差异金额、自动修复率 | 判断权威事实是否分叉 | 比较双方、时间窗口、差异类型 |
| 业务链路 | 支付成功率、库存预占成功率、履约成功率 | 连接技术指标与业务结果 | 商品、渠道、地域、版本 |
| 性能与容量 | 锁等待、事务耗时、写入吞吐、事件积压 | 判断强一致边界是否成为瓶颈 | P95/P99、热点键、分片 |
这些指标不能只看平均值。对于资金和库存,少量严重错误也可能比大量普通请求失败更重要;对于补偿任务,则要同时关注积压数量和最老任务年龄。一个“平均 5 秒收敛”的指标,如果其中一批任务已经等待 3 天,就会掩盖真实风险。
高准确性链路还应定义业务 SLO,例如“支付成功事实在 30 秒内被订单服务确认的比例”“库存预占超时后 5 分钟内释放的比例”“对账差异在下一个工作日内完成分类的比例”。这些数字不是行业通用标准,应根据渠道 SLA、资源成本、客服承诺和监管要求测量或协商确定。
7.2.3 用可验证不变量替代口号
一个好的不变量必须可以通过数据库约束、状态条件、查询或对账验证:
- 库存不变量:
可用 + 预占 + 已售 = 总量,且每个分量不能小于零。 - 账务不变量:每笔记账必须有借方和贷方,借贷金额相等,业务流水号不可重复入账。
- 支付不变量:同一业务订单在同一支付意图下不能产生两个有效成功扣款。
- 状态不变量:支付成功不能回退为待支付,库存已确认不能被普通取消动作再次释放。
- 补偿不变量:每次补偿动作必须关联原始动作,且重复执行不能制造新的副作用。
- 版本不变量:同一聚合的状态更新必须携带版本或前置状态条件,旧请求不能覆盖新事实。
可以把不变量转成技术检查:
| 不变量 | 代码或存储约束 | 对账方式 |
|---|---|---|
| 同一支付意图只成功一次 | (order_id, payment_intent_id) 唯一约束 | 渠道成功单与本地成功单逐笔比对 |
| 库存不能负数 | 条件更新 available >= quantity、库存流水唯一 | 总量、预占、已售和订单聚合比对 |
| 状态不能逆向 | 状态转移表、版本号和前置状态条件 | 扫描非法边、检查状态事件顺序 |
| 账务借贷平衡 | 记账凭证号唯一、分录金额约束 | 按凭证、账户、币种汇总借贷差额 |
| 事件不能造成双重副作用 | 消费者已处理表或业务唯一键 | 事件、消费记录和业务流水关联检查 |
《Designing Data-Intensive Applications》把记录、派生数据、复制和一致性放在同一套数据系统权衡中讨论;对本章而言,直接含义是先确定哪份数据是事实,再决定哪些数据可以异步派生,而不是先选择一个“看起来可靠”的中间件。[5]
7.2.4 把一致性写成接口契约
一个可评审的接口契约至少要说明四种语义:
- 写入语义:请求成功表示事实已经提交,还是只表示任务已接受?
- 读取语义:读取主库、快照、缓存还是派生视图?读到旧数据时是否允许作出交易决策?
- 重复语义:相同幂等键再次到达时返回第一次结果、当前状态,还是拒绝请求?
- 未知语义:超时后到底是明确失败,还是进入待查询状态?谁负责查询,什么时候转人工?
如果这些语义没有写进 API、状态机和运行手册,调用方就会自行猜测。猜测在正常路径上通常不会暴露,一旦网络抖动,两个系统对“成功”的理解就会分裂。TiDB 文档也提醒,数据库对外使用的隔离级别名称可能与内部实际实现存在差异,不能只根据配置名称推断并发语义。[19] 具体数据库的锁、快照、当前读和隔离级别边界,应以所使用版本的官方手册为准。[20]
7.3 核心模型:事实、状态、不变量和权威源
7.3.1 业务事实必须有唯一权威源
系统中经常同时出现订单状态、支付状态、渠道回调状态、账户余额、账务流水和缓存状态。如果每一层都被当成“真相”,系统就会在异常时陷入争论:到底应该相信哪个状态?
建议为每类关键事实明确权威源:
| 事实 | 权威源示例 | 其他系统的角色 | 禁止的误用 |
|---|---|---|---|
| 支付渠道结果 | 支付单和渠道查单结果 | 订单系统消费支付事实 | 只根据一次回调覆盖本地状态 |
| 订单状态 | 订单聚合或订单状态机 | 履约、通知、营销读取派生状态 | 让支付服务直接改订单任意字段 |
| 库存数量 | 库存账或库存服务 | 搜索和详情展示缓存视图 | 让缓存扣减成为唯一库存事实 |
| 资金流水 | 不可变账本 | 余额、报表和风控是派生结果 | 直接覆盖历史流水或余额 |
| 事件投递 | Outbox 或事件日志 | MQ 是传输通道 | 把“消息已发送”当成业务已完成 |
缓存、消息和搜索索引都可以很快,但它们通常不应该成为支付金额、库存事实或账本流水的唯一权威源。一个系统可以有多个读模型,却只能有一个负责裁决冲突的写事实;否则补偿时没有基准,对账时也不知道应该修哪边。
7.3.2 命令、事实与事件不能混为一谈
命令表达意图,例如“提交支付”“预占库存”“申请退款”;它通常带有发起者、权限、幂等键和前置条件。命令可能被拒绝,也可能因为超时而处于未知状态。
事实表达已经被权威服务确认的结果,例如“支付单已成功”“库存预占已建立”“账务凭证已入账”。事实需要持久化、可查询和可审计。
事件是把已经发生的事实通知给其他系统,例如 PaymentSucceeded 或 InventoryReserved。事件传递失败不应撤销已经提交的本地事实;它应进入重试、补偿或重放流程。
这个区分可以防止一个常见错误:调用支付渠道的命令返回 HTTP 200,就立即把订单写成“已支付”。真正的支付事实可能还需要渠道确认、签名校验、金额核对和本地流水落账。只有事实被权威服务确认后,才能发布成功事件。
7.3.3 状态机比一个布尔字段更可靠
高准确性场景通常不是“成功 / 失败”两个状态,而是包含等待、未知和补偿中的中间态。
支付状态可以抽象为:
待支付 → 支付中 → 支付成功
├→ 支付失败
├→ 待查单
└→ 已关闭
库存预占可以抽象为:
可预占 → 已预占 → 已确认
└→ 已释放
订单状态则应把支付、库存和履约的事实组合起来,而不是简单复制某个下游字段:
草稿 → 待支付 → 支付处理中 → 已支付 → 待履约 → 已完成
├→ 已取消 └→ 待查单 └→ 异常履约
状态机至少应定义:
- 允许的状态集合、终态和不可达状态。
- 每个状态的进入条件、退出条件和责任方。
- 哪些迁移由同步请求触发,哪些由事件或定时任务触发。
- 重复迁移如何返回幂等结果,冲突迁移如何返回当前权威状态。
- 发生未知结果时进入什么状态,查询任务的最大等待时间是多少。
- 超时、补偿和人工操作能否改变状态,以及它们需要什么权限。
状态转移还要考虑并发顺序。支付成功回调、用户取消、客服补单和超时任务可能同时到达。处理器不能只按到达时间覆盖 status,而应使用版本号、前置状态条件和权威事件版本。例如,UPDATE payment SET status = 'SUCCESS', version = version + 1 WHERE payment_id = ? AND status IN ('PROCESSING', 'QUERYING') AND version = ?,受影响行数为零时再读取权威状态判断是重复、竞态还是非法请求。
7.3.4 预占模型是资源生命周期模型
“预占 + 确认 / 取消”不是简单的两次接口调用,而是资源生命周期的显式建模。预占记录至少要包含资源 ID、业务单号、数量、预占时间、过期时间、当前状态、幂等键和释放原因。确认和释放都必须带上版本或状态条件,避免旧请求把已经确认的资源再次释放。
它的价值不只在于防超卖,还在于把失败恢复转成明确动作:
- 预占锁定资源并创建可审计的占用记录。
- 确认把预占转为已售、已扣减或已生效。
- 释放把预占归还可用池,并记录是支付失败、用户取消、超时还是人工操作。
- 过期扫描处理没有收到确认或释放命令的中间态。
库存、额度、余额冻结和优惠券占用都可以使用这个模型,但不能机械套用。余额冻结需要同时记录账户、币种和可用余额;优惠券占用需要防止资格重新计算导致的重复发放;外部供应商库存可能只能“尝试预订”,确认和释放还会受第三方语义约束。本章推导是:资源越稀缺、失败后越难恢复,越应该把预占设计成独立的权威事实。
7.3.5 幂等、防重和去重必须内建
强一致场景最危险的事故,很多不是“完全失败”,而是“成功被执行了两次”。系统必须默认会遇到请求重试、回调重复、消息重复投递、任务重复执行和人工补单重复触发。
可以把幂等分成四层:
| 层次 | 幂等键示例 | 保护对象 | 推荐实现 |
|---|---|---|---|
| 请求入口 | client_request_id | 重复创单、重复支付意图 | 唯一约束 + 保存第一次结果 |
| 外部回调 | channel + transaction_id | 重复回调和重放 | 回调流水表 + 状态条件 |
| 业务动作 | order_id + action | 重复确认、重复释放、重复退款 | 动作记录唯一键 |
| 消息消费 | consumer + event_id | 重复消费 | 已处理消息表或业务唯一键 |
Stripe 的幂等 API 设计保存第一次请求的状态码和响应体,使网络失败后的重试可以复用相同结果;它也要求同一个幂等键不能被用于不同参数的请求。[12] 这个经验说明,幂等键不只是“去重字段”,还应绑定请求语义、参数快照、保留时长和结果查询方式。
幂等不是无条件地“重复返回成功”。如果第一次动作仍在执行,第二次请求应返回处理中;如果第一次动作已经失败且失败是可重试的,应返回可查询的失败原因;如果相同键对应不同业务参数,应直接拒绝并告警。对账任务和人工补偿也必须复用原始动作的业务键,不能每次重试都生成一笔新的资金或库存动作。
7.3.6 账本优先于余额字段
余额通常是账本流水的投影,而不是唯一事实。直接修改余额字段会让系统失去解释能力:无法说明余额为什么变化,也无法在发现错误后重放和重算。
更稳妥的结构是:
业务命令 → 记账凭证 → 借贷分录 → 余额投影 → 查询缓存 / 报表
每个记账动作要有唯一业务流水号、金额、币种、借方账户、贷方账户、来源事件和时间。余额可以是事务内维护的快速投影,也可以由分录异步重算;无论采用哪种方式,账本分录都必须可追溯、不可随意覆盖。事件溯源的核心思想也是保存状态变化事件,以便重建过去状态;但它并不意味着所有系统都必须把事件日志作为唯一存储,是否采用要根据重放、查询、合规、归档和团队能力判断。[13]
7.4 参考架构:核心事实本地原子提交,外围流程可靠收敛
7.4.1 一条可落地的总体链路
高准确性系统的通用链路可以表示为:
请求入口
→ 鉴权、参数校验、幂等校验
→ 核心服务本地事务
├─ 写入业务事实
├─ 推进状态机
├─ 写入预占 / 账本 / 审计记录
└─ 写入 Outbox 事件
→ 事件可靠投递
→ 下游本地事务和幂等消费
→ 超时扫描、查单、补偿、对账
→ 结果查询与人工接管
这条链路没有试图把所有系统放进一个全局事务,而是把问题拆成三个边界。企业集成模式的价值也正在于为异步消息、路由、转换、重试和请求-响应交互提供共同词汇;组件名称不应替代这些交互语义。[6]
这条链路可以拆成三个边界:
- 事实边界:关键事实在自己的权威服务内原子提交。
- 传播边界:本地提交后的事实通过 Outbox、事务消息或日志可靠传播。
- 收敛边界:跨系统的业务目标通过状态机、补偿、查单和对账最终收敛。
可以用一张责任表防止边界混乱:
| 层次 | 主要责任 | 失败时的动作 | 不应承担的责任 |
|---|---|---|---|
| 权威服务 | 提交事实、保护不变量、推进本地状态 | 回滚本地事务或进入明确中间态 | 等待所有下游同步成功 |
| Outbox / 事务消息 | 可靠记录并传播已提交事实 | 重试、回查、进入死信或人工队列 | 判断下游业务是否已完成 |
| 下游消费者 | 幂等应用事实、维护自己的状态 | 重复忽略、重试、补偿或拒绝乱序事件 | 修改上游权威事实 |
| 对账与补偿 | 发现分叉、分类差异、驱动修复 | 自动修复确定性差异,人工处理未知差异 | 静默覆盖历史数据 |
7.4.2 本地事务与 Outbox 的配合
如果服务需要同时更新业务数据并发布事件,直接先写数据库再发消息会产生双写窗口:数据库提交成功但进程在发消息前崩溃,或者消息发送成功但数据库随后回滚。传统的 2PC 可能把数据库和消息代理纳入同一提交协调,但参与方能力、锁持有时间和故障恢复成本会显著增加;Pat Helland 对大规模系统中全局分布式事务的工程代价有专门讨论。[8]
Transactional Outbox 的做法是把业务事实和待发送事件写在同一个本地事务中,再由独立 relay 投递到消息系统。该模式保证“本地事务提交后有待发送记录”,但不能让消息只发送一次:relay 可能在发送成功后、记录发送结果前崩溃,于是恢复后再次投递。消费者必须幂等,这正是 Outbox 与 Idempotent Consumer 通常配套使用的原因。[10][11]
Outbox 表不应只是一个没有治理的消息垃圾桶,至少要设计:
- 事件 ID、聚合 ID、聚合版本、事件类型和 schema 版本。
- 创建时间、可投递时间、尝试次数、最后错误和锁定信息。
- 事件状态,例如待投递、投递中、已确认、重试中和死信。
- 业务事实与事件的关联键,便于从订单追到事件,也能从事件反查事实。
- 归档与清理策略,避免事件表增长拖慢核心事务和扫描任务。
如果同一个聚合的状态变化会产生多个事件,relay 还要确保版本顺序。消息代理可以提供分区内顺序,但业务必须决定按订单、支付单还是库存项分区;不能把“某个队列当前有序”误认为“跨所有消费者和所有业务对象全局有序”。
7.4.3 事务消息的能力边界
事务消息可以把本地事务结果与消息投递状态联系起来,但它通常保证的是“本地主分支与消息发送”的一致性,而不是消息消费者处理结果与上游事务的全局一致。RocketMQ 官方文档明确区分了半事务消息、二次确认、事务回查和消费端自行重试,并指出事务消息仍会存在下游处理完成前的中间不一致状态。[18]
因此,事务消息和 Outbox 的选择要看系统现状:已有关系库且希望减少新组件,可以优先使用本地 Outbox;已有成熟消息平台且需要生产者事务回查,可以评估事务消息;无论哪一种,消费者都必须具备幂等、乱序处理和失败重试能力。消息机制不能替代业务状态机。
7.4.4 跨系统流程的主控与补偿
当订单、库存、支付和履约分别拥有自己的数据库时,主控方需要保存流程状态和每一步的结果,而不是只保存一个“订单处理中”字段。每一步最好记录:
- 请求和业务幂等键。
- 发送时间、响应时间和超时时间。
- 下游返回的业务状态与技术状态。
- 已执行的动作及其补偿动作。
- 当前重试次数、下一次重试时间和失败原因。
- 对应的上游事件、下游流水和人工处理单。
Saga 把跨服务事务拆成多个本地事务,并为失败设计补偿;原始 Sagas 论文就是为长生命周期事务提出可交错执行的本地事务和补偿事务模型。[7] 微服务环境下,Saga 可以采用编排式或协同式;编排式更容易集中保存流程状态,协同式耦合更松但更难追踪。无论选择哪种方式,Saga 都不是全局原子提交,也不能自动保证补偿一定成功。[9]
补偿还必须尊重业务事实。已扣款后不能用“把支付状态改成失败”伪造退款,已发货后不能用“释放库存”掩盖履约事实,已入账后不能直接删除分录。正确的补偿通常是建立一笔新的退款、冲正、释放或调账动作,并引用原始流水。
7.4.5 可观测性必须跟随业务事实
只记录 HTTP 状态码不足以排查支付和库存问题。日志、链路和指标必须携带订单号、支付单号、库存预占号、账务流水号、幂等键、事件 ID、状态版本和责任域。OpenTelemetry 将 traces、metrics 和 logs 作为可关联的遥测信号,为跨服务定位事实传播和状态变化提供了统一的观测基础。[15]
对于一个“支付成功但订单未更新”的事件,系统至少要能够回答:
- 支付事实是否已经落地,金额和币种是否核对通过?
- Outbox 事件是否生成,事件版本和支付流水是否关联?
- relay 是否读取并投递,是否发生重复投递或死信?
- 订单消费者是否收到并处理,处理事务是否提交?
- 订单状态是否因为版本冲突、金额不符或状态不允许而拒绝更新?
- 是否进入补偿、查单、对账或人工队列,最老任务年龄是多少?
这类问题应能通过一个关联 ID 从用户请求追到业务事实、事件、消费者、补偿和对账结果,而不是依赖工程师在多个日志系统里猜测时间线。
7.5 方案选型:用 ADR 记录获得了什么、牺牲了什么
7.5.1 ADR 不只是写“选择了某个组件”
本章的方案选型必须回答一个问题:在当前约束下,为什么接受一种取舍,而不是另一种取舍。ADR 至少包含:
- 背景和问题。
- 决策驱动因素。
- 候选方案。
- 最终决策。
- 获得的能力。
- 主动牺牲的能力。
- 已接受的风险和缓解措施。
- 验证指标与重新评估条件。
“采用 Kafka”“使用 Redis 锁”“引入分布式事务框架”都不是决策本身。决策应描述它如何保护哪个不变量、在什么前提下成立、不能解决什么问题、失败后怎么办。
7.5.2 方案对比
| 方案 | 主要获得 | 主要牺牲 | 适用前提 | 不能自动解决的问题 |
|---|---|---|---|---|
| 单库本地事务 | 原子性强、语义简单、延迟低 | 跨服务能力弱、扩展边界受限 | 事实可以收敛在一个服务和数据库内 | 下游传播和外部渠道结果 |
| 本地事务 + Outbox | 避免业务数据与事件双写丢失 | 需要 relay、重复投递和消费者幂等 | 核心事实已在本地确定,需要可靠传播 | 下游业务动作是否成功 |
| 预占 + 确认 / 取消 | 资源生命周期清晰,失败可释放 | 状态更多,需要 TTL、扫描和补偿 | 资源可预留,确认和释放边界明确 | 外部供应商是否接受确认 |
| 事务消息 | 本地事务结果与消息提交关联 | 中间态、回查和消费端重试仍存在 | 消息平台支持生产者事务回查 | 消费者处理的全局原子性 |
| TCC | Try、Confirm、Cancel 语义显式 | 参与方改造成本高,空回滚/悬挂/幂等复杂 | 多参与方都有资源预留能力 | 第三方不可控接口和人工流程 |
| Saga | 跨服务流程可推进,参与方自治 | 没有全局瞬时原子性,补偿可能失败 | 每一步有本地事务和可执行补偿 | 补偿动作本身的自动成功 |
| 分布式锁 | 解决局部并发互斥 | 不解决事件丢失、账本和跨系统提交 | 只需要保护短时单资源临界区 | 业务状态、消息和资金正确性 |
| XA / 2PC | 统一提交语义直接 | 长事务、锁竞争、可用性和运维成本高 | 参与方受控且确实需要统一提交 | 不支持 2PC 的第三方服务 |
Seata 官方文档把 AT、TCC、Saga 和 XA 分成不同模式,并分别说明本地事务、业务 Try/Confirm/Cancel、补偿和资源协调的前提;这说明“分布式事务”不是一个单一开关,而是一组不同侵入性、隔离性和恢复语义的方案。[16][17]
7.5.3 选择路径:先问边界,再问组件
可以按以下顺序做选型:
- 关键事实能否通过领域边界调整,收敛到一个服务和一个数据库?如果能,优先本地事务。
- 如果事实已经落地,问题只是可靠传播,优先本地事务加 Outbox 或受控事务消息。
- 如果资源需要先锁定再决定最终结果,优先预占、确认、释放模型。
- 如果流程跨多个服务且允许中间态,使用 Saga 或显式编排状态机。
- 如果参与方都能提供稳定的 Try、Confirm、Cancel,并且确实需要更强的隔离,再评估 TCC。
- 只有在参与方受控、边界很窄且团队能够承担阻塞与故障恢复成本时,才考虑 XA / 2PC。
分布式锁只应出现在这个判断链条的局部位置:它可以保护一个短时间内的资源竞争,却不能把多个数据库写入、消息投递和第三方调用变成一个事务。把锁的租约时间、续租、失效和 fencing token 设计好,也不能替代账本、状态机和对账。
7.5.4 示例 ADR:电商创单采用局部原子 + 可靠事件
背景:订单、库存和支付属于不同服务;库存需要预占,支付渠道存在回调延迟和未知状态,外围通知和积分不影响交易事实。业务既要防止超卖和重复扣款,又要承受活动期并发。
决策驱动因素:不能超卖,不能重复扣款,支付结果必须可查;同时需要控制锁等待和第三方依赖带来的尾延迟,不能让通知或报表系统成为全局事务参与者。
候选方案:三方 XA / 2PC、TCC、全链路同步 RPC、Saga + Outbox、订单服务单体化。
最终决策:订单服务在本地事务中创建订单、支付意图和 Outbox;库存采用预占、确认、释放;支付以支付单和渠道查单作为权威事实;跨服务推进使用 Saga 或显式状态机;通知、积分、推荐和报表全部异步化。订单创建只在价格快照、库存预占和本地事实满足条件后进入待支付,不等待所有外围动作完成。
获得的能力:
- 订单事实可以在本地事务内原子落地。
- 库存有明确的预占和释放语义,超时可以扫描。
- 支付未知状态可以通过查单、回调和对账继续推进。
- 事件可以重试,消费者可以幂等,外围系统可以独立扩缩容。
- 每个异常都有订单、支付单、库存预占单或账务流水作为人工接管对象。
主动牺牲的能力:
- 不再追求订单、库存、支付三方瞬时原子提交。
- 用户可能短暂看到“支付处理中”或“订单待确认”。
- 系统需要维护更多状态、补偿任务、对账任务和人工流程。
- 设计和运维复杂度高于单体本地事务,开发团队必须维护事件版本和兼容策略。
已接受风险与缓解措施:支付渠道或消息系统发生长时间故障时,订单会积累在处理中;通过待查单队列、超时查询、用户可见的处理中状态和人工队列限制影响范围。补偿逻辑可能因下游再次故障而延期;通过幂等动作记录、指数退避、死信和对账任务避免无限重试。库存确认长期失败时可能出现已支付但不可履约;进入异常履约和退款流程,不能静默关闭订单。
验证指标:重复扣款为零,超卖差异为零,Outbox 积压的最老事件在目标时间内处理,待查单任务在目标时间内收敛,对账差异自动修复率达到目标,人工接管任务有上限;这些目标应通过压测、故障演练和历史数据测量,而不是凭经验填充。
重新评估条件:单库事务已经成为容量瓶颈、跨地域合规要求改变、支付和库存需要独立扩展,或当前补偿复杂度已经超过引入受控分布式事务的成本。重新评估时不能只看吞吐,还要比较锁等待、异常处理人力、恢复时间和错误影响半径。
7.6 完整案例:电商创单、支付与库存如何闭环
7.6.1 先声明假设和边界
下面使用一个标准电商创单场景演示方法,不把示例数字当成通用标准。假设商品中心提供价格快照,库存中心管理可售库存和预占,订单中心是订单状态权威源,支付中心管理支付意图和渠道流水;通知、积分、推荐和报表是外围消费者。
强一致核心段是:价格快照校验、库存预占、订单创建、支付事实和账务入账。最终一致外围段是:订单通知、积分发放、购物车清空、搜索索引刷新和经营报表。库存预占 TTL、支付查询间隔和人工接管时限应由商品稀缺性、渠道 SLA 和客服承诺测量得到。
7.6.2 正常链路
用户提交订单时,结算服务生成结算快照,包含商品版本、价格、优惠资格、币种、配送信息和渠道。订单服务验证价格版本、用户资格和幂等键;库存服务以订单号和行项目号创建预占记录;支付服务创建支付意图,但此时不应把“支付意图已创建”误写为“支付已成功”。订单服务在本地事务中保存订单、支付意图引用、库存预占引用和 Outbox 事件,状态置为“待支付”。
用户完成支付后,支付服务先验证渠道回调签名、支付单号、订单号、金额和币种,再把支付事实落到自己的事务中。支付事实提交成功后发布 PaymentSucceeded 事件。订单服务消费事件时,以支付单号、事件 ID 和订单当前版本做幂等更新;库存服务收到确认指令后,将预占转为已确认;订单进入“已支付”或“待履约”。积分和通知消费订单事实,不能反过来决定订单是否支付成功。
这条链路中的关键点是:支付成功不能仅以 HTTP 回调成功为依据,库存确认不能仅以 Redis 预扣为依据,订单成功也不能仅以前端收到响应为依据。每个核心状态都必须有自己的持久化事实,并且通过业务主键可以反查上下游流水。
7.6.3 状态、键和责任矩阵
| 动作 | 业务主键 | 权威记录 | 成功后的事件 | 超时后的状态 |
|---|---|---|---|---|
| 创建订单 | order_request_id | 订单记录 | OrderCreated | 查询订单创建结果 |
| 预占库存 | order_id + line_id | 库存预占记录 | InventoryReserved | 待释放或重查 |
| 发起支付 | payment_intent_id | 支付单 | PaymentStarted | 支付中或待查单 |
| 接收支付成功 | channel + transaction_id | 支付事实流水 | PaymentSucceeded | 回查渠道和对账 |
| 确认库存 | order_id + reservation_id | 库存流水 | InventoryConfirmed | 异常履约或补偿 |
| 退款/冲正 | payment_id + refund_id | 退款单和账务分录 | RefundSucceeded | 待查退款 |
重复请求的结果不应该简单返回“重复错误”,而应返回原始业务结果。例如第一次请求已经创建订单,第二次请求应返回同一个订单号,而不是重新创建订单或让用户陷入未知状态。入口层要保存幂等键与参数摘要,避免同一个键被错误地用于另一份订单。
7.6.4 重复请求、重复回调和重复消费
客户端可能因为网络超时重复提交订单,支付渠道可能重复发送回调,消息 relay 可能重复投递事件。系统必须把这些重复行为当作正常输入。
入口幂等可以使用业务幂等键和唯一约束;支付回调幂等可以使用“渠道标识 + 渠道流水号”;消息消费幂等可以使用事件 ID、状态版本或已处理消息表。重复的 PaymentSucceeded 事件如果订单已经是已支付,应记录为已处理但不再触发新的确认、发货或积分动作;如果订单状态仍为支付处理中,则推进一次;如果订单已经关闭,则进入异常队列,等待对账与人工判断。
重复确认和重复释放也需要状态条件。例如库存已经确认后再次收到释放指令,不能把可用库存增加两次;库存已经释放后再次收到确认指令,不能直接把已释放记录改为已确认。状态机应返回“已经完成”“当前状态冲突”或“需要人工处理”,而不是让每个调用方自行决定。
7.6.5 超时和未知结果
最危险的情况不是明确失败,而是请求在服务端可能已经成功执行,但客户端没有收到响应。
例如支付请求发出后连接断开,客户端不能立即认为支付失败,也不能无限次用新支付单重试。正确处理是保留原支付单号,进入“待查单”,通过渠道查询、异步回调、支付对账或人工核验确认最终结果。查询结果需要再次核对订单号、金额、币种和渠道流水,不能只因为渠道返回“成功”就更新任意订单。
同理,库存确认超时后不能立即重复扣减;订单创建超时后不能只依据客户端响应判断。所有未知结果都需要显式状态、查询接口和后续确认任务。客户端可以通过 GET /orders/{id} 查询订单事实,服务端可以通过 GET /payments/{id} 查询支付事实;查询接口本身也要说明它返回的是权威状态还是派生视图。
7.6.6 失败与补偿
| 失败点 | 已完成事实 | 禁止的处理 | 后续动作 |
|---|---|---|---|
| 预占失败 | 订单草稿或结算快照可能存在 | 继续开放支付入口 | 订单置为库存不足,禁止进入支付 |
| 支付失败 | 库存已预占 | 只改订单状态而不释放库存 | 关闭支付单,幂等释放库存,记录取消原因 |
| 支付成功但订单未更新 | 支付事实已落地 | 重新创建订单或重新扣款 | 重投支付事件,主动查询订单消费记录 |
| 支付成功但库存确认失败 | 资金事实和订单可能已成功 | 静默把订单标成完成 | 进入异常履约、替代供给或退款流程 |
| 订单创建成功但 Outbox 未投递 | 本地订单事实已落地 | 重新创单以“补发事件” | relay 重试原事件,不能重建业务事实 |
| 预占超时 | 资源仍被锁定 | 无条件释放所有同商品库存 | 按预占状态和版本安全释放 |
| 退款请求超时 | 渠道可能已受理退款 | 生成另一笔新退款 | 复用原退款号查询并对账 |
补偿不是简单的“把状态改回去”。退款、库存释放和账务冲正都可能是新的业务动作,因此补偿动作本身也必须有幂等键、流水和审计记录。补偿应遵循原动作的业务语义和依赖顺序:先确认支付结果,再决定是否退款;先确认预占仍存在,再决定是否释放;先确认原分录,再生成冲正分录。
7.6.7 支付、库存和账务如何互相约束
支付中心不能把渠道回调直接当作订单命令,而应先建立支付事实;订单中心不能根据展示缓存判断已支付;库存中心不能根据搜索索引判断可售量;账务中心不能根据订单状态自行推断已经入账。每个系统消费上游事实,但对自己的事实负责。
一个安全的支付成功推进顺序可以是:
校验渠道结果
→ 落支付事实
→ 生成账务记账意图或记账凭证
→ 发布支付成功事件
→ 订单状态推进
→ 库存确认
→ 履约、通知、积分异步推进
不同业务可以把订单确认和账务入账的先后顺序调整,但必须在 ADR 中写清楚。如果支付事实已经成功而账务入账失败,系统不能把支付事实删除;应进入待记账或异常对账状态,最终通过重试、补账或退款处理。这个顺序保护的是“事实不可丢失”,不是追求所有系统同时返回成功。
7.7 故障与治理:把异常闭环做成一等能力
7.7.1 对账设计
对账应先明确三件事:比较双方、比较口径和允许窗口。没有这三件事,对账任务只能制造“差异数量”,却无法说明哪些差异是正常延迟,哪些差异需要修复。
| 对账对象 | 比较双方 | 核心口径 | 常见差异 | 自动处理 |
|---|---|---|---|---|
| 支付对账 | 渠道成功记录与本地支付事实 | 订单号、支付号、金额、币种、时间窗口 | 渠道有本地无、本地成功渠道无、金额不符 | 明确缺失可补事实,金额差异转人工 |
| 订单对账 | 订单状态与支付/履约结果 | 状态组合和事件版本 | 支付成功订单仍待支付、关闭订单仍有支付 | 重放事件或进入异常订单 |
| 库存对账 | 库存账与预占/订单流水 | 总量、可用、预占、已售 | 重复释放、未释放预占、库存差额 | 确定性释放可自动修复,未知差异冻结 |
| 账务对账 | 业务流水、总账分录与余额快照 | 凭证号、借贷、账户、币种 | 缺分录、重复入账、借贷不平 | 重试缺失凭证,金额差异人工审批 |
| 退款对账 | 渠道退款与本地退款单/冲正 | 原支付号、退款号、金额 | 渠道已退款本地未更新 | 查询并补充事实,禁止重复退款 |
差异至少分为数据延迟、重复记录、缺失记录、金额差异、状态冲突、超时未收敛和无法自动判断。自动修复只能覆盖确定性差异;金额不明或跨系统冲正必须进入人工队列。对账结果本身也应保存输入范围、版本、执行时间、处理人和修复动作,避免“任务跑过了但无法复盘”。
7.7.2 审计和人工接管
人工接管不是承认系统失败,而是高准确性系统在不可判定状态下的安全边界。人工操作必须:
- 使用专门的操作类型,不能直接修改核心字段。
- 记录操作者、原因、审批单、原始事实和操作前后的状态。
- 经过状态机校验,不能绕过不变量。
- 支持撤销或追加冲正,而不是覆盖历史记录。
- 限制权限范围,资金、库存和账务操作应分离授权。
- 具备幂等保护,重复点击同一个处理单只能产生一个动作。
后台页面不应提供“把订单改成已支付”“把库存加回来”这类直接写字段能力。更安全的操作是“确认渠道支付事实”“创建退款任务”“释放某条预占”“生成库存调账申请”,由领域服务根据当前事实执行并写入审计。这样即使人工判断错误,也能通过反向动作修正,而不是丢失原始证据。
7.7.3 监控和告警
至少应该监控:支付未知状态年龄、库存预占超时、Outbox 积压、消息重复率、消费者失败率、补偿失败率、对账差异金额、账本借贷差额、锁等待、事务回滚率和人工队列最老任务年龄。告警要区分“数量突然增加”和“单个任务超过最大允许年龄”,后者通常更能反映资金和库存风险。
过载时不能无条件扩大重试。Google SRE 对过载处理的经验是,服务要能够降级、拒绝或限制重试,避免客户端和服务端相互放大负载;重试预算、快速失败和不同请求类型的优先级都应成为容量设计的一部分。[14] 对高准确性写链路而言,降级不应表现为“少记一笔账”或“先扣款后补库存”,而应表现为暂时关闭非核心入口、限制热点资源并把未知请求转入查询队列。
7.7.4 故障演练和发布治理
发布前应演练以下场景:
- 支付回调重复、乱序、签名错误和金额不符。
- 消息积压、重复投递、消费进程在提交后崩溃。
- 数据库主从切换、锁等待升高和事务提交超时。
- 库存预占成功但订单创建失败,订单已支付但库存确认失败。
- 支付渠道不可用、查单接口超时、退款结果未知。
- 对账任务中断、补偿任务重复执行、人工操作权限错误。
演练的通过条件不能只看服务是否恢复,还要检查业务事实:有没有重复扣款、库存是否平衡、账本是否平衡、未知状态是否进入正确队列、重复执行是否保持幂等、审计记录是否完整。高准确性系统的回滚也要分成代码回滚、配置回滚和业务冲正;部署旧版本并不等于已经撤销新版本产生的资金或库存事实。
7.7.5 评审时的证据链
一次方案评审至少应沿着一条业务事实追问:从用户命令开始,事实在哪里落地,谁能改变它,事件如何传播,消费者如何去重,超时如何判断,失败如何补偿,最终如何对账。每个环节都要有表、状态、键、指标或操作手册作为证据。
如果只能画出成功时序图,却回答不了“响应丢失后怎么办”“回调和取消同时到达怎么办”“补偿执行两次怎么办”“渠道成功但本地没有记录怎么办”,说明设计还停留在接口编排层,没有进入高准确性系统真正的治理问题。
7.8 方法论总结
7.8.1 十个判断句
- 高准确性首先保护的是业务不变量,而不是某个组件的“强一致”标签。
- 强一致边界应围绕不可出错的事实划定,而不是覆盖整条用户链路。
- 线性一致性、事务隔离和跨服务业务一致性是不同语义,必须分别写清楚。[2][3]
- 本地事务能解决的问题,不要用跨系统事务解决。
- 可靠事件只能保证事实传播,不能自动保证下游业务正确;消费者仍需幂等。
- 预占、确认和释放是一套资源生命周期模型,不能只实现成功路径。
- 支付、库存和账务都必须显式建模未知状态。
- Saga 解决流程收敛,不等于全局原子提交;补偿本身也是需要治理的业务动作。[7][9]
- 账本、状态事件和审计记录要保留解释能力,不能用直接改字段掩盖差异。
- 没有对账、补偿、审计和人工接管,就没有完整的高准确性系统设计。
7.8.2 一份可执行的评审清单
| 维度 | 必须回答的问题 |
|---|---|
| 权威事实 | 哪个服务拥有支付、订单、库存和账务事实?缓存和消息是否被误当成事实? |
| 不变量 | 金额、库存、借贷、状态和幂等约束如何通过代码、数据库或对账验证? |
| 事务边界 | 哪些操作在本地事务中原子提交?事务内是否调用慢 RPC 或第三方接口? |
| 状态模型 | 是否有处理中、待查单、补偿中等未知状态?哪些状态迁移被禁止? |
| 幂等语义 | 请求、回调、业务动作、消息消费和人工处理分别使用什么键? |
| 传播机制 | Outbox 或事务消息失败后如何重试?重复投递会造成什么副作用? |
| 资源模型 | 预占的 TTL、确认、释放、过期扫描和版本保护是否完整? |
| 补偿恢复 | 每个已执行动作的补偿动作是什么?补偿失败、重复和部分成功怎么办? |
| 对账审计 | 比较双方、口径、时间窗口、自动修复条件和人工审批是否明确? |
| 运维治理 | 告警、限流、重试预算、故障演练、回滚和人工接管是否可执行? |
7.8.3 一句话表达
高准确性与强一致性系统设计的核心,不是把整条链路做成昂贵的全局原子事务,而是先识别不可错的业务事实,再用本地事务、状态机、预占模型、幂等、可靠事件、账本、补偿和对账,把核心事实保护住,把跨系统流程收敛住。
这套方法也说明了系统如何演进:早期可以用单体和单库保护核心事实;拆分后用 Outbox 和状态机维持边界;流量增长后再对热点资源分片、排队或引入受控事务协调。每一步都应以不变量和故障证据为依据,而不是以组件数量或架构图复杂度证明系统更成熟。
7.9 参考资料
[1] Eric Brewer, “CAP Twelve Years Later: How the Rules Have Changed”, Computer, 2012。
[2] Maurice Herlihy, Jeannette M. Wing, “Linearizability: A Correctness Condition for Concurrent Objects”, ACM Transactions on Programming Languages and Systems, 1990。
[3] Hal Berenson, Phil Bernstein, Jim Gray, Jim Melton, Elizabeth O’Neil, Patrick O’Neil, “A Critique of ANSI SQL Isolation Levels”, SIGMOD Record, 1995。
[4] James C. Corbett et al., “Spanner: Google’s Globally-Distributed Database”, OSDI, 2012。
[5] Martin Kleppmann, Chris Riccomini, Designing Data-Intensive Applications, 2nd Edition, O’Reilly, 2026。
[6] Gregor Hohpe, Bobby Woolf, Enterprise Integration Patterns, Addison-Wesley, 2003。
[7] Hector Garcia-Molina, Kenneth Salem, “Sagas”, Proceedings of ACM SIGMOD, 1987。
[8] Pat Helland, “Life beyond Distributed Transactions: an Apostate’s Opinion”, CIDR, 2007。
[9] Chris Richardson, “Saga Pattern”, Microservices Patterns。
[10] Chris Richardson, “Transactional Outbox”, Microservices Patterns。
[11] Chris Richardson, “Idempotent Consumer”, Microservices Patterns。
[12] Stripe, “Idempotent requests”, Stripe API Reference。
[13] Martin Fowler, “Event Sourcing”, 2005。
[14] Google SRE, “Handling Overload”, Site Reliability Engineering。
[15] OpenTelemetry, “Documentation”, OpenTelemetry Project。
[16] Apache Seata, “Seata 是什么?”, Apache Seata 官方文档,2025。
[17] Apache Seata, “Seata TCC 模式”, Apache Seata 官方文档。
[18] Apache RocketMQ, “事务消息”, Apache RocketMQ 官方文档。
[19] PingCAP, “TiDB 事务隔离级别”, TiDB 文档中心。
[20] Oracle, “InnoDB Transaction Isolation Levels”, MySQL 8.4 Reference Manual。