Lecture 15: Consensus and the FLP Impossibility — 共识问题与 FLP 不可能性

目录 · ← l14 · l16 →

Lecture 15: Consensus and the FLP Impossibility — 共识问题与 FLP 不可能性

讲义对应:CS 425 FA2026 Lecture 17「Consensus(FLP 不可能性)」(原始讲义 L15.A.FA25.pdf《Impossibility of Consensus》,41 页)。本章在本笔记中编号为 Lecture 15,位于”经典分布式算法”部分。相关素材另参考 L15.B.FA25.pdf(Paxos 对 FLP 的讨论)、L13.FA25.pdf(安全性与活性的定义、割与因果性)、L6.FA25.pdf(Chandra–Toueg 关于故障检测器与共识的关系)。 教材对应:Coulouris 5th Ed. Ch. 15 Coordination and Agreement(§15.5 共识与相关问题,其中 §15.5.2 即 FLP 结果)、§14.5(全局状态、安全性与活性);补充:§15.1(故障检测器)、Ch. 18(复制)。 阅读材料必读(全体学生,不可选):Fischer, Lynch & Paterson, Impossibility of Distributed Consensus with One Faulty Process, JACM 1985Sections 1–3。补充:Lamport, Shostak & Pease, The Byzantine Generals Problem, ACM TOPLAS 1982;Chandra & Toueg, Unreliable Failure Detectors for Reliable Distributed Systems, JACM 1996;Castro & Liskov, Practical Byzantine Fault Tolerance, OSDI 1999。

15.1 概述

本章回答分布式系统理论中最深刻的一个问题:$N$ 个进程各自提出一个值,只通过消息通信,能否保证最终所有正确的进程都对同一个值达成一致? 这里的”能否”不是工程意义上的”能不能写出来”,而是在给定的系统模型下是否存在任何算法。答案令人震惊:在同步系统中,共识可以简单地用 $f+1$ 轮全体广播解决;而在异步系统中,即使只允许一个进程崩溃、即使算法是确定性的并且拥有无限的计算时间,也不存在保证终止的共识算法——这就是 FLP 不可能性(FLP Impossibility),由 Fischer、Lynch 与 Paterson 于 1985 年在 JACM 上证明,被公认为分布式系统领域最根本的结果之一。

本讲在整门课中处于理论枢纽的位置:向前,它把课程 Lecture 13(Snapshots)建立的安全性(Safety)与活性(Liveness)框架、”割与因果性”,以及课程 Lecture 5/6故障检测器(Failure Detector)三者汇合成一个精确的不可能性陈述;向后,它是理解 Paxos/Raft(课程 Lecture 19)领导者选举(课程 Lecture 18)复制状态机与两阶段提交(课程 Lecture 19、21/22) 为什么长成现在这个样子的唯一途径——这些系统全部都在”绕开 FLP”。本章的黄金法则是:

FLP 不是告诉我们”共识不可能”,而是告诉我们”共识的代价是什么”:你必须在异步性、确定性、总终止性三者中至少放弃一个。真实系统选择放弃”总是终止”,因为安全性被违反会损坏数据(不可恢复),而暂时不终止只是暂时不可用(可恢复)。


15.2 核心概念与分布式机制图解

15.2.1 共识问题(Consensus Problem)的严格定义

  • 定义与目的:系统中有 $N$ 个进程 $p_1,\dots,p_N$。每个进程 $p_i$ 有一个输入变量(input variable) $x_i$(讲义取二值:$x_i \in \{0,1\}$,称为 binary consensus;一般情形 $x_i$ 可取任意值),以及一个输出变量(output variable) $y_i$,初值为 $\bot$(undecided,未决定),并且一旦写定就不可更改。进程之间只能通过消息通信,通信图是完全图(任何两个进程都能直接通信)。共识要求设计一个协议,使得最终所有正确的(correct,即未发生故障的)进程都写出同一个值。讲义的表述是:结束时要么所有进程把输出置为 0(all-0’s),要么全部置为 1(all-1’s)。

  • 三个必须同时满足的性质(本章的基础,请务必背下来)

    性质形式化陈述通俗含义违反它的后果
    终止性 / 活性(Termination)每个正确进程最终都执行”决定(decide)”动作:$\forall i \in \text{correct}:\ \Diamond(y_i \neq \bot)$每个活着的人最后都投了票系统”卡死”:永远没有结果
    一致性 / 安全性(Agreement)不存在两个正确进程决定不同的值:$\forall i,j \in \text{correct}:\ y_i \neq \bot \wedge y_j \neq \bot \Rightarrow y_i = y_j$大家投的票必须一样系统”分裂”:不同副本产生不同历史,数据损坏
    合法性(Validity / Integrity)决定的值必须是某个进程提议过的值:$\exists j:\ y_i = x_j$票不能凭空产生系统”撒谎”:决定了一个谁都没提过的值

    注意三个细节,它们正是初学者最常搞错的地方

    1. 终止性要求的不是”最终达成一致”,而是”每个正确进程都做出决定“。这两者的差别恰恰是 FLP 中被违反的那一条:在 FLP 构造的执行里,大家从来没有”不一致”,而是永远没人决定。所以 FLP 的结论是”终止性无法保证”,不是”一致性无法保证”。
    2. 终止性只约束正确进程。崩溃的进程不需要决定,这正是容错的定义:故障进程可以什么都不做,协议只对幸存者负责
    3. 合法性有强弱之分。讲义给出的两个版本是:Validity(若所有进程提议同一个值 $v$,则被决定的就是 $v$)与 Integrity(决定的值必须被某个进程提议过);另外还有 Non-triviality(非平凡性):存在至少一个初始状态最终导致 all-0’s,也存在至少一个初始状态最终导致 all-1’s。非平凡性不是”锦上添花”,而是 FLP 证明的必要前提——否则”所有人都决定 0”这个平凡协议就能满足一致性与终止性(它只违反合法性)。
  • 直觉解释(”它是什么?”):把共识想成一群人开会表决,但会议室的规则极其苛刻:没有主持人、没有时钟、没有”投票截止时间”,任何人可能中途离场(崩溃),而每个还在场的人都必须在没有任何人告诉他”投票结束了”的情况下,自己判断出一个最终结论,并且必须与所有其他人判断出的结论完全一致。这就是为什么它比”投票”难得多——投票有主持人宣布开始与结束,而共识没有。

  • 共识 $\neq$ 多数表决(Majority Voting):讲义明确把这一点列为思考题。多数表决解决的是”我这一票该投给谁”,它假设了投票的时机与集合是已知的;共识解决的是”在没有全局时钟、部分参与者可能消失的情况下,如何让所有人对同一个值做出不可撤销的决定”。多数表决实现的是单次收集,共识需要的是在故障与延迟下仍然收敛的过程。此外,多数表决本身并不保证活性(少数派可能永远等不到多数派),也完全无法对付”有人撒谎”(那需要拜占庭容错,见 15.2.12)。

  • 共识的平凡解与它的非法之处:讲义思考题”什么是共识的平凡解(trivial solution)”的答案是:“所有人无条件决定 0”。它满足终止性(每个人都决定了)与一致性(大家决定的值相同),却违反合法性(如果没人提议过 0,决定 0 就是凭空产生)以及非平凡性(永远不可能出现 all-1’s)。这个平凡解提醒我们:不可能性定理必须把合法性写进前提,否则结论没有价值

  • 关键假设与系统模型:本章默认 crash-stop(失败即停止) 故障模型——进程要么正确运行,要么永久停止(不发送任何消息),不会发出错误的内容;通道是可靠(reliable)的,即不会丢失、不会篡改、不会凭空产生消息(这一假设在异步模型里只要求”最终送达”,不要求任何时间上界),并且是有向点对点的。当讨论拜占庭(Byzantine)故障时(15.2.12 起),故障进程可以任意行为,包括撒谎、合谋、对不同的接收者说不同的话。

15.2.2 为什么共识如此重要:它是最”小”的同步原语

  • 定义与目的:共识不是一个孤立的学术问题,而是分布式系统的核心原语(core primitive)。讲义用一页幻灯片列出了四个看似无关的任务:让所有服务器”以相同顺序收到相同的更新”、维护彼此的成员列表并在有人离开或故障时同步更新、选出一个领导者并让所有人知道、保证对某个临界资源(例如文件)的互斥访问。它们的共同点是:一群进程要对某个东西的”值”达成一致——消息的顺序、某个进程的 up/down 状态、谁是领导者、谁拥有临界资源的访问权。

  • 用途全景(每一条都能在本课程后面找到对应章节)

    问题需要”达成一致的到底是什么值”对应课程内容
    全序多播 / 原子广播(Total-Order / Atomic Multicast)每条消息在所有接收者处的相对顺序课程 Lecture 15(Multicast)
    领导者选举(Leader Election)“谁是领导者”课程 Lecture 18;把选出的进程编号的最后一比特当作共识决定值即可
    分布式互斥 / 锁服务(Mutual Exclusion / Lock Service)“谁持有临界资源”,即进入临界区的次序课程 Lecture 16;Chubby、ZooKeeper 的锁就是共识的应用
    复制状态机(Replicated State Machine)每个副本执行的操作序列(只要顺序一致,状态就一致)课程 Lecture 19/22;Paxos、Raft、Zab、Viewstamped Replication 的骨架
    成员管理(Membership)“当前成员集合是什么”课程 Lecture 5/6
    原子提交(Atomic Commit,2PC/3PC)“全体提交还是全体回滚”课程 Lecture 21/22
    跨分片事务(Distributed Transactions)多个分片的提交顺序与事务时间戳Spanner(TrueTime + Paxos)、CockroachDB、TiDB
  • 理论洞见:共识是”最小”的同步原语。讲义给出的方向是“许多分布式问题等价于共识,或者比共识更难”完美故障检测器(Perfect Failure Detection)等价于共识;领导者选举等价于共识(选出一个进程后,用它的编号的最后一比特作为共识的决定值即可);而一致性(Agreement,即所有进程对某个值达成一致但不要求合法性)比共识更难。之所以说”等价”,是因为两个方向都能互相归约:有了共识就能实现全序多播(把每条消息当作一个提议,用共识决定序号)、实现领导者选举(对”我推荐谁”达成共识)、实现复制状态机(对操作日志的每个槽位做共识);反过来,任何一个能实现上述功能的系统,只要把它跑起来再看结果,就已经解决了一次共识。“归约”是分布式系统理论的核心工具:不可能性会沿着归约链向上传染。

  • 直觉解释(”它是什么?”):共识像一根钢梁。它本身没什么用,但一旦有了它,桥、楼、吊车(全序多播、选举、复制、锁服务)全都能搭起来;而如果这根钢梁在某个模型下造不出来,那么所有需要它的建筑在那个模型下都造不出来。这就是 FLP 让当年一大批”我们是 100% 可靠”的宣传一夜之间消失的原因:它们的可靠性宣称暗含了”我们能解共识”,而 FLP 说那不可能。

  • 关键假设与系统模型:上面所有”等价”都必须在同一个系统模型下成立。异步模型下不可能实现的东西,在同步模型下可能有简单解;反之,同步模型下的算法搬到异步模型下可能丧失活性。讨论分布式算法时,第一句话永远是”系统模型是什么”——这也是讲义在给出”同步可解、异步不可解”之前,先花两页讲清楚两种模型的原因。

15.2.3 两种系统模型:同步与异步

  • 定义与目的同步系统模型(Synchronous System Model)对时间做出三条强假设:(1) 每条消息都在已知上界内送达;(2) 每个进程本地时钟的漂移率有已知上界;(3) 每个进程的每一步执行时间落在一个已知区间 $[lb, ub]$ 内(讲义原文:$\text{lb} < \text{time} < \text{ub}$)。异步系统模型(Asynchronous System Model)则完全不做这些假设:进程执行没有时间上界、时钟漂移率任意、消息传输延迟没有上界。

  • 直观解释(”它是什么?”):同步系统像一条装配线:每个工位有节拍,传送带的速度有保证,所以”这个零件 3 秒后一定到下一站”。典型的例子是讲义给出的多处理器 / 共享总线的超级计算机(例如 Cray)——所有处理器共享一个公共时钟与一条总线,时序是可控的。异步系统像寄平信:信一定会到(可靠),但可能明天到,也可能三个月后到,而且你无法从”信还没到”推出”这封信丢了”。讲义给出的例子是 Internet、ad-hoc 网络、传感器网络

  • 机制图解(两个模型的关键差别)

   同步模型: 每一步、每条消息都在已知上界内完成 ——「慢」是有限的
   ┌────┐  ≤ d_max  ┌────┐  ≤ d_max  ┌────┐
   │ p1 │ ────────▶ │ p2 │ ────────▶ │ p3 │     超时 => 一定故障(可判定)
   └────┘           └────┘           └────┘
   轮次(r)概念成立: 一轮的长度 >> d_max  =>  一轮之内的消息"必然全部送达"

   异步模型: 没有任何上界 ——「慢」与「死」不可区分
   ┌────┐  ?        ┌────┐  ?        ┌────┐
   │ p1 │ ────────▶ │ p2 │ ────────▶ │ p3 │     等待 => 永远不知道还要不要等
   └────┘           └────┘           └────┘
   没有可用的轮次概念: "等多久"无法用任何有限常数界定
  • 模型之间的包含关系(讲义特别强调的一点)异步模型比同步模型更一般、也更难。因此
    • 为异步系统设计的协议,自动适用于同步系统(异步协议不依赖任何时间假设,同步系统额外提供的时间保证它用不上);
    • 反之不成立:依赖超时的同步协议在异步系统中可能永远不终止(这正是 Paxos/Raft 的活性只在”部分同步”时得到保证的根源);
    • 不可能性结果沿反方向传染:既然异步模型是最一般的模型,那么”异步下不可能”意味着”任何比它更弱的假设下也不可能”;而”同步下可能”只是一个更强假设下的特例。
  • 关键假设与系统模型:本讲最重要的方法论是讲义的“证明不可能性时可以同时收紧模型并放松问题”(Proof Setup):为了证明异步系统中共识不可能,我们可以 (1) 采用更受限的系统模型(例如假设消息缓冲区是”全局的”、进程一步事件是原子的、通道不重复不丢失),(2) 考虑更容易的问题(例如只要求”某个进程最终写下输出”,而不是所有进程都决定;并且只允许一个进程崩溃,而且崩溃的是哪一个由我们选择,即对手可以挑选最有利于它的崩溃者)。为什么这样做是合法的? 因为如果连”更容易的问题 + 更受限的模型”都不可能,那么”更难的问题 + 更一般的模型”当然也不可能。这个”削弱问题”的技巧贯穿整个 FLP 证明:FLP 允许对手挑一个进程让它崩溃,而正是这”一个”崩溃造成了不可能性

15.2.4 同步系统中的共识:作为对照

  • 定义与目的:在同步系统模型下,共识可解,而且解法出奇地简单——全体广播 + 集合取并 + 用确定的规则挑一个值。讲义给出的算法在 $f+1$ 轮内完成($f$ 为最多可能崩溃的进程数),每轮每人把”自己上一轮新知道的值”多播给所有人,最后按”编号最小的提议者”(consistent minimum based on id,而不是最小数值)取值。完整伪代码、正确性论证与复杂度见 15.3.1

  • 直观解释(”它是什么?”):像会议室里传纸条:每个人都把自己知道的所有提议写在一张新纸条上发给所有人。第 1 轮结束时,所有人都知道了所有人的提议(只要没人出事)。若有人出事——例如某人刚把纸条发给你就晕倒了,没能发给别人——那就需要再传一轮:你手里”多出来的那条信息”会在下一轮被转发给其他人。关键洞察是:每多一个可能出事的人,就需要多一轮来”把消息补上”,因为一轮最多能”吸收”一次崩溃造成的缺口。

  • 机制图解($f+1$ 轮的直观必要性)

   第1轮: 每人广播自己的初始值
   第2轮: 每人广播"上一轮新学到的值"
        ...
   第 f+1 轮: 仍在广播

   为什么必须 f+1 轮? 想象一条"信息链条":
     r=1: p_k 把值 v 只发给了 p_i (然后崩溃)        ← 第 1 个崩溃
     r=2: p_i 把 v 只发给了 p_j (然后崩溃)          ← 第 2 个崩溃
     ...                                            ← ...
     r=f+1: 每多走一轮, 就多需要一次"崩溃"来阻断传播
    需要 f+1 次崩溃才能让 v 在 f+1 轮后仍未被所有人知道
    => 但崩溃总数 <= f, 所以 f+1 轮之后不可能还有"未传播到的值"
  • 能容忍多少故障:在 crash-stop(fail-stop) 模型下,只要 $f < N$(即至少有一个进程永远正确)共识就可解;$f = N$ 时系统里没有”正确进程”,共识命题退化(没有进程需要决定),因此没有讨论价值。讲义的归纳证明用到的恰好是”$f+1$ 轮内不可能发生 $f+1$ 次崩溃”。故障模型的差别很重要
    • fail-stop / crash-stop:进程停止后永远不再出现,其他进程看到它”静默”;
    • crash-recovery:进程崩溃后可能重启并恢复(恢复后是继续参与还是需要重新同步状态,取决于协议),此时”崩溃次数”与”故障进程数”分离,成员管理(课程 Lecture 5/6)要处理”它回来了但我以为它死了”;
    • omission(遗漏)故障:进程还在运行但可能丢消息,比 crash-stop 弱一些但更难推理;
    • Byzantine(拜占庭)故障:进程任意行为(撒谎、对不同的接收者说不同的话、合谋),见 15.2.12——这时需要的冗余从 $f+1$ 变成 $3f+1$。
  • 同步模型假设有多强? 讲义明确点出:同步系统的典型例子是共享总线/公共时钟的多处理器(如 Cray、多核机器)。而互联网、云、跨数据中心的网络都不满足这些假设:消息延迟没有上界(拥塞、路由抖动、GC 停顿、虚拟机迁移、时钟漂移都能让延迟任意变大)。因此”同步模型下的共识算法”在实践中不能直接使用——它需要你相信一个你不该相信的假设。这正是我们必须认真研究异步模型的原因:在互联网上,唯一的”最坏情况”假设就是异步,而在异步下,共识不可能。

15.2.5 FLP 不可能性:定理陈述与精确含义

  • 定理陈述(FLP Impossibility,Fischer–Lynch–Paterson, JACM 1985)

    在一个异步系统中,即使只允许一个进程发生崩溃故障(crash failure),也不存在任何确定性(deterministic)算法能够在有限时间内解决共识问题。 更精确地说:对任何满足一致性(Agreement)与合法性(Validity)的确定性协议,都存在一个执行(execution),使得其中所有进程永远不做出决定。

    讲义的口径是:“无论你提出什么协议/算法,总存在一个最坏情况的执行(包含故障与消息延迟),使得系统无法达成共识”,并强调“这对任何算法都成立”。1983 年结果首次发表(PODC),1985 年发表于 JACM。

  • 这个定理到底说了什么?六条必须逐一澄清的含义

    1. 它说的是”不可能同时保证一致与总终止”,而不是”共识不可能实现”。FLP 构造的执行里,各进程从未决定不同的值——被破坏的是终止性。因此你永远可以说:”我的协议是安全的(永远不会出现两个不同的决定),只是不保证一定给出结果。”
    2. 它只针对确定性算法。随机化算法(每个进程用随机数打破对称性)可以绕过它:终止性变成”以概率 1 终止”,期望有限时间——详见 15.3.2 的 Ben-Or 算法。
    3. 它只针对异步系统。同步模型下共识可解(15.2.4);部分同步(partially synchronous)模型下也可解——这是 Paxos/Raft/Zab 的基础。
    4. “一个”故障就够了,这是最惊人的地方。不是”故障太多导致不可解”,而是”只要允许 $f=1$ 且崩溃者由对手挑选,就足以不可解”。因此”加大冗余”(用 5 个副本而不是 3 个、用 100 个副本)完全不能解决异步下的 FLP 问题。
    5. 它假设没有(完美的)故障检测器。若系统提供完美故障检测器 P(强完备 + 强准确),异步模型就等价于同步模型,共识立刻可解。Lecture 5/6 的结论与此严丝合缝:在丢包网络中无法同时保证故障检测器的完整性与准确性,否则就能解共识
    6. 最重要的实践含义FLP 不是悲观的终结论,而是一张精确的”代价清单”。真实共识系统(Paxos、Raft)明确地放弃”总是终止”:它们保证安全性(Safety)永远不被违反,只在”网络条件足够好”时保证活性(Liveness)(Paxos 讲义原话:safety and eventual livenessFLP result still applies: Paxos is not guaranteed to reach Consensus, ever, or within any bounded time)。它们换来的东西是:这些协议可以在真实互联网上运行,而”总是终止的异步共识”只存在于论文里。
  • 现实类比(以及这个类比的边界):把共识想成三个朋友约饭,其中一个可能手机没电

   朋友 A: "咱们吃火锅吧?"    处理: 等 B、C 回复
   朋友 B: "行, 火锅"          处理: 等 A、C 回复
   朋友 C: (手机没电 / 只是在地铁里)
   ─────────────────────────────────────────────────────────────
   A 和 B 永远无法区分这两种情况:
       (1) C 的手机真的没电了        => 应该"不等了, 我们自己定"
       (2) C 只是暂时收不到消息      => 应该"再等等, 不然定的不是大家的意思"
   而"再等等"与"不等了"这两个决定, 正是终止性与一致性之间的取舍。

