土法炼钢兴趣小组的算法知识备份

【分布式系统百科】分布式系统模型:你的假设决定你的命运

文章导航

分类入口
distributed
标签入口
#system-model#distributed-systems#FLP#partial-synchrony#Byzantine#crash-recovery#consensus

目录

你在设计一个分布式存储系统。三个节点,数据要强一致。你选了 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。

形式化地说:

在这个模型下,超时检测(timeout-based failure detection)是完美的。如果你在 2*Delta 时间内没收到某个节点的回复,你可以确定它已经崩溃了——因为如果它还活着,消息一定已经到了。

同步模型下共识问题容易得多。经典的 Dolev-Strong 协议(1983)可以在同步网络中容忍任意数量的拜占庭故障节点(只要诚实节点数 >= 1),代价是需要 f+1 轮通信。

问题在于:现实网络不是同步的。 TCP 重传、路由抖动、垃圾回收停顿、虚拟机调度延迟,都可能让消息延迟远超任何预设上界。如果你基于同步模型设计协议,一旦网络行为超出假设,协议的安全性(safety)或活性(liveness)就可能被破坏。

那为什么还要研究同步模型?两个原因:

  1. 它是理论分析的基准线——在最好的情况下能做到什么。
  2. 有些协议确实依赖同步假设,比如 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 做了不同的要求:

换句话说,协议在网络不稳定时可以”卡住”(不推进),但绝不能”出错”(做出互相矛盾的决定)。一旦网络恢复稳定,协议必须能继续推进。

这正是 Paxos、Raft、PBFT 等主流共识协议采用的模型。它们都保证:

回到开头的例子: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)

遗漏故障模型允许进程丢失消息,但不允许进程发送错误的消息。它分两种:

遗漏故障比 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 实现假设:

Raft 论文中明确写了一个时序要求:

broadcastTime << electionTimeout << MTBF

其中 broadcastTime 是广播一条消息并收到回复的平均时间,electionTimeout 是选举超时,MTBF 是节点的平均无故障时间。这个不等式就是部分同步假设的具体体现——系统必须在大部分时间满足这个条件。

假设被打破时会发生什么?

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)都工作在拜占庭故障 + 部分同步的模型下。

这是最困难的组合之一:既要容忍恶意节点,又不能假设网络是同步的。代价是什么?

Safety vs Liveness 的分离

PBFT 和 Tendermint 都保证:

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:

这个权衡没有通用的最优解,只有针对具体部署环境的合理取舍。

超时与选举参数的调优经验

不同系统和部署环境需要不同的超时配置。以下是来自生产实践的经验值:

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{})
}

这个模拟器展示了三种模型的核心区别:

七、模型的组合与现实的缝隙

理论模型是清晰的,但现实世界总是在模型的缝隙中运行。

灰色故障(Gray Failures)

2017 年,微软研究院发表了一篇论文,描述了他们在 Azure 中观察到的一类故障:灰色故障(Gray Failures)。这类故障不属于传统故障模型的任何一个类别。

一个典型例子:一个节点的网卡性能退化。它没有崩溃,仍然能发送和接收消息,但吞吐量下降了 90%。从 crash 模型看,它是活的;从协议层面看,它几乎和死了一样。如果它恰好是 Leader,整个集群的吞吐量都会被拖垮。

另一个例子:磁盘出现坏扇区。写入操作偶尔返回成功,但实际上数据没有正确持久化。这比 crash-recovery 更隐蔽——节点以为自己持久化了,但实际上丢了数据。

这些场景提醒我们:真实系统的故障模式远比理论模型丰富。 模型是简化的工具,帮助我们推理协议的正确性。但在生产环境中,你必须在模型之上叠加监控、健康检查、自动隔离等工程手段。

部分同步中的 GST 到底意味着什么

部分同步模型中的 GST 是一个理论构造——它假设”从某个时刻起,网络就稳定了”。但现实中网络稳定是一个渐进过程,不是一个开关。

实践中的做法是用自适应超时来近似 GST 的效果。etcd 允许配置 heartbeat-intervalelection-timeout。如果网络延迟经常抖动,你需要设置更大的超时值。但超时值越大,故障检测越慢——你需要在”误判活节点为死”(false positive)和”漏判死节点为活”(false negative)之间权衡。

八、总结

系统模型不是学术概念的堆砌。它是分布式系统设计的基础框架——你做的每一个设计决策,都隐含了一组模型假设。

核心结论:

  1. 通信模型决定了共识的可能性边界。 同步模型下共识容易,异步模型下确定性共识不可能(FLP),部分同步模型是实践中的平衡点。

  2. 故障模型决定了容错的代价。 从 crash-stop 到 Byzantine,容错能力递增,但节点数量、通信复杂度、协议复杂度的代价也递增。n >= 2f+1(crash)和 n >= 3f+1(Byzantine)不是随意的数字,而是由 quorum intersection 的数学性质决定的。

  3. 真实系统都在某个模型空间中运行。 Raft 在 crash-recovery + 部分同步,Bitcoin 在 Byzantine + 同步,PBFT/Tendermint 在 Byzantine + 部分同步。理解它们的模型位置,你才能理解它们的能力和边界。

  4. 假设被打破时,系统行为是可预测的。 部分同步协议在网络不稳定时丢失 liveness 但保持 safety;同步协议在网络延迟超出预期时可能丢失 safety。知道你的协议会以哪种方式失败,比盲目信任”容错”二字有用得多。

下一篇我们会深入探讨 FLP、CAP 和其他不可能性结果——在分布式系统中,知道什么是不可能的,往往比知道什么是可能的更有价值。

系统模型频谱图

上图展示了主要协议在”时序模型 x 故障模型”二维空间中的位置。蓝色虚线框内是确定性共识可解的区域;红色虚线框是 FLP 不可能区域(异步 + crash,确定性共识不可解)。注意 Bitcoin 位于右上角(Byzantine + 同步),而 Raft/etcd 位于中间偏左(crash-recovery + 部分同步)。一个系统从左下到右上移动,意味着它要应对更强的故障、付出更高的代价。


导航


参考资料

论文

源码

书籍

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-13 · distributed

【分布式系统百科】Raft 深度重写:从论文的 18 页到 etcd 的 15000 行

Raft 论文 18 页就能读完,但 etcd/raft 用了 15000 行 Go 才把它变成能在生产环境跑的代码。这篇文章从论文的每一个核心机制出发,逐一拆解工程实现中论文没说的东西:PreVote、ReadIndex、LeaderTransfer、ConfChange V2、流水线复制、Async Apply,以及 TiKV 的 Multi-Raft 实践。最后做一次精确的 Paxos 对比,并坦诚讨论 Raft 的已知缺陷。


By .