为什么需要一张速查表
一致性讨论最常见的失败不是"选错模型",而是"用同一个词指不同的事"。有人说"我们最终一致",意思是"异步复制,几秒内能看到";另一个人理解成"冲突总会被解决,多久不确定";第三个人以为"最终一致就是可以暂时超卖"。三种理解写进同一份设计文档,评审时看起来很和谐,上线后各说各话。
这张表只做三件事:
- 给每种一致性模型一个可判定的判据;
- 给一个能说明它不成立的反例;
- 给一条从业务不变量反推模型的路径。
它不替代教材,也不追求形式化证明。需要严格定义时,回到 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] | A | write(x, 1) | ok |
| [1, 3] | B | read(x) | 0 |
| [4, 5] | B | read(x) | 0 |
第一段读与写重叠,可以排在写之前,因此返回 0 合法。第二段读在写返回之后才开始,必须看到 1;返回 0 就构成线性一致违例。
判定步骤可以固化成四步:
- 把每个操作写成"调用时间、返回时间、对象、操作、返回值";
- 只保留读写同一对象的操作;
- 检查是否存在一个全序,既尊重实时区间,又让每次读返回其前最后一次写;
- 找不到全序即为违例,找到即对这个历史成立。
真实系统的历史检查器会做剪枝和搜索,但判据不变。第 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 | 线性一致条件更新 | 条件更新、流水校验 | 透支、资损 |
| 下单后本人可见 | 会话内读到自己已完成的写 | 读己之写 | 会话令牌、读主 | 重复下单 |
| 列表与统计最终正确 | 收敛后与源数据一致 | 最终一致 + 收敛目标 | 异步投影、对账 | 报表偏差 |
这张表有三个使用要点:
- “最弱但足够"不是越弱越好。库存不超卖用最终一致,就必须接受补偿和超卖窗口;多数业务不接受。
- 一个业务动作可能同时需要多种保证。下单既要库存的线性一致,又要订单详情的读己之写。
- 形式化表达要能被测试直接使用。
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 与时钟漂移的可见后果 。
第六,把幂等等同于"加一张去重表"。去重键的作用域、写入时机、保留窗口、与业务副作用的原子性,任何一项错了都会漏。
第七,认为"副本越多越一致"。读请求落到哪个副本、该副本应用到哪个位点,才决定读到什么;副本数量本身不提供保证。
小结
这张速查表可以压缩成三句话:
- 先写业务不变量,再选一致性模型;不要从技术栈倒推需求。
- 故障语义决定算法假设;订单系统默认按崩溃恢复、丢包、重复和分区建模。
- CAP 与 PACELC 是描述取舍的语言,不是给系统贴的标签;真正要回答的是"分区时这个业务动作怎么办"。
需要把模型落成可执行检查时,转到附录 C 的六类场景;需要对照 Go 与 Java 的具体组件时,转到附录 B。
评审一个设计时,可以依次问五个问题:
- 这个动作破坏了哪条业务不变量?不变量能否写成可执行断言?
- 该不变量涉及几个键、几个分片?单键原子性能否覆盖?
- 读路径实际经过哪些副本、缓存或索引?端到端保证是什么?
- 超时、重复、分区发生时,系统停在哪个中间态?谁来推进?
- 收敛时间目标是多少?超出目标后由哪个任务、哪个人兜底?
五个问题都能回答,再讨论选 Raft、选 Saga 还是选 outbox。顺序反过来,通常会得到一个"技术很先进但说不清业务后果"的方案。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。