《后端架构设计·分布式与数据一致性》附录 A 一致性模型与故障语义速查

卷三附录 A:用判据、反例与业务映射三种口径整理线性一致、顺序一致、因果一致、读己之写、单调读和最终一致,说明崩溃停止、崩溃恢复、消息丢失、重复、分区与拜占庭六类故障语义,纠正 CAP 与 PACELC 的常见误读,并给出订单、库存、支付不变量到最弱一致性模型的对照表。

为什么需要一张速查表

一致性讨论最常见的失败不是"选错模型",而是"用同一个词指不同的事"。有人说"我们最终一致",意思是"异步复制,几秒内能看到";另一个人理解成"冲突总会被解决,多久不确定";第三个人以为"最终一致就是可以暂时超卖"。三种理解写进同一份设计文档,评审时看起来很和谐,上线后各说各话。

这张表只做三件事:

  1. 给每种一致性模型一个可判定的判据;
  2. 给一个能说明它不成立的反例;
  3. 给一条从业务不变量反推模型的路径。

它不替代教材,也不追求形式化证明。需要严格定义时,回到 6.1 线性一致、顺序一致与因果一致 ;需要把模型落到订单状态机时,回到 6.3 从一致性模型到业务不变量 。

先分清三种"一致"

同一个"一致性"在三个层次上含义不同,混用是大部分争论的起点。

层次回答的问题典型表述失效后果
数据一致性业务规则是否始终成立库存不超卖、余额非负账实不符、资损
副本一致性多个副本对同一对象看到什么线性一致、最终一致读到旧值、状态倒退
会话一致性同一个客户端能否看到自己的写读己之写、单调读用户困惑、重复提交

三者不是同一维度的强弱关系。一个系统可以副本最终一致,但通过条件更新保证库存不超卖;也可以副本线性一致,却因为没有事务而破坏跨对象不变量。设计时先写业务不变量,再决定副本与会话保证,顺序不能倒。

六种模型的一句话判据

模型一句话判据允许的反常主要代价常见实现
线性一致每个操作在调用与返回之间某点原子生效,且尊重实时顺序无(对单对象读写而言)协调、往返、可用性下降单主同步复制、共识读
顺序一致所有进程看到同一个全局操作顺序,但不必尊重实时顺序读到"尚未发生"的旧值全局排序、元数据单主日志、全序广播
因果一致有因果关系的操作在所有节点顺序一致并发操作顺序不同版本向量、依赖跟踪因果广播、会话粘滞
读己之写同一会话的读能看到该会话此前已完成的写其他会话可能读旧会话状态、路由会话令牌、读主、位点
单调读同一会话后续读不会看到更旧的状态不同会话之间无保证会话记录已见版本版本水位、副本筛选
最终一致停止写入后,所有副本在有限时间内收敛收敛前任意旧值冲突解决、对账异步复制、反熵

表里的"有限时间"是定义的一部分,不是修辞。工程上必须把它变成可观测的目标,例如(示例口径)“正常情况下 1 秒内、故障恢复后 5 分钟内”,并说明超出目标时由谁报警、谁来对账。没有收敛目标的"最终一致",等价于"不知道什么时候一致"。

逐项判据与反例

线性一致

判据:存在一个全序,把所有操作排成一条线,这条线既尊重每个操作的实时区间(调用开始到返回结束),又让每次读返回该全序中它之前最后一次写的值。

反例:客户端 A 写 x = 1 并收到成功响应;随后客户端 B 发起读,返回 0。两次操作在时间上不重叠,且读发生在写完成之后,任何合法全序都无法解释这个结果。

需要区分两个容易混淆的结论:

  • 线性一致是单对象、单操作的属性,不自动保证跨对象事务的原子性;
  • “可串行化"讨论的是多对象事务的等价串行执行,两者组合才有"严格可串行化”。

订单场景:库存扣减后的余量查询、支付状态查询、幂等键的首次占用判定,通常需要线性一致或等价的单键原子条件更新。

顺序一致

