你在设计一个分布式存储系统。三个节点,数据要强一致。你选了 Raft,部署上线,跑了三个月没出问题。然后有一天,机房之间的网络延迟从 1ms 飙到 30 秒,Leader 选举超时了,集群不可用。
你去排查,发现 Raft 的选举超时(election timeout)默认是几百毫秒到几秒。延迟一旦超过这个范围,Follower 就会认为 Leader 挂了,发起选举。频繁选举意味着系统在选举期间无法对外服务。
问题来了:Raft 不是号称”容错”的吗?为什么网络一慢就不好使了?
答案很简单:Raft 依赖的系统模型(system model)对网络时序有假设。具体来说,它假设系统是部分同步(partially synchronous)的——网络延迟可能暂时不可预测,但最终会恢复到一个有界范围。如果你的网络长时间不满足这个假设,Raft 的活性(liveness)保证就失效了。
这不是 Raft 的 bug,是模型的边界。
每个分布式协议都建立在一组假设之上:网络是同步的还是异步的?节点崩溃后能不能恢复?节点可不可能撒谎? 这些假设构成了所谓的”系统模型”。你的模型假设越强(比如假设网络完全同步),能设计出的协议就越简单、性能越好;假设越弱(比如允许拜占庭故障),协议就越复杂、代价越高。
这篇文章拆解三个维度:通信模型(timing model)、故障模型(failure model)、进程模型(process model)。然后把 etcd/Raft、Bitcoin、PBFT、Tendermint 这些真实系统放回它们的模型空间,看看假设和现实之间的差距会导致什么后果。
一、通信模型:消息到底多久能到?
通信模型回答一个根本问题:你对消息传递的延迟和处理器的速度做了多强的假设?
这个问题的答案直接决定了共识协议的可能性边界。
同步模型(Synchronous Model)
同步模型做最强的假设:存在一个已知的上界 Delta,任何消息的传递延迟不超过 Delta;存在一个已知的上界 Phi,任何处理器的一步计算时间不超过 Phi。
形式化地说:
- 对任意消息 m,从发送到接收的时间 d(m) <= Delta
- 对任意进程 p 的一步计算时间 s(p) <= Phi
在这个模型下,超时检测(timeout-based failure detection)是完美的。如果你在 2*Delta 时间内没收到某个节点的回复,你可以确定它已经崩溃了——因为如果它还活着,消息一定已经到了。
同步模型下共识问题容易得多。经典的 Dolev-Strong 协议(1983)可以在同步网络中容忍任意数量的拜占庭故障节点(只要诚实节点数 >= 1),代价是需要 f+1 轮通信。
问题在于:现实网络不是同步的。 TCP 重传、路由抖动、垃圾回收停顿、虚拟机调度延迟,都可能让消息延迟远超任何预设上界。如果你基于同步模型设计协议,一旦网络行为超出假设,协议的安全性(safety)或活性(liveness)就可能被破坏。
那为什么还要研究同步模型?两个原因:
- 它是理论分析的基准线——在最好的情况下能做到什么。
- 有些协议确实依赖同步假设,比如 Bitcoin 的 Nakamoto 共识(后面会详细分析)。
异步模型(Asynchronous Model)
异步模型走到另一个极端:不对消息延迟做任何假设。消息最终会到达(no message loss),但可能要等任意长的时间。处理器的速度也没有上界——一个进程可能比另一个慢任意倍。
这是最弱的假设,也是最安全的建模方式——如果你的协议在异步模型下正确,那它在任何网络条件下都正确。
但 1985 年,Fischer、Lynch 和 Paterson 证明了一个让整个分布式计算领域震动的结果:
FLP 不可能性定理:在异步系统中,即使只有一个进程可能崩溃(crash-stop),也不存在确定性的共识协议能同时保证 safety 和 liveness。
– Fischer, Lynch, Paterson. “Impossibility of Distributed Consensus with One Faulty Process.” JACM, 1985.
直觉上这很反直觉。只有一个进程可能挂,其他都好好的,怎么就不能达成共识了?
FLP 的关键在于异步系统中无法区分”慢”和”死”。一个进程长时间没响应,你不知道它是崩溃了还是只是特别慢。任何确定性协议在某些执行序列下都会陷入这样的决策困境:要么等下去(牺牲 liveness),要么不等直接决定(可能和那个”慢”进程的决定冲突,牺牲 safety)。
FLP 证明了不可能性,但没有终结共识研究。它告诉我们的是:要解决共识,你必须放松某个假设。 要么放松时序假设(不要求纯异步),要么放松确定性要求(允许随机化),要么接受概率性终止。
部分同步模型(Partially Synchronous Model)
1988 年,Dwork、Lynch 和 Stockmeyer(DLS)提出了部分同步模型,这是分布式系统理论中最重要的突破之一。
DLS 论文定义了两种部分同步的形式化方式:
形式一:未知上界(Unknown Bound)。 消息延迟存在一个有限上界 Delta,但这个上界的值事先不知道。协议必须在不知道 Delta 具体值的情况下保证正确性。
形式二:全局稳定时间(Global Stabilization Time, GST)。 存在一个未知的时间点 GST,在 GST 之后,系统表现得像同步系统(消息延迟有已知上界 Delta)。在 GST 之前,消息延迟可以任意大。
这两种形式在理论上等价(DLS 论文中有证明),但形式二在实践中更直观,也是大多数后续工作采用的表述。
部分同步模型的精妙之处在于它对 safety 和 liveness 做了不同的要求:
- Safety(安全性):在任何时候都必须成立,包括 GST 之前网络行为任意恶劣的时期。
- Liveness(活性):只要求在 GST 之后(系统稳定后)成立。
换句话说,协议在网络不稳定时可以”卡住”(不推进),但绝不能”出错”(做出互相矛盾的决定)。一旦网络恢复稳定,协议必须能继续推进。
这正是 Paxos、Raft、PBFT 等主流共识协议采用的模型。它们都保证:
- 无论网络状况如何,已提交的值不会被撤销(safety)。
- 只要网络最终稳定,新的提案最终会被提交(liveness)。
回到开头的例子:Raft 在网络延迟飙升时无法选出 Leader,这就是 GST 之前的行为——Raft 的 liveness 暂时失效,但 safety 没有被破坏。一旦网络恢复,选举会正常完成。
部分同步在真实系统中的近似
真实网络在绝大多数时间都表现得像同步系统。数据中心内的网络延迟通常在亚毫秒到个位数毫秒级别,抖动极小,几乎可以用确定性上界来描述。但”几乎”不等于”永远”——GC 停顿(特别是 Java 的 Full GC 可达数百毫秒甚至数秒)、交换机故障、路由重收敛、内核网络栈的缓冲区溢出,都可能制造突发性的异步窗口。在这些窗口中,消息延迟可能比正常值大几个数量级,且持续时间不可预测。
GST 抽象精确地捕捉了这种模式。它不是一个字面意义上的”开关”——现实中没有一个瞬间网络从混沌突然变为有序。GST 是对收敛行为的理想化建模:经历一段不确定长度的扰动后,网络最终会恢复到一个稳定状态,消息延迟重新回到有界范围内。协议设计者不需要知道 GST 何时到来,只需要知道它最终会到来。
这一抽象的实践含义非常明确:协议设计者必须确保 safety 在异步窗口中无条件成立,不依赖任何时序假设。具体来说,即使消息延迟任意大、节点的超时判断全部失效、Leader 选举无法完成,已提交的决定也不能被推翻。时序假设只被用于 liveness——只有当网络恢复稳定(GST 之后),协议才需要保证能继续推进。这种 safety 与 liveness 的不对称设计,是部分同步模型的核心工程价值所在。
sequenceDiagram
participant A as 节点A
participant B as 节点B
participant C as 节点C
rect rgb(255, 230, 230)
Note over A,C: GST 之前(异步窗口)
A->>B: msg1(延迟 2ms)
A->>C: msg2(延迟 8500ms)
Note right of C: 消息延迟不可预测
B->>C: msg3(延迟 3200ms)
Note over A,C: Safety 仍然成立,但 Liveness 不保证
end
rect rgb(230, 255, 230)
Note over A,C: GST 之后(同步恢复)
A->>B: msg4(延迟 < Delta)
A->>C: msg5(延迟 < Delta)
B->>A: msg6(延迟 < Delta)
C->>A: msg7(延迟 < Delta)
Note over A,C: Safety + Liveness 均成立
end
上图展示了 GST 前后消息传递行为的本质差异。在 GST 之前的异步窗口中,msg2 和 msg3 的延迟达到数秒级别,远超正常值,协议可能因此无法推进(Leader 选举超时、提案无法收集到足够回复),但已提交的决定不会被撤销。GST 之后,所有消息在已知上界 Delta 内到达,协议的 liveness 条件得到满足,新的提案能够正常提交。
三种模型的对比
| 维度 | 同步 | 部分同步 | 异步 |
|---|---|---|---|
| 消息延迟 | 已知上界 Delta | GST 后有上界 | 无上界 |
| 故障检测 | 完美 | GST 后可靠 | 不可能 |
| 确定性共识 | 可解 | 可解(GST 后) | 不可解(FLP) |
| 典型协议 | Dolev-Strong, lock-step | Paxos, Raft, PBFT | Ben-Or(随机化) |
| 现实匹配度 | 低 | 高 | 过于悲观 |
部分同步模型之所以成为实践中的主流选择,是因为它精确地捕捉了真实网络的行为特征:大部分时间网络是稳定的(延迟在可预测范围内),但偶尔会出现不可预测的抖动或中断。
二、故障模型:节点会以什么方式出错?
通信模型处理的是”消息”的行为,故障模型处理的是”节点”的行为。一个节点可能以多种方式失败,不同的故障假设构成了一个从弱到强的层级关系。
崩溃停止(Crash-Stop)
最简单的故障模型:进程在某个时刻停止运行,之后永远不再恢复。停止之前,进程的行为完全正确——它不会发送错误的消息,不会做错误的计算。
形式化定义:进程 p 在时刻 t 崩溃,意味着 p 在 t 之后不再执行任何步骤,不再发送或接收任何消息。
这个模型在理论分析中非常有用,因为它极大地简化了推理。Lamport 在 Paxos 论文中的基础模型就是 crash-stop。
但 crash-stop 在实践中几乎不成立。服务器崩溃后通常会重启;进程被 OOM Killer 杀掉后会被 systemd 拉起来。如果你的协议假设了 crash-stop,那一个崩溃后恢复的节点就是”模型之外”的行为,可能导致不可预期的后果。
崩溃恢复(Crash-Recovery)
比 crash-stop 更贴近现实:进程可以崩溃,也可以恢复。但恢复后,进程丢失了所有内存中的状态——只有写入持久化存储(stable storage)的数据才能在恢复后被读回。
这意味着协议必须考虑:
- 哪些状态需要在做决定之前写入磁盘?
- 恢复后的进程如何重新加入协议?
- 一个进程在发送消息之后、写入磁盘之前崩溃了怎么办?
Raft 和 ZAB(ZooKeeper 使用的协议)都基于 crash-recovery 模型。这就是为什么 Raft 要求在回复投票请求之前先把投票结果持久化——如果一个节点投了票但没持久化,崩溃恢复后可能再投一次,导致同一个 term 内出现两个 Leader。
etcd 的 Raft 实现中,WAL(Write-Ahead Log)就是 stable storage 的具体形态。每条日志在被确认之前必须先写入 WAL:
// etcd/server/etcdserver/raft.go(etcd v3.5 源码摘录,有删减)
// 节点在应用日志前,必须先持久化到 WAL
func (r *raftNode) start(rh *raftReadyHandler) {
// ...
for {
select {
case rd := <-r.Ready():
// 先持久化 HardState 和 Entries 到 WAL
if err := r.storage.Save(rd.HardState, rd.Entries); err != nil {
// 持久化失败,直接 fatal
r.lg.Fatal("failed to save Raft hard state and entries", zap.Error(err))
}
// 持久化成功后,才应用到状态机
// ...
}
}
}crash-recovery 模型下的法定人数(quorum)计算也比 crash-stop 复杂。在 crash-stop 模型中,崩溃的节点永远不会回来,所以只需要在活着的节点中达成多数。在 crash-recovery 模型中,一个节点可能崩溃后恢复,但恢复后它的内存状态已经丢失。如果协议设计不当,恢复的节点可能”忘记”自己之前的承诺,导致 safety 被破坏。
stateDiagram-v2
state "Crash-Stop 模型" as CS {
[*] --> Running_CS: 启动
Running_CS --> Crashed_CS: 进程崩溃
Crashed_CS --> [*]: 永久终止
note right of Crashed_CS: 崩溃后永不恢复\n节点从集群中永久消失
}
state "Crash-Recovery 模型" as CR {
[*] --> Running_CR: 启动
Running_CR --> Crashed_CR: 进程崩溃
Crashed_CR --> Recovering_CR: 重启
Recovering_CR --> Running_CR: 从持久化状态恢复
note right of Crashed_CR: 内存状态全部丢失\n仅保留 stable storage 数据
}
上图对比了两种进程模型的状态转换。Crash-Stop 模型中,进程崩溃是一个终态——一旦节点停止运行,它就从系统中永久消失,协议只需考虑剩余活跃节点之间的共识。Crash-Recovery 模型中,进程可以在崩溃后重启并重新加入集群,但所有易失性内存状态在崩溃瞬间丢失,只有事先写入持久化存储(如 WAL)的数据才能被恢复。这就是为什么 Raft 要求在回复投票前必须先持久化投票记录——恢复的节点必须能够”记住”自己之前的承诺。
遗漏故障(Omission Faults)
遗漏故障模型允许进程丢失消息,但不允许进程发送错误的消息。它分两种:
- 发送遗漏(Send Omission):进程正确执行了发送操作,但消息没有到达网络。
- 接收遗漏(Receive Omission):网络正确传递了消息,但进程没有收到。
遗漏故障比 crash 更一般化:crash 可以看作一种极端的遗漏——从某个时刻开始,进程遗漏了所有后续消息。
在实践中,遗漏故障对应的是网络层面的丢包、队列溢出、TCP 连接超时等场景。大部分容错协议不会单独针对遗漏故障建模,而是把它吸收到 crash-recovery 或部分同步模型中处理——如果消息丢了,发送方会超时重传。
拜占庭故障(Byzantine Faults)
最强的故障模型:进程可以表现出任意行为。它可以发送错误的消息、发送互相矛盾的消息给不同的节点、完全停止运行、伪造数据、甚至和其他拜占庭节点合谋。
这个名称来自 Lamport、Shostak 和 Pease 在 1982 年提出的”拜占庭将军问题”(The Byzantine Generals Problem)。原始论文用军事隐喻描述了这个问题:多个将军需要协调进攻或撤退,但其中可能有叛徒会故意传递虚假命令。
拜占庭故障模型有一个重要的定量结论:
在同步网络中容忍 f 个拜占庭节点,至少需要 n >= 3f + 1 个节点。在部分同步网络中同样如此。
– Lamport, Shostak, Pease. “The Byzantine Generals Problem.” ACM TOPLAS, 1982.
为什么是 3f+1 而不是 2f+1?直觉上的解释:
假设有 n 个节点,其中 f 个是拜占庭节点。一个正确节点需要收到多数节点的一致回复才能做决定。但拜占庭节点可能对不同的正确节点说不同的话。
考虑最坏情况:f 个拜占庭节点给节点 A 发一个值,给节点 B 发另一个值。节点 A 和 B 各自收到 f 个拜占庭节点的消息加上一些正确节点的消息。为了让 A 和 B 能排除拜占庭节点的干扰,正确节点的数量必须足够多,使得任意两个”多数集合”之间的交集中一定包含至少一个正确节点。
设法定人数大小为 q,则需要 2q - n > f(两个 quorum 的交集大于 f),同时 q > f(quorum 中正确节点数多于拜占庭节点数)。解这个不等式组,得到 n > 3f,即 n >= 3f+1。
对比 crash 故障:crash 节点不会发送错误消息,所以两个 quorum 的交集中任何节点都是可信的,只需要 2q - n >= 1 且 q >= 1,得到 n >= 2f+1。
故障模型的包含关系
这四种故障模型形成了一个严格的层级:
Byzantine(拜占庭)
|
|-- 包含 Omission(遗漏)
|
|-- 包含 Crash-Recovery(崩溃恢复)
|
|-- 包含 Crash-Stop(崩溃停止)
“包含”的含义是:能容忍上层故障的协议,自动能容忍下层故障。一个 BFT(Byzantine Fault Tolerant)协议在所有拜占庭节点实际上只表现出 crash 行为时,仍然正确——只是杀鸡用了牛刀。
反过来不行。一个只容忍 crash 故障的协议(如 Raft),在面对拜占庭节点时没有任何保障。如果一个 Raft 节点被攻击者控制,它可以伪造日志、篡改投票,整个集群的一致性保证就失效了。
这就是为什么模型选择如此重要:选错了故障模型,你的协议在真实环境中就像一座建在沙子上的房子。
三、进程模型:确定性还是随机化?
确定性 vs 随机化
FLP 定理证明了在异步系统中确定性共识不可能。但如果允许进程使用随机数(coin flip),情况就不同了。
Ben-Or 在 1983 年提出了第一个异步随机化共识算法。其核心思路:当进程无法确定应该选择哪个值时,不是死等,而是随机选一个。只要正确节点足够多,它们最终(以概率 1)会随机选到相同的值。
随机化绕过 FLP 的方式很精确:FLP 证明的是没有确定性算法能在所有可能的执行序列中同时保证 safety 和 liveness。随机化算法的 liveness 是概率性的——存在执行序列使得算法长时间不终止,但这些执行序列的概率测度为 0。
用更直白的话说:FLP 的”反例”是一个精心构造的对抗性调度(adversarial schedule),它总能让确定性算法卡住。但如果算法本身会抛硬币,对抗性调度就无法提前预测算法的行为,也就无法精确构造那个致命的执行序列。
现代实践中的共识协议通常不直接使用随机化来绕过 FLP,而是依赖部分同步假设。但随机化在 BFT 协议中仍然重要——例如 Algorand 使用密码学随机抽签(Verifiable Random Function)来选择出块者。
故障数量假设
无论选哪种故障模型,你都需要回答一个问题:系统能容忍多少个同时出故障的节点?
这个问题的答案是一个数字 f,通常用总节点数 n 和 f 的关系来表达:
| 故障类型 | 最低节点数要求 | 法定人数大小 | 含义 |
|---|---|---|---|
| Crash(同步) | n >= f+1 | f+1 | 只要有一个正确节点就行 |
| Crash(部分同步/异步) | n >= 2f+1 | f+1 | 需要多数正确节点 |
| Byzantine(同步) | n >= 2f+1 | – | Dolev-Strong |
| Byzantine(部分同步) | n >= 3f+1 | 2f+1 | PBFT, Tendermint |
为什么不同模型下要求不同?关键在于 quorum intersection argument:
Crash + 部分同步:协议需要 quorum(多数节点)来做决定。两个 quorum 必须有交集,以保证新 Leader 能看到之前的决定。n 个节点中,quorum 大小为 ceil(n/2)。两个 quorum 的交集大小为 2*ceil(n/2) - n >= 1。但其中可能包含 f 个崩溃节点,所以需要交集大小 > f,即 n > 2f。
Byzantine + 部分同步:两个 quorum 的交集中不仅要有正确节点,还要确保正确节点占多数(否则拜占庭节点可以在交集中撒谎)。需要交集大小 > 2f(交集中至少有 f+1 个正确节点),即 2q - n > 2f。同时 q 不能包含所有拜占庭节点后仍不够,所以 n - f >= q,合起来得到 n > 3f。
flowchart TB
subgraph cluster["n=5 节点集群(Crash 模型,f=2)"]
direction TB
subgraph Q1["Quorum 1(size=3)"]
N1_1["节点 1"]
N2_1["节点 2"]
N3_1["节点 3"]
end
subgraph Q2["Quorum 2(size=3)"]
N3_2["节点 3"]
N4_2["节点 4"]
N5_2["节点 5"]
end
end
N3_1 -.- N3_2
style N3_1 fill:#f96,stroke:#333,color:#000
style N3_2 fill:#f96,stroke:#333,color:#000
style N1_1 fill:#69b,stroke:#333,color:#fff
style N2_1 fill:#69b,stroke:#333,color:#fff
style N4_2 fill:#69b,stroke:#333,color:#fff
style N5_2 fill:#69b,stroke:#333,color:#fff
上图以 n=5、f=2 的 Crash 模型为例展示了 quorum 交集的核心机制。每个 quorum 大小为 3(即 ceil(5/2)),任意两个 quorum 至少有 1 个节点重叠(图中橙色的节点 3)。这个重叠节点保证了信息的连续性——新 quorum 中至少有一个成员参与了旧 quorum 的决定,因此能将之前的决策传递给新的 Leader。对于拜占庭模型(n=3f+1),quorum 大小增至 2f+1,确保交集中正确节点数量超过拜占庭节点数量,从而排除恶意节点的干扰。
四、真实系统的模型映射
理论说完了,回到工程。每个真实系统都隐含地选择了一组模型假设。理解这些假设,你才能理解系统的能力边界。
etcd/Raft:崩溃恢复 + 部分同步
etcd 的 Raft 实现假设:
- 故障模型:crash-recovery。节点可能崩溃并重启,但不会发送错误消息。所有持久化状态通过 WAL 保存。
- 通信模型:部分同步。Raft 使用心跳和选举超时来检测 Leader 故障,这隐含地假设了网络延迟最终会稳定在一个可预测的范围内。
- 节点数量:n = 2f+1。一个 3 节点集群容忍 1 个节点故障,5 节点容忍 2 个。
Raft 论文中明确写了一个时序要求:
broadcastTime << electionTimeout << MTBF
其中 broadcastTime 是广播一条消息并收到回复的平均时间,electionTimeout 是选举超时,MTBF 是节点的平均无故障时间。这个不等式就是部分同步假设的具体体现——系统必须在大部分时间满足这个条件。
假设被打破时会发生什么?
- 如果网络持续不稳定(broadcastTime 接近甚至超过 electionTimeout),集群会频繁选举,导致可用性下降。Safety 不受影响:Raft 保证已提交的日志不会被覆盖,无论网络状况如何。
- 如果节点被入侵并发送恶意消息,Raft 没有任何防护。一个被控制的 Leader 可以向 Follower 发送伪造的日志条目,整个集群的数据一致性被破坏。这就是 crash-recovery 模型的边界——它不考虑节点主动作恶。
Bitcoin/Nakamoto 共识:拜占庭 + 同步
Bitcoin 的共识机制(Nakamoto Consensus)做了一个让很多人意外的假设:它需要同步网络。
直觉上,Bitcoin 似乎很”去中心化”很”异步”。但仔细分析就会发现,PoW(Proof of Work)的安全性依赖一个关键假设:诚实矿工在下一个区块被挖出之前,能看到最新的区块。
用更形式化的语言说:设 Delta 为网络中消息传播的最大延迟,T 为出块间隔。Nakamoto 共识需要 Delta << T。Bitcoin 的 T 大约是 10 分钟,这给消息传播留了充足的时间。
如果这个假设不成立会怎样?假设消息传播非常慢,矿工 A 挖出了块 100,但矿工 B 在收到这个块之前就挖出了另一个块 100。这就产生了分叉(fork)。分叉越多,攻击者需要的算力比例就越低。
Pass、Seeman 和 shelat 在 2017 年的论文中形式化了这一点:Nakamoto 共识在同步模型下,只要诚实算力严格超过 50%,安全性就成立。但在异步模型下,即使攻击者只有 1% 的算力,也可能破坏一致性——因为异步网络中消息可以被无限延迟,攻击者可以利用这个窗口制造任意长的分叉。
这和 Raft 形成了一个有趣的对比:
| 维度 | Raft | Nakamoto |
|---|---|---|
| 故障模型 | Crash-recovery | Byzantine |
| 通信模型 | 部分同步 | 同步 |
| 安全性保证 | 确定性 | 概率性(等 6 个确认) |
| 终局性 | 即时(commit 即 final) | 需要等待(越多确认越安全) |
| 节点数量 | 固定,已知 | 动态,未知 |
Raft 在故障模型上假设更弱(只需要 crash),但在通信模型上假设也更弱(只需要部分同步)。Nakamoto 共识容忍更强的故障(Byzantine),但需要更强的通信假设(同步)。没有绝对更好的选择,只有适合不同场景的权衡。
PBFT 与 Tendermint:拜占庭 + 部分同步
PBFT(Practical Byzantine Fault Tolerance,Castro and Liskov, 1999)和 Tendermint(后来的 CometBFT)都工作在拜占庭故障 + 部分同步的模型下。
这是最困难的组合之一:既要容忍恶意节点,又不能假设网络是同步的。代价是什么?
- 节点数量:需要 n >= 3f+1。一个 4 节点系统只能容忍 1 个拜占庭节点。
- 通信复杂度:PBFT 的消息复杂度是 O(n^2)(每个 replica 需要和其他所有 replica 通信),这限制了它的扩展性。Tendermint 通过减少通信轮次优化了部分问题,但基本的 O(n^2) 瓶颈仍然存在。HotStuff(2019)进一步优化到了 O(n) 的消息复杂度(线性通信),代价是需要更多轮次。
Safety vs Liveness 的分离:
PBFT 和 Tendermint 都保证:
- Safety 在任何时候成立:即使网络完全异步(GST 之前),已达成共识的值也不会被推翻。
- Liveness 只在 GST 之后保证:如果网络持续异步,协议可能无法推进新的共识。
Tendermint 的特殊之处在于它提供了即时终局性(instant finality):一旦一个区块被提交(commit),它就是 final 的,不会被回滚。这和 Nakamoto 共识形成对比——Nakamoto 共识只有概率性终局,需要等待多个确认。
假设被打破的真实案例
理论分析之外,看看假设被打破时真实系统发生了什么。
案例一:Cloudflare 2020 年事件
2020 年 11 月,Cloudflare 的控制平面使用的 etcd 集群出现了问题。根据其事后复盘,原因之一是跨数据中心的网络延迟突然增大,超过了 etcd 的选举超时配置。Raft 的 Leader 频繁切换,导致控制平面服务不稳定。
这就是部分同步假设在真实世界的样子:大部分时间网络延迟在可预测范围内,etcd 运行良好。但当跨数据中心延迟出现异常时,broadcastTime 不再远小于 electionTimeout,Raft 的活性保证失效。
解决方案不是换协议,而是调整参数:增大选举超时,让 Raft 对延迟抖动更宽容。这是一种工程手段——本质上是在告诉协议”GST 可能需要更长时间才能到来,请更有耐心”。
故障检测与超时参数调优
Cloudflare 事件的根因是超时参数与实际网络延迟不匹配。这引出了分布式系统中一个核心工程问题:如何检测节点故障,以及如何设置超时参数。
从二元判断到连续怀疑度:phi 累积故障检测器
传统的故障检测器输出二元结果:节点要么”活着”,要么”死了”。但在真实网络中,这种非黑即白的判断过于粗暴。Hayashibara 等人在 2004 年提出了 phi(phi)累积故障检测器(The phi Accrual Failure Detector),将故障检测从二元判断转变为连续的怀疑度输出。
phi 累积故障检测器的核心思想是:基于历史心跳到达时间的统计分布,计算当前时刻”该节点已经故障”的概率,并将其映射为一个对数刻度的怀疑值 phi。具体而言,phi = -log10(1 - F(timeSinceLastHeartbeat)),其中 F 是基于历史数据拟合的累积分布函数。phi = 1 意味着错误判断的概率约为 10%,phi = 2 约为 1%,phi = 8 约为 10^-8。应用层可以根据自身对误判的容忍度选择不同的 phi 阈值——对可用性敏感的场景选择较低阈值(快速检测,容忍少量误判),对一致性敏感的场景选择较高阈值(避免误判,接受较慢检测)。Cassandra 和 Akka 等系统在生产中使用了这种故障检测器。
心跳检测的根本权衡
基于心跳的故障检测面临一个不可调和的根本权衡:检测速度与误判率之间的矛盾。设心跳间隔为 h,超时阈值为 T:
- 减小 T(更快检测):节点故障后更快被发现,系统恢复时间短。但正常的网络抖动也更容易触发误判,导致不必要的 Leader 切换、集群震荡。
- 增大 T(减少误判):只有在节点真的不可达时才触发故障判定,系统更稳定。但节点真正故障后,系统需要等待更长时间才能检测到并启动恢复流程。
这个权衡没有通用的最优解,只有针对具体部署环境的合理取舍。
超时与选举参数的调优经验
不同系统和部署环境需要不同的超时配置。以下是来自生产实践的经验值:
etcd 的默认配置为 heartbeat-interval=100ms、election-timeout=1000ms,选举超时是心跳间隔的 10 倍。这组参数适用于同数据中心内低延迟网络。对于跨数据中心部署,官方文档建议将参数调整为 heartbeat-interval=500ms、election-timeout=5000ms,以适应更高的网络延迟和更大的抖动范围。
Raft 论文给出了超时参数的基本约束:broadcastTime << electionTimeout << MTBF。其中 broadcastTime 是同一数据中心内的广播往返时间(通常 0.5-10ms),electionTimeout 应设为 broadcastTime 的 10 到 50 倍,MTBF(平均无故障时间)通常在月级别。这三个量级之间保持至少一个数量级的差距,才能确保系统在正常运行中不会因为偶发延迟触发选举,同时在节点真正故障时能及时检测并恢复。
一个实用的经验法则:选举超时应至少为网络 P99 RTT 的 5 到 10 倍。例如,如果跨数据中心的 P99 RTT 为 50ms,选举超时至少应设为 250-500ms。这个倍数留出了足够的安全边际,使得 99% 以上的正常网络抖动不会触发误判。
生产环境中一个常见的错误是在不同部署环境(裸金属服务器、Kubernetes 容器、跨数据中心)中使用相同的超时配置。裸金属环境的网络延迟通常在微秒到亚毫秒级别,Kubernetes 的 overlay 网络会增加额外延迟和抖动,跨数据中心的延迟则在数十毫秒量级。同一套超时参数不可能同时适配这三种场景——在裸金属上合理的 100ms 选举超时,放到跨数据中心环境可能导致持续性的选举风暴。
案例二:以太坊 2016 年 DAO 攻击的模型视角
2016 年 The DAO 攻击本身不是共识层面的问题(是智能合约漏洞),但以太坊社区随后决定硬分叉来回滚攻击交易。从系统模型的角度看,这相当于在协议层面”打破”了 safety 保证——一个已经被共识确认的交易被人为撤销了。
这揭示了一个系统模型理论通常不讨论的问题:模型中的”正确节点”是谁定义的? 在 The DAO 事件中,“正确”的定义发生了分歧——一部分人认为回滚是正确的(以太坊),另一部分人认为不应该干预(以太坊经典)。系统模型假设了一个客观的”正确”标准,但现实中这个标准有时是社会共识而非技术共识。
五、模型选择的工程决策框架
理论上的模型分类清楚了,但工程师面对具体系统设计时,怎么选?下面是一个决策框架。
第一个问题:你的节点之间是否互信?
如果所有节点都在你自己的控制下(比如同一个公司的内部服务),crash-recovery 模型通常足够。你信任自己的代码不会主动作恶(虽然 bug 可能导致类似效果,但大部分时候 crash-recovery 的假设是合理的)。
如果节点分属不同的利益方(比如多方协作、跨组织的系统、公链),你需要 Byzantine 模型。
第二个问题:你的网络延迟可预测吗?
同一个数据中心内的网络延迟通常在毫秒级,且抖动较小。这种环境下,部分同步假设很容易满足。
跨数据中心、跨国的网络延迟可能在几十到几百毫秒,且受到路由变化、拥塞控制等因素影响。部分同步仍然适用,但你需要更大的超时参数。
互联网上的 P2P 网络延迟最不可预测。如果你需要在这种环境下运行共识,同步假设通常需要很大的安全边际(比如 Bitcoin 的 10 分钟出块间隔)。
第三个问题:你能接受什么样的代价?
| 模型 | 节点代价 | 性能代价 | 复杂度代价 |
|---|---|---|---|
| Crash + 部分同步 | 2f+1 | 低(O(n) 消息) | 低 |
| Byzantine + 部分同步 | 3f+1 | 高(O(n^2) 消息) | 高 |
| Byzantine + 同步 | 2f+1 | 取决于同步参数 | 中 |
容忍拜占庭故障的代价不仅是多用节点,更主要的是通信复杂度上升和延迟增加。PBFT 在 4 个节点时表现不错,但扩展到 100 个节点时,O(n^2) 的消息量会成为瓶颈。
六、代码实验:不同故障模型下的消息传递行为
为了让这些抽象概念更具体,下面用 Go 写一个模拟器,展示三种故障模型下的消息传递行为差异。
package main
import (
"fmt"
"math/rand"
"strings"
"sync"
"time"
)
// Message 表示节点间传递的消息
type Message struct {
From int
To int
Content string
Round int
}
// Node 表示一个分布式系统中的节点
type Node struct {
ID int
Alive bool
Received []Message
mu sync.Mutex
}
// FaultModel 定义故障模型的行为
type FaultModel interface {
Name() string
// ShouldDeliver 决定一条消息是否应该被送达,以及可能被篡改成什么
ShouldDeliver(msg Message, faultyNodes map[int]bool) (deliver bool, modified Message)
// IsActive 决定一个节点在某一轮中是否活跃
IsActive(nodeID int, round int, faultyNodes map[int]bool) bool
}
// CrashStop 崩溃停止模型:节点崩溃后永远不再恢复
type CrashStop struct {
CrashRound map[int]int // 节点ID -> 崩溃的轮次
}
func (c *CrashStop) Name() string { return "Crash-Stop" }
func (c *CrashStop) ShouldDeliver(msg Message, faultyNodes map[int]bool) (bool, Message) {
// crash-stop 节点不会发送消息,不会篡改消息
if faultyNodes[msg.From] && msg.Round >= c.CrashRound[msg.From] {
return false, msg
}
return true, msg
}
func (c *CrashStop) IsActive(nodeID int, round int, faultyNodes map[int]bool) bool {
if !faultyNodes[nodeID] {
return true
}
return round < c.CrashRound[nodeID]
}
// CrashRecovery 崩溃恢复模型:节点崩溃后可以恢复,但丢失内存状态
type CrashRecovery struct {
CrashRound map[int]int // 节点ID -> 崩溃的轮次
RecoverRound map[int]int // 节点ID -> 恢复的轮次
}
func (c *CrashRecovery) Name() string { return "Crash-Recovery" }
func (c *CrashRecovery) ShouldDeliver(msg Message, faultyNodes map[int]bool) (bool, Message) {
if !faultyNodes[msg.From] {
return true, msg
}
cr := c.CrashRound[msg.From]
rr := c.RecoverRound[msg.From]
// 崩溃期间不发消息
if msg.Round >= cr && msg.Round < rr {
return false, msg
}
return true, msg
}
func (c *CrashRecovery) IsActive(nodeID int, round int, faultyNodes map[int]bool) bool {
if !faultyNodes[nodeID] {
return true
}
cr := c.CrashRound[nodeID]
rr := c.RecoverRound[nodeID]
// 在崩溃和恢复之间不活跃
return round < cr || round >= rr
}
// Byzantine 拜占庭模型:节点可以发送任意消息
type Byzantine struct{}
func (b *Byzantine) Name() string { return "Byzantine" }
func (b *Byzantine) ShouldDeliver(msg Message, faultyNodes map[int]bool) (bool, Message) {
if !faultyNodes[msg.From] {
return true, msg
}
// 拜占庭节点篡改消息内容
modified := msg
modified.Content = fmt.Sprintf("TAMPERED(%s)", msg.Content)
return true, modified
}
func (b *Byzantine) IsActive(nodeID int, round int, faultyNodes map[int]bool) bool {
return true // 拜占庭节点始终活跃(它们会主动发送恶意消息)
}
// simulate 运行模拟
func simulate(n int, rounds int, faultyIDs []int, model FaultModel) {
nodes := make([]*Node, n)
for i := 0; i < n; i++ {
nodes[i] = &Node{ID: i, Alive: true}
}
faultyNodes := make(map[int]bool)
for _, id := range faultyIDs {
faultyNodes[id] = true
}
fmt.Printf("\n=== %s Model (n=%d, f=%d, faulty=%v) ===\n",
model.Name(), n, len(faultyIDs), faultyIDs)
for round := 0; round < rounds; round++ {
fmt.Printf("\n--- Round %d ---\n", round)
for _, sender := range nodes {
if !model.IsActive(sender.ID, round, faultyNodes) {
fmt.Printf(" Node %d: CRASHED (inactive)\n", sender.ID)
continue
}
role := "correct"
if faultyNodes[sender.ID] {
role = "faulty"
}
for _, receiver := range nodes {
if sender.ID == receiver.ID {
continue
}
msg := Message{
From: sender.ID,
To: receiver.ID,
Content: fmt.Sprintf("value=%d", sender.ID),
Round: round,
}
deliver, finalMsg := model.ShouldDeliver(msg, faultyNodes)
if deliver && model.IsActive(receiver.ID, round, faultyNodes) {
receiver.mu.Lock()
receiver.Received = append(receiver.Received, finalMsg)
receiver.mu.Unlock()
fmt.Printf(" Node %d (%s) -> Node %d: %q\n",
sender.ID, role, receiver.ID, finalMsg.Content)
} else if !deliver {
fmt.Printf(" Node %d (%s) -> Node %d: [NOT DELIVERED]\n",
sender.ID, role, receiver.ID)
}
}
}
}
// 统计每个正确节点收到的消息
fmt.Printf("\n--- Summary ---\n")
for _, node := range nodes {
if faultyNodes[node.ID] {
continue
}
node.mu.Lock()
tampered := 0
for _, m := range node.Received {
if strings.HasPrefix(m.Content, "TAMPERED") {
tampered++
}
}
fmt.Printf(" Node %d received %d messages (%d tampered)\n",
node.ID, len(node.Received), tampered)
node.mu.Unlock()
}
}
func main() {
rand.New(rand.NewSource(time.Now().UnixNano()))
n := 5
rounds := 4
faultyIDs := []int{2} // 节点 2 是故障节点
// 场景 1:Crash-Stop,节点 2 在第 1 轮崩溃,永不恢复
simulate(n, rounds, faultyIDs, &CrashStop{
CrashRound: map[int]int{2: 1},
})
// 场景 2:Crash-Recovery,节点 2 在第 1 轮崩溃,第 3 轮恢复
simulate(n, rounds, faultyIDs, &CrashRecovery{
CrashRound: map[int]int{2: 1},
RecoverRound: map[int]int{2: 3},
})
// 场景 3:Byzantine,节点 2 篡改所有消息
simulate(n, rounds, faultyIDs, &Byzantine{})
}这个模拟器展示了三种模型的核心区别:
- Crash-Stop:节点 2 在第 1 轮崩溃后,后续轮次完全沉默。正确节点收到的消息数量减少,但所有收到的消息都是真实的。
- Crash-Recovery:节点 2 在第 1-2 轮不可用,第 3 轮恢复。恢复后节点 2 丢失了内存状态(模拟中体现为重新开始发送消息)。正确节点需要处理”消失又出现”的节点。
- Byzantine:节点 2 始终活跃,但发送的消息被篡改。正确节点收到了错误信息。如果协议不具备 BFT 能力,这些错误信息可能导致不一致。
七、模型的组合与现实的缝隙
理论模型是清晰的,但现实世界总是在模型的缝隙中运行。
灰色故障(Gray Failures)
2017 年,微软研究院发表了一篇论文,描述了他们在 Azure 中观察到的一类故障:灰色故障(Gray Failures)。这类故障不属于传统故障模型的任何一个类别。
一个典型例子:一个节点的网卡性能退化。它没有崩溃,仍然能发送和接收消息,但吞吐量下降了 90%。从 crash 模型看,它是活的;从协议层面看,它几乎和死了一样。如果它恰好是 Leader,整个集群的吞吐量都会被拖垮。
另一个例子:磁盘出现坏扇区。写入操作偶尔返回成功,但实际上数据没有正确持久化。这比 crash-recovery 更隐蔽——节点以为自己持久化了,但实际上丢了数据。
这些场景提醒我们:真实系统的故障模式远比理论模型丰富。 模型是简化的工具,帮助我们推理协议的正确性。但在生产环境中,你必须在模型之上叠加监控、健康检查、自动隔离等工程手段。
部分同步中的 GST 到底意味着什么
部分同步模型中的 GST 是一个理论构造——它假设”从某个时刻起,网络就稳定了”。但现实中网络稳定是一个渐进过程,不是一个开关。
实践中的做法是用自适应超时来近似 GST 的效果。etcd
允许配置 heartbeat-interval 和
election-timeout。如果网络延迟经常抖动,你需要设置更大的超时值。但超时值越大,故障检测越慢——你需要在”误判活节点为死”(false
positive)和”漏判死节点为活”(false negative)之间权衡。
八、总结
系统模型不是学术概念的堆砌。它是分布式系统设计的基础框架——你做的每一个设计决策,都隐含了一组模型假设。
核心结论:
通信模型决定了共识的可能性边界。 同步模型下共识容易,异步模型下确定性共识不可能(FLP),部分同步模型是实践中的平衡点。
故障模型决定了容错的代价。 从 crash-stop 到 Byzantine,容错能力递增,但节点数量、通信复杂度、协议复杂度的代价也递增。n >= 2f+1(crash)和 n >= 3f+1(Byzantine)不是随意的数字,而是由 quorum intersection 的数学性质决定的。
真实系统都在某个模型空间中运行。 Raft 在 crash-recovery + 部分同步,Bitcoin 在 Byzantine + 同步,PBFT/Tendermint 在 Byzantine + 部分同步。理解它们的模型位置,你才能理解它们的能力和边界。
假设被打破时,系统行为是可预测的。 部分同步协议在网络不稳定时丢失 liveness 但保持 safety;同步协议在网络延迟超出预期时可能丢失 safety。知道你的协议会以哪种方式失败,比盲目信任”容错”二字有用得多。
下一篇我们会深入探讨 FLP、CAP 和其他不可能性结果——在分布式系统中,知道什么是不可能的,往往比知道什么是可能的更有价值。
上图展示了主要协议在”时序模型 x 故障模型”二维空间中的位置。蓝色虚线框内是确定性共识可解的区域;红色虚线框是 FLP 不可能区域(异步 + crash,确定性共识不可解)。注意 Bitcoin 位于右上角(Byzantine + 同步),而 Raft/etcd 位于中间偏左(crash-recovery + 部分同步)。一个系统从左下到右上移动,意味着它要应对更强的故障、付出更高的代价。
导航
- 下一篇:FLP、CAP 与不可能性结果
参考资料
论文
- Fischer, M. J., Lynch, N. A., Paterson, M. S. “Impossibility of Distributed Consensus with One Faulty Process.” Journal of the ACM, 32(2):374-382, 1985.
- Dwork, C., Lynch, N., Stockmeyer, L. “Consensus in the Presence of Partial Synchrony.” Journal of the ACM, 35(2):288-323, 1988.
- Lamport, L., Shostak, R., Pease, M. “The Byzantine Generals Problem.” ACM Transactions on Programming Languages and Systems, 4(3):382-401, 1982.
- Castro, M., Liskov, B. “Practical Byzantine Fault Tolerance.” OSDI, 1999.
- Ben-Or, M. “Another Advantage of Free Choice: Completely Asynchronous Agreement Protocols.” PODC, 1983.
- Ongaro, D., Ousterhout, J. “In Search of an Understandable Consensus Algorithm.” USENIX ATC, 2014.(Raft 论文)
- Pass, R., Seeman, L., shelat, a. “Analysis of the Blockchain Protocol in Asynchronous Networks.” EUROCRYPT, 2017.
- Buchman, E., Kwon, J., Milosevic, Z. “The latest gossip on BFT consensus.” arXiv:1807.04938, 2018.(Tendermint)
- Yin, M., Malkhi, D., Reiter, M. K., Gueta, G. G., Abraham, I. “HotStuff: BFT Consensus with Linearity and Responsiveness.” PODC, 2019.
- Huang, P., Guo, C., Zhou, L., Lorch, J. R., Dang, Y., Chintalapati, M., Yao, R. “Gray Failure: The Achilles’ Heel of Cloud-Scale Systems.” HotOS, 2017.
- Hayashibara, N., Défago, X., Yared, R., Katayama, T. “The phi Accrual Failure Detector.” IEEE SRDS, 2004.
源码
- etcd/raft: https://github.com/etcd-io/raft (Go 实现的 Raft 库)
- CometBFT (原 Tendermint): https://github.com/cometbft/cometbft
书籍
- Cachin, C., Guerraoui, R., Rodrigues, L. Introduction to Reliable and Secure Distributed Programming. 2nd Edition, Springer, 2011.(系统模型形式化定义的经典教材)
- Attiya, H., Welch, J. Distributed Computing: Fundamentals, Simulations, and Advanced Topics. 2nd Edition, Wiley, 2004.
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【分布式系统百科】FLP、CAP 与不可能性结果:被误读的三座里程碑
FLP 说异步系统中共识不可能,但 Raft 跑得好好的。CAP 说三选二,但 Spanner 声称兼得。矛盾不在定理本身,在于读定理时跳过了精确条件。本文回到 FLP、CAP、共识数和收割/产出四篇原始论文,逐一拆解它们的真实陈述、证明思路与工程边界。
【分布式系统百科】共识协议的工程权衡:Raft vs Multi-Paxos vs EPaxos 实测对比
从性能基准、选型决策、隐藏成本三个维度,系统对比 Raft、Multi-Paxos、EPaxos 三大共识协议在工程实践中的真实表现,帮助架构师做出有据可依的选型决策。
【分布式系统百科】Raft 深度重写:从论文的 18 页到 etcd 的 15000 行
Raft 论文 18 页就能读完,但 etcd/raft 用了 15000 行 Go 才把它变成能在生产环境跑的代码。这篇文章从论文的每一个核心机制出发,逐一拆解工程实现中论文没说的东西:PreVote、ReadIndex、LeaderTransfer、ConfChange V2、流水线复制、Async Apply,以及 TiKV 的 Multi-Raft 实践。最后做一次精确的 Paxos 对比,并坦诚讨论 Raft 的已知缺陷。
【分布式系统百科】EPaxos 与 Flexible Paxos:打破 Leader 瓶颈的两条路
Multi-Paxos 和 Raft 都依赖单一 Leader 排序所有写请求,Leader 成为吞吐瓶颈和延迟下限。EPaxos 用无主依赖图替代全序日志,Flexible Paxos 用不对称 Quorum 让写路径绕过多数节点。两条路的核心机制、隐含假设、工程代价和已知陷阱。