类比的边界(必须点明):(1) 约饭里 A、B 通常有”三点前必须定”这种外部时钟——一旦有了它,你就偷偷假设了部分同步,FLP 的前提消失了;(2) 约饭里偶尔”没达成一致”只是不方便,而分布式系统里”两个副本决定不同的值”是数据损坏;(3) 类比里只有 3 个人,但 FLP 的结论与人数无关——一万个副本也一样,因为问题出在”无法区分慢与死”这一认识论困境上,而不是”人不够多”。这个”无法区分(indistinguishability)”正是 FLP 证明的技术核心,我们马上会在引理 2 中看到它的第一次出场。

  • 关键假设与系统模型(精确版)
    • 异步消息传递:消息延迟任意大但有限(可靠通道:不丢、不重复、不篡改);进程执行速度任意(但每一步有限);
    • 确定性(deterministic):进程的下一步动作完全由当前状态与收到的消息决定,不含随机选择,也不读取真实时间;
    • 故障模型:至多一个进程崩溃(且对手可以选择崩溃哪一个);崩溃 = 从此不再执行任何事件;
    • 网络模型(讲义的做法):把整个网络看成一个全局消息缓冲区(Global Message Buffer)——send(p', m) 把消息放进缓冲区,receive(p') 从缓冲区中取出并投递一条消息,可能返回 null(即没有任何消息可投递)投递哪条消息、什么时候投递、是否投递,全部由对手/调度器决定。这一点至关重要:它把”消息的任意延迟”变成了”在配置树中任选一条分支”。
    • 问题被削弱:只要求”某个进程最终写下输出”(而不是所有正确进程都决定),崩溃数 $f=1$,且崩溃者由对手挑选。
   FLP 的网络模型: 网络 = 一个巨大的缓冲区, 投递时机由对手决定
   ┌───────┐   send(p', m)    ┌──────────────────────────────┐
   │  p    │ ───────────────▶ │   全局消息缓冲区 (multiset)   │
   └───────┘                  │  消息可被任意延迟、任意排序    │
   ┌───────┐   receive(p')    │  receive 可能返回 null        │
   │  p'   │ ◀─────────────── │  (没有消息可投递 = 消息"还没到")│
   └───────┘   (可能 null)    └──────────────────────────────┘
   ★关键: "消息还在缓冲区里"与"消息永远不会被投递"对进程完全不可区分

15.2.6 FLP 证明的词汇表:配置、事件、执行、价

  • 定义与目的:FLP 的证明是一段漂亮的组合论证。为了读懂它,首先需要四个精确定义(讲义用两页幻灯片给出,注意这里的”事件(Event)”不是 Lamport 事件):

    1. 进程状态(Process State):程序计数器、寄存器、栈、局部变量,加上输入寄存器 $x_p$(初值 0 或 1)与输出寄存器 $y_p$(初值 $\bot$)。
    2. 配置(Configuration)全局状态 $C$ = 每个进程的局部状态 $\times$ 全局消息缓冲区的内容。一个配置就是”世界的一张完整快照”(对应 Lecture 13 的全局状态概念)。
    3. 事件(Event):一个事件是原子的,由三步组成:(a) 某个进程 $p$ 收到一条消息(记 $e=(p,m)$,$m$ 是消息,可能是 null);(b) 处理这条消息(可能改变 $p$ 的状态,也可能写定 $y_p$);(c) 发送该事件产生的所有消息(放进缓冲区)。事件 $e$ 作用于配置 $C$ 得到新配置 $C^{\prime}$,记作 $C^{\prime} = C + e$。
    4. 调度 / 执行(Schedule / Execution / Run):一个事件序列。若 $C_0$ 是初始配置,$s = (e_1, e_2, \dots)$ 是事件序列,则 $C_0 + s$ 表示依次应用这些事件得到的配置。注意:执行是有限还是无限由对手决定——这正是”终止性”要对付的东西。
  • “决定”与”已决定配置”:若某进程的输出寄存器被写定为 0 或 1,我们就说它决定了决定一旦做出不可撤销(输出寄存器只能被写一次)。这个”不可撤销”是 FLP 证明反复使用的性质:一旦某个进程决定了 $v$,此后无论发生什么事件,这个进程的决定值都是 $v$,因此该配置之后的所有可达配置的决定值集合中都包含 $v$。

  • 配置的价(Valence):设从配置 $C$ 出发所有可能执行(在允许的故障范围内)所能决定的值构成集合 $V(C)$:
    • 若 $\vert V(C)\vert = 2$(既能走到决定 0,也能走到决定 1),称 $C$ 是双价的(bivalent)——讲义原话:”bivalent means outcome is unpredictable(结果是不可预测的)”;
    • 若 $\vert V(C)\vert = 1$,称 $C$ 是单价的(univalent);进一步,若 $V(C)=\{0\}$ 称 0-价(0-valent),若 $V(C)=\{1\}$ 称 1-价(1-valent)
    • 若 $\vert V(C)\vert = 0$(从 $C$ 出发永远不会决定任何值——例如所有进程都崩溃了),这种配置按讲义的口径不属于两类之一;在 FLP 的证明中我们只关心”还会决定某个值”的配置
  • 为什么”价”这个概念是证明的枢纽:终止性要求”总有一条路径走到决定”。如果存在一条无限执行,路径上每个配置都是双价的,那么这条路径上的任何配置都不可能已经决定了值(因为一个已经决定了 0 的配置不能再走到决定 1,就不再是双价了)。于是”无限双价路径”$\Leftrightarrow$”一个永远不决定的执行”$\Leftrightarrow$”终止性被违反”。整个 FLP 证明就是证明这条无限路径存在,而它分两步走:引理 2(起点:存在双价的初始配置) + 引理 3(引擎:从任何双价配置都能走到另一个双价配置)。再加上一个”交通规则”引理 1(不相交调度可交换)。讲义把这三点列成”我们要证明的东西”清单:

    1. There exists an initial configuration that is bivalent.
    2. Starting from a bivalent config., there is always another bivalent config. that is reachable.

15.2.7 引理 1:不相交调度可交换(Commutativity)

  • 定义与目的(引理 1):设 $\sigma_1$、$\sigma_2$ 是两个调度(事件序列),它们作用于不相交的接收进程集合(即 $\sigma_1$ 里的每个事件的接收进程都不出现在 $\sigma_2$ 里,反之亦然),并且都能在配置 $C$ 上应用。那么 \(C + \sigma_1 + \sigma_2 \;=\; C + \sigma_2 + \sigma_1\) 也就是说,两个不冲突的调度可以交换顺序,结果完全相同

  • 直观解释(”它是什么?”):像两个人各自写自己的日记:A 在自己的本子上写东西,B 在另一个本子上写东西,两人互不干涉。那么”A 写完 B 再写”和”B 写完 A 再写”,最后两个本子上的内容一模一样。注意这里的”互不干涉”是按接收进程划分的:一个事件只改变”收到消息的那个进程”的状态,并把新消息放进缓冲区;若两个事件作用在不同进程上,第一个事件不可能改变第二个事件的”触发条件”(消息 $m$ 在缓冲区内,可能被任意延迟),因此我们可以自由交换它们的顺序。

  • 机制图解

        C                        应用顺序 σ1, σ2  或 σ2, σ1, 结果相同
       / \                            ┌──────────────┐
  σ1  /   \  σ2                       │  配置 C      │
     /     \                          └──────┬───────┘
   C1       C2                σ1 ┌───────────┴───────────┐ σ2
     \     /                      ▼                       ▼
  σ2  \   /  σ1                  C1                      C2
       \ /                         │ σ2                    │ σ1
        ▼                          ▼                       ▼
       C''  (同一个配置)            └──────▶  C''  ◀────────┘

   条件: σ1 与 σ2 的接收进程集合不相交, 且二者都能在 C 上应用
  • 为什么这个”交通规则”是必需的:FLP 要构造的是一条无限执行,而为了在双价配置之间”无限地走下去”,必须能够自由地延迟某个事件(把事件 $e$ 推迟到后面执行)。交换性告诉我们:”推迟一个事件”不会破坏执行的可应用性,也不会改变最终结果——只要这个事件与其他事件作用于不同进程。异步性的本质就体现在这里:消息可以被任意延迟而不被任何进程察觉,因此对手总能选择”先做别的事”。

  • 关键假设与系统模型:交换性依赖于“事件是局部的”——一个事件的接收者只有一个进程。若事件是”全局的”(例如一次广播的原子提交),交换性就不成立。它也依赖于缓冲区的多重集语义:消息在缓冲区中的顺序不重要,重要的是被哪个进程、以什么顺序取出。

15.2.8 引理 2:存在双价的初始配置(以及”一个故障就够”的根源)

  • 定义与目的(引理 2)存在一个初始配置,从它出发既能决定 0、也能决定 1(即它是双价的)。

  • 证明(严格遵守讲义的论证结构)
    1. 反设:所有初始配置都是单价的(要么 0-价,要么 1-价),没有双价初始配置。
    2. 构造格(lattice):$N$ 个进程的初始配置共有 $2^N$ 个(每个 $x_p \in \{0,1\}$)。把它们排成一个格:相邻的两个配置恰好只在一个进程的输入值上不同
    3. 找跳变点:全 0 配置(所有进程提议 0)必然是 0-价的(合法性/非平凡性:大家都提议 0,怎么可能决定 1);全 1 配置必然是 1-价的。于是沿着格中的任意一条”从全 0 到全 1”的路径(例如每次只把一个进程的输入从 0 翻成 1),数值必然发生跳变,因此存在一对相邻配置 $C_0, C_1$,其中 $C_0$ 是 0-价、$C_1$ 是 1-价。设二者唯一不同的是进程 $p$ 的输入值。
    4. 让 $p$ 崩溃(关键一步):考虑这样一个执行——$p$ 从一开始就崩溃,一步都不走(事件序列中不包含任何以 $p$ 为接收者的事件)。那么从 $C_0$ 出发和从 $C_1$ 出发的执行,对其余所有进程完全不可区分:唯一的差别是 $p$ 的输入寄存器是 0 还是 1,而 $p$ 从不发送任何消息,这个差别永远不会被任何其他进程观察到
    5. 矛盾:不可区分意味着,其余进程在两条执行中收到相同的消息、做相同的状态转换、最终决定同一个值 $v$。可是 $C_0$ 是 0-价(所有执行都决定 0),$C_1$ 是 1-价(所有执行都决定 1)——特别地,”$p$ 崩溃”这一条执行也必须决定 0 与 1 两者之一,矛盾。
    \[\text{所以"所有初始配置都单价"是错的} \;\Longrightarrow\; \boxed{\text{存在双价的初始配置}} \qquad \blacksquare\]
  • 机制图解(引理 2 的两张图)
   图 A: 初始配置的格与「价」的跳变
   ────────────────────────────────────────────────────────────────
   沿"每次只翻一个进程的输入"的路径, 从全 0 走到全 1:

   (0,0,0) ──▶ (1,0,0) ──▶ (1,1,0) ──▶ (1,1,1)
    0-价        0-价      ↑  ?  ?       1-价
                          └── 必然存在一对相邻配置 (C0, C1)
                              一个 0-价、一个 1-价, 且只差一个进程 p 的输入
   ────────────────────────────────────────────────────────────────
   图 B: 为什么这一对不可能是「一 0-价、一 1-价」
   ────────────────────────────────────────────────────────────────
        C0: p 输入 = 0              C1: p 输入 = 1
         │                              │
         │  令 p 一开始就崩溃, 一步不走    │
         ▼                              ▼
      其余进程看到的:              其余进程看到的:
      · 完全相同的消息序列          · 完全相同的消息序列
      · 完全不知道 p 的输入是 0 还是 1   · 完全不知道
         │                              │
         ▼                              ▼
      必须决定同一个值 v            必须决定同一个值 v
      ────────────────────────────────────────────
      => C0 与 C1 同价, 与「一个是 0-价、一个是 1-价」矛盾
      => 反设不成立: 某个初始配置必为双价  ★
  • 这一引理为什么是”1 个故障就够了”的根源:证明里只用到一个进程的崩溃(那个输入值被翻转的 $p$),而且这个崩溃是我们(对手)主动选择的。所以 FLP 的不可解性与”能容忍几个故障”无关——一个就够了。请把这句话记牢:不是”故障太多”,而是”慢与死不可区分”。只要允许对手挑选一个进程让它沉默,它就能在”恰好让价格发生跳变的那个初始配置”上制造不可判定性。

  • 为什么必须把 $p$ 的输入而不是别的什么东西翻转? 因为在初始配置里,唯一的信息就是各进程的输入。价格(谁能被决定)作为初始配置的函数,必须从”全 0 侧的值 0”过渡到”全 1 侧的值 1”;而”过渡”只能发生在某条相邻边上——“翻转一个进程的输入”这件事的可观测后果,恰恰可以被”该进程崩溃”抹掉,因为崩溃的进程不发言,其输入对别人不可见。这就是引理 2 的全部魔法。

  • 关键假设与系统模型:引理 2 只需要 (1) 存在至少两个可能的决定值(非平凡性,保证全 0 配置是 0-价、全 1 配置是 1-价);(2) 至多一个进程崩溃就已经足够,而且崩溃者可选;(3) 进程不能读取真实时间或随机数(确定性),也不能通过”试探性地联系 $p$”来区分——异步模型中这种试探永远不会返回一个”确定没人在”的答案。

15.2.9 引理 3:从双价配置总能走到另一个双价配置

  • 定义与目的(引理 3)从一个双价配置出发,总存在一个事件序列,使得到达的配置仍然是双价的。(讲义原话:Starting from a bivalent config., there is always another bivalent config. that is reachable.)这个引理是”无限路径”的引擎:它保证我们不会被”卡”在某个双价配置上无路可走。

  • 证明的骨架(讲义的三步走):设 $C$ 是一个双价配置,$e=(p,m)$ 是任意一个可以在 $C$ 上应用的事件(例如”把缓冲区里的一条消息投递给 $p$”)。定义两个集合:

    • $\mathcal{C}$:不执行 $e$ 就能从 $C$ 到达的所有配置的集合(即”先把 $e$ 压在缓冲区里,先干别的”能走到的所有局面);
    • $\mathcal{D} = e(\mathcal{C}) = \{\,c + e \;:\; c \in \mathcal{C}\,\}$:把 $e$ 应用到 $\mathcal{C}$ 中每个配置后得到的配置集合。

    断言:$\mathcal{D}$ 中必有一个双价配置。 一旦这个断言成立,引理 3 就得证了——因为 $\mathcal{D}$ 里的配置当然是从 $C$ 可达的。

  • 反证与两种情形(这是整个 FLP 证明最精细的地方):反设 $\mathcal{D}$ 中全是单价配置
    1. 因为 $C$ 是双价的,从 $C$ 出发既能到达 0 也能到达 1,于是可以找到 $\mathcal{D}$ 中的 $d_0$(0-价)、$d_1$(1-价);
    2. 设 $d_0 = c_0 + e$、$d_1 = c_1 + e$,其中 $c_0, c_1 \in \mathcal{C}$;
    3. 关键的结构性事实:在 $\mathcal{C}$ 中取一条”从一个’$+e$ 之后呈 0-价’的配置 $c^0$ 出发、走到一个’$+e$ 之后呈 1-价’的配置 $c^1$”的路径(每一步只施加一个事件)。沿着这条路径,”$+e$ 之后的价”必然在某一处从 0 跳到 1;取跳变处的相邻两个配置作为 $c_0, c_1$,于是它们之差恰好是一个事件:$c_1 = c_0 + e^{\prime}$,其中 $e^{\prime} = (p^{\prime}, m^{\prime})$;同时 $d_0 = c_0 + e$ 是 0-价、$d_1 = c_1 + e$ 是 1-价。

    然后按 $e^{\prime}$ 的接收进程 $p^{\prime}$ 与 $e$ 的接收进程 $p$ 是否相同,分成两种情形:

    • 情形 I:$p^{\prime} \neq p$(两个事件作用在不同进程上)。由引理 1,$e$ 与 $e^{\prime}$ 可交换,于是 \(d_0 + e^{\prime} \;=\; (c_0+e)+e^{\prime} \;=\; (c_0+e^{\prime})+e \;=\; c_1 + e \;=\; d_1\) 也就是说:从 0-价的 $d_0$ 出发,施加一个事件 $e^{\prime}$ 就到达了 1-价的 $d_1$。于是 $d_0$ 同时具备两种能力:作为 0-价配置,从它出发的执行决定 0;而沿着 $e^{\prime}$ 走一步到 $d_1$ 之后,从 $d_1$ 出发的执行决定 1——按定义 $d_0$ 就是双价的。这与”$\mathcal{D}$ 中全是单价配置”的假设矛盾。$\blacksquare$

         情形 I  (p' ≠ p): 由图中的两条路径到达同一个配置
               c0 ──e──▶ d0 (0-价)
                │            │
             e' │            │ e'     这两个 e' 是同一个事件,
                ▼            ▼         因为 e 与 e' 可交换(引理 1)
               c1 ──e──▶ d1 (1-价)     =>  d0 + e' = d1
         于是 d0 既能决定 0(自己是 0-价), 又能走到 d1 去决定 1 => d0 双价 => 矛盾!
      
    • 情形 II:$p^{\prime} = p$(两个事件的接收者是同一个进程)。此时 $e$ 与 $e^{\prime}$ 都作用于 $p$,二者的顺序不能随便交换,引理 1 直接用不上。讲义的做法是引入一条“$p$ 一步都不走”的有限、已决定的执行 $s$:

      • $s$ 为什么存在:因为”最多一个进程崩溃”中的那个崩溃者由我们自由选择,就取 $p$ 为崩溃者。于是其余 $N-1$ 个进程都是正确的,它们必须在有限时间内做出决定(这就是 FLP 把问题削弱成”某个进程最终写下输出”的用处)。
      • 设 $A = c_0 + s$,则 $A$ 是一个已决定的配置,记它的决定值为 $v$。由于 $p$ 在 $s$ 中一步未走,$s$ 与任何”只作用于 $p$ 的事件”都作用于不相交的进程集合,由引理 1 可以交换顺序。于是我们得到两条都能到达 $A$ 的后续的路径: \(A + e \;=\; (c_0+s)+e \;=\; (c_0+e)+s \;=\; d_0 + s\) \(A + (e^{\prime},e) \;=\; (c_0+s)+(e^{\prime},e) \;=\; (c_0+(e^{\prime},e))+s \;=\; d_1 + s\)
      • 两次利用价:$A + e = d_0 + s$ 说明”从 0-价的 $d_0$ 出发,沿着 $s$ 可以走到一个’已经有人决定 $v$’的配置”,而 $A$ 中那个进程的决定不可撤销,所以 $v$ 是 $d_0$ 可达的决定值 ⇒ $v = 0$。同理 $A+(e^{\prime},e) = d_1 + s$ 说明 $v$ 也是 $d_1$ 可达的决定值 ⇒ $v = 1$。
      • $v$ 同时等于 0 和 1 ⇒ 矛盾
         情形 II (p' = p): 故意让 p 全程沉默, 让其余进程必须自己决定
               c0 ──s──▶ A          (s 有限, 结束时已有进程决定 v; s 中 p 一步不走)
                │        │
             e  │        │ e          ← e 只作用于 p, s 不含 p => 可交换(引理 1)
                ▼        ▼
               d0 ──s──▶ A + e       d0 是 0-价 => d0 能决定的只能是 0 => v = 0
               c1 ──e──▶ d1 ──s──▶ A + (e', e)      (同理 e' 也与 s 可交换)
                                    d1 是 1-价 => d1 能决定的只能是 1 => v = 1
          => v 同时是 0 和 1 => 矛盾  => 反设"D 中全是单价配置"不成立 ∎
      
  • 为什么这个证明如此依赖异步性:情形 I 用到了”事件可交换(消息可被任意延迟而不被察觉)”;情形 II 用到了”$p$ 可以一步都不走”——在异步系统里,”$p$ 的消息还没到”与”$p$ 已经死了”是同一件事,因此对手可以无限期地不给 $p$ 投递消息,同时让其他进程继续跑。如果系统是同步的,对手就不能这么做:超时之后”$p$ 死了”成为公共知识,其他进程可以把它排除掉、按剩下的集合做决定——这就是同步模型下 $f+1$ 轮算法能够成功的原因,也是”绕过 FLP”的第一条路径(部分同步)的起点。

  • 关键假设与系统模型:引理 3 依赖 (1) 事件局部性(引理 1);(2) 共 1 个故障、故障者可选(情形 II 里我们选择让 $p$ 沉默);(3) 决定不可撤销;(4) 缓冲区语义(消息可以被任意延迟)。

15.2.10 定理:把引理 2 与引理 3 串起来(配置树与无限双价路径)

  • 定理(FLP 不可能性,讲义 Putting it all Together)

    引理 2:存在双价的初始配置。 引理 3:从任何双价配置出发,总能到达另一个双价配置。 定理:因此在异步分布式系统中,总存在一条事件序列(一个执行),使得这组进程永远停留在双价、永远不达成共识

  • 证明:取引理 2 给出的双价初始配置 $C_0$。反复应用引理 3:得到双价配置序列 $C_0, C_1, C_2, \dots$,其中每个 $C_{k+1}$ 都是从 $C_k$ 出发施加某个有限事件序列后到达的双价配置。把所有这些事件首尾相接,就得到一条无限执行。在这条执行上,每个配置都是双价的,而”双价”意味着”还能走到决定 0、也能走到决定 1”,特别地,这条路径上没有任何进程已经决定(一旦有进程决定 $v$,该配置就只能走到决定 $v$,就不再是双价)。于是这条无限执行上没有任何进程做出决定——终止性被违反。因此不存在同时保证一致性、合法性与终止性的确定性异步共识协议。$\blacksquare$

  • 机制图解(本章最重要的一张图:配置树与无限双价路径)

                            配置树 (configuration tree)
        根 = 某个初始配置           每个节点 = 一个配置
        边 = 一个事件 (e=(p,m))     每条从根向下的路径 = 一个执行

                              ┌──────────┐
                              │  初始配置 │
                              └────┬─────┘
              ┌────────────────────┼────────────────────┐
              │                    │                    │
          ┌───▼───┐            ┌───▼───┐            ┌───▼───┐
          │ 0-价  │            │ 双价★ │            │ 1-价  │   ← 引理 2:
          └───────┘            └───┬───┘            └───────┘     必有双价节点
                     ┌─────────────┼─────────────┐
                     │             │             │
                 ┌───▼───┐     ┌───▼───┐     ┌───▼───┐
                 │ 0-价  │     │ 双价★ │     │ 1-价  │        ← 引理 3:
                 └───────┘     └───┬───┘     └───────┘          双价节点下面
                     ┌─────────────┼─────────────┐              永远还能找到
                     │             │             │              双价子节点
                 ┌───▼───┐     ┌───▼───┐     ┌───▼───┐
                 │ 1-价  │     │ 双价★ │     │ 0-价  │
                 └───────┘     └───┬───┘     └───────┘
                                   │
                                   ⋮   ← 无限延伸: 对手永远可以选"双价"这一支
                                   │
                              ┌────▼────┐
                              │ 双价★   │
                              │   ⋮     │
                              └─────────┘

   ★ 存在一条无限长的执行, 途中所有配置都是双价
     => 这条路径上没有任何进程决定过任何值
     => 违反 Termination  => 不存在这样的确定性算法  ∎
  • 这张图怎么读(三个要点)
    1. 树的形状由对手决定:在异步模型里,每个配置的下一个事件(投递哪条消息、给谁)是不确定的,因此一个配置有许多子节点;”执行”就是从根出发的一条路径。
    2. 单价节点是”死胡同”:到了 0-价节点,不管怎么走都只会决定 0;决定一旦发生,价值就固定。所以”永不决定”的执行必须全程避开单价节点
    3. 引理 2 给出起点、引理 3 给出”不断链”的保证,两者合起来就是”存在一条无限的双价路径”。注意这条路径是存在的(existence),不是”所有执行都不终止”——FLP 说的是”总有那么一个最坏的执行”,这也解释了为什么实际系统大部分时候跑得很好:最坏执行需要对手精确配合(恰好延迟正确的那些消息),而真实的网络故障往往是随机的、不一致的。

15.2.11 绕过 FLP:必须放弃某一样东西

  • 定义与目的:FLP 的前提是三条假设的合取异步 + 确定性 + 允许一个崩溃(且无故障检测器)。因此要”绕过”它,就必须至少放松其中之一。这不是权宜之计,而是逻辑上的必然:所有可用的共识算法都可以被精确地归类为”放松了哪一条”。下表是本讲最重要的一张总结表:

    放松/改变的假设新的系统模型代表算法/系统付出的代价
    放松”异步”部分同步(Partially Synchronous):消息延迟与时钟漂移最终有界,但不知道界何时开始成立Paxos、Raft、Zab、Viewstamped Replication在延迟真的无界时算法可能不终止(不保证活性);但安全性永远成立
    放松”确定性”随机化(每个进程可以抛硬币)Ben-Or(1983)、Rabin 的随机化共识、PBFT 的随机化视图变更只能保证以概率 1 终止:存在概率为 0 的不终止执行;期望轮数有限(共享硬币 $O(1)$,私有硬币 $O(2^{n-f})$)
    放松”无故障检测器”引入(最终型)故障检测器:$\Diamond P$、$\Diamond S$、$\Diamond W$Chandra–Toueg 共识(1996)检测器本身无法在异步丢包网络里被完美实现(Lecture 5/6):完整性与准确性不能同时保证,因此”最终准确”只能靠超时近似
    (补充)改变故障类型拜占庭故障 + 部分同步OM(m)、PBFT、Tendermint、HotStuff副本数从 $f+1$ 升到 $3f+1$,消息复杂度从 $O(N)$ 升到 $O(N^2)$,并需要签名/MAC
    (对照)不做任何放松纯异步 + 确定性 + 1 个崩溃不存在—— 这就是 FLP
  • 路径一:放松异步 $\Rightarrow$ 部分同步与”安全性优先”的工程妥协。部分同步模型假设:消息延迟与时钟速率最终会落在某个(未知的)界内,系统可以”好一阵子、坏一阵子”。这正是 Paxos/Raft/Zab 的行为方式:它们用超时来推进(选主、重试),而在超时之前它们什么都不做——宁可等待也不冒险。
    • 代价的精确表述:如果网络永远不进入同步期(或者总在最坏的时刻抖动),这些协议可能永远选不出领导者、永远无法提交新的请求——也就是活性(liveness)没有保证。讲义对 Paxos 的原话是:Paxos provides safety and eventual liveness … FLP result still applies: Paxos is not guaranteed to reach Consensus (ever, or within any bounded time)
    • 必须点明的关键权衡实际共识算法用”安全性永远保证 + 活性尽力而为”换取可行性。这个不对称不是偷懒,而是 15.2.17 要讲的工程哲学。
  • 路径二:放松确定性 $\Rightarrow$ 随机化算法。用随机数打破对称性:如果所有进程都”随机地”选一个新值,那么总有可能(概率大于 0、且独立于历史)在某一步上大家一起选中同一个值,从而跳出 FLP 的无限双价路径。代价是终止性从”必然”降级为”以概率 1”:任何有限轮的失败概率都严格大于 0,但 $\Pr[\text{永不终止}] = 0$,且期望轮数有限。详见 15.3.2 的 Ben-Or。

  • 路径三:引入故障检测器。如果系统能提供完美故障检测器 P(强完备:每个崩溃最终被所有正确进程怀疑;强准确:被怀疑的进程确实崩溃了),那么”$p$ 是否已经死了”成为一个可判定的问题,异步模型就等价于同步模型,共识立刻可解。但 Lecture 5/6 已经证明:在丢包网络中不可能同时保证完整性与准确性,否则就能解共识——这本身就是 FLP 的一个推论(若存在完美检测器,用它来”屏蔽”崩溃进程后剩下的系统是同步的)。于是只能退而求其次:
    • $\Diamond P$(最终完美):最终强完备 + 最终强准确;
    • $\Diamond S$(最终强):强完备 + 最终弱准确(最终会有一个正确的进程不被无端怀疑);
    • $\Diamond W$:更弱,用于”多数进程可能故障”的情形。 Chandra–Toueg 定理$\Diamond S$ 足以解决共识(在多数进程正确的假设下),并且 $\Diamond S$ 是最弱的能解决共识的故障检测器。这就是 15.3.6 的算法基础。Paxos/Raft 实际上内建了 $\Diamond S$ 型的检测器:它们的超时机制就是”最终强烈的怀疑”——超时太短会误判(准确性问题),太长会导致活性差(检测速度问题),工程上必须在两者之间做取舍。
  • (补充)路径四:改变故障类型并不”绕过”FLP。一个常见的误解是”拜占庭容错更强,所以更难”,但要注意方向:拜占庭故障是更强的故障假设,它并不让 FLP 消失。在异步系统中,确定性拜占庭共识同样不可能;PBFT/Tendermint/HotStuff 之所以能工作,是因为它们同时采用了部分同步(PBFT 的安全性不依赖同步,活性依赖同步窗口)或随机化视图变更“更强的容错”与”绕过 FLP”是两件正交的事。

15.2.12 拜占庭故障与拜占庭将军问题

  • 定义与目的拜占庭故障(Byzantine Failure)指故障进程可以任意行为:发送错误的值、对不同接收者发送互相矛盾的消息、伪造/重放消息、伪装成别人、与其他故障进程合谋、干脆不发消息。它得名于 Lamport、Shostak 与 Pease 1982 年的论文 The Byzantine Generals Problem(ACM TOPLAS):一群拜占庭将军包围了一座城市,必须决定”进攻”还是”撤退”;将军之间只能靠信使通信;其中有些将军是叛徒,会想尽办法让忠诚的将军无法达成一致。
    • 为什么这个模型是现实需求:crash-stop 假设”出错的机器只是停下来”,但现实中的出错方式要坏得多——软件 bug 会让副本计算出不同的结果(非确定性、越界、空指针)、磁盘静默损坏(silent data corruption)会让副本读到不同的数据、被入侵的节点会主动作恶、配置错误会让一个节点用旧版本代码运行。这些都不是”停机”,而是”胡说八道”。
    • 问题的形式化(两个条件)
      • IC1:所有忠诚的将军决定相同的行动计划(Agreement);
      • IC2:若指挥官是忠诚的,则所有忠诚的将军服从它的命令(Validity/正确性)。
    • 消息模型口头消息(Oral Messages)——消息内容可能被篡改,叛徒可以转述任何它想说的东西(但不能伪造一个忠诚者未曾说过的话,也就是”没有签名”);书面/签名消息(Signed Messages)——消息带不可伪造的签名,叛徒无法伪造别人的签名,忠诚者可以把”有签名的证据”转发给别人。
  • 机制图解(口头消息模型下的两种”耳语”能力)
   口头消息: 叛徒可以 (a) 对不同人说不同的话   (b) 转述时撒谎
   ┌───────────────────────────────────────────────────────────┐
   │        指挥官 C (叛徒)                                     │
   │        ├── 对 p1 说 "进攻"                                 │
   │        └── 对 p2 说 "撤退"                                 │
   └───────────────────────────────────────────────────────────┘
   ★ p1、p2 互相转发自己收到的命令后, 各自看到 "一个进攻、一个撤退",
     他们无法判断: 是 C 在撒谎? 还是对方在撒谎? ——「不可区分性」再次出现
  • 关键假设与系统模型:故障数上界为 $f$;通信图是完全图(每个将军能直接联系每个将军)——这一点很重要,否则连消息传递本身都成问题;同步与异步的差别在拜占庭模型下同样致命:口头消息算法 OM(m) 的正确性依赖同步(每轮消息在有限时间内送达),异步下它无法工作(FLP 的拜占庭版本同样成立)。

15.2.13 核心定理:口头消息下需要 $N \ge 3f+1$

  • 定理(Lamport–Shostak–Pease, 1982)

    口头消息模型下,$N$ 个将军中若有 $f$ 个叛徒,则当且仅当 $N \ge 3f+1$ 时存在同时满足 IC1 与 IC2 的算法。相应地,算法 $OM(m)$ 在 $N > 3m$ 时正确。

  • $f=1, N=3$ 为什么不够:三种场景的不可区分性(必考内容)。设指挥官下令值 $\in \{1=\text{进攻}, 0=\text{撤退}\}$,考虑三个”对某个忠诚中尉来说难以区分”的场景:
   场景 1: 指挥官忠诚(下令 1); 中尉 p2 是叛徒(对 p1 谎报 0)
   ┌───────────────┐
   │  C 忠诚: 下令 1 │
   └───┬───────┬───┘
     1 │       │ 1
       ▼       ▼
   ┌────────┐ ┌──────────┐
   │ p1 忠诚 │ │ p2 叛徒  │
   └────────┘ └────┬─────┘
       ▲           │ 谎报 0
       └───────────┘
   p1 的视角 = (指挥官说 1, 同伴 p2 说 0)
   IC2 要求: p1 必须决定 1  (因为指挥官是忠诚的)

   场景 2: 指挥官是叛徒(对 p1 说 1、对 p2 说 0); p1、p2 都忠诚
   ┌───────────────┐
   │  C 叛徒       │
   └───┬───────┬───┘
     1 │       │ 0     ← 对两个人说不同的话(自相矛盾)
       ▼       ▼
   ┌────────┐ ┌────────┐
   │ p1 忠诚 │ │ p2 忠诚 │
   └───┬────┘ └───┬────┘
       │ 1        │ 0    ← 各自如实转述"自己听到的那个值"
       └────┬─────┘
            ▼
   p1 的视角 = (指挥官说 1, 同伴 p2 转述 0) = (1, 0)  ← 与场景 1 中 p1 的视角【完全相同】
   p2 的视角 = (指挥官说 0, 同伴 p1 转述 1) = (0, 1)
   IC1 要求: p1 与 p2 必须决定同一个值

   场景 3: 指挥官忠诚(下令 0); 中尉 p2 是叛徒(对 p1 谎报 1)
   p1 的视角 = (指挥官说 0, 同伴 p2 谎报 1) = (0, 1)  ← 与场景 2 中 p2 的视角【完全相同】
   IC2 要求: p1 必须决定 0

不可能性的完整推理(三步,务必掌握)

  1. 场景 1 的 IC2:任何算法在视角 $(1,0)$ 上必须输出 1(否则那个忠诚中尉就违背了忠诚指挥官的命令);
  2. 场景 3 的 IC2:任何算法在视角 $(0,1)$ 上必须输出 0
  3. 于是在场景 2 中,p1 的视角是 $(1,0)$、p2 的视角是 $(0,1)$,二人必然输出 1 与 0 两个不同的值违反 IC1结论:$N=3, f=1$ 时不存在任何同时满足 IC1 与 IC2 的算法——不是因为算法不够聪明,而是因为三种场景对当事人而言无法区分。这与 FLP 的”引理 2”用的是同一种武器:不可区分性(indistinguishability)
  • $N = 4, f = 1$ 为什么就行:多一个忠诚的将军,就多了一票”真话”,使多数表决能够压过叛徒的谎话。
   N=4, f=1: 指挥官忠诚(下令 1); 中尉 p3 是叛徒(对 p1、p2 谎报 0)
   ┌───────────────┐
   │  C 忠诚: 下令 1 │
   └──┬────┬────┬──┘
    1 │  1 │  1 │
      ▼    ▼    ▼
   ┌────┐┌────┐┌─────────┐
   │ p1 ││ p2 ││ p3 叛徒 │
   └─┬──┘└─┬──┘└────┬────┘
     │1    │1       │0  0     ← 撒谎
     ▼     ▼        ▼  ▼
   p1 收到的值 = [1 (来自 C), 1 (p2 转述), 0 (p3 撒谎)]  => 多数 1 ✔
   p2 收到的值 = [1 (来自 C), 1 (p1 转述), 0 (p3 撒谎)]  => 多数 1 ✔
   => 3 个忠诚者中只要有 2 个说真话, 就能压过 1 个叛徒  => IC1 & IC2 成立

   对比 N=3, f=1 (场景 1):
   p1 收到的值 = [1 (来自 C), 0 (p2 撒谎)]  => 1:1 平局 => 无法判断 => 失败
  • 为什么是 $3f+1$:两层直觉
    1. 投票算术(第一层):多数表决要能过滤谎话,必须让真话严格多于假话,即忠诚者 $N-f$ 要大于叛徒 $f$,给出 $N > 2f$。这解释了下界至少是 $2f+1$。
    2. 转述链的”影子”(第二层,这才是 $3f$ 的来源):$OM(m)$ 的递归让每个中尉转述自己听到的值,于是叛徒在每一层都能撒谎。最坏情况下,一个叛徒可以让一组忠诚者相信”指挥官说的是 0”,同时让另一组相信”指挥官说的是 1”——两组人各自看到的世界都自洽,只是相差”一个叛徒的影子”。要防住它,忠诚者必须有能力在两种可能的世界里都占多数:$N-f$ 个忠诚者要同时压过”$f$ 个真叛徒”和”$f$ 个被污染的转述”,即 $N-f > 2f$,也就是 \(\boxed{\,N \ge 3f+1\,}\)
    3. 必要性:$N \le 3f$ 时,可以把将军们划分成三组(每组不超过 $f$ 个),并构造两个对所有忠诚者”看起来一样”的场景:一个场景里”指挥官忠诚、某一组是叛徒”,另一个场景里”指挥官是叛徒、另一组是叛徒”。两组忠诚者被要求做出不同决定,而他们看到的消息完全相同 ⇒ 矛盾。因此 $N \ge 3f+1$ 既是充分条件也是必要条件
  • 关键假设与系统模型:$OM(m)$ 要求同步(每轮在有限时间内完成)与完全图(任意两人可直接通信)。它同时满足 IC1 与 IC2,消息复杂度却是 $O(N^{f+1})$ 级别(见 15.3.4)——叛徒越多,要交换的”证据”就呈指数爆炸,这正是 PBFT 用三个阶段把复杂度压回 $O(N^2)$ 的动机。

15.2.14 签名消息:用密码学把成本从三倍降到加一

  • 定义与目的:如果消息带不可伪造的签名(unforgeable signature),叛徒就无法伪造别人说过的话,也无法篡改别人发过的内容。于是忠诚的将军可以把”指挥官亲笔签名的命令“当作证据转发给其他将军:一个自相矛盾的指挥官会被当场识破。讲义与论文的结论是:签名消息下 $N \ge f+2$ 即可(即只需要”忠诚者能互相核对证据”,而不是”忠诚者是叛徒的三倍”)。更强的形式化定理甚至允许叛徒数量任意多,只要忠诚将军之间能够通信并验证签名。直观类比:口头消息像”传话游戏”,签名消息像”书面合同”——传话会被歪曲,合同不能。

  • 机制图解(签名如何识破一个自相矛盾的指挥官)

   没有签名(N=3,f=1):                    有签名(N=3,f=1):
   C 对 p1 说 1, 对 p2 说 0              C 对 p1 签"1"、对 p2 签"0"
        │                                     │
   p1 转述"1"给 p2, p2 转述"0"给 p1      p1 把 (1, 签名C) 转发给 p2
        │                                     p2 把 (0, 签名C) 转发给 p1
        ▼                                     ▼
   p1 看到 [1, 0] 平局 => 无从判断        p1 看到 C 签名的 1 和 0 两份命令
   p2 看到 [0, 1] 平局 => 无从判断        => 直接判定「C 是叛徒」=> 回退默认值
   (每个将军都可能是叛徒)                p2 同理 => 两人一致 ✔
  • 重要的范围界定(很多教材讲错的地方)
    1. $N \ge f+2$ 是”拜占庭将军问题(同步、口头/书面消息)”这一形式化下的结论
    2. 在异步/部分同步的 BFT 共识里,签名并不能把副本数降到 $f+2$:$N \ge 3f+1$ 依然是必需的下界(PBFT、Tendermint、HotStuff 都用 $3f+1$)。原因是异步下还需要quorum 交集来保证安全性(15.2.15),签名只能防伪造,不能提供”何时可以安全推进”的信息。
    3. 签名的代价是密钥基础设施(PKI)+ 计算开销:签名/验签通常比 MAC 慢几个数量级。因此 PBFT 在副本之间用 MAC(消息认证码)、只在客户端回复等场合用签名,而现代 BFT(HotStuff)则通过门限签名(threshold signature)把 $O(N^2)$ 的消息压缩成 $O(N)$ 的证书。

15.2.15 PBFT:把拜占庭容错做成”可用”的三阶段协议

  • 定义与目的:$OM(m)$ 的指数复杂度使它无法实用。Castro 与 Liskov 在 OSDI 1999 提出的 PBFT(Practical Byzantine Fault Tolerance)三阶段(pre-prepare → prepare → commit)+ quorum 证书 + 视图变更把消息复杂度压到 $O(N^2)$,并能在部分同步网络(Internet)上运行。它是第一个”实用”的 BFT 协议,也是所有现代 BFT(Tendermint、HotStuff、Casper、Byzantine Paxos)的共同祖先。

  • 系统模型:$N = 3f+1$ 个副本(replica),最多 $f$ 个拜占庭故障;部分同步(安全性不需要同步假设,活性在同步窗口内保证);消息可能被篡改但不能伪造签名/MAC;副本通过 view(视图) 编号,每个视图有一个 primary(主副本),其余为 backup。客户端等待 $f+1$ 个不同副本的相同回复才认为请求完成(因为 $f$ 个副本可能撒谎,$f+1$ 个中至少有一个是诚实的)。

  • 机制图解(正常路径的三阶段)

   Client ── request(o, t, c) ──▶ Primary R0
                                    │
   ┌────────────────────────────────┴─────────────────────────────────┐
   │ ① PRE-PREPARE(v, n, D(m)): Primary 广播给所有 backup             │
   │    (v = 视图号, n = 序号, D(m) = 请求摘要)                        │
   └────────────────────────────────┬─────────────────────────────────┘
        ┌──────────┬────────────────┼────────────────┬──────────┐
        ▼          ▼                ▼                ▼          ▼
      ┌────┐    ┌────┐          ┌────┐           ┌────┐     ┌────┐
      │ R1 │    │ R2 │   ...    │ Rf │    ...    │R3f │     │ R0 │
      └─┬──┘    └─┬──┘          └─┬──┘           └─┬──┘     └─┬──┘
        │ ② PREPARE(v, n, D(m), i): 每个副本广播给其他所有副本
        └─────────┴────────────────┴────────────────┴──────────┘
        ▼
   当副本 i 拥有: 1 条匹配的 PRE-PREPARE + 2f 条匹配的 PREPARE(来自不同 backup, 含自己)
   => 进入 prepared 状态  (这份 2f+1 个副本的"prepared 证书"是安全性的核心)
        │
        │ ③ COMMIT(v, n, i): 每个副本广播给其他所有副本
        ▼
   当副本 i 收到 2f+1 条匹配的 COMMIT(含自己) => committed-local
   => 执行请求, 把结果直接回复给 Client
        │
        ▼
   Client: 收到 f+1 个不同副本的相同回复 => 请求完成 ✔
   ★ 任意两个 2f+1 的 quorum 至少相交 f+1 个副本, 其中至少 1 个是诚实的
     => 两个冲突的请求不可能都拿到 prepared 证书 => 安全性
  • 视图变更(View Change):如果 primary 是拜占庭的(例如它故意不给某个请求分配序号、或对不同副本发不同的 pre-prepare),backup 会通过超时发起视图变更:每个副本广播 VIEW-CHANGE(v+1, 最近稳定检查点, 自己持有的 prepared 证书集合)新 primary 收集 $2f+1$ 条视图变更消息(构成 new-view 证书)后广播 NEW-VIEW,其中包含它必须重新提议的请求集合与对应的 PRE-PREPARE。副本验证后进入新视图。这一步就是 PBFT 里”活性被同步假设兜底”的地方:只有超时最终能生效(网络进入同步期)时,视图变更才会成功。

  • 关键假设与系统模型与对比

     Paxos / Raft(崩溃容错)PBFT(拜占庭容错)
    副本数$N \ge 2f+1$(多数即可)$N \ge 3f+1$
    故障模型crash-stop / crash-recovery任意行为(含撒谎、合谋)
    每请求消息数$O(N)$(Leader 广播 + 多数 ack)$O(N^2)$(每个副本广播给所有副本)
    quorum$\ge \lfloor N/2\rfloor+1$$\ge 2f+1$
    客户端确认1 个回复(Leader 的回复)$f+1$ 个不同副本的相同回复
    密码学通常不需要(可用 MAC/签名)需要(MAC / 签名 / 门限签名)
    同步假设部分同步(活性)部分同步(活性)

    一句话总结这个对比容忍更坏的故障,就要付出更多的副本与更多的消息——故障越”坏”(从沉默到撒谎),算法需要的”证据”就越多,因为你不能再相信任何单一消息,只能相信”足够多的独立证人”

15.2.16 现代 BFT 与区块链:共识的复兴

  • 定义与目的:PBFT 之后的二十年里,BFT 协议沿着两条路线演进:降低复杂度改变”信任来源”
    • Tendermint(2014–2018,Cosmos):PBFT 的简化变体,用 propose → prevote → precommit 三轮投票 + 锁定(locking) 规则,把”视图变更”融进正常路径;$N=3f+1$,消息 $O(N^2)$。
    • HotStuff(2019,Diem/Libra):用门限签名把 quorum 证书压缩成 $O(1)$ 大小、把通信从”全互连”变成”星型(Leader 收集)”⇒ $O(N)$ 消息复杂度(每次视图),并把视图变更做成”流水线(pipelining)”,成为现代 BFT 的主流骨架(DiemBFT、Aptos、Sui 等)。
    • Casper FFG / Ethereum:把 BFT 的”最终性(finality)”机制叠加在 PoS 之上。
    • Byzantine Paxos(Lamport, 2011):把 Paxos 的 acceptor 换成 $3f+1$ 个副本,展示”经典共识 + 拜占庭”的组合。
    • 区块链的共识(PoW / PoS):这是另一条路——不追求”用 quorum 精确地用多数压过少数”,而是用资源(算力)或权益(stake)加上概率安全性来达成一致:PoW 中”最长链”只有在攻击者掌握多数算力时才可能被改写,因此安全性是概率性的(需要等待若干个区块确认)。用经济激励(诚实比作恶更赚钱)+ 概率最终性,替代了 BFT 的”确定性最终性”——代价是吞吐低、延迟高(PoW 的能耗与确认时间),好处是开放网络(无需许可)下的成员管理问题被消解。
  • 机制图解(”确定性最终性” vs “概率最终性”)
   BFT 类 (PBFT/Tendermint/HotStuff):  确定性最终性
       quorum 证书一旦形成 => 该值【永远】不会被推翻 (安全性是逻辑必然)
       代价: 需要固定的成员集合(N=3f+1)、O(N^2) 或 O(N) 消息、对网络同步有依赖

   PoW / PoS (Nakamoto 共识):          概率最终性
       区块被"确认"k 次 => 被推翻的概率随 k 指数下降 (例如 2^-(k))
       代价: 吞吐低、延迟高、能耗/资本门槛; 好处: 开放成员、无需许可

15.2.17 安全性与活性的不对称:分布式系统设计的核心哲学

  • 定义与目的:Lecture 13 给出了两条”正确性”的定义:安全性(Safety)= “坏事永远不会发生”(例如:不会有两个进程决定不同的值);活性(Liveness)= “好事最终会发生”(例如:所有进程最终会决定)。FLP 告诉我们:在异步系统里这两者不可兼得(讲义原话:Consensus: Decisions (Liveness) and correct decisions (Safety) cannot both be guaranteed by any consensus protocol in an asynchronous distributed system)。
  • 工程上如何取舍(本讲最重要的实践结论)几乎所有真实系统都选择”安全性绝对保证 + 活性尽力而为”。原因是两条性质的失败后果不对称

     违反安全性违反活性
    后果两个副本决定不同的值 ⇒ 数据损坏、状态分叉、已确认的写入消失系统暂时无法写入/无法选主 ⇒ 暂时不可用
    可恢复性不可恢复(需要人工介入、可能永久损失数据)可恢复(网络恢复后自动继续)
    用户感知静默错误,最危险超时/报错,用户会重试
    因此工程上的选择绝不妥协:宁可不返回,也不返回错误结果尽力而为:用超时、重试、退避来最大化”最终能推进”的概率
  • 这正是 FLP 的”建设性”解读:FLP 不是一句”做不到”的判决,而是一张精确的代价清单: \(\text{异步} \wedge \text{确定性} \wedge \text{总终止} \;\Longrightarrow\; \text{一致性不可保证}\) 因此你要明确地放弃一样东西。Paxos/Raft 放弃的是”总终止”(在极端情况下无限等待 leader);Ben-Or 放弃的是”确定性与有界时间”(改成概率 1 终止);ZooKeeper/etcd 的运维手册放弃的是”少数派可用性”(分区时少数派不可写)。没有免费的午餐,但你可以选择买哪一份。

15.3 算法伪代码与正确性分析

本节给出六份算法:同步 Flooding 共识(讲义的同步解法)、Ben-Or 随机化共识(绕过 FLP 的随机化路径)、FLP 双价执行构造(把 15.2 的证明写成可执行的构造性伪代码)、拜占庭口头消息 OM(m)PBFT 三阶段协议、以及 Chandra–Toueg 的 $\Diamond S$ 故障检测器共识。每份都按”假设与系统模型 → 伪代码 → 逻辑解说 → 正确性论证(安全性/活性/合法性)→ 复杂度”五段展开。

算法 15.3.1:同步系统下的 Flooding 共识

假设与系统模型

  • 同步系统:所有进程按 round(轮次)推进,一轮的长度 » 最大消息传输延迟,因此”一轮之内的消息必然全部送达”;有超时机制,能判定”某个进程本轮没发消息”。
  • 故障模型crash-stop,至多 $f$ 个进程崩溃(崩溃后不再发送任何消息);崩溃发生在轮次边界(最坏情形可建模为”崩溃那一轮只发给部分人”)。
  • 通道:可靠、有向、完全图(每个进程能直接发给每个进程)。
  • 规模:$N$ 个进程,$f < N$;协议需要知道 $f$(否则不知道该跑几轮)。
  • 目标:$f+1$ 轮后,所有正确进程决定同一个值。

伪代码

# 进程 pi 的局部状态
#   Values_i : { 提议者 id -> 值 }, 自己目前知道的所有提议    (讲义: Values^r_i)
#   prev_i   : 上一轮开始时的 Values_i                       (讲义: Values^{r-1}_i)

初始化:
    Values_i <- { (i, v_i) }          # 讲义: Values^0_i = {} ; Values^1_i = { v_i }
    prev_i   <- {}

for r = 1 to f+1 do                    # 同步轮次; 轮长 >> 最大传输延迟
    # ---------- 步骤 1: 多播"上一轮新知道的值" ----------
    delta <- { (p,v) in Values_i : p not in prev_i }
    for each j != i do
        send (VALUES, r, delta) to pj                 # 讲义: multicast(Values^r_i - Values^{r-1}_i)

    # ---------- 步骤 2: 取回本轮消息并合并 ----------
    prev_i  <- Values_i                               # Values^r_i 成为下一轮的 Values^{r-1}
    for each (VALUES, r, V_j) received from pj do     # 同步保证: 本轮的存活者全部到达
        for each (p,v) in V_j do
            Values_i[p] <- v                      # 讲义: Values^{r+1}_i = Values^r_i U V_j
    # ---------- 步骤 3: 超时/崩溃处理 ----------
    if 本轮没有收到 pj 的任何消息 then
        mark pj as crashed;  continue                 # 同步模型下"没收到"是可判定的, 直接忽略
end for

# ---------- 决定 ----------
d_i <- v, where (p, v) in Values_i with the smallest proposer id p
decide(d_i)

算法逻辑解说(数值走一遍) 取 $N=3, f=1$,提议值 $p_0=1,\ p_1=0,\ p_2=0$;$p_0$ 在第 1 轮崩溃,且它只把消息发给了 $p_1$(最坏的定向丢失):

  • 第 1 轮:$p_0$ 把 $\{p_0{:}1\}$ 发给 $p_1$(没发给 $p_2$);$p_1$、$p_2$ 各自把值发给所有人。轮末:$Values_1=\{p_0{:}1,p_1{:}0,p_2{:}0\}$,$Values_2=\{p_1{:}0,p_2{:}0\}$——两人掌握的信息不同
  • 第 2 轮(= $f+1$):$p_1$ 多播自己的新值 $\{p_0{:}1\}$(这正是”上一步新知道的值”),$p_2$ 多播 $\{p_1{:}0\}$。轮末:两人都变成 $\{p_0{:}1,p_1{:}0,p_2{:}0\}$。
  • 决定:两人都取”编号最小的提议者 $p_0$”的值 ⇒ 都决定 1 ✔。
  • 如果只跑 $f=1$ 轮:$p_1$ 决定 1、$p_2$ 决定 0 ⇒ 一致性被违反。这就是”少一轮”的代价,也是 15.4.1 代码里实验 2a/2b 的对照。

正确性论证

  • Agreement(所有正确进程决定相同的值)——讲义给出的反证法,逐步展开:
    1. 反设两个正确进程 $p_i$、$p_j$ 在第 $f+1$ 轮结束时的值集合不同,取 $v \in Values_i \setminus Values_j$;
    2. $p_i$ 必然是在最后一轮(第 $f+1$ 轮)才收到 $v$ 的。否则(若它更早就知道 $v$),根据步骤 1 的规则,它会在它知道 $v$ 之后的第一轮把 $v$ 作为新值多播给所有人,$p_j$ 就会在那一轮收到 $v$;
    3. 既然 $v$ 是在最后一轮到达 $p_i$ 的,那么在最后一轮里,必定存在第三个进程 $p_k$ 把 $v$ 发给了 $p_i$,却没有发给 $p_j$——即 $p_k$ 在发完 $p_i$ 之后、发给 $p_j$ 之前崩溃了(这是唯一能让两个正确进程在同一轮收到不同消息的原因);
    4. 同理,$p_k$ 又必然是在倒数第二轮才从某个进程 $p_{k^{\prime}}$ 那里学到 $v$ 的,而 $p_{k^{\prime}}$ 也在那一轮崩溃了(否则 $p_k$ 与 $p_j$ 都会收到 $v$);
    5. 如此上推一轮一轮地推,每一轮都必须有一个不同的进程崩溃(已经崩溃的进程不可能在更早的轮次里发送消息,所以这些崩溃者互不相同),总共需要 $f+1$ 次崩溃;
    6. 但前提是至多 $f$ 个进程崩溃 ⇒ 矛盾。$\blacksquare$
  • Agreement 的推论:既然所有正确进程的最终值集合完全相同,而决定规则(”编号最小的提议者”)是该集合的确定性函数,所有正确进程必然得到同一个决定值 ✔。
  • Validity / Integrity(决定值是某个进程提议过的值):决定值来自 $Values_i$ 中的某个二元组 $(p,v)$,而 $(p,v)$ 一定源于 $p$ 自己的提议或在消息中传播而来的提议,因此必然是某个进程的提议值,不会凭空产生 ✔。另外,若所有进程都提议同一个值 $v$,则所有值集合都只含 $\{v\}$,直接决定 $v$ ✔(讲义口径的 Validity)。
  • Termination(每个正确进程最终决定):同步模型下每轮有固定的时间上界(轮长 > 最大延迟),因此每个正确进程必然完成 $f+1$ 轮并执行 decide;崩溃的进程不需要决定 ✔。注意这条论证依赖于同步假设与”$f$ 已知”——异步模型下”这一轮结束了”无法判定,这正是整个算法失效的地方。
  • 前提被破坏时会怎样(重要):如果实际崩溃数超过 $f$,$f+1$ 轮的归纳链条就不够长,可能出现两个正确进程最后掌握的值集合不同 ⇒ Agreement 失效。15.4.1 的实验 3 给出了 $f_{\text{假设}}=1$ 但实际崩溃 2 个进程时一致性被破坏的完整执行($p_1$ 决定 1,$p_2$ 决定 0)。

复杂度

  • 轮数(时间):$f+1$ 轮;每轮耗时 $O(\max\text{delay})$ ⇒ 总时间 $O(f \cdot d_{\max})$。
  • 消息数:每轮每进程向 $N-1$ 个进程各发 1 条 ⇒ 每轮 $O(N^2)$ 条,总共 $O(N^2 f)$ 条
  • 每条消息的载荷:$\delta$ 最多含 $N$ 个提议值 ⇒ 每轮传输 $O(N^3)$ 个值,总共 $O(N^3 f)$ 个值;若改为直接发全量集合,量级不变。
  • 空间:每进程 $O(N)$(保存自己的值集合)+ 每轮 $O(N)$ 的收发缓冲。
  • 改进空间:若只需容忍 $f$ 个崩溃且不要求”知道所有值”,可以把”集合”换成”当前最小值 + 计数”,把载荷降到 $O(1)$;但在最坏情况下仍需 $O(N^2 f)$ 条消息。

算法 15.3.2:Ben-Or 随机化共识(绕过 FLP 的路径之二)

假设与系统模型

  • 异步消息传递:没有时钟、没有超时;唯一的等待条件是”收到 $n-f$ 条消息”。由于没有时间概念,“消息还没到”与”消息永远不会到”不可区分——这正是 FLP 的温床。
  • 故障模型crash-stop,至多 $f$ 个进程崩溃,且要求 $n \ge 2f+1$(即正确进程严格占多数;拜占庭版本的阈值见本节末)。
  • 随机性:每个进程可以使用硬币。两种模型:
    • 私有硬币(private coin):每个进程自己抛,互不相关;
    • 共享硬币 / 公共硬币(shared / common coin):第 $r$ 轮所有进程拿到同一个随机位(可用门限签名或可验证秘密分享 VSS 实现——这本身是一个小型的同步原语,见 15.7 的陷阱说明)。
  • 目标以概率 1 终止的共识(而不是必然终止)——这是 FLP 允许的唯一”出逃口”。

伪代码

# 每个进程 pi 维护: est_i (当前的提议估计, 初值 = 自己的提议值 xi), decided_i (是否已决定)
# 阈值 (见下方推导):  D = f+1  (决定阈值)      A = 1  (采纳阈值)

for r = 1, 2, 3, ... do                                # 轮号是局部的, 没有全局轮次概念
    # ============ 阶段 1: 提议 (propose) ============
    for each j != i do send (EST, r, est_i) to pj
    wait until 收到 n-f 条 (EST, r, *) 消息              # 崩溃进程不发消息; 其余"慢消息"被无限延迟
    if 收到的 n-f 条 estimate 全部等于同一个值 v then
          pref_i <- v                                   # 「一致」才表态
    else  pref_i <- ⊥                                   # 不敢表态

    # ============ 阶段 2: 投票 + 决定 (vote / decide) ============
    for each j != i do send (PREF, r, pref_i) to pj
    wait until 收到 n-f 条 (PREF, r, *) 消息
    cnt(v) <- |{ 收到的 pref == v }|                     # 忽略 ⊥
    v* <- argmax_v cnt(v)                                # 得票最多的值
    if cnt(v*) >= D then                                # 门槛 1: 决定
          decided_i <- v* ; est_i <- v* ; decide(v*)
    else if cnt(v*) >= A then                           # 门槛 2: 采纳, 固定为下一轮提议
          est_i <- v*
    else                                                # 门槛 3: 没有任何支持
          # ============ 阶段 3: 抛硬币 (coin) ============
          if 使用共享硬币 then est_i <- COMMON_COIN(r)
          else                est_i <- 自己抛一枚硬币
          # 这一轮的"先例"就此作废, 用随机性打破对称性
    if decided_i then 停止参与 (或只转发自己的决定)
end for

一轮的结构(ASCII 图)

                     ┌───────────────────────────────────────────────────┐
                     │ 第 r 轮 (每个进程自己数轮号, 没有全局时钟)          │
                     └───────────────────────────────────────────────────┘
   ┌──────────────────────────────────────────────────────────────────────────┐
   │ ① 提议 PROPOSE: 广播 est_i  ──▶ 等待收齐 n-f 条                            │
   │       收到的 n-f 条全都 = v ?  ──是──▶  pref_i = v                        │
   │                                 └─否──▶  pref_i = ⊥  (不敢表态)            │
   ├──────────────────────────────────────────────────────────────────────────┤
   │ ② 投票 VOTE: 广播 pref_i  ──▶ 等待收齐 n-f 条                              │
   │       cnt(v) = 收到的 v 票数 (⊥ 不计)                                      │
   │         cnt(v) >= D = f+1  ──是──▶  ★ 决定 v  (并 est_i := v)             │
   │         cnt(v) >= A = 1    ──是──▶  est_i := v  (锁定, 下一轮继续提议 v)   │
   ├──────────────────────────────────────────────────────────────────────────┤
   │ ③ 抛硬币 COIN: 没有任何值得到支持   ──▶  est_i := 硬币(0/1)                │
   │       (私有: 各人自己抛 / 共享: 全轮同一个随机位)                          │
   └──────────────────────────────────────────────────────────────────────────┘
        │
        ▼   进入第 r+1 轮
   ★ 只要出现"所有正确进程的 est 相同"的局面, 下一轮必然全体决定
     概率性终止: 每轮 >= 2^-(n-f) 的概率进入该局面 => 轮数服从(重尾)几何分布

阈值是怎么来的(必须理解,否则只是背参数):设决定阈值为 $D$、采纳阈值为 $A$,它们要同时满足三条约束:

\[\underbrace{D-f \;\ge\; A}_{\text{传播性:决定必须能被别人看到}}\qquad \underbrace{D \;\le\; n-f}_{\text{可行性:阶段 1 一致时能看到 } n-f \text{ 张票}}\qquad \underbrace{D \ge 1,\ A \ge 1}_{\text{非平凡}}\]
  • 传播性为什么是 $D-f \ge A$:设正确进程 $p$ 决定 $v$,它在阶段 2 收到 $\ge D$ 张 $v$ 票。任何其他正确进程 $q$ 只收 $n-f$ 条消息,最多漏看 $f$ 条(把最慢的 $f$ 个发送者当成崩溃),因此 $q$ 至少看到 $D-f$ 张 $v$ 票。要让 $q$ 也把 $v$ 固定下来(否则两人的估计会分叉,未来可能决定出不同的值),就必须有 $A \le D-f$。
  • 在 $n \ge 2f+1$ 下的最小选择:取 $\boxed{D = f+1,\ A = 1}$。此时 $D = f+1 \le n-f \iff n \ge 2f+1$ ✔,且 $D - f = 1 = A$ ✔。
  • 为什么阶段 1 用”一致(unanimity)”而不是”多数(majority)”:这是本算法最容易被忽视的一处设计。若阶段 1 用”严格多数”,那么在实际崩溃数 $c < f$ 时(存活进程多于 $n-f$,各进程看到的子集不同),两个不同的值可能同时各获多数,于是阶段 2 里一个进程会同时看到两个值的票,”该采纳哪一个”变得含糊,传播性论证随之失效;补救办法是把 $A$ 提高到 $A \ge n-D$,而这会要求 $2D \ge n+f$,把可容忍的 $f$ 压到 $f \le 2$($n$ 为偶数时)。改用”一致”可以严格证明至多一个值能在同一轮获得选票(见下面的不变式 I),从而让 $A=1$ 站得住。这是一个”阈值必须互相匹配”的典型例子:正确性不是来自某个孤立的参数,而是来自一组不等式的联合成立。

算法逻辑解说

  • 三个阶段的分工:阶段 1 试探”大家是否已经一致”阶段 2 把试探结果变成票,票足够多就锁定并决定阶段 3 只在”什么支持都没有”时启用,用随机性把所有人从”谁都不服谁”的僵局里拉出来。
  • 关键点是 的含义:它表示”我这轮不敢表态”,而不是”我反对”。这样设计的好处是:撒谎/模糊不会污染真正一致的候选值,一个值要么被大家共同确认(阶段 1 一致),要么就得不到任何票。
  • 执行示例(15.4.2 代码的真实输出,$N=3, f=1$,提议 $[1,0,0]$,$p_1$ 崩溃):
    第1轮: 估计=['0', 'X', '1']  pref=['.', 'X', '.']  硬币=各自翻转
    第2轮: 估计=['0', 'X', '0']  pref=['.', 'X', '.']  硬币=各自翻转
    第3轮: 估计=['0', 'X', '0']  pref=['0', 'X', '0']  硬币=各自翻转  => 全部决定 0

第 1 轮:$p_0$ 与 $p_2$ 的估计不同 ⇒ 双方都没有”一致”(pref = ⊥)⇒ 没有票 ⇒ 两人各自抛硬币,正好都抛到 0,于是第 2 轮两人估计相同 ⇒ 第 2 轮阶段 1 达成一致 ⇒ 第 3 轮阶段 2 双方都看到 $n-f=2 \ge D=2$ 张 0 票 ⇒ 一并决定 0。这里的决定值 0 来自硬币,但它在二值共识下并不违反合法性:只要硬币真的被用到,就说明两个值都曾被提议过(见下面的合法性论证)。

正确性论证(安全性:与随机性无关,绝对成立)

不变式 I(同一轮至多一个值获得选票):设 $Z_v$ 为本轮中”在阶段 1 为 $v$ 达成一致”的进程集合。若 $v \ne w$ 都有非空 $Z_v, Z_w$,则 $Z_v \cap Z_w = \emptyset$(一个进程的视角只能一致于一个值)。任一 $z \in Z_v$ 在阶段 1 收到的 $n-f$ 条 estimate 全为 $v$,故发送者中至少有 $n-f$ 个进程的估计是 $v$;同理至少有 $n-f$ 个进程的估计是 $w$。于是发送者总数 $\ge 2(n-f) \ge n+1 > n$,与实际发送者数 $\le n$ 矛盾。因此每轮至多一个值能拿到任何票。$\blacksquare$

Agreement($y_i = y_j$ 对所有正确进程成立)

  1. 由不变式 I,一轮中最多只有一个候选值 $v$ 能获得票。
  2. (传播) 若正确进程 $p$ 决定 $v$(看到 $\ge D = f+1$ 张 $v$ 票),则任何其他正确进程 $q$ 至多漏看 $f$ 个发送者,故至少看到 $1$ 张 $v$ 票;由 $A = 1$,$q$ 必定把 $est_q$ 固定为 $v$。因此”一个人决定”这个事实会在一轮之内强制传播给所有正确进程。
  3. (锁定) 若所有正确进程的估计都等于 $v$,则在下一轮阶段 1 中,每个正确进程收到的 $n-f$ 条 estimate 全为 $v$(崩溃进程不发消息)⇒ 全体达成一致 ⇒ 全体 $\text{pref}=v$ ⇒ 阶段 2 每人看到 $n-f \ge f+1 = D$ 张 $v$ 票 ⇒ 全体决定 $v$
  4. (不可逆) 一旦所有正确进程的估计被锁在 $v$,任何其他值 $w$ 都不可能再在阶段 1 达成一致(所有人的估计都是 $v$,只有崩溃进程”可能”持有 $w$,而它们不发消息),因此不可能有 $w$ 的票、也就不可能有人决定 $w$
  5. 由 1–4:任意两个正确的决定都必然都是 $v$。$\blacksquare$(注意:这条证明没有用到任何概率论,因此 Agreement 是”绝对”成立的——随机化只影响终止性。

Validity / Integrity

  • 若所有进程提议同一个值 $v$,则第 1 轮阶段 1 全体一致 ⇒ 全体 $\text{pref}=v$ ⇒ 每个进程看到 $n-f \ge f+1$ 张 $v$ 票 ⇒ 第 1 轮就决定 $v$ ✔。
  • Integrity(决定值是某个进程提议过的值)在二值共识下自动成立,理由分两步:
    1. 硬币只有在”提议出现了分歧”时才可能被用到。若所有正确进程都提议同一个值 $v$(崩溃进程的提议不影响它们是否发言),那么第 1 轮阶段 1 中每个正确进程收到的 $n-f$ 条估计全是 $v$(崩溃者不发消息),全体一致 ⇒ 全体 $\text{pref}=v$ ⇒ 每人看到 $n-f \ge f+1 = D$ 张 $v$ 票 ⇒ 第 1 轮就决定 $v$,根本轮不到硬币
    2. 既然如此,只要某轮真的抛了硬币,就说明两个值都曾(由正确的进程)被提议过,硬币无论取 0 还是 1 都落在”被提议过的值”里 ⇒ 决定值必然合法 ✔。 (注意:这条便利只属于二值共识。多值共识下硬币可能给出一个没人提议过的值,必须把硬币的取值范围限制在”已被提议的值集合”内——这是随机化共识实现中一个真实的 bug 来源。)

Termination 以概率 1 成立(活性分析)

  • 定义”收敛事件“:某一轮结束时,所有正确进程的估计相同。
    • 情形 (a):本轮没有任何进程在阶段 1 达成一致 ⇒ 所有正确进程都 $\text{pref}=\bot$ ⇒ 全部走硬币分支。若所有硬币取值相同(概率 $2 \cdot 2^{-(n-f)} = 2^{-(n-f)+1}$,私有硬币)则收敛;若使用共享硬币,则这一情形必然收敛
    • 情形 (b):某些进程为 $v$ 达成一致、其余为 $\bot$ ⇒ 前者估计为 $v$,后者取硬币。若所有硬币取值都等于 $v$(概率 $2^{-(n-f)}$)则收敛;若不然,下一轮估计是”$v$ 与 $\lnot v$ 的混合”,于是没有任何子集能达成一致 ⇒ 退化成情形 (a),再一轮即可用共享硬币收敛。
  • 因此每一轮至少以 $p = 2^{-(n-f)}$ 的概率进入”全体估计相同”的状态,而一旦进入该状态,下一轮必定全体决定(见 Agreement 第 3 条)。由于各轮硬币独立,决定所需轮数被参数为 $p$ 的几何分布随机占优: \(\mathbb{E}[\text{轮数}] \;\le\; \frac{1}{p} \;=\; 2^{\,n-f},\qquad \Pr[\text{永不终止}] \;=\; \lim_{k \to \infty}(1-p)^k \;=\; 0\) ⇒ 以概率 1 终止,且并非”某一轮之后必然终止”——任何有限轮之后都还残留 $(1-p)^k > 0$ 的不终止概率。这正是 FLP 的”最坏执行”在随机化算法里的化身。$\blacksquare$
  • 共享硬币的威力(实验结论):若每轮所有进程拿到同一个随机位,(a) 情形必然收敛、(b) 情形只需最多两轮 ⇒ 期望 $O(1)$ 轮。所以”私有硬币 vs 共享硬币”的差别不是常数因子,而是 $2^{\Theta(n)}$ 与 $O(1)$ 的差别:15.4.2 的实验里 $N=7, f=2$ 时私有硬币平均 14.77 轮、最长 79 轮(重尾),共享硬币平均 1.93 轮、最长 2 轮。

复杂度

  • 消息:每轮 2 个阶段 × $n$ 个进程各广播 $n-1$ 条 ⇒ $O(n^2)$ 条/轮(可用”只发给 $n-f$ 个进程再加转发”优化到 $O(n \cdot f)$,但复杂度量级仍是二次)。
  • 轮数:私有硬币期望 $O(2^{n-f})$、最坏无界;共享硬币期望 $O(1)$、最坏无界(这正是 FLP 的印记)。
  • 空间:每进程 $O(n)$(保存本轮收到的估计/票)。
  • 通信量随规模:$O(n^2)$ 每轮 × 期望轮数 —— 私有硬币下随规模指数爆炸,因此只在 $n$ 很小时实用;共享硬币则给出实用的 $O(n^2)$。
  • 拜占庭版本:把阈值换成 $A = f+1$、$D = 2f+1$,并要求 $n \ge 3f+1$(此时 $D \le n-f$ ✔,传播性 $D - f = f+1 = A$ ✔)。

算法 15.3.3:FLP 双价执行构造(把证明写成可执行的构造)

假设与系统模型

  • 异步消息传递;确定性协议 $P$(可把它看成一个纯函数:配置 × 事件 → 配置);故障模型为”至多一个进程崩溃,且崩溃者由构造者选择“;网络 = 全局消息缓冲区,投递时机由构造者控制。
  • 这不是一个”运行在真实系统上的协议”,而是一段分析性伪代码(analytical pseudocode):它是 FLP 定理的构造性证明,同时也是”对手如何逼出一个不终止执行”的算法说明。

伪代码

构造 (对手视角): 输入 = 确定性共识协议 P, 进程数 N, f = 1
输出: 一条无限执行, 使所有进程永不决定

# ---- 准备: 由引理 2 得到一个双价的初始配置 ----
C <- 由引理 2 的存在性论证给出的双价初始配置
choose p_faulty <- "输入翻转导致跳变的那一个进程"      # 破产的候选: 引理 2 中的 p
mark p_faulty as crashed                               # p_faulty 从此不再执行任何事件

# ---- 主循环: 每一步都保持在"双价"上 ----
loop forever:
    E <- { 所有当前可应用于 C 的事件 }                 # 缓冲区里所有消息的投递 (以及 null 事件)
    found <- false
    for each e in E do                                 # 尝试每一个事件, 找一个"保持双价"的
        if 从 (C + e) 出发仍可到达"决定 0"的执行
           and 从 (C + e) 出发仍可到达"决定 1"的执行 then
              C <- C + e                               # 引理 3 保证这样的 e 一定存在
              deliver(e)                               # 真的把这条消息投递给它的接收者
              found <- true
              break
    if not found then
        # 引理 3 断言这永远不会发生: D = e(C) 中必有双价配置
        abort "与引理 3 矛盾"

# ---- 结果 ----
# 循环永不退出; 途中每个配置 C 都是双价
# => 这条执行上没有任何进程决定过任何值
# => 违反 Termination
# => 不存在同时满足 Agreement + Validity + Termination 的确定性异步协议  ∎

算法逻辑解说

  • 伪代码里的关键是那个 for 循环:它枚举”当前缓冲区里可投递的消息”,并故意挑选那些’投递之后仍然双价’的消息。这对应 FLP 引理 3 中”总存在一个事件 $e$ 使得 $D=e(\mathcal{C})$ 里还有双价配置”的断言。
  • deliver(e) 就是真实的”把消息投递出去”:构造出的执行是合法的——它遵守异步模型(消息可以任意延迟,因此”投递哪条、什么时候投递”完全合法),且只有一个进程崩溃。
  • 注意 found 永远不会是 false:这正是引理 3 的内容;换言之,”永远留在双价分支上”是结构性的,不是运气。

正确性论证(构造的正确性 = FLP 定理的证明)

  • 引理 2(起点存在):见 15.2.8。给出双价初始配置 $C_0$,并确认”翻转输入的那一个进程”可以作为那个唯一的崩溃者。
  • 引理 3(可无限延伸):见 15.2.9。它保证每轮迭代都能找到保持双价的事件 $e$,分”$p^{\prime} \ne p$(用引理 1 交换性)”与”$p^{\prime} = p$(用’$p$ 全程沉默的有限已决定执行’)”两种情形证明。
  • 不变式:主循环的每一步之后,$C$ 都是双价配置。
  • 结论:由不变式,$C$ 是双价的含义是”从 $C$ 出发还能走到决定 0、也能走到决定 1”,特别地,这条执行上没有任何进程已经决定过任何值(一旦有人决定 $v$,该配置就只能走到 $v$,与双价矛盾)。于是这条无限执行永远不决定——终止性被违反,定理得证。$\blacksquare$

复杂度:不存在(这是一个不终止的构造);有意义的是它的信息论含义:对手只需控制”消息投递顺序”这一件事,就能让任意确定性协议失效——不需要伪造消息、不需要超过一个故障

算法 15.3.4:拜占庭口头消息算法 OM(m)

假设与系统模型

  • 同步系统(每一轮的递归调用都在有限时间内完成,收不到即视为默认值)。
  • 拜占庭故障:至多 $m$ 个叛徒;口头消息(叛徒可以撒谎与矛盾,但不能伪造忠诚者说过的话);完全图。
  • 值域 $\{1=\text{ATTACK}, 0=\text{RETREAT}\}$,默认值为 RETREAT(Lamport 等的约定:拿不定主意就撤退)。
  • 目标:IC1(所有忠诚将军决定相同的值)与 IC2(指挥官忠诚时,忠诚将军服从它)。

伪代码

majority(v1, ..., v_{n-1}) :                       # 多数函数, 平局取默认值
    if 存在值 v 出现次数 > (n-1)/2 then return v
    else return DEFAULT (RETREAT)

OM(0) —— 指挥官 c 直接把值发给每个中尉:
    c 把值 v 发给每个中尉
    每个中尉 i: v_i <- 收到的值 (若 c 叛变, v_i 可能是任意值; 收不到则 DEFAULT)
    return (v_i 为中尉 i 的决定)

OM(m) —— m > 0, 指挥官 c, 中尉集合 L = { l_1, ..., l_{n-1} }:
    步骤 1: c 把值 v 发给每个 l in L           # c 是叛徒时, 可以对不同人发不同的值
    步骤 2: for each l in L:
                v_l <- 步骤 1 中 l 收到的值 (收不到 => DEFAULT)
                l 作为指挥官, 对 L \ {l} 中的每个 l' 执行 OM(m-1), 发送值 v_l
                # 即: l' 会从"l 主持的 OM(m-1)"中拿到一个值 (记作 从 l' 视角看到的 v_l)
    步骤 3: for each l in L:
                values_l <- { v_l } U { 步骤 2 中 l 从其他中尉 l' 那里得到的值 }
                decide_l <- majority(values_l)          # 注意 values_l 有 n-1 项
    return (decide_l 为中尉 l 的决定)

算法逻辑解说($N=4, f=1$ 走一遍)

  • 指挥官 $C$ 忠诚,下令 ATTACK(1);中尉 $p_3$ 是叛徒。
  • 步骤 1:$C$ 对三个中尉都说 1 ⇒ $v_{p_1}=v_{p_2}=v_{p_3}=1$。
  • 步骤 2:每个中尉作为指挥官执行 $OM(0)$:$p_1$ 对 $p_2,p_3$ 说 1;$p_2$ 对 $p_1,p_3$ 说 1;$p_3$(叛徒)对 $p_1,p_2$ 说 0。
  • 步骤 3:$p_1$ 的 $values=[1(\text{来自 }C), 1(p_2), 0(p_3)]$ ⇒ 多数 1 ⇒ 决定 ATTACK ✔;$p_2$ 同理决定 ATTACK ✔。IC1、IC2 同时成立。
  • 对照 $N=3, f=1$(同样的场景):$p_1$ 的 $values=[1(\text{来自 }C), 0(p_2)]$ ⇒ 1:1 平局 ⇒ 默认 RETREAT ⇒ 违反 IC2。多出来的那一个忠诚中尉就是”打破平局”的那一票

正确性论证

  • 归纳的骨架(先记住这条统一的计数引理):$OM(m)$ 的证明对 $m$ 归纳(基础 $m=0$:不存在叛徒,故指挥官必然是忠诚的,$OM(0)$ 显然满足 IC1/IC2)。归纳步中,每个忠诚中尉的决定都是一次对 $n-1$ 个值的多数表决,而这 $n-1$ 个值可分成两部分:
    • 共同部分:来自忠诚中尉主持的子协议(以及自己从忠诚指挥官那里收到的值)。由归纳假设($OM(m-1)$ 满足 IC1/IC2),对所有忠诚中尉来说这一部分完全相同,其大小 $\ge n-1-m$;
    • 可变部分:来自至多 $m$ 个”可能撒谎”的条目($\le m-1$ 个叛徒中尉转述的值 + 自己从叛徒指挥官那里听来的值),其大小 $\le m$。 由于 $n > 3m \Rightarrow n > 2m+1 \Rightarrow n-1-m > m$,共同部分本身就构成严格多数,多数表决的结果只由共同部分决定 ⇒ 所有忠诚中尉得到同一个多数值(任何”杂音票”都不足 $(n-1)/2$ 张,改变不了结论)。下面两种情形只是这条引理的两个应用:
  • IC2(指挥官忠诚):忠诚的指挥官把 $v$ 发给所有中尉,每个忠诚中尉的 $v_l = v$。在步骤 2 中,每个忠诚中尉 $l$ 用它拿到的正确值 $v$ 主持 $OM(m-1)$;由归纳假设($l$ 是该子协议的忠诚指挥官,适用 IC2),每个忠诚中尉 $l^{\prime}$ 从 $l$ 的子协议里得到的值都是 $v$ ⇒ 共同部分全部是 $v$,且大小 $\ge n-1-m > m \ge$ 可变部分 ⇒ $majority$ 返回 $v$ ⇒ 所有忠诚中尉决定 $v$ ✔。
  • IC1(所有忠诚者一致):若指挥官忠诚,直接由 IC2 得一致 ✔。若指挥官是叛徒,则叛徒中尉至多 $m-1$ 个:对每个忠诚中尉 $j$(在它自己主持的 $OM(m-1)$ 里它是忠诚指挥官),由归纳假设的 IC2 可知,任何忠诚中尉 $i$ 从 $j$ 那里得到的值都恰好是 $v_j$——对所有忠诚的 $i$ 而言都是同一个 $v_j$,这正构成上面的”共同部分”;因此所有忠诚中尉的多数表决都由共同部分决定 ⇒ 他们得到相同的决定值 ✔。$\blacksquare$
  • IC2 的必要性($N \le 3f$ 时的失败):见 15.2.13 的三种场景——场景 1 与场景 3 迫使算法在视角 $(1,0)$、$(0,1)$ 上分别输出 1 和 0;于是在场景 2(指挥官叛徒、两名中尉忠实)中两个人必然输出不同的值,违反 IC1。因此 $N \ge 3f+1$ 既是充分也是必要条件。$\blacksquare$

复杂度

  • 消息数:$T(0) = n-1$,$T(m) = (n-1) + (n-1)\cdot T(m-1)$ ⇒ \(T(m) \;=\; \sum_{k=1}^{m+1}\frac{(n-1)!}{(n-k-1)!} \;=\; O\!\left(n^{\,m+1}\right)\) 即对叛徒数 $m$ 呈指数级($m=f$ 时约 $O(n^{f+1})$)。这就是 $OM(m)$ 不能实用的原因,也是 PBFT 用三阶段 quorum 把复杂度压回 $O(n^2)$ 的动机。
  • 轮数(时间):$m+1$ 轮消息往返(递归深度 $m+1$)。
  • 空间:每个将军保存 $O(n^m)$ 级别的”转述历史”(因为要记住谁转述了什么)。

算法 15.3.5:PBFT 的三阶段协议与视图变更

假设与系统模型

  • $N = 3f+1$ 个副本,至多 $f$ 个拜占庭故障;部分同步(安全性不依赖同步,活性依赖最终同步);
  • 消息通道可靠(可能延迟、重排,但不会丢失/篡改);副本之间的消息用 MAC(共享密钥)或签名保护;
  • 副本按 view(视图) 编号 $v$,主副本 $\text{primary} = v \bmod N$;序号 $n$ 唯一标识每个请求的提交位置;$D(m)$ 是请求 $m$ 的摘要(digest)
  • 客户端:发送请求后等待 $f+1$ 个不同副本的相同回复才算完成。

伪代码

# 副本 i 的局部状态: v(视图号), n(序号), log, prepared 证书集合, committed 集合
# 常量: f = (N-1)/3 ; QUORUM = 2f+1

---------------- 正常路径 (primary = p) ----------------
upon 收到 client 的 request m at primary p:
    分配序号 n (单调递增), 广播 PRE-PREPARE(v, n, D(m)) 给所有副本
    # 注: PBFT 要求"同一 (v,n) 上只接受一个摘要", 否则视为主副本作恶

upon 副本 i 收到 PRE-PREPARE(v, n, d) from primary:
    if 摘要校验通过 and 未在 (v,n) 上接受过别的摘要 then
        记录该 pre-prepare; 广播 PREPARE(v, n, d, i) 给所有副本

upon 副本 i 收到 PREPARE(v, n, d, j) from 副本 j:
    记录;  if 已收集到 { PRE-PREPARE(v,n,d) } U { 2f 条来自不同副本的 PREPARE(v,n,d,*) }
    then 进入 prepared(v, n, d) 状态;  广播 COMMIT(v, n, i) 给所有副本
    # 「prepared 证书」= 1 条 pre-prepare + 2f 条 prepare = 2f+1 个副本的共同确认

upon 副本 i 收到 COMMIT(v, n, j) from 副本 j:
    记录;  if 收到 2f+1 条来自不同副本的 COMMIT(v, n, *) (含自己)
    then 进入 committed-local(v, n) 状态
        等待所有 n' < n 的请求都已执行 (保序) 后执行 m
        把结果 reply 直接发给 client

upon client 收到 f+1 个不同副本对同一 (n, 结果) 的相同 reply:
    请求完成 ✔      # f 个副本可能撒谎, 因此需要 f+1 个一致回复(其中至少 1 个诚实)

---------------- 视图变更 (primary 疑似故障) ----------------
upon 副本 i 超时 (primary 在同步窗口内没有推进):
    广播 VIEW-CHANGE(v+1, 最近稳定检查点, 自己持有的所有 prepared 证书)

upon 新 primary p' = (v+1) mod N 收到 2f+1 条 VIEW-CHANGE:
    构造 NEW-VIEW(v+1, V, O):
        V = 这 2f+1 条视图变更消息 (作为"新视图证书")
        O = 需要重新提议的请求集合 (由 V 中最新/最高的检查点与 prepared 证书决定)
    广播 NEW-VIEW(v+1, V, O); 对 O 中每个请求重新广播 PRE-PREPARE

upon 副本 i 收到合法的 NEW-VIEW(v+1, V, O):
    验证 V 的签名/摘要一致, 按 O 重新执行 prepare/commit 流程; 进入视图 v+1
    把已 prepared 但未 committed 的请求继续走完 => 活性恢复

算法逻辑解说

  • 三阶段各解决一个问题:PRE-PREPARE 定序(把请求绑定到唯一的 $(v,n)$)、PREPARE 确认”整个集群都看到了同样的定序”(防止主副本对不同副本说不同的话)、COMMIT 确认”足够多的副本已经准备好执行它”(防止视图切换时的”旧请求丢失/重复执行”)。
  • 为什么是 $2f+1$:任何两个 $2f+1$ 的 quorum 至少相交 $(2f+1)+(2f+1)-(3f+1) = f+1$ 个副本,其中至少有一个是忠诚的,于是”同一序号上出现过两个不同摘要”这件事会被这个忠诚副本发现并拒绝 ⇒ 安全性。
  • 为什么客户端要等 $f+1$ 个回复:$f$ 个副本可能是叛徒并合谋返回错误结果,$f+1$ 个一致回复里至少有一个来自忠诚副本,而忠诚副本只会返回它真正执行过的结果 ⇒ 客户端不会被骗。
  • 执行顺序:PBFT 是复制状态机(课程 Lecture 19/22),因此必须”按序号顺序执行”,这要求低序号请求的缺失会阻塞高序号请求(可用 checkpoint + 状态转移补齐)。

正确性论证

  • 安全性(Agreement,不依赖同步假设)
    1. 同一 $(v,n)$ 上不可能有两个不同的请求都进入 prepared 状态:进入 prepared 需要 $2f+1$ 个副本的 PREPARE(或 pre-prepare + 2f 个 PREPARE),两个不同的摘要各需要一个 $2f+1$ 的 quorum,二者相交 $f+1$ 个副本、其中至少一个忠诚,而忠诚副本在同一 $(v,n)$ 上只会接受一个摘要 ⇒ 矛盾。(主副本作恶试图”双重分配”会被 quorum 交集挡住。)
    2. 跨视图不冲突:视图变更时,新主副本必须提交 $2f+1$ 条视图变更消息(同样是一个 quorum),因此至少有一个忠诚副本的 prepared 证书被包含进来,任何”已经 prepared 的请求”都会被重新提议,不会被丢弃、也不会被替换成别的请求 ⇒ 已经 committed 的请求在所有副本上的执行结果不变。
    3. 客户端不会看到两个冲突的结果:它要求 $f+1$ 个一致的回复,而其中至少一个是忠诚副本的真实执行结果 ⇒ 两个不同的结果不可能都拿到 $f+1$ 个一致回复。
  • 活性(Liveness):在最终同步的窗口内,超时能够正确触发视图变更;由于 $N = 3f+1$ 且主副本轮转,最终会出现一个忠诚的主副本,它诚实定序、能收集到 quorum ⇒ 请求最终被提交并执行。注意:如果网络永远不进入同步期,PBFT 也可能永远卡在”不断视图变更”里——这正是 FLP 允许的那种”不终止”,而安全性始终未破。
  • Validity / 正确性:副本只执行客户端真正提交的请求;摘要校验使得被篡改的请求无法进入 prepared 状态;执行结果由副本真实的状态机计算得出 ⇒ 结果来源于真实请求 ✔。

复杂度

  • 消息复杂度:正常路径每请求 $O(N^2)$ 条消息(pre-prepare 广播 1 轮、prepare 与 commit 各 $N$ 个副本广播给 $N-1$ 个副本),视图变更为 $O(N^2)$(每副本广播一次视图变更 + 新主广播 NEW-VIEW)。
  • 时间:正常路径 3 个阶段 ≈ 3 个 RTT(再加客户端确认);视图变更额外 2 个 RTT 量级。
  • 密码学开销:$O(N^2)$ 次 MAC 校验(或签名校验);用门限签名可以把证书压缩到 $O(1)$,使 HotStuff 一类协议达到 $O(N)$ 消息/视图。
  • 规模瓶颈:$N = 3f+1$ 意味着每容忍 1 个拜占庭节点就要 3 个副本;$O(N^2)$ 使 PBFT 在 $N$ 超过几十时就不实用(这也是区块链采用”委员会抽样”来缩小 $N$ 的原因)。

算法 15.3.6:Chandra–Toueg 的 $\Diamond S$ 故障检测器共识(绕过 FLP 的路径之三)

假设与系统模型

  • 异步消息传递,但系统提供故障检测器 $\Diamond S$:强完备性(每个崩溃的进程最终被所有正确进程怀疑)+ 最终弱准确性(存在某个正确进程,最终不再被任何正确进程怀疑)。
  • 故障模型:crash-stop,多数进程正确($n \ge 2f+1$)。
  • 算法结构:轮转协调者(rotating coordinator) + 两轮消息。

伪代码

# 每个进程 pi: est_i (估计值), r (当前轮号, 从 1 开始), decided
# 常量: n, f, QUORUM = n - f

for r = 1, 2, 3, ...:
    c <- ((r - 1) mod n) + 1                     # 第 r 轮的协调者 (轮流当值)

    # ---------- 阶段 1: 所有人把估计报给协调者 ----------
    for each j != i do send (EST, r, est_i) to pj
    if i == c then
        wait until 收到 n-f 条 (EST, r, *) 消息      # 用 ◇S 判断谁已经崩溃, 不再等它
        v <- 收到的消息中"最新"的那个值              # 选择规则可任意(如按到达顺序取最后一条)
        for each j != c do send (COORD, r, v) to pj  # 协调者广播自己选定的值

    # ---------- 阶段 2: 采纳协调者的值并回执 ----------
    wait until 收到 (COORD, r, v) from pc, 或 ◇S 判定 pc 已崩溃
    if 收到 (COORD, r, v) then
        est_i <- v ;  send (ACK, r, i) to pc
    else
        est_i <- ⊥                                   # 协调者不可用, 本轮作废

    # ---------- 阶段 3: 协调者宣布决定 ----------
    if i == c then
        wait until 收到 n-f 条 (ACK, r, *) 消息
        decide(v) ;  for each j do send (DECIDE, r, v) to pj
    upon 收到 (DECIDE, r, v) from pc:
        est_i <- v ;  decide(v)
    # 某个进程在本轮决定后, 广播 (DECIDE) 让所有人一起决定(可靠广播)

算法逻辑解说

  • “轮转协调者”是活性的关键:即使当前协调者崩溃或不可用,下一轮换一个人来当,$\Diamond S$ 最终会停止怀疑某个正确的进程 $c^$;当轮次轮到 $c^$ 且它收集到 $n-f$ 个 ACK 时,决定就发生了。
  • “$n-f$ 个”而不是”$n$ 个”:这正是”不等待已崩溃进程”的体现——异步系统里我们分不清”慢”与”死”,但 $\Diamond S$ 的最终弱准确性保证了最终有一个正确的进程不会被无端怀疑,于是所有正确进程都能凑齐 $n-f$ 条、不会永远阻塞。
  • 一个具体节奏:$n=3, f=1$,若第 1 轮协调者 $p_1$ 崩溃,则第 1 轮无果(所有正确进程 $\text{est}=\bot$);第 2 轮协调者 $p_2$ 正常,收集到 2 个 EST(含 $p_3$),广播值,收到 2 个 ACK ⇒ 决定 ✔。

正确性论证

  • Agreement:设 $p_i$ 在第 $r$ 轮决定 $v$、$p_j$ 在第 $r^{\prime} \ge r$ 轮决定 $w$。
    • 若 $r^{\prime} = r$:两者看到的是同一轮协调者广播的同一个 $v$,且都要求 $n-f$ 个 ACK/EST;同一轮只有一个协调者、只广播一个值 ⇒ $v = w$ ✔。
    • 若 $r^{\prime} > r$:关键引理是”如果某个正确进程在第 $r$ 轮决定 $v$,那么到了第 $r+1$ 轮,所有正确进程的估计都已经是 $v$(或者它们的估计不足以选出别的值)“。理由:$p_i$ 决定 $v$ 说明至少 $n-2f \ge 1$ 个正确进程收到了 $v$ 并把它当作自己的估计($n-f$ 个 ACK 中至多 $f$ 个来自崩溃/故障者),而这些正确进程在后续各轮向协调者报告的估计都是 $v$;任何后续协调者收集 $n-f$ 个 EST 时,其中至少 $n-2f$ 个来自正确进程……严格论证需要对”协调者选择最新值”的规则做归纳(Chandra–Toueg 原文用”$v$ 会一直存活到被决定”的不变式),这里给出结论:由于固定的 $n-f$ 与 $f$ 的算术关系($n \ge 2f+1$),一个已经由某个正确进程决定的 $v$ 无法被后续协调者”洗掉” ⇒ $w = v$ ✔。
  • Validity:决定值一定来自某轮的 EST 消息,即某个进程的估计;而估计的初值是提议值,之后只会被”采纳某个已由协调者广播的值”覆盖——归纳可知决定值必然是某个进程提议过的值 ✔。
  • Termination(在 $\Diamond S$ 的最终保证下):设 $t$ 时刻之后 $\Diamond S$ 满足弱准确性,且存在正确进程 $c^$ 不再被怀疑;因为协调者轮转,$c^$ 最终会成为协调者;届时它收齐 $n-f$ 个 EST(坏消息被 $\Diamond S$ 排除/超时不再等待)、广播值、收齐 $n-f$ 个 ACK ⇒ 决定,并通过可靠广播让所有正确进程一起决定 ✔。注意这个”最终”是 $\Diamond S$ 提供的额外假设——正是它替换掉了 FLP 中被禁止的那个假设(”能区分慢与死”)。
  • 与 FLP 的关系(必须点明):FLP 说”没有故障检测器时不可能”。$\Diamond S$ 是一个比完美检测器弱、但比”什么都没有”强的额外假设。Lecture 5/6 已经告诉我们:在丢包网络里不可能同时保证完整性与准确性——所以 $\Diamond S$ 在真实系统中只能用”最终超时”近似实现,而超时的选取又回到”活性 vs 准确性”的取舍上。共识不可能被”免费”解决,只能被”收费”解决:费用就是你愿意相信的额外假设。
  • Chandra–Toueg 定理:$\Diamond S$ 是最弱的、在多数进程正确时足以解决共识的故障检测器;若允许多数进程故障,则需要 $\Diamond W$。Paxos/Raft 里的超时机制本质上就是 $\Diamond S$ 的一个工程实现:领导者等待多数派响应,超时就”怀疑”并重新选举。

15.4 代码示例与分布式实现

本节给出三个只用 Python 标准库、固定随机种子、可直接 python3 运行的模拟器,分别验证本章三种核心机制:同步模型下的 $f+1$ 轮 Flooding 共识异步随机化的 Ben-Or 共识、以及拜占庭将军的口头消息与签名消息算法

仿真约定(三个程序通用)

  • 用”轮次制 + 显式消息投递控制“来模拟分布式环境:每个进程维护自己的局部状态,消息通过一个”投递函数”送达(可以指定”某人崩溃后不再发送”或”某一轮只发给部分人”),没有任何共享内存
  • 崩溃模型是”故障即静默”:崩溃进程不再发送任何消息,这与真实 crash-stop 行为一致(对手可以让它”恰好在对某个人撒谎/漏发之后就死”);
  • 随机性一律用 random.Random(seed) 固定,输出可复现;
  • 每个程序都自带断言式校验(Agreement / Validity / IC1 / IC2),把”理论判决”变成”可见的通过/失败”。

15.4.1 同步 Flooding 共识模拟器

#!/usr/bin/env python3
"""
同步系统下的 Flooding 共识模拟器   (CS 425 Lecture 15 = 课程 Lecture 17)

系统模型
  * 同步系统: 所有进程按 round 同步推进, 一轮内消息必然送达 (round 长度 >> 最大传输延迟)
  * 故障模型: fail-stop (crash-stop); 崩溃进程从此不再发送
  * 通道可靠; 额外支持 "崩溃那一轮只把消息发给了部分人" 的定向丢失(用来演示假设被突破)

算法 (讲义 Consensus in Synchronous System)
  Values^0_i = {};  Values^1_i = {v_i}
  for round = 1 .. f+1:
      multicast(Values^round_i - Values^{round-1}_i)     # 只发"上一轮新知道"的值
      Values^{round+1}_i = Values^round_i
      for each Vj received: Values^{round+1}_i |= Vj
  decide = 取"编号最小的提议者"的值 (consistent minimum based on id, 而非最小数值)
"""

import random


def simulate(N, proposals, alive_until, partial_drop, rounds, verbose=True):
    """
    N               : 进程数
    proposals[i]    : 进程 i 的初始提议值 (0/1)
    alive_until[i]  : 进程 i 参与发送的最后一轮; None = 全程存活
    partial_drop    : {(i, r): {接收者}} 进程 i 在它崩溃的那一轮 r 只发给这些接收者
    rounds          : 实际执行轮数
    返回 (decisions, known) ; decisions[i] in {0,1,"CRASHED"}
    """
    known = [dict() for _ in range(N)]      # Values^r_i : {提议者 -> 值}
    prev = [dict() for _ in range(N)]       # Values^{r-1}_i
    for i in range(N):
        known[i][i] = proposals[i]

    def crashed(i, r):                      # 第 r 轮是否已经不再发送
        return alive_until[i] is not None and r > alive_until[i]

    for r in range(1, rounds + 1):
        # ---- 步骤 1: 每个存活进程多播"上一轮新知道的值" ----
        inbox = [[] for _ in range(N)]
        for i in range(N):
            if crashed(i, r):
                continue
            delta = {p: v for p, v in known[i].items() if p not in prev[i]}
            targets = list(range(N))
            if alive_until[i] == r and (i, r) in partial_drop:
                targets = sorted(partial_drop[(i, r)])     # 崩溃前只发给了这几个人
            for j in targets:
                if j != i:
                    inbox[j].append((i, delta))
        # ---- 步骤 2: 每个进程把收到的集合并入自己的 Values ----
        for j in range(N):
            if crashed(j, r):
                continue
            prev[j] = dict(known[j])
            for (_sender, delta) in inbox[j]:
                for p, v in delta.items():
                    known[j].setdefault(p, v)
        if verbose:
            cells = []
            for i in range(N):
                if crashed(i, r):
                    cells.append(f"p{i}=崩溃 ")
                else:
                    ids = "".join(str(p) for p in sorted(known[i]))
                    cells.append(f"p{i}:{{{ids}}}")
            print(f"    第 {r} 轮末 值集合(按提议者 id) " + "  ".join(cells))

    # ---- 步骤 3: 决定: 取自己知道的、编号最小的提议者的值 ----
    decisions = []
    for i in range(N):
        if alive_until[i] is not None:           # 中途崩溃过 => 故障进程, 不要求它决定
            decisions.append("CRASHED")
        else:
            decisions.append(known[i][min(known[i].keys())])
    return decisions, known


def run_case(title, N, proposals, alive_until, partial_drop, rounds):
    print("-" * 76)
    print(title)
    print(f"  N={N}  提议={proposals}  执行轮数={rounds}")
    print("  崩溃: " + (", ".join(
        f"p{i} 在第 {alive_until[i]} 轮崩溃"
        + (f"(只发给 {sorted(partial_drop[(i, alive_until[i])])})"
           if (i, alive_until[i]) in partial_drop else "")
        for i in range(N) if alive_until[i] is not None) or "无"))
    decisions, known = simulate(N, proposals, alive_until, partial_drop, rounds)
    correct = [d for d in decisions if d != "CRASHED"]
    agree = len(set(correct)) == 1
    print(f"  正确进程最终决定值 = {decisions}  =>  "
          f"{'Agreement 成立 ✔' if agree else 'Agreement 被违反 ✘'}")
    print()
    return agree


if __name__ == "__main__":
    random.seed(425)

    print("############ 实验 1: 崩溃数 <= f, 执行 f+1 轮  => 所有正确进程一致 ############\n")
    N, f = 6, 2
    proposals = [random.randint(0, 1) for _ in range(N)]
    alive = [None] * N
    alive[0] = 1                       # p0 第 1 轮崩溃
    alive[2] = 2                       # p2 第 2 轮崩溃
    partial = {(0, 1): {1}, (2, 2): {3}}
    run_case(f"实验 1: N={N}, f={f}, 执行 f+1={f+1} 轮", N, proposals, alive, partial, f + 1)

    print("############ 实验 2: 崩溃数仍 <= f, 但只执行 f 轮  => 失败 ############\n")
    N2, f2 = 3, 1
    run_case(f"实验 2a: N={N2}, f={f2}, 只执行 f={f2} 轮 (少跑一轮)",
             N2, [1, 0, 0], [1, None, None], {(0, 1): {1}}, f2)
    run_case(f"实验 2b: 同样的崩溃, 执行 f+1={f2 + 1} 轮 => 一致",
             N2, [1, 0, 0], [1, None, None], {(0, 1): {1}}, f2 + 1)

    print("############ 实验 3: 实际崩溃数 = f+1 > f  => f+1 轮也不够 ############\n")
    run_case("实验 3: N=4, 假设 f=1, 执行 2 轮, 但实际崩溃 2 个进程",
             4, [1, 0, 0, 0], [1, None, None, 2], {(0, 1): {3}, (3, 2): {1}}, 2)
    print("  说明: p0 的值只传给了 p3(第 1 轮崩溃), p3 又只把该值传给了 p1(第 2 轮崩溃),")
    print("        于是 p1 知道 1、p2 只能按它知道的最小编号提议者决定 0 —— 不一致。")
    print("        这正是讲义归纳证明中'每一轮各需要一个崩溃'的链条: f+1 轮只能容忍 f 个崩溃。\n")

    print("############ 实验 4: 随机批量试验 (f <= N-1, 执行 f+1 轮) ############\n")
    total, ok_cnt = 0, 0
    for trial in range(300):
        random.seed(1000 + trial)
        N4 = random.choice([3, 5, 7, 9])
        f4 = random.randint(0, N4 - 1)
        prop = [random.randint(0, 1) for _ in range(N4)]
        al = [None] * N4
        for i in random.sample(range(N4), f4):          # 崩溃进程及其崩溃轮次
            al[i] = random.randint(1, f4 + 1)
        part = {}
        for i in range(N4):
            if al[i] is not None:                        # 崩溃那一轮只发给一个随机子集
                part[(i, al[i])] = set(random.sample(
                    [j for j in range(N4) if j != i],
                    random.randint(0, N4 - 2)))
        dec, _ = simulate(N4, prop, al, part, f4 + 1, verbose=False)
        correct = [d for d in dec if d != "CRASHED"]
        total += 1
        ok_cnt += (len(set(correct)) == 1)
    print(f"  随机 {total} 次 (N in {{3,5,7,9}}, 0 <= f <= N-1, 崩溃那一轮随机丢消息, 执行 f+1 轮):")
    print(f"  Agreement 成立 {ok_cnt}/{total} 次  =>  "
          f"{'全部成功 ✔ (与定理一致)' if ok_cnt == total else '存在失败 ✘'}")
    print()

运行输出(真实运行结果)

############ 实验 1: 崩溃数 <= f, 执行 f+1 轮  => 所有正确进程一致 ############

----------------------------------------------------------------------------
实验 1: N=6, f=2, 执行 f+1=3 轮
  N=6  提议=[1, 0, 0, 1, 0, 0]  执行轮数=3
  崩溃: p0 在第 1 轮崩溃(只发给 [1]), p2 在第 2 轮崩溃(只发给 [3])
    第 1 轮末 值集合(按提议者 id) p0:{012345}  p1:{012345}  p2:{12345}  p3:{12345}  p4:{12345}  p5:{12345}
    第 2 轮末 值集合(按提议者 id) p0=崩溃   p1:{012345}  p2:{012345}  p3:{012345}  p4:{012345}  p5:{012345}
    第 3 轮末 值集合(按提议者 id) p0=崩溃   p1:{012345}  p2=崩溃   p3:{012345}  p4:{012345}  p5:{012345}
  正确进程最终决定值 = ['CRASHED', 1, 'CRASHED', 1, 1, 1]  =>  Agreement 成立 ✔

############ 实验 2: 崩溃数仍 <= f, 但只执行 f 轮  => 失败 ############

----------------------------------------------------------------------------
实验 2a: N=3, f=1, 只执行 f=1 轮 (少跑一轮)
  N=3  提议=[1, 0, 0]  执行轮数=1
  崩溃: p0 在第 1 轮崩溃(只发给 [1])
    第 1 轮末 值集合(按提议者 id) p0:{012}  p1:{012}  p2:{12}
  正确进程最终决定值 = ['CRASHED', 1, 0]  =>  Agreement 被违反 ✘

----------------------------------------------------------------------------
实验 2b: 同样的崩溃, 执行 f+1=2 轮 => 一致
  N=3  提议=[1, 0, 0]  执行轮数=2
  崩溃: p0 在第 1 轮崩溃(只发给 [1])
    第 1 轮末 值集合(按提议者 id) p0:{012}  p1:{012}  p2:{12}
    第 2 轮末 值集合(按提议者 id) p0=崩溃   p1:{012}  p2:{012}
  正确进程最终决定值 = ['CRASHED', 1, 1]  =>  Agreement 成立 ✔

############ 实验 3: 实际崩溃数 = f+1 > f  => f+1 轮也不够 ############

----------------------------------------------------------------------------
实验 3: N=4, 假设 f=1, 执行 2 轮, 但实际崩溃 2 个进程
  N=4  提议=[1, 0, 0, 0]  执行轮数=2
  崩溃: p0 在第 1 轮崩溃(只发给 [3]), p3 在第 2 轮崩溃(只发给 [1])
    第 1 轮末 值集合(按提议者 id) p0:{0123}  p1:{123}  p2:{123}  p3:{0123}
    第 2 轮末 值集合(按提议者 id) p0=崩溃   p1:{0123}  p2:{123}  p3:{0123}
  正确进程最终决定值 = ['CRASHED', 1, 0, 'CRASHED']  =>  Agreement 被违反 ✘

  说明: p0 的值只传给了 p3(第 1 轮崩溃), p3 又只把该值传给了 p1(第 2 轮崩溃),
        于是 p1 知道 1、p2 只能按它知道的最小编号提议者决定 0 —— 不一致。
        这正是讲义归纳证明中'每一轮各需要一个崩溃'的链条: f+1 轮只能容忍 f 个崩溃。

############ 实验 4: 随机批量试验 (f <= N-1, 执行 f+1 轮) ############

  随机 300 次 (N in {3,5,7,9}, 0 <= f <= N-1, 崩溃那一轮随机丢消息, 执行 f+1 轮):
  Agreement 成立 300/300 次  =>  全部成功 ✔ (与定理一致)

【代码做什么?】

  1. simulate() 用两个字典数组模拟每个进程的 $Values^r_i$ 与 $Values^{r-1}_i$;known[i] 记录”进程 $i$ 知道谁的提议值”,键是提议者 id(这样最后才能按 id 取”一致的最小值”)。
  2. 每一轮先让存活进程广播 $Values^{r}_i \setminus Values^{r-1}_i$(即”上一轮新知道的值”),再统一把收到的集合并入 known——顺序严格对应讲义伪代码的”先 multicast、再更新集合”。
  3. alive_until[i] 指定进程 $i$ 参与发送的最后一轮;partial_drop[(i,r)] 描述”$i$ 在第 $r$ 轮崩溃,只把消息发给了这几个人”,这正是 FLP/讲义归纳证明里最关键的”定向丢失”。
  4. 实验 1 用 $N=6, f=2$ 跑满 $f+1=3$ 轮:可以看到第 1 轮末 $p_2,\dots,p_5$ 还不知道 $p_0$ 的值(p2:{12345} 缺了 0),而第 2 轮就被 $p_1$ 补上了;第 3 轮所有人集合相同 ⇒ Agreement 成立
  5. 实验 2a 只跑 $f=1$ 轮(”少一轮”)⇒ $p_1$ 决定 1、$p_2$ 决定 0,一致性被违反;实验 2b 用同样的崩溃模式跑满 $f+1=2$ 轮 ⇒ 两人都决定 1。两行输出之差就是 $f+1$ 轮的价值。
  6. 实验 3 让实际崩溃数达到 $f+1=2 > f$(且 $p_0$ 的值只传给 $p_3$、$p_3$ 只传给 $p_1$)⇒ 即使跑满 $f+1$ 轮,$p_1$ 与 $p_2$ 仍然不一致:“至多 $f$ 个崩溃”这个前提一旦被打破,算法就不再保证任何东西。(若 $f \ge N$,则所有进程都可能崩溃、系统里已经没有”正确进程”,共识命题本身退化——因此”突破 $f$”这一情形才是可观测的失败边界,也是本实验要展示的东西。)
  7. 实验 4 做 300 次随机试验($N \in \{3,5,7,9\}$、$0 \le f \le N-1$、崩溃那一轮随机丢消息),Agreement 300/300 成立,与定理一致。

【分布式机制透视】

  • 同步性是靠”轮屏障”模拟的:每一轮里,所有存活进程都先发完、再统一收,这等价于”轮长 » 最大传输延迟”——真实同步系统里由硬件/总线保证,仿真里由循环结构保证。
  • “崩溃”被建模为”停止发送 + 不再更新状态”,而不是”进程对象被删除”:这很关键,因为存活进程仍然会(在它们的集合里)保留崩溃者此前发出的值——真实系统里也是如此(数据不会因为节点死掉而消失)。
  • “定向丢失”(partial_drop)是现实故障的忠实抽象:真实进程崩溃前往往已经发出了一部分消息(TCP 缓冲、异步发送队列),所以”对 A 发了、对 B 没发”是完全可能的。这正是让 $f+1$ 轮证明的链条”每一轮都需要一个新崩溃”得以实现的东西。
  • 决定规则用”最小编号提议者”而不是”最小值”:与讲义注释一致(consistent minimum based on say, id (not minimum value))。原因是决定值必须是值集合的确定性函数:只要两个人集合相同,按 id 取和按数值取都会一致;但按 id 取更”中立”,不会因为值域本身偏向某个数而引入偏差(在非二值共识里这一点很重要)。

【与理论的对应】

  • 代码中的 for r in range(1, rounds+1)delta 计算,逐行对应 15.3.1 伪代码的步骤 1/2;
  • 实验 2a 的失败正是 15.3.1 中 Agreement 归纳证明链条的第 5–6 步(”每一轮需要一个不同的崩溃,共需 $f+1$ 次”)被反向使用的样子:崩溃只有 1 个、轮数也只有 1,于是”$v$ 只到 $p_1$ 不到 $p_2$”的局面被保留到了最后;
  • 实验 3 说明终止性证明的前提($f$ 已知且真实)一致性证明的前提(最多 $f$ 个崩溃)是两条独立的假设,缺一条都不能推出结论。

15.4.2 Ben-Or 随机化共识模拟器

#!/usr/bin/env python3
"""
Ben-Or 随机化共识模拟器   (CS 425 Lecture 15 = 课程 Lecture 17)

系统模型
  * 异步消息传递: 没有时钟、没有超时; "收齐 n-f 条消息"是唯一的等待条件。
    仿真中: 崩溃进程一律视为"从头就静默"(最坏情况), 若实际崩溃数 c < f, 则存活进程多于
    n-f, 每个进程随机收集其中 n-f 条 —— 模拟"其余消息被判为慢、被无限延迟"。
  * 故障模型: crash-stop, 至多 f < N/2 个进程崩溃
  * 随机性: 私有硬币(每个进程自己翻) 或 共享硬币(每轮所有进程拿到同一个随机位)

算法 (Ben-Or 1983, 崩溃故障版, N >= 2f+1)
  第 r 轮:
    阶段1 提议: 广播 estimate; 收 n-f 条; 若收到的 n-f 条全部相同 = v => pref=v, 否则 pref=⊥
    阶段2 投票: 广播 pref; 收 n-f 条; cnt(v) = 收到的 v 票数(⊥ 不计):
                  cnt(v) >= f+1 => 决定 v;  否则 cnt(v) >= 1 => estimate=v;  否则转阶段3
    阶段3 硬币: 无足够支持时用硬币打破对称性, 以概率 1 收敛 (FLP 允许的唯一出路之一)
"""

import random
from collections import Counter


def benor_run(N, proposals, f, rng, use_shared_coin, crash_count=None,
              max_rounds=20000, trace_limit=0):
    """跑一次 Ben-Or。返回 (decisions, rounds_used_or_None, trace_lines)"""
    c = f if crash_count is None else crash_count
    crashed = set(rng.sample(range(N), c))          # 崩溃进程: 从头静默
    est = list(proposals)
    decided = {}
    lines = []
    rounds_used = None

    for r in range(1, max_rounds + 1):
        alive = [i for i in range(N) if i not in crashed]
        # ---------- 阶段 1: 提议 ----------
        pref = {}
        for i in alive:
            others = [j for j in alive if j != i]
            got = rng.sample(others, N - f - 1) if len(others) > N - f - 1 else others
            vals = ([est[i]] + [est[j] for j in got])[:N - f]
            pref[i] = vals[0] if len(set(vals)) == 1 else None      # 一致才采纳, 否则 ⊥
        # ---------- 阶段 2: 投票 + 决定 ----------
        coin = rng.randint(0, 1) if use_shared_coin else None
        for i in alive:
            others = [j for j in alive if j != i]
            got = rng.sample(others, N - f - 1) if len(others) > N - f - 1 else others
            votes = ([pref[i]] + [pref[j] for j in got])[:N - f]
            cnt = Counter(v for v in votes if v is not None)
            top = max(cnt.items(), key=lambda kv: (kv[1], -kv[0]), default=(None, 0))
            if top[1] >= f + 1:                                     # 决定阈值 D = f+1
                decided.setdefault(i, top[0])
                est[i] = top[0]
            elif top[1] >= 1:                                       # 采纳阈值 A = 1
                est[i] = top[0]
            else:                                                   # ---------- 阶段 3: 硬币
                est[i] = coin if use_shared_coin else rng.randint(0, 1)
        if trace_limit and r <= trace_limit:
            def fmt(i, v):
                return "X" if i in crashed else ("." if v is None else str(v))
            lines.append(f"    第{r}轮: 估计={[fmt(i, est[i]) for i in range(N)]}  "
                         f"pref={[fmt(i, pref.get(i)) for i in range(N)]}  "
                         f"硬币={'共享=' + str(coin) if use_shared_coin else '各自翻转'}"
                         f"{'  => 全部决定 ' + str(decided[alive[0]]) if all(i in decided for i in alive) else ''}")
        if all(i in decided for i in alive):                        # 所有正确进程都已决定
            rounds_used = r
            break

    decisions = {i: decided.get(i) for i in alive}
    return decisions, rounds_used, lines


def histogram(data, width=46):
    """按对数区间分桶, 便于看清'绝大多数很快 + 偶尔很久'的重尾形状"""
    buckets = [(1, 1), (2, 2), (3, 4), (5, 8), (9, 16), (17, 32), (33, 64), (65, 128), (129, 10 ** 9)]
    counts = {b: 0 for b in buckets}
    for d in data:
        for b in buckets:
            if b[0] <= d <= b[1]:
                counts[b] += 1
                break
    mx = max(counts.values())
    lines = []
    for b in buckets:
        if counts[b] == 0:
            continue
        label = f"{b[0]}" if b[0] == b[1] else (f"{b[0]}-{b[1]}" if b[1] < 10 ** 9 else f">={b[0]}")
        lines.append(f"    {label:>7} 轮 |{'#' * max(1, int(counts[b] / mx * width)):<{width}}| "
                     f"{counts[b]:>4} 次 ({counts[b] / len(data) * 100:4.1f}%)")
    return "\n".join(lines)


if __name__ == "__main__":
    print("################ 1) 单次运行全过程 (N=3, f=1, 私有硬币) ################\n")
    prop = [1, 0, 0]
    dec, ru, lines = benor_run(3, prop, 1, random.Random(7), False, trace_limit=40)
    print(f"  提议值={prop}  (X=崩溃进程, .=⊥ 未表态)")
    for ln in lines:
        print(ln)
    print(f"  => 决定: {dec}{ru}\n")

    print("################ 2) 共享硬币 (N=7, f=2) 全程 ################\n")
    dec2, ru2, lines2 = benor_run(7, [1, 0, 1, 0, 0, 1, 0], 2, random.Random(11), True, trace_limit=40)
    for ln in lines2:
        print(ln)
    print(f"  => 决定: {dec2}{ru2}\n")

    print("################ 3) Agreement / Validity 检验 (每配置 200 次, 私有硬币) ################\n")
    print(f"  {'N':>3} {'f':>3} {'f/N':>6} {'N-f':>5} {'Agreement':>11} {'Validity':>10} "
          f"{'平均轮数':>9} {'最长轮数':>9} {'未终止':>7}")
    for (N, f) in [(3, 1), (5, 1), (5, 2), (7, 2), (7, 3), (9, 2), (9, 4), (11, 5)]:
        agree = valid = trunc = 0
        rl = []
        for t in range(200):
            rg = random.Random(50_000 + 97 * t + N * 7 + f)
            proposals = [rg.randint(0, 1) for _ in range(N)]
            crash_count = rg.randint(f // 2, f)                 # 实际崩溃数 <= f
            dec, ru, _ = benor_run(N, proposals, f, rg, False, crash_count=crash_count)
            vals = list(dec.values())
            agree += (len(set(vals)) == 1)
            valid += all(v in proposals for v in vals)
            if ru is None:
                trunc += 1
            else:
                rl.append(ru)
        mr = sum(rl) / len(rl) if rl else float("nan")
        print(f"  {N:>3} {f:>3} {f/N:>6.2f} {N-f:>5} {agree:>8}/200 {valid:>7}/200 "
              f"{mr:>9.1f} {max(rl) if rl else 0:>9} {trunc:>7}")
    print("\n  Agreement 与 Validity 全程 200/200 成立 (安全性不依赖随机性);")
    print("  平均轮数随正确进程数 N-f 增大而迅速上升 —— 私有硬币下'所有硬币恰好相同'的概率是")
    print("  2^-(N-f)+1, 因此轮数分布是重尾的几何分布。\n")

    print("################ 4) 轮数分布直方图: 私有硬币 vs 共享硬币 (N=7, f=2) ################\n")
    for label, shared in [("私有硬币", False), ("共享硬币", True)]:
        data = []
        for t in range(400):
            rg = random.Random(9_000 + 13 * t)
            proposals = [rg.randint(0, 1) for _ in range(7)]
            _, ru, _ = benor_run(7, proposals, 2, rg, shared, max_rounds=4000)
            if ru:
                data.append(ru)
        print(f"  --- {label}: 平均 {sum(data) / len(data):.2f} 轮, 最长 {max(data)} 轮, "
              f"样本 {len(data)}/400 ---")
        print(histogram(data))
        print()

    print("################ 5) 共享硬币下逐步放大 N (每配置 200 次) ################\n")
    print(f"  {'N':>3} {'f':>3} {'平均轮数':>9} {'最长轮数':>9} {'Agreement':>11}")
    for (N, f) in [(4, 1), (7, 2), (10, 3), (13, 4), (16, 5)]:
        rl, agree = [], 0
        for t in range(200):
            rg = random.Random(777 + 31 * t + N)
            proposals = [rg.randint(0, 1) for _ in range(N)]
            dec, ru, _ = benor_run(N, proposals, f, rg, True, max_rounds=500)
            if ru:
                rl.append(ru)
            agree += (len(set(dec.values())) == 1 and all(v in proposals for v in dec.values()))
        print(f"  {N:>3} {f:>3} {sum(rl) / len(rl):>9.2f} {max(rl):>9} {agree:>8}/200")
    print("\n  结论: 共享硬币把'所有进程拿到同一个随机位'变成免费事件, 因此轮数降到常数级;")
    print("        这正是 Ben-Or 期望 O(1) 轮、但需要 common coin 这一额外同步原语的原因。")
    print()

运行输出(真实运行结果)

################ 1) 单次运行全过程 (N=3, f=1, 私有硬币) ################

  提议值=[1, 0, 0]  (X=崩溃进程, .=⊥ 未表态)
    第1轮: 估计=['0', 'X', '1']  pref=['.', 'X', '.']  硬币=各自翻转
    第2轮: 估计=['0', 'X', '0']  pref=['.', 'X', '.']  硬币=各自翻转
    第3轮: 估计=['0', 'X', '0']  pref=['0', 'X', '0']  硬币=各自翻转  => 全部决定 0
  => 决定: {0: 0, 2: 0}   共 3 轮

################ 2) 共享硬币 (N=7, f=2) 全程 ################

    第1轮: 估计=['1', '1', '1', 'X', 'X', '1', '1']  pref=['.', '.', '.', 'X', 'X', '.', '.']  硬币=共享=1
    第2轮: 估计=['1', '1', '1', 'X', 'X', '1', '1']  pref=['1', '1', '1', 'X', 'X', '1', '1']  硬币=共享=1  => 全部决定 1
  => 决定: {0: 1, 1: 1, 2: 1, 5: 1, 6: 1}   共 2 轮

################ 3) Agreement / Validity 检验 (每配置 200 次, 私有硬币) ################

    N   f    f/N   N-f   Agreement   Validity      平均轮数      最长轮数     未终止
    3   1   0.33     2      200/200     200/200       2.0        11       0
    5   1   0.20     4      200/200     200/200       6.3        34       0
    5   2   0.40     3      200/200     200/200       3.7        21       0
    7   2   0.29     5      200/200     200/200      11.3        49       0
    7   3   0.43     4      200/200     200/200       5.8        34       0
    9   2   0.22     7      200/200     200/200      38.1       245       0
    9   4   0.44     5      200/200     200/200       9.3        71       0
   11   5   0.45     6      200/200     200/200      15.2        74       0

  Agreement 与 Validity 全程 200/200 成立 (安全性不依赖随机性);
  平均轮数随正确进程数 N-f 增大而迅速上升 —— 私有硬币下'所有硬币恰好相同'的概率是
  2^-(N-f)+1, 因此轮数分布是重尾的几何分布。

################ 4) 轮数分布直方图: 私有硬币 vs 共享硬币 (N=7, f=2) ################

  --- 私有硬币: 平均 14.77 轮, 最长 79 轮, 样本 400/400 ---
          1 轮 |#############                                 |   30 次 ( 7.5%)
          2 轮 |###########                                   |   25 次 ( 6.2%)
        3-4 轮 |#####################                         |   47 次 (11.8%)
        5-8 轮 |###############################               |   69 次 (17.2%)
       9-16 轮 |########################################      |   88 次 (22.0%)
      17-32 轮 |##############################################|  100 次 (25.0%)
      33-64 轮 |################                              |   36 次 ( 9.0%)
     65-128 轮 |##                                            |    5 次 ( 1.2%)

  --- 共享硬币: 平均 1.93 轮, 最长 2 轮, 样本 400/400 ---
          1 轮 |###                                           |   30 次 ( 7.5%)
          2 轮 |##############################################|  370 次 (92.5%)

################ 5) 共享硬币下逐步放大 N (每配置 200 次) ################

    N   f      平均轮数      最长轮数   Agreement
    4   1      1.73         2      200/200
    7   2      1.92         2      200/200
   10   3      1.98         2      200/200
   13   4      2.00         2      200/200
   16   5      2.00         2      200/200

  结论: 共享硬币把'所有进程拿到同一个随机位'变成免费事件, 因此轮数降到常数级;
        这正是 Ben-Or 期望 O(1) 轮、但需要 common coin 这一额外同步原语的原因。

【代码做什么?】

  1. benor_run() 实现 15.3.2 的三个阶段:阶段 1 广播估计并检查”收到的 $n-f$ 条是否完全一致”,阶段 2 统计票数并按阈值 $D=f+1$(决定)、$A=1$(采纳)行动,阶段 3 在”毫无支持”时抛硬币。
  2. “收 $n-f$ 条”是这样模拟的others 是当前存活的其他进程;若存活者多于 $n-f$,就 rng.sample 随机挑 $n-f-1$ 个——这代表”其余消息被无限延迟”,也顺便让代码覆盖了”实际崩溃数 $c < f$”这一更宽松的情形(此时各进程看到的子集不同,正是传播性论证 $\ge D-f$ 起作用的地方)。
  3. 崩溃进程用 crashed 集合表示,从头就静默(最坏情况),因此它们不出现在任何 alive 列表里,也不会出现在别人的收件集合中。
  4. use_shared_coin=True 时,每轮用 rng.randint(0,1) 生成一个位并让所有进程使用它——这就是”公共硬币”的抽象(真实系统里由门限签名/VSS 实现)。
  5. 实验 1($N=3,f=1$,私有硬币)打印逐轮的 估计 / pref / 硬币:可以清楚看到”估计不一致 ⇒ 全体 $\bot$ ⇒ 抛硬币 ⇒ 恰好一致 ⇒ 下一轮决定”这条路径。
  6. 实验 3 对 8 组 $(N,f)$ 各跑 200 次,统计 Agreement(全部 200/200)与 Validity(全部 200/200),并给出平均/最长轮数——可以看到平均轮数随正确进程数 $N-f$ 迅速上升($N-f=7$ 时平均 38.1 轮、最长 245 轮)。
  7. 实验 4 用对数分桶直方图展示轮数分布:私有硬币呈典型重尾(7.5% 一轮就结束,但有 1.2% 需要 65–128 轮);共享硬币则在 1–2 轮内结束(92.5% 为 2 轮)。
  8. 实验 5 把 $N$ 从 4 放大到 16($f=(N-1)/3$ 附近),共享硬币下平均轮数始终在 2 左右——“期望 $O(1)$”不是口号,是可以量出来的

【分布式机制透视】

  • “异步”的模拟方式:没有全局轮次(每个进程自己数自己的轮),等待条件只有”收到 $n-f$ 条消息”;”慢消息”通过随机子集采样来体现。真实异步系统里,这个”慢”可能是 GC、网络重排、CPU 排队。
  • 崩溃与慢的不可区分性:崩溃进程不发消息,”幸存者集合”因此缩小;代码没有、也无法实现”检测谁死了”——这正是 FLP 的前提,也是算法只依赖 $n-f$ 计数的原因
  • 随机源的作用:私有硬币下每个进程独立抛硬币,因此”所有人恰好抛到同一边”的概率随 $n-f$ 指数下降;共享硬币把这一事件变成”要么全对、要么全错”的公共事件,于是收敛快得多——但代价是必须实现 common coin 这个额外原语(它本身需要一轮或多轮通信,这就是”没有免费午餐”的具体体现)。
  • 决定后继续参与:代码里已决定的进程仍保留 est 并继续投票(相当于把决定值当作自己的估计转发),这在工程上很常见(例如让决定”扩散”得更快);理论上决定后退出也正确,因为 Agreement 已经由前三步不变式保证。

【与理论的对应】

  • 逐行对应 15.3.2 伪代码;top[1] >= f+1 就是 $D$,top[1] >= 1 就是 $A$;
  • 200/200 的 Agreement/Validity 结果验证的是“安全性不依赖随机性”这一论断:无论硬币怎么翻,一致性都成立(不变式 I → 传播 → 锁定 → 不可逆);
  • 轮数分布验证的是终止性只能以概率 1 成立:存在极少数运行需要几十甚至上百轮(重尾),而理论上”任何有限轮之后都仍可能未终止”——这正是 FLP 在工程上的影子:随机化算法不保证有限时间终止,只是让它”几乎总是很快”;
  • 共享硬币把期望轮数从 $2^{\Theta(n)}$ 降到 $O(1)$,对应 15.3.2 里活性分析的两种情形。

15.4.3 拜占庭将军模拟器(OM(m) 与签名消息)

#!/usr/bin/env python3
"""
拜占庭将军模拟器: 口头消息 OM(m) 与签名消息 SM(m)   (CS 425 Lecture 15 = 课程 Lecture 17)

模型
  * 值: ATTACK=1 / RETREAT=0; 收不到消息时用默认值 RETREAT (Lamport-Shostak-Pease 的约定)
  * 口头消息: 叛徒可以任意撒谎、对不同人发矛盾的值, 但"转述"只能转述自己收到的值
    (不能凭空伪造一个忠诚者说过的话) —— 这正是口头消息模型的定义
  * 签名消息: 忠诚者给消息签名, 叛徒无法伪造别人的签名, 因此任何忠诚者都可以把
    "指挥官亲笔签名的命令"转给其他人作为证据

OM(m)  (Lamport, Shostak, Pease 1982)
  OM(0): 指挥官把值发给每个中尉; 中尉采用收到的值(收不到则默认值)
  OM(m), m>0: 1) 指挥官把值发给每个中尉, 中尉 i 记收到的值为 v_i
              2) 中尉 i 作为指挥官, 用 v_i 执行 OM(m-1), 发给其余 n-2 个中尉
              3) 中尉 i 对收到的全部值(含 v_i)取多数 majority(); 平局取默认值
  定理: 当 n > 3m 时 OM(m) 同时满足 IC1(忠诚者一致) 与 IC2(指挥官忠诚时大家服从)
"""

import itertools
import random

ATTACK, RETREAT = 1, 0
DEFAULT = RETREAT


def majority(vals):
    ones = sum(vals)
    if ones * 2 > len(vals):
        return ATTACK
    if ones * 2 < len(vals):
        return RETREAT
    return DEFAULT                              # 平局 => 默认 RETREAT


class Adversary:
    """叛徒的发送策略: table[(path, sender, recipient)] = 要说谎成的值"""

    def __init__(self, table=None):
        self.table = {} if table is None else dict(table)
        self.seen = set()                       # 记录被查询过的键 => 得到需要搜索的键空间
        self.record_only = table is None

    def send(self, path, sender, recipient, honest_value):
        self.seen.add((path, sender, recipient))
        if self.record_only:
            return ATTACK                       # 只用来枚举键空间, 取值任意
        return self.table.get((path, sender, recipient), honest_value)


def om(commander, cvalue, group, m, traitors, adv, path=(), log=None):
    """执行 OM(m); group = 中尉列表; 返回值 {中尉: 决定值}"""
    recv = {}
    for l in group:                                             # 步骤 1: 指挥官发言
        recv[l] = (adv.send(path + (commander,), commander, l, cvalue)
                   if commander in traitors else cvalue)
    if log is not None and len(path) <= 1:
        tag = "第1步: 指挥官" if not path else f"第2步: p{commander} 转述(作为其 OM 子协议的指挥官)"
        log.append(f"      {tag} p{commander} 对 " +
                   ", ".join(f"p{l}{recv[l]}" for l in group) +
                   ("   <== 对不同人说了不同的值(撒谎)!" if commander in traitors
                    and len(set(recv.values())) > 1 else ""))
    if m == 0:
        return dict(recv)
    sub = {}
    for l in group:                                             # 步骤 2: 中尉各自当指挥官
        sub[l] = om(l, recv[l], [x for x in group if x != l], m - 1,
                    traitors, adv, path + (commander,), log)
    out = {}                                                    # 步骤 3: 多数表决
    for l in group:
        vals = [recv[l]] + [sub[j][l] for j in group if j != l]
        out[l] = majority(vals)
        if log is not None and l not in traitors and not path:
            log.append(f"      根节点: 忠诚中尉 p{l} 收到的值 = {vals}  => 最终决定 {out[l]}")
    return out


def check(n, f, cmdr_traitor, lt_traitors, table, log=None):
    """跑一次, 返回 (是否同时满足 IC1 与 IC2, 忠诚中尉的决定)"""
    traitors = set(lt_traitors) | ({0} if cmdr_traitor else set())
    lieutenants = list(range(1, n))
    dec = om(0, ATTACK, lieutenants, f, traitors, Adversary(table), log=log)
    loyal = {l: dec[l] for l in lieutenants if l not in traitors}
    ic1 = len(set(loyal.values())) == 1                                  # 忠诚者必须一致
    ic2 = cmdr_traitor or all(v == ATTACK for v in loyal.values())       # 服从忠诚指挥官
    return (ic1 and ic2), loyal


def search(n, f, trials=2500, seed=425):
    """枚举所有「谁是叛徒」的布局: 键空间小则穷举撒谎策略, 否则随机搜索"""
    rng, checked = random.Random(seed), 0
    layouts = [(ct, combo) for ct in (False, True)
               for combo in itertools.combinations(range(1, n), f - 1 if ct else f)]
    for (cmdr_traitor, lt_tr) in layouts:
        adv = Adversary(None)
        om(0, ATTACK, list(range(1, n)), f, set(lt_tr) | ({0} if cmdr_traitor else set()), adv)
        keys = sorted(adv.seen)
        checked += 1
        if len(keys) <= 14:                       # 键空间小: 穷举所有撒谎策略
            tables = [dict(zip(keys, bits)) for bits in itertools.product([0, 1], repeat=len(keys))]
        else:                                     # 键空间大: 随机采样
            tables = [{k: rng.randint(0, 1) for k in keys} for _ in range(trials)]
        for idx, table in enumerate(tables, 1):
            ok, loyal = check(n, f, cmdr_traitor, lt_tr, table)
            if not ok:
                who = (f"指挥官{'叛徒' if cmdr_traitor else '忠诚'} + 叛徒中尉 "
                       + (",".join(f"p{x}" for x in lt_tr) if lt_tr else "无"))
                log = []
                check(n, f, cmdr_traitor, lt_tr, table, log=log)
                return checked, (f"  [{who}] 第 {idx}/{len(tables)} 个撒谎策略就破坏了共识 "
                                 f"(忠诚中尉决定 {loyal})\n" + "\n".join(log[:8]))
    return checked, None


def three_generals_exhaustive():
    """N=3,f=1 的穷举不可能性证明: 忠诚中尉的视角 = (指挥官说的值, 同伴说的值)"""
    views = [(c, p) for c in (0, 1) for p in (0, 1)]
    bad = 0
    for bits in itertools.product([0, 1], repeat=len(views)):
        D = dict(zip(views, bits))            # 一张确定性决策表就是一种算法
        sc1 = D[(1, 0)] == ATTACK             # 场景1: 指挥官忠诚下令 1, 叛徒谎报 0 => 必须服从
        sc3 = D[(0, 1)] == RETREAT            # 场景3: 指挥官忠诚下令 0, 叛徒谎报 1 => 必须服从
        sc2 = D[(1, 0)] == D[(0, 1)]          # 场景2: 指挥官是叛徒(对 p1 说 1、对 p2 说 0) => 两中尉必须一致
        bad += not (sc1 and sc2 and sc3)
    return 2 ** len(views), bad


def sm_demo(n, cmdr_traitor, lt_traitors):
    """签名消息 SM(1): 返回忠诚将军的决定"""
    traitors = set(lt_traitors) | ({0} if cmdr_traitor else set())
    lieutenants = list(range(1, n))
    V = {}                                                # 第 1 轮: 指挥官签名广播(可自相矛盾)
    for l in lieutenants:
        V[l] = {(ATTACK if (not cmdr_traitor or l % 2 == 1) else RETREAT, (0,))}
    V2 = {l: set(V[l]) for l in lieutenants}              # 第 2 轮: 每人转发自己收到的签名消息
    for l in lieutenants:
        if l in traitors:
            continue                                      # 叛徒选择沉默(最坏情况之一)
        for other in lieutenants:
            if other != l:
                for (val, sigs) in V[l]:
                    V2[other].add((val, sigs + (l,)))
    dec = {}
    for l in lieutenants:                                 # 决定: 只看「指挥官签名的值」
        vals = {v for (v, sigs) in V2[l] if 0 in sigs}    # 叛徒伪造的签名链里没有 p0, 被丢弃
        dec[l] = vals.pop() if len(vals) == 1 else DEFAULT  # 两个值 => 指挥官是叛徒 => 默认值
    return {l: dec[l] for l in lieutenants if l not in traitors}


if __name__ == "__main__":
    random.seed(425)
    print("############ 1) 口头消息 OM(m) 在 N = 3f+1 前后的成败 ############\n")
    for (n, f) in [(3, 1), (4, 1), (6, 2), (7, 2)]:
        checked, bad = search(n, f, trials=2500)
        if bad is None:
            print(f"  N={n}, f={f}: 检查了 {checked} 种叛徒布局 (小键空间穷举, 大键空间随机 2500 个策略)")
            print(f"    => 未发现任何反例 ✔   (N={n} >= 3f+1={3 * f + 1}, 满足定理条件)\n")
        else:
            print(f"  N={n}, f={f}: 找到反例 ✘   (N={n} < 3f+1={3 * f + 1}, 定理保证必然存在)")
            print(bad + "\n")

    print("############ 2) 三个将军一个叛徒: 穷举所有可能的确定性算法 ############\n")
    total, bad = three_generals_exhaustive()
    print("  把「忠诚中尉的决策」看成一个函数: 输入 = (指挥官告诉它的值, 同伴告诉它的值),")
    print(f"  输出 = 0/1, 一共有 2^4 = {total} 种确定性算法。逐一检验 IC1/IC2:")
    print(f"    => {bad}/{total} 种算法都至少违反 IC1 或 IC2 中的一条 ✘\n")
    print("  例如取 majority() 并让平局回退到 RETREAT, 看场景 1:")
    log = []
    ok, loyal = check(3, 1, False, (2,), {((0, 2), 2, 1): RETREAT}, log=log)
    for ln in log:
        print(ln)
    print(f"      忠诚中尉 p1 收到 [1, 0] 平局 => 决定 {loyal[1]}, 而忠诚指挥官下的是 ATTACK=1"
          f" => 违反 IC2 ✘")
    print("      若把平局改成取 ATTACK, 则值对称的场景 3(指挥官下令 0、叛徒谎报 1)会违反 IC2;")
    print("      若让两种视角都倒向同一侧, 则场景 2(指挥官是叛徒)的两名忠诚中尉必然不一致。\n")

    print("############ 3) 签名消息: 用密码学把 N >= 3f+1 降为 N >= f+2 ############\n")
    for (n, ct, lt, desc) in [(3, False, (2,), "N=3,f=1 指挥官忠诚、中尉 p2 是叛徒 (口头消息下违反 IC2)"),
                              (3, True, (), "N=3,f=1 指挥官是叛徒、对 p1/p2 说矛盾的话"),
                              (4, True, (3,), "N=4,f=2 < 3f+1=7: 指挥官叛徒 + 中尉 p3 叛徒"),
                              (5, True, (3, 4), "N=5,f=3: 指挥官叛徒 + 两个叛徒中尉")]:
        loyal = sm_demo(n, ct, lt)
        print(f"  {desc}\n    忠诚将军决定 = {loyal}  =>  "
              f"{'一致 ✔' if len(set(loyal.values())) == 1 else '不一致 ✘'}")
    print("\n  原因: 忠诚者可以把「指挥官亲笔签名的命令」当证据转发给其他人, 而叛徒无法伪造")
    print("        别人的签名; 于是自相矛盾的指挥官一定会被识破(大家回退到默认值)。")

运行输出(真实运行结果)

############ 1) 口头消息 OM(m) 在 N = 3f+1 前后的成败 ############

  N=3, f=1: 找到反例 ✘   (N=3 < 3f+1=4, 定理保证必然存在)
  [指挥官忠诚 + 叛徒中尉 p1] 第 1/2 个撒谎策略就破坏了共识 (忠诚中尉决定 {2: 0})
      第1步: 指挥官 p0 对 p1 说 1, p2 说 1
      第2步: p1 转述(作为其 OM 子协议的指挥官) p1 对 p2 说 0
      第2步: p2 转述(作为其 OM 子协议的指挥官) p2 对 p1 说 1
      根节点: 忠诚中尉 p2 收到的值 = [1, 0]  => 最终决定 0

  N=4, f=1: 检查了 4 种叛徒布局 (小键空间穷举, 大键空间随机 2500 个策略)
    => 未发现任何反例 ✔   (N=4 >= 3f+1=4, 满足定理条件)

  N=6, f=2: 找到反例 ✘   (N=6 < 3f+1=7, 定理保证必然存在)
  [指挥官忠诚 + 叛徒中尉 p1,p2] 第 1/2500 个撒谎策略就破坏了共识 (忠诚中尉决定 {3: 1, 4: 0, 5: 1})
      第1步: 指挥官 p0 对 p1 说 1, p2 说 1, p3 说 1, p4 说 1, p5 说 1
      第2步: p1 转述(作为其 OM 子协议的指挥官) p1 对 p2 说 1, p3 说 0, p4 说 0, p5 说 1   <== 对不同人说了不同的值(撒谎)!
      第2步: p2 转述(作为其 OM 子协议的指挥官) p2 对 p1 说 1, p3 说 1, p4 说 0, p5 说 0   <== 对不同人说了不同的值(撒谎)!
      第2步: p3 转述(作为其 OM 子协议的指挥官) p3 对 p1 说 1, p2 说 1, p4 说 1, p5 说 1
      第2步: p4 转述(作为其 OM 子协议的指挥官) p4 对 p1 说 1, p2 说 1, p3 说 1, p5 说 1
      第2步: p5 转述(作为其 OM 子协议的指挥官) p5 对 p1 说 1, p2 说 1, p3 说 1, p4 说 1
      根节点: 忠诚中尉 p3 收到的值 = [1, 0, 0, 1, 1]  => 最终决定 1
      根节点: 忠诚中尉 p4 收到的值 = [1, 0, 0, 0, 1]  => 最终决定 0

  N=7, f=2: 检查了 21 种叛徒布局 (小键空间穷举, 大键空间随机 2500 个策略)
    => 未发现任何反例 ✔   (N=7 >= 3f+1=7, 满足定理条件)

############ 2) 三个将军一个叛徒: 穷举所有可能的确定性算法 ############

  把「忠诚中尉的决策」看成一个函数: 输入 = (指挥官告诉它的值, 同伴告诉它的值),
  输出 = 0/1, 一共有 2^4 = 16 种确定性算法。逐一检验 IC1/IC2:
    => 16/16 种算法都至少违反 IC1 或 IC2 中的一条 ✘

  例如取 majority() 并让平局回退到 RETREAT, 看场景 1:
      第1步: 指挥官 p0 对 p1 说 1, p2 说 1
      第2步: p1 转述(作为其 OM 子协议的指挥官) p1 对 p2 说 1
      第2步: p2 转述(作为其 OM 子协议的指挥官) p2 对 p1 说 0
      根节点: 忠诚中尉 p1 收到的值 = [1, 0]  => 最终决定 0
      忠诚中尉 p1 收到 [1, 0] 平局 => 决定 0, 而忠诚指挥官下的是 ATTACK=1 => 违反 IC2 ✘
      若把平局改成取 ATTACK, 则值对称的场景 3(指挥官下令 0、叛徒谎报 1)会违反 IC2;
      若让两种视角都倒向同一侧, 则场景 2(指挥官是叛徒)的两名忠诚中尉必然不一致。

############ 3) 签名消息: 用密码学把 N >= 3f+1 降为 N >= f+2 ############

  N=3,f=1 指挥官忠诚、中尉 p2 是叛徒 (口头消息下违反 IC2)
    忠诚将军决定 = {1: 1}  =>  一致 ✔
  N=3,f=1 指挥官是叛徒、对 p1/p2 说矛盾的话
    忠诚将军决定 = {1: 0, 2: 0}  =>  一致 ✔
  N=4,f=2 < 3f+1=7: 指挥官叛徒 + 中尉 p3 叛徒
    忠诚将军决定 = {1: 0, 2: 0}  =>  一致 ✔
  N=5,f=3: 指挥官叛徒 + 两个叛徒中尉
    忠诚将军决定 = {1: 0, 2: 0}  =>  一致 ✔

  原因: 忠诚者可以把「指挥官亲笔签名的命令」当证据转发给其他人, 而叛徒无法伪造
        别人的签名; 于是自相矛盾的指挥官一定会被识破(大家回退到默认值)。

【代码做什么?】

  1. om() 递归实现 $OM(m)$:步骤 1 由指挥官把自己的值(叛徒则按 Adversary 的表格撒谎)发给所有中尉;步骤 2 每个中尉作为指挥官、用自己收到的值执行 $OM(m-1)$;步骤 3 每个中尉对自己拿到的全部值取 majority()(平局回退 RETREAT),与论文/讲义完全一致。
  2. Adversary 把”叛徒的策略”表示成一张表:table[(path, sender, recipient)] = 要说谎成的值path 记录递归路径,从而区分”同一个叛徒在不同层级的 OM 子协议里”的不同谎言——这是穷举”最恶劣策略”所必需的自由度
  3. search() 先枚举所有”谁是叛徒”的布局(指挥官是否叛变 × 中尉组合),再用一次”只记录”的执行收集所有可能的撒谎位置(键空间):键数少($\le 14$)时穷举 $2^k$ 种撒谎策略,键数多时随机采样 2500 种。只要找到一种让 IC1 或 IC2 失败,就打印完整的破坏过程
  4. 实验 1 的结果与 $N \ge 3f+1$ 定理严格吻合:$N=3,f=1$ 与 $N=6,f=2$(都 $< 3f+1$)第一个/第一批随机策略就破坏了一致性;$N=4,f=1$ 与 $N=7,f=2$(都 $= 3f+1$)在 4 种 / 21 种叛徒布局、合计五万余次策略检查中没有反例($N=4$ 是小键空间,$2^9=512$ 种策略全部穷举;$N=7$ 是 21 × 2500 次随机采样)。
  5. three_generals_exhaustive() 做了一件更彻底的事:把”忠诚中尉的决策”抽象成一张从”视角 $(c,p)$”到”决定值”的函数表,穷举全部 $2^4=16$ 种确定性算法,逐一检验三个场景 ⇒ 16/16 全部违反 IC1 或 IC2。这是 $N=3,f=1$ 不可能性的机器验证版
  6. 实验 3 用 sm_demo() 演示签名消息:忠诚者把”指挥官签名的值”转发出去,叛徒无法伪造签名(代码里体现为”决定时只统计签名链中含指挥官 $p_0$ 的消息”)。结果:连 $(N=4,f=2)$、$(N=5,f=3)$ 这类远小于 $3f+1$ 的规模,只要签名不可伪造,忠诚将军依然能达成一致

【分布式机制透视】

  • “口头消息”语义的建模:叛徒只能改变自己发出的值Adversary.send),而忠诚者转述的永远是它收到的值om 中递归使用 recv[l])。这精确对应”不能凭空伪造一个忠诚者说过的话”这一模型假设——如果允许伪造,那就已经越界到”签名被攻破”,而不是口头消息模型了。
  • majority() 的平局规则是协议的一部分:平局回退到 RETREAT 不是随意选择,而是 Lamport 等论文里的默认值约定。它同时暴露了 $N=3$ 的困境:视角 $(1,0)$ 与 $(0,1)$ 都是平局,任何平局规则都会在其中一个场景违背 IC2(代码与文字都验证了这一点)。
  • 穷举搜索 = “最坏情况对手”:这是分布式系统验证里非常实用的技巧——把”对所有可能的对手行为都成立”变成”对枚举出来的每一张策略表都成立”。当键空间可控时,穷举能给出比随机测试强得多的证据。
  • 签名的建模:签名链 sigs(如 (0, 1) 表示”指挥官 $p_0$ 签过、中尉 $p_1$ 又签过”)模拟”可验证的转发证据”。叛徒能做的只有”沉默”或”发自己的东西”,而这两者都无法动摇”指挥官签名的原始值”。

【与理论的对应】

  • 逐行对应 15.3.4 的 $OM(m)$ 伪代码;len(set(recv.values())) > 1 的日志标记就是”叛徒对不同人发矛盾值”的直接可视化;
  • 实验 1 的成败分界精确落在 $N = 3f+1$($N=4,f=1$ 与 $N=7,f=2$ 通过;$N=3,f=1$、$N=6,f=2$ 失败),验证了 15.2.13 的充分性 + 必要性
  • 实验 2 的 16/16 验证了 15.2.13 中三个场景的不可区分性论证:不是”某个算法不够好”,而是所有确定性算法的决策表都被穷尽了
  • 实验 3 验证了 15.2.14 的结论:签名把同步拜占庭将军问题所需的规模从 $3f+1$ 降到 $f+2$(并且必须记住:这一结论不适用于异步 BFT,那里 $N \ge 3f+1$ 仍然是硬下界)。

15.5 性能与可扩展性分析

15.5.1 各共识算法的复杂度与容错能力对比

算法系统模型故障模型与容错能力轮数 / 时间消息复杂度安全性活性
同步 Flooding(15.3.1)同步(延迟有已知上界)crash-stop,$f < N$$f+1$ 轮$O(N^2)$/轮,总 $O(N^2 f)$绝对保证绝对保证(只需同步假设)
Ben-Or(私有硬币)(15.3.2)异步crash-stop,$f < N/2$期望 $O(2^{N-f})$,最坏无界$O(N^2)$/轮绝对保证(不依赖随机性)以概率 1 终止
Ben-Or(共享硬币)异步 + common coincrash-stop,$f < N/2$期望 $O(1)$,最坏无界$O(N^2)$/轮绝对保证概率 1 终止(尾延迟远好于私有硬币)
Chandra–Toueg($\Diamond S$)(15.3.6)异步 + 故障检测器crash-stop,$f < N/2$轮转协调者:每轮 $O(1)$ 次广播(失败则换人重试)$O(N^2)$/轮绝对保证需要 $\Diamond S$ 的”最终”保证
Paxos / Raft部分同步crash-stop,$f < N/2$稳定期每个决议 $O(1)$ 轮(常为 2 RTT,带流水线可 1 RTT)$O(N)$/请求绝对保证(不依赖同步)仅在同步期保证
PBFT(15.3.5)部分同步拜占庭,$f < N/3$正常路径 3 阶段 ≈ 3 RTT$O(N^2)$/请求绝对保证视图变更后恢复(依赖同步期)
Tendermint / HotStuff部分同步拜占庭,$f < N/3$每视图 3–4 阶段HotStuff 用门限签名降到 $O(N)$绝对保证同上
$OM(m)$(15.3.4)同步(拜占庭将军)拜占庭,$N > 3m$$m+1$ 轮$O(N^{m+1})$(对叛徒数指数绝对保证绝对保证
PoW / PoS(区块链)开放网络,无固定成员经济假设(算力/权益多数诚实)按区块(分钟~小时)全网广播概率性($k$ 次确认后失效概率 $\sim 2^{-k}$)概率性

15.5.2 “共识的成本”:为什么不能把它用在热路径上

  • 延迟下界 ≈ 1–2 个 RTT 起:要形成”多数派确认”,消息必须至少跨网络一个来回才能真正成立(Paxos 的两阶段 = 2 RTT;PBFT 的三阶段 ≈ 3 RTT;带稳定 leader 与流水线时可以把每个请求摊薄到 ~1 RTT,但首字节延迟仍受 RTT 支配)。因此跨数据中心的共识写入延迟通常是几十到几百毫秒,与”本地内存访问的百纳秒”相差 6 个数量级。
  • 吞吐受限于”多数派往返”:所有写入都要经过 leader + quorum,无法像无主复制那样并行写任意节点;PBFT 还要额外付出 $O(N^2)$ 的消息与密码学运算。
  • 结论(本讲最重要的工程判断)共识只应该用在”低频但关键”的决策上。真实系统中的用法几乎完全一致:
    • 配置变更 / 元数据:ZooKeeper(Zab)、etcd(Raft)、Consul(Raft)——存的是”谁是 leader”“成员列表”“锁的持有者”这类小数据;
    • 领导者选举 / 故障转移:Raft 的选举、HDFS/YARN 的 HA、Kubernetes 的控制器(通过 etcd 的 watch + CAS);
    • 跨分片事务:Spanner(Paxos 组 + TrueTime 提交等待)、CockroachDB、TiDB(Raft 组 + 分布式事务);
    • 复制状态机的日志定序:Kafka 的 controller、各类分布式数据库的日志提交。
    • 而不是:每条用户请求、每次 KV 读写、每次指标上报。
  • “共识的复兴”与它的反面:正因为共识昂贵,过去十年出现了两条相反的路:
    1. 把共识做便宜:Raft(易实现、可流水线)、Multi-Paxos(批量决议)、HotStuff($O(N)$ 消息 + 门限签名)、以及把共识组件化(etcd/Consul/ZooKeeper 成为”标准件”);
    2. 干脆绕开共识:当数据操作可交换(commutative)时根本不需要定序 —— CRDT(Conflict-free Replicated Data Type) 用”可交换、可结合、幂等”的合并函数(G-Counter、PN-Counter、OR-Set、LWW-Register)实现无需协调的最终一致因果一致性(Causal Consistency) 只要求”有因果关系的操作有序、并发操作可任意序”,用向量时钟/版本向量实现,不要求全序、也就不要求共识。这类系统(Riak、Redis CRDT、Antidote、部分 Dynamo 系系统)换来的是低延迟与分区可用性,代价是”不能表达需要全局定序的业务”(如唯一性约束、跨对象不变量)。
      • 判断准则只有当”多个副本必须对同一件事的顺序达成一致”时,才需要共识;如果操作天然可合并(加计数、加集合、并集),就用 CRDT;如果只需要”因果有序”,就用因果一致性 + 版本向量(详见本笔记”一致性模型”与”时间与顺序”两章)。

15.5.3 可解性矩阵(一张表看懂”该用什么模型”)

系统模型(时间假设)故障模型算法类型共识可解性代表算法 / 系统
同步(延迟/时钟/步骤都有界)crash-stop确定性可解($f+1$ 轮,$f<N$)讲义 Flooding;共享总线多处理器
同步拜占庭确定性可解($N \ge 3f+1$;签名下 $N \ge f+2$)$OM(m)$、$SM(m)$
部分同步(最终有界)crash-stop确定性可解(安全性无条件;活性在同步期)Paxos、Raft、Zab、Viewstamped Replication
部分同步拜占庭确定性可解($N \ge 3f+1$)PBFT、Tendermint、HotStuff
异步crash-stop确定性不可解(FLP)——
异步 + 完美故障检测器 $P$crash-stop确定性可解(等价于同步)Chandra–Toueg
异步 + $\Diamond S$crash-stop确定性可解(多数正确;$\Diamond S$ 是最弱的)Chandra–Toueg 轮转协调者共识
异步crash-stop随机化可解(以概率 1 终止)Ben-Or、Rabin(共享硬币)
异步拜占庭($N \ge 3f+1$)确定性不可解需部分同步或随机化(PBFT + 随机化视图变更)

读表要点:横着看是”模型强弱”,竖着看是”确定性 vs 随机化”与”故障类型”。FLP 只封锁了其中一格(异步 + 确定性 + crash),而工程上的全部智慧都在于”用哪一格来近似现实”:现实网络既不是同步也不是纯异步,而是”大部分时候接近同步、偶尔严重抖动”——这恰好就是部分同步模型,也正是 Paxos/Raft 的立足点。


15.6 关键要点

  1. 共识的三条性质必须一起记住:终止性(每个正确进程都决定)、一致性(所有决定相同)、合法性(决定值来自提议值)。FLP 违反的是第一条——在它构造的执行里,大家从来没有”不一致”,而是”永远没人决定”。
  2. FLP 不可能性:异步 + 确定性 + 允许 1 个崩溃(且无故障检测器)⇒ 不存在保证终止的共识算法。 惊人的地方不是”故障太多”,而是一个故障就够;因此加大副本数完全无用。证明结构是:引理 1(不相交调度可交换)+ 引理 2(存在双价初始配置)+ 引理 3(双价配置总能走到另一个双价配置)⇒ 存在一条无限双价路径 ⇒ 永远无人决定
  3. FLP 的”1 个故障就够”根源是”故障进程不可区分性”:把某个进程的输入翻转、再让它一开始就崩溃,其余进程看到的执行完全相同——初始配置的”价”因此不可能在一格之间从 0 跳到 1,于是必有双价配置;而”消息可以被无限延迟而不被察觉”(异步性的本质)则让对手可以永远选择”先做别的事”,守住双价分支。
  4. 要绕过 FLP,必须放松三条假设之一:放松异步(部分同步 ⇒ Paxos/Raft,安全性永远保证、活性尽力而为)、放松确定性(随机化 ⇒ Ben-Or,以概率 1 终止)、或引入故障检测器($\Diamond S$ ⇒ Chandra–Toueg,而 $\Diamond S$ 也只能近似实现)。 此外,改变故障类型(拜占庭)是另一条正交的轴,它并不让 FLP 消失:$N \ge 3f+1$、$O(N^2)$ 消息、异步下仍不可解。
  5. 安全性与活性的失败代价不对称:违反安全性 = 数据损坏(不可恢复),违反活性 = 暂时不可用(可恢复)。所以真实系统的设计哲学是”安全性绝对保证 + 活性尽力而为”——这让”在异步网络上造出能用的共识系统”成为可能。共识的代价是 1–2 个 RTT 起的延迟与 $O(N)$–$O(N^2)$ 的消息,因此只用于配置变更、选主、成员管理、跨分片事务这些低频关键决策;可交换的数据操作则用 CRDT + 因果一致性绕开共识。

15.7 常见陷阱与注意事项

  1. 陷阱:把 FLP 理解成”共识不可能实现”。
    • 为什么错:FLP 只否定”同时保证一致与总终止确定性算法在异步系统中的存在性”。它不否定”安全性永远成立、活性尽力而为”的算法(Paxos/Raft),也不否定随机化算法。
    • 正确做法:把 FLP 当作一张代价清单:你必须明确说出”我放弃的是哪一条”。工程界的一句口号很好地概括了 Paxos/Raft 的立场:安全性永远保证,活性在网络表现正常时给出safety always, liveness when the network behaves)。
  2. 陷阱:以为”多几个副本 / 更快的网络 / 更好的硬件”能绕过 FLP。
    • 为什么错:FLP 的构造只需要一个进程崩溃(而且崩溃者由对手挑),与副本数无关;”更快的网络”只是把延迟上界变小,而异步模型恰恰假设没有任何上界——最坏情况的延迟可以任意长。
    • 正确做法:要提高活性,只能改变模型(引入最终同步假设、故障检测器或随机化),而不是提高配置
  3. 陷阱:把”终止性”理解成”最终达成一致”。
    • 为什么错:一致性与终止性是两条独立性质。FLP 破坏的是”每个正确进程都做出决定“,而不是”大家决定的相同”。
    • 正确做法:写协议规格时把三条性质逐条形式化($\forall$ 正确进程 $\Diamond$ decided;两个决定相同;决定值 $\in$ 提议值集合),并分别给出论证。
  4. 陷阱:在异步系统里用”超时”当故障检测器,却把它当成完美的。
    • 为什么错:Lecture 5/6 的结论是完整性与准确性不可兼得(否则就能解共识);超时太短会误判(准确性差,可能引发不必要的重配置、甚至脑裂),太长则活性差。
    • 正确做法:明确你的检测器是 $\Diamond P$ / $\Diamond S$ / $\Diamond W$ 中的哪一个,并接受”最终准确”这个只在未来成立的承诺;用自适应超时(如 Φ 累加检测器)、怀疑机制(suspicion + incarnation)来降低误判代价。
  5. 陷阱:把”签名消息下 $N \ge f+2$”套用到异步 BFT 上。
    • 为什么错:$f+2$ 是同步、拜占庭将军问题(口头/书面消息)这一形式化的结论;在异步/部分同步的 BFT 共识里,还需要 quorum 交集来提供安全性,因此 $N \ge 3f+1$ 依然是硬下界(PBFT、Tendermint、HotStuff 全部如此)。签名只能防伪造,不能提供”何时可以安全推进”。
    • 正确做法:区分”消息能否被伪造”(签名解决)与”需要多少票才能推进”(quorum 大小,由故障模型与时间模型决定)。
  6. 陷阱:以为共享硬币(common coin)是”免费”的。
    • 为什么错:共享硬币本身是一个同步原语:它要求所有进程对”第 $r$ 轮的随机位”取得同一个值,通常要用门限签名或可验证秘密分享(VSS)实现,需要额外的通信轮次,并且在纯异步模型下也不是随手可得
    • 正确做法:比较”私有硬币($2^{\Theta(n)}$ 期望轮数、无额外原语)”与”共享硬币($O(1)$ 期望轮数、需要密码学与额外轮次)”的总代价(轮数 × 每轮成本 + 原语成本),而不是只比轮数。
  7. 陷阱:把”以概率 1 终止”当成”保证终止”。
    • 为什么错:概率 1 终止只排除了”永不终止”这一零测集事件;任何有限轮之后都仍有正概率还没终止,而且分布是重尾的(本讲实验:私有硬币在 $N=7,f=2$ 时平均 14.77 轮、最长 79 轮,$N=9,f=2$ 时最长 245 轮)。
    • 正确做法:工程实现里要设置”最大轮数 + 退避 + 上层超时/重试”,并把”极少数慢路径”纳入 SLO(尾延迟 p99.9 才是用户体验)。
  8. 陷阱:同步 Flooding 里的两个细节错误。
    • 错法一:只跑 $f$ 轮(少了”把最后一个缺口补上”的那一轮)⇒ 15.4.1 实验 2a 直接违反一致性;错法二:用”最小值”作为决定规则,或忘记”$f$ 必须已知”。此外,若实际崩溃数超过 $f$,$f+1$ 轮的归纳链就断了(实验 3)。
    • 正确做法:严格按 $f+1$ 轮执行;决定规则取”值集合的确定性函数“(讲义用”最小编号提议者”);把”$f$ 是上界且真实成立”写进假设,并让运维监控实际故障率是否越界。

15.8 思考题(带答案)

Q1(计算题) 在同步 Flooding 共识中取 $N=7$、$f=2$,假设每条消息最多携带 $N$ 个提议值。求:(a) 需要多少轮?(b) 总消息条数是多少?(c) 总共传输多少个”提议值”?(d) 如果只跑 $f$ 轮会发生什么?请结合本讲实验说明。

答案

  • (a) 轮数 $= f+1 = 3$。
  • (b) 每轮每个进程向其余 $N-1=6$ 个进程各发 1 条 ⇒ 每轮 $7 \times 6 = 42$ 条,总共 $42 \times 3 = 126$ 条;与公式 $O(N^2 f) = 7^2 \times 2 \approx 98$ 同量级(精确值 $N(N-1)(f+1) = 126$)。
  • (c) 每条消息最多含 $N=7$ 个值 ⇒ 最多 $126 \times 7 = 882$ 个值;即 $O(N^3 f)$。
  • (d) 只跑 $f=2$ 轮时,”最后一个缺口”来不及补上,两个正确进程可能掌握不同的值集合 ⇒ 决定值不同。15.4.1 实验中 $N=3,f=1$ 的”实验 2a(1 轮)对比 2b(2 轮)”就是最小实证:少一轮 ⇒ $p_1$ 决定 1、$p_2$ 决定 0;补上那一轮 ⇒ 两人都决定 1。

Q2(错误直觉题) 有同学说:”FLP 说异步下 1 个故障就解不了共识,那我们把副本从 3 个加到 5 个、再把网络升级到 100 Gbps,问题不就解决了吗?”请指出这个想法错在哪里,并说明正确的做法是什么。

答案:错的根源是把”配置”当成了”模型”

  • FLP 的结论是”不存在任何确定性算法”——它不依赖副本数:证明里只用了 1 个崩溃进程,而且崩溃者可以自由选择(让”输入值翻转导致价跳变”的那个进程沉默)。把 $N$ 从 3 加到 5、500、5000,都无法消除”慢与死不可区分”这一认识论困境;恰恰相反,$N$ 越大,”某个进程恰好卡住”的概率越高。
  • “更快的网络”只降低平均延迟,而异步模型允许任意长的延迟(GC 停顿、路由抖动、虚拟机迁移、跨洲链路拥塞都可能造成秒级甚至分钟级停顿),FLP 的最坏执行正是建立在”某条消息被无限延迟”之上的。
  • 正确做法:改变模型而不是配置——(1) 采用部分同步假设并用超时推进(Paxos/Raft:安全性永远保证、活性尽力而为);(2) 采用随机化(Ben-Or/Rabin:以概率 1 终止);(3) 引入故障检测器($\Diamond S$:最终能识别出”谁是慢、谁是死”)。三者都是”用额外的假设换取可能性”,而不是”用更多的机器换取可能性”。

Q3(概念题) Ben-Or 的阶段 1 为什么采用”收到的 $n-f$ 条估计全部一致“(unanimity)而不是”严格多数“?如果改成严格多数,会破坏哪一条不变式?阈值 $D$ 与 $A$ 之间的不等式 $D-f \ge A$ 又是干什么的?

答案

  • $D-f \ge A$ 是传播性(propagation)要求:决定者 $p$ 看到 $\ge D$ 张 $v$ 票;任何其他正确进程 $q$ 最多漏看 $f$ 个发送者(它只收 $n-f$ 条),因此至少看到 $D-f$ 张 $v$ 票。要保证 $q$ 也把估计锁定到 $v$(否则两人的估计分叉,未来可能决定出不同的值),就必须 $A \le D-f$。取 $D=f+1, A=1$ 正好取等。
  • 为什么阶段 1 用 unanimity:它保证同一轮至多一个值能获得选票(不变式 I):两个不同值的一致集合互不相交,每个都需要 $n-f$ 个进程的估计一致,于是发送者总数 $\ge 2(n-f) \ge n+1 > n$,矛盾。有了这条不变式,”$A=1$ 的采纳规则”才有明确语义(不会出现”同时看到两个值的票、不知道该采纳哪个”的情形)。
  • 改成严格多数会怎样:当实际崩溃数 $c < f$ 时(存活进程多于 $n-f$,各进程看到不同的子集),可能两个值同时各获多数(两个多数集合的大小之和可以不超过发送者总数),于是阶段 2 里同时出现两个值的票,传播性论证失效;补救办法是把采纳阈值提高到 $A \ge n-D$,而这要求 $2D \ge n+f$,把可容忍的 $f$ 压到 $f \le 2$($n$ 为偶数时)。结论:阈值不是可以随意挑选的参数,而是一组必须联合成立的不等式——这是分布式算法设计中极常见、也极容易被忽视的一类错误。

Q4(推演题) 在 $N=3, f=1$ 的拜占庭将军场景中,请写出”忠诚中尉的视角”(即”指挥官告诉它的值”与”同伴告诉它的值”)在下列三种场景下的取值,并说明为什么任何确定性算法都必然失败:(i) 指挥官忠诚下令 1、中尉 $p_2$ 叛徒谎报 0;(ii) 指挥官是叛徒、对 $p_1$ 说 1、对 $p_2$ 说 0,两名中尉都忠诚;(iii) 指挥官忠诚下令 0、中尉 $p_2$ 叛徒谎报 1。进一步说明:加上不可伪造的签名后,为什么同样三个场景就不再是问题。

答案

  • (i) $p_1$ 的视角 $=(1, 0)$;由 IC2(指挥官忠诚 ⇒ 必须服从)⇒ 算法在此视角必须输出 1
  • (iii) $p_1$ 的视角 $=(0, 1)$;由 IC2 ⇒ 算法在此视角必须输出 0
  • (ii) $p_1$ 的视角 $=(1,0)$、$p_2$ 的视角 $=(0,1)$(两人都忠实转发);由上面两条,$p_1$ 输出 1、$p_2$ 输出 0 ⇒ 违反 IC1
  • 为什么是”任何”算法:一个确定性算法就是从”视角”到”决定”的函数;视角只有 4 种 $(c,p) \in \{0,1\}^2$,因此全部算法只有 $2^4 = 16$ 种。逐一检验可知这 16 种全部至少违反 IC1 或 IC2(本讲 15.4.3 的实验 2 正是这个穷举,输出 16/16 种算法都至少违反 IC1 或 IC2 中的一条)。直观地说:$p_1$ 在场景 (i) 中看到的证据与场景 (ii) 完全相同,却被要求做出不同的决定——不可区分性使任何算法都无能为力。
  • 签名之后为什么可以:有了不可伪造的签名,$p_1$ 与 $p_2$ 可以把”指挥官亲笔签名的命令“互相转发作为证据。在场景 (ii) 中,两人各自都会看到”$p_0$ 签名的 1”与”$p_0$ 签名的 0”两份互相矛盾的命令 ⇒ 直接判定指挥官是叛徒,于是都回退到默认值 RETREAT ⇒ 两人一致;在场景 (i)/(iii) 中,叛徒中尉无法伪造指挥官的签名,忠诚中尉只会看到一份真实签名 ⇒ 服从指挥官。这就是”签名把 $N \ge 3f+1$ 降到 $N \ge f+2$”的机制(15.4.3 实验 3 验证了 $N=3,f=1$、$N=4,f=2$、$N=5,f=3$ 在签名下都能达成一致;但再次提醒:异步 BFT 仍需 $N \ge 3f+1$)。