判据:所有进程观察到相同的操作顺序,每个进程的本地操作按程序顺序出现在这个全局顺序中;但全局顺序不必与真实时间一致。

反例:两个副本对同一组写操作给出不同顺序,导致客户端 A 先看到"创建"后看到"取消",客户端 B 先看到"取消"后看到"创建"。即使每个副本内部自洽,也不满足顺序一致。

顺序一致比线性一致弱的地方在于:一个已经返回的写,可能被排在另一个稍后才发起的操作之后。它适合"所有节点按同一日志应用"的复制系统,但不适合要求"读到最后一次已完成写"的场景。

因果一致

判据:如果操作 A 在因果上先于操作 B,那么所有节点都以 A 在 B 之前的顺序观察到它们;并发操作可以有不同的观察顺序。

反例:用户先发帖,再回复自己的帖子。另一个用户先看到回复、后看到原帖,就是因果违例。现实中的表现是"回复引用了一条不存在的消息"。

因果一致需要编码依赖。Lamport 时钟能给出全序但不能判断并发;向量时钟能判断并发,但元数据随参与者数量增长。工程上常用混合逻辑时钟压缩元数据,具体实现见 2.2 逻辑时钟与向量时钟 与 2.3 混合逻辑时钟与版本向量 。

读己之写

判据:同一个会话中,读操作至少能看到该会话此前已经完成的写操作。它不要求其他会话也看到。

反例:用户提交订单,接口返回成功;用户立刻打开订单列表,显示"暂无订单"。写已提交,但读被路由到一个尚未应用该写的副本。

实现方式有三种常见路线:

路线做法代价风险
读主写后一段时间内该会话的读走主库主库压力上升时间窗难调
会话令牌写返回携带位点或版本,读请求带上需要透传客户端不传就退化
粘性路由同一会话固定到同一副本负载可能不均副本故障后失效

读己之写常被误当成强一致。它只约束"自己的写",不约束别人;两个用户同时下单,仍然需要库存侧的原子条件更新来防超卖。

单调读

判据:同一会话中,如果一次读看到了版本 v,后续读不会看到比 v 更旧的版本。

反例:用户刷新页面,第一次看到"已支付",第二次看到"待支付"。两次读可能命中了不同副本,后一次副本的复制位点更旧。

单调读依赖"已见版本"的记录。服务端可以在会话中保存水位,读时选择位点不低于水位的副本;也可以在客户端携带上次响应的版本号。它与读己之写经常一起出现,但解决的是不同问题:前者防倒退,后者防看不到自己的写。

最终一致

判据:在停止新写入之后,所有副本在有限时间内收敛到相同状态。

反例:两个副本各自接受写,冲突解决规则是"最后写入获胜",但两边的物理时钟偏差导致较新的写被较旧的写覆盖。系统确实"收敛"了,收敛到了一个丢失更新的状态。这不是最终一致的合法反例,而是冲突解决策略的错误;但它说明"收敛"不等于"正确"。

最终一致必须配三样东西:冲突解决规则、收敛时间目标、兜底对账。缺任何一样,“最终"都会变成"也许”。

模型之间的强弱关系

在只讨论单对象读写的常见定义下,可以画成一条粗线:

线性一致
  └─ 蕴含 ─> 顺序一致
        └─ 蕴含 ─> 因果一致
              └─ 蕴含 ─> 最终一致

读己之写、单调读:会话级保证,可叠加在因果一致或最终一致之上

这张图只用于建立直觉,不应当作形式化证明。不同文献对因果一致的定义细节不同(是否包含读依赖、是否要求会话内程序顺序),跨文档引用时必须写清采用的是哪一种定义。把"某系统是因果一致"当成万能结论,往往会漏掉它并不保证跨对象事务。

用一段历史判定线性一致

假设只有一个寄存器 x,初值为 0。两个客户端的时间线如下:

时间区间客户端操作返回
[0, 2]Awrite(x, 1)ok
[1, 3]Bread(x)0
[4, 5]Bread(x)0

第一段读与写重叠,可以排在写之前,因此返回 0 合法。第二段读在写返回之后才开始,必须看到 1;返回 0 就构成线性一致违例。

判定步骤可以固化成四步:

  1. 把每个操作写成"调用时间、返回时间、对象、操作、返回值";
  2. 只保留读写同一对象的操作;
  3. 检查是否存在一个全序,既尊重实时区间,又让每次读返回其前最后一次写;
  4. 找不到全序即为违例,找到即对这个历史成立。

真实系统的历史检查器会做剪枝和搜索,但判据不变。第 6.1 线性一致、顺序一致与因果一致 一节给了一个可运行的检查器骨架,附录这里只保留判定口径。

故障语义表

一致性模型描述"正常时看到什么",故障模型描述"可能坏成什么样"。算法假设与故障模型不匹配,是分布式系统最隐蔽的错误来源。

故障语义进程是否回来稳定存储对算法的影响订单系统默认
崩溃停止进程永久退出,不再发送或响应否不适用需要副本、选主、故障检测选主场景的简化基线
崩溃恢复进程可能重启,内存状态丢失,稳定存储保留是保留需要预写日志、幂等重放默认建模
消息丢失消息可能不到达,也可能只到一部分不涉及不涉及需要确认、重传、超时默认建模
消息重复同一条消息可能被投递多次不涉及不涉及需要幂等、去重、幂等键默认建模
网络分区节点之间双向或单向不通,持续未知时长是保留需要多数派、租约、fencing必须演练
拜占庭节点可能任意错误,包括伪造消息可能可能被篡改需要 3f+1、签名、共识默认不建模

订单系统默认按"崩溃恢复 + 消息丢失 + 消息重复 + 分区"建模,理由是这四类在真实环境中都能观察到,而拜占庭故障需要额外的信任假设与成本。这个选择必须写进设计文档:如果某个依赖实际是拜占庭环境(例如开放网络中的未知参与者),沿用默认模型就会得出错误结论。

故障模型的细节见 1.2 故障模型:崩溃、丢包、分区与拜占庭 。

部分失败的三种结果

远程调用返回的不是"成功/失败"两态,而是三态。把"未知"当成"失败",是重复副作用的常见起点。

调用方看到服务端可能的真实状态正确动作
成功响应已提交继续,但下游仍要幂等
明确失败(拒绝、参数错误)通常未执行可重试;不可重试错误直接返回
超时、连接中断可能已提交,也可能未执行用幂等键重试,或先查询再决定
5xx可能部分执行查询、补偿或交给对账

超时尤其危险:调用方释放了连接,服务端可能仍在执行。此时立刻重试会放大并发;完全不重试又可能丢失业务。正确做法是"带幂等键的有限重试 + 可查询的操作状态",两者缺一不可。相关设计见 7.3 TCC、本地消息表与事务状态的可见性 。

CAP 的正确表述

CAP 常被简化成"三选二",这个说法在三个地方失真。

先给准确表述:

  • C(Consistency)指线性一致,不是"有副本"或"数据不丢";
  • A(Availability)指每个非故障节点在有限时间内返回非错误响应,不是"低延迟";
  • P(Partition tolerance)指网络可能丢弃任意多的消息,不是"机器会坏"。

定理说的是:当分区发生时,系统无法同时保证线性一致与每个非故障节点都返回非错误响应。它不讨论延迟,也不直接约束多对象事务。

三个常见误读:

误读问题更准确的说法
“CAP 三选二”无分区时 C 与 A 可以同时成立分区发生时在 C 与 A 之间取舍
“我们选 CA”分布式系统无法放弃 P只能选择分区期间的行为
“选了 AP 就没有一致性”AP 系统通常提供因果或最终一致放弃的是线性一致,不是所有保证

还要注意:CAP 的 C 是单对象的线性一致。一个系统可以在单键上线性一致,却在跨键事务上不满足任何强保证;反之亦然。用 CAP 讨论"订单和库存是否一致",问错了层级。

PACELC 补上正常时的取舍

PACELC 把问题拆成两段:

  • 如果发生分区(P),在可用性(A)与一致性(C)之间选择;
  • 否则(E,else),在延迟(L)与一致性(C)之间选择。
类型分区时正常时典型取舍
PC/EC保一致保一致每次写都要多数派确认,延迟高
PA/EL保可用保低延迟异步复制,读取可能陈旧
PC/EL保一致保低延迟依赖本地读加租约等机制,边界复杂
PA/EC保可用保一致少见,通常只在特定读路径成立

PACELC 的价值在于提醒:即使没有分区,强一致也不是免费的。跨地域同步复制带来的往返延迟,会直接进入写路径的尾延迟。卷二的延迟预算方法在这里仍然适用,只是多了一个"一致性成本"科目。

一致性模型到业务不变量的映射

这张表是附录的核心。用法是从左往右读:先写清不变量,再找最弱但足够的保证,最后选实现手段。不要从"我们用 Raft"倒推业务需要什么。

业务不变量形式化表达需要的最弱保证实现手段破坏后的后果
库存不超卖available = total - reserved - sold >= 0单 SKU 的线性一致条件更新条件 UPDATE、单分区共识超卖、赔付
订单状态单向推进每次转移属于允许边集合单订单的因果或线性一致版本号 CAS、状态机校验状态倒退、重复履约
支付不重复扣款每订单成功扣款次数 <= 1幂等键唯一约束 + 读己之写去重表、唯一索引重复扣款、客诉
余额不为负balance >= 0线性一致条件更新条件更新、流水校验透支、资损
下单后本人可见会话内读到自己已完成的写读己之写会话令牌、读主重复下单
列表与统计最终正确收敛后与源数据一致最终一致 + 收敛目标异步投影、对账报表偏差

这张表有三个使用要点:

  1. “最弱但足够"不是越弱越好。库存不超卖用最终一致,就必须接受补偿和超卖窗口;多数业务不接受。
  2. 一个业务动作可能同时需要多种保证。下单既要库存的线性一致,又要订单详情的读己之写。
  3. 形式化表达要能被测试直接使用。available >= 0 可以写成断言,库存要准确 不能。

按业务动作选模型

业务动作可接受的最低模型不应接受理由
扣减库存单键线性一致无补偿的最终一致超卖难以事后修复
查询本人订单读己之写 + 单调读无会话保证的副本读用户会重复提交
商品浏览计数最终一致线性一致偏差可接受,强一致浪费
支付回调入账幂等 + 读己之写仅"至少一次"投递重复入账是资损
跨服务状态展示因果一致任意乱序先看到支付后看到订单
风控名单同步有界延迟的最终一致无延迟上界需要明确生效时间

选型时先问"错了能不能补”。能补的(计数、推荐、统计)可以放松;不能补或补救成本高的(扣款、扣库存、发券)必须收紧。

读路径决定实际看到的模型

同一个存储,从不同路径读,得到的保证可能完全不同。设计文档写"系统是线性一致的"之前,先把读路径逐条列出来。

读路径实际保证适用场景主要代价
主库读,写已同步复制到多数派接近线性一致库存余量、支付状态主库压力、跨地域延迟
主库读,写仅本地提交读己之写通常成立,故障切换可能丢尾部订单详情RPO 不为零
从库读,带会话水位读己之写 + 单调读订单列表需要透传水位
从库读,不带水位最终一致商品浏览、统计可能读到旧值
本地缓存读取决于失效策略低频变更的配置失效延迟、击穿
物化视图或搜索索引有界延迟的最终一致列表检索重建与对账成本

这张表解释了一个常见矛盾:团队说"我们用了强一致存储",但用户在列表页仍然看到旧状态。原因往往不是存储,而是那条读路径走了缓存或异步索引。一致性是端到端的属性,不是存储单点的属性,这与 1.3 远程调用的八个假设与端到端原则 的结论一致。

跨对象不变量:单键原子性不够

库存条件更新只能保护一个键。一旦不变量跨越订单、库存、支付三个聚合,单键原子性就不再充分。

不变量涉及对象单键方案为何不够可选方案
下单成功必须占用库存Order、InventoryReservation两个键可能落在不同分片Saga + 补偿、outbox
支付成功必须对应订单Payment、Order支付系统与订单系统独立提交本地消息表、对账
退款不能超过实付Refund、Payment需要读取历史扣款汇总条件更新 + 流水约束
优惠券只能用一次Coupon、Order核销与下单不在同一事务唯一约束、状态机

跨对象不变量的通用思路是:把"必须同时成立"降级为"必须最终可判定"。具体做法是本地事务写业务状态与事件,异步推进另一侧,再用对账发现并修复差异。这条路线牺牲的是即时性,换来的是可恢复性;它不能用在"超卖不可接受"的场景,除非补偿本身足够可靠。分布式事务的各方案见 7.1 2PC 与 3PC 与 7.2 Saga:补偿、语义锁与不可补偿步骤 。

一致性预算:把"强"换算成成本

强一致不是开关,它有可计量的成本。评审时把成本写成预算,比争论"要不要强一致"更有效。

成本项来源预算写法(示例口径)
写延迟多数派确认、跨地域往返写 P99 增加一个往返时间
可用性分区时少数派拒绝写单分区故障时的可写比例
吞吐协调与串行化单键写入串行,热点键上限
运维复杂度成员变更、快照、恢复变更窗口与回滚演练次数
故障恢复日志回放、状态机重建RTO 目标与验证方式

上表的数字必须由团队按自己的网络与业务确认,不能照抄。附录只提供科目,不提供结论;任何写进设计文档的数值都要标注来源是"实测"“目标"还是"示例”。

七个常见误读

第一,认为"用了 Raft 就线性一致"。共识只保证日志复制;如果读走本地状态机而未经过 ReadIndex 或租约读,仍可能读到陈旧值。读路径必须单独设计,见 4.3 成员变更、快照与线性一致读 。

第二,把"最终一致"当成"很快一致"。没有收敛目标和对账,最终一致无法验收。

第三,把 CAP 当成选型标签。CAP 描述分区期间的行为,不描述正常时的延迟与一致性取舍。

第四,把读己之写当成强一致。它只约束会话自身,跨用户仍然可能读到旧值。

第五,认为"时钟同步了就有全局顺序"。NTP 只能把偏差控制在某个范围,范围内的乱序仍然存在;全局顺序需要逻辑时钟或共识,见 2.1 物理时钟、NTP 与时钟漂移的可见后果 。

第六,把幂等等同于"加一张去重表"。去重键的作用域、写入时机、保留窗口、与业务副作用的原子性,任何一项错了都会漏。

第七,认为"副本越多越一致"。读请求落到哪个副本、该副本应用到哪个位点,才决定读到什么;副本数量本身不提供保证。

小结

这张速查表可以压缩成三句话:

  1. 先写业务不变量,再选一致性模型;不要从技术栈倒推需求。
  2. 故障语义决定算法假设;订单系统默认按崩溃恢复、丢包、重复和分区建模。
  3. CAP 与 PACELC 是描述取舍的语言,不是给系统贴的标签;真正要回答的是"分区时这个业务动作怎么办"。

需要把模型落成可执行检查时,转到附录 C 的六类场景;需要对照 Go 与 Java 的具体组件时,转到附录 B。

评审一个设计时,可以依次问五个问题:

  1. 这个动作破坏了哪条业务不变量?不变量能否写成可执行断言?
  2. 该不变量涉及几个键、几个分片?单键原子性能否覆盖?
  3. 读路径实际经过哪些副本、缓存或索引?端到端保证是什么?
  4. 超时、重复、分区发生时,系统停在哪个中间态?谁来推进?
  5. 收敛时间目标是多少?超出目标后由哪个任务、哪个人兜底?

五个问题都能回答,再讨论选 Raft、选 Saga 还是选 outbox。顺序反过来,通常会得到一个"技术很先进但说不清业务后果"的方案。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查