22.1 故障类型 (崩溃 / 遗漏 / 时序 / 拜占庭)
一句话
故障模型决定算法能做到什么——崩溃可以靠多数派和重试兜住,遗漏要靠重传与去重,时序要区分“慢”与“死”,拜占庭要 3f+1 个副本才够;拿崩溃方案解拜占庭,等于没解。
五类故障:从“最好处理”到“最难处理”
| 类型 | 故障节点会做什么 | 检测难度 | 典型应对 | 副本公式 |
|---|---|---|---|---|
| 崩溃-停止(crash-stop) | 挂掉后永不恢复,不再发任何消息 | 低(超时) | 多数派 + 重试 + 选主 | 2f+1 |
| 崩溃-恢复(crash-recovery) | 挂掉后重启,可能丢内存状态 | 中(重启后可能“失忆”) | 持久化日志 + 恢复协议 | 2f+1 |
| 遗漏(omission) | 漏发/漏收消息(网络丢包、队列满) | 中(与“慢”难分) | 重传 + 幂等 + 去重 | 2f+1(需重传) |
| 时序/性能(timing) | 不丢消息,但慢到超过超时 | 高(与崩溃无法区分) | 自适应超时、φ 值、部分同步假设 | 2f+1(加假设) |
| 拜占庭(byzantine) | 任意行为:说谎、伪造、选择性不发 | 极高 | BFT 协议、签名、3f+1 | 3f+1 |
🔑 这五类不是“五种不同的 bug”,而是五种假设。你选的假设越弱(越坏),算法越复杂、副本越多、性能越差。工程上 99% 的系统只假设“崩溃-恢复”,因为拜占庭容错的成本是 3 倍副本 + 数倍消息。
每一类故障在工程里的样子
| 类型 | 线上表现 | 最容易误判成 |
|---|---|---|
| 崩溃-停止 | 进程消失、端口不通、心跳全无 | “网络问题” |
| 崩溃-恢复 | 重启后日志回放、内存缓存丢失、重复消费 | “数据不一致” |
| 遗漏 | 偶发超时、重传风暴、消息重复 | “下游慢” |
| 时序 | P99 抖动、GC 停顿、跨城链路拥塞 | “崩溃”(然后误切换) |
| 拜占庭 | 节点返回错误但格式合法的数据、双花、伪造签名 | “业务 bug” |
最容易出事的是“时序”:它不丢消息、不宕机,只是慢(这正是互斥里最危险的一类,见 20.1 互斥问题与临界资源 (安全性 / 活性))。而所有故障检测都基于超时——慢到超过阈值就被判死,触发切换;切换后原节点恢复,继续以“我还活着”的姿态服务,于是双主(见 17.3 分区期间的取舍)。
相关故障:比“拜占庭”更常见的杀手
教科书讨论“单个节点坏”,现实里更常见的是相关故障:一个机架、一个 AZ、一个交换机、一次配置推送,同时让多个副本不可用。
| 故障 | 独立故障假设 | 现实 |
|---|---|---|
| 3 副本跨 3 AZ,挂 1 个 AZ | 多数派仍可用(2/3) | 与假设一致 |
| 3 副本全在同机房,机房断电 | 3 个“独立”故障同时发生 | 全部不可用 |
| 一次配置推送打挂所有节点 | 概率极低 | 一次变更即全灭 |
| 时钟同步故障 | 各节点独立漂移 | 全体同时漂移,租约同时失效 |
结论:副本数买的是独立故障的容忍度,副本分布买的是相关故障的容忍度。这也是“两地三中心”必须把 quorum 放进三个故障域的原因(17.4 PACELC)。
拜占庭:为什么是 3f+1,而不是 2f+1
共识的定义与副本公式见 21.1 共识问题定义。崩溃模型下,多数派 f+1 就够:任意两个多数派必相交,交集里至少一个是诚实的,它见过之前的值。
拜占庭模型下,交集里可能全是叛徒——如果两个 quorum 只交 1 个节点,而它恰好是恶意的,它可以对两边说不同的话(equivocation)。所以 quorum 必须放大到 2f+1,副本总数 n ≥ 3f+1:
n = 3f+1,q = 2f+1
任意两个 quorum 的交集大小 ≥ 2q - n = 2(2f+1) - (3f+1) = f+1
→ 交集里至少有 1 个诚实节点 → 它不会对两边说不同的话
用程序把这两条边界验证一遍:
package main
import "fmt"
// 两个大小 q 的 quorum 是否必相交:不相交 ⇔ 2q <= n
func disjointExists(n, q int) bool { return 2*q <= n }
func main() {
fmt.Println("=== 崩溃模型:多数派 quorum 必相交 ===")
fmt.Printf("%-4s %-4s %-8s %-10s\n", "n", "q", "2q>n", "可不相交")
for n := 3; n <= 7; n++ {
q := n/2 + 1
fmt.Printf("%-4d %-4d %-8v %-10v\n", n, q, 2*q > n, disjointExists(n, q))
}
fmt.Println("\n=== 拜占庭:n=3f+1、q=2f+1 必相交 ===")
fmt.Printf("%-3s %-8s %-7s %-8s %-10s\n", "f", "n=3f+1", "q=2f+1", "2q>n", "交集下界")
for f := 0; f <= 3; f++ {
n, q := 3*f+1, 2*f+1
fmt.Printf("%-3d %-8d %-7d %-8v %-10d\n", f, n, q, 2*q > n, 2*q-n)
}
fmt.Println("\n=== 反例:n=4、f=1,若用 q=2 ===")
fmt.Println("2q=4 不大于 n=4 → {1,2} 与 {3,4} 可以不相交")
fmt.Println("若 {1,2} 里的节点是恶意的,它可以对一组说 v=1、对另一组说 v=2")
fmt.Println("两组都以为自己是多数派 → 双决策;所以必须 q=2f+1=3")
}
本机实测(macOS,Apple M1,Go 1.27):
=== 崩溃模型:多数派 quorum 必相交 ===
n q 2q>n 可不相交
3 2 true false
4 3 true false
5 3 true false
6 4 true false
7 4 true false
=== 拜占庭:n=3f+1、q=2f+1 必相交 ===
f n=3f+1 q=2f+1 2q>n 交集下界
0 1 1 true 1
1 4 3 true 2
2 7 5 true 3
3 10 7 true 4
=== 反例:n=4、f=1,若用 q=2 ===
2q=4 不大于 n=4 → {1,2} 与 {3,4} 可以不相交
若 {1,2} 里的节点是恶意的,它可以对一组说 v=1、对另一组说 v=2
两组都以为自己是多数派 → 双决策;所以必须 q=2f+1=3
| 数字 | 含义 |
|---|---|
崩溃:2q > n 恒成立 | 多数派天然相交,f 个故障可容忍 |
拜占庭:n = 3f+1 | f=1 要 4 个副本,f=2 要 7 个 |
交集下界 f+1 | 恶意节点再多,也至少有一个诚实节点在交集里 |
💡 注意:
3f+1是口头消息(无签名)下的经典界;有签名/认证时模型会变化,但工程上 PBFT 类协议仍按3f+1副本、2f+1quorum 设计,因为签名不能防止“被攻陷的节点用合法密钥作恶”。
故障模型怎么选:一张决策表
| 你的场景 | 假设的故障 | 需要的机制 |
|---|---|---|
| 普通微服务 + 数据库 | 崩溃-恢复 + 遗漏 | 重试 + 幂等 + 多数派复制 |
| 元数据/配置(etcd) | 崩溃-恢复 | Raft(2f+1) |
| 金融清算(内部) | 崩溃 + 时序 | 多数派 + 对账 + 审计 |
| 跨组织联盟链 | 拜占庭 | BFT(3f+1)+ 签名 |
| 边缘设备/不可信节点 | 拜占庭 | BFT 或“不信任 + 校验” |
| 强监管、防内部作恶 | 拜占庭(部分) | 双人复核、签名、可验证日志 |
判断口诀:节点是“我的”,还是“别人的/可能被攻陷的”?前者用崩溃模型,后者才需要拜占庭。
故障注入:把假设变成测试
| 注入什么 | 验证什么 |
|---|---|
kill -9 进程 | 崩溃恢复、重启后状态是否正确 |
| 网络丢包/延迟(tc netem) | 遗漏与时序假设、重传与幂等 |
| 时钟跳变(libfaketime) | 租约与超时逻辑 |
| 磁盘满/只读 | 持久化失败路径 |
| 单 AZ / 单机房断网 | 相关故障下的 quorum 行为 |
| 拜占庭模拟(返回错误数据) | 校验、签名、BFT 逻辑 |
反模式
| 反模式 | 后果 |
|---|---|
| 用崩溃模型处理拜占庭节点 | 恶意节点伪造数据,多数派也救不了(算法差异见 21.12 共识算法对比) |
| 假设“各副本独立故障” | 同机房/同配置的副本一起挂 |
| 用固定超时判断“死” | GC 停顿被误判,触发双主 |
| 只做单点故障演练 | 真实故障常是相关故障(整个 AZ) |
| 认为“有签名就安全” | 密钥泄露后签名照样伪造,仍需 quorum 与审计 |
| 不区分“慢”与“死” | 频繁无谓切换,比故障本身更伤可用性 |
自检
Q1 为什么崩溃模型只要 2f+1 个副本,拜占庭却要 3f+1?
▸ 答案:崩溃模型里,坏节点只是“不说话”,任意两个多数派(f+1)必相交,交集里的诚实节点一定见过之前的值,所以不会出现两个决定。拜占庭模型里,坏节点会“说不同的话”,若两个 quorum 只交 1 个节点且它恰好恶意,就能对两边各说一套;要让交集里至少有 f+1 个节点(其中至少 1 个诚实),需要 2q - n ≥ f+1,代入 q = 2f+1、n = 3f+1 得交集 f+1,所以副本必须 3f+1。
Q2 “节点 5 秒没回心跳”能判定它崩溃了吗?这说明哪类故障最难处理?
▸ 答案:不能。5 秒没回可能是 GC 停顿、CPU 抢占、网络拥塞(时序故障),也可能是进程真挂了(崩溃)。超时只能给出“可疑”,不能给出“确定”——这正是时序故障最难的地方:它不丢消息、不宕机,只是慢,而所有检测都基于“多久算慢”。处理办法是自适应检测(φ accrual,见 35.4 故障检测)、滞回(连续 N 次)、以及 fencing token 让“误判”不至于造成双写(48.3 分布式锁)。
Q3 为什么“3 副本跨 3 AZ”比“3 副本同机房”更可靠,尽管副本数一样?
▸ 答案:因为故障是相关的。同机房时,一次断电/交换机故障/空调故障会同时打掉 3 个副本,多数派瞬间消失;跨 3 AZ 时,单 AZ 故障只影响 1 个副本,剩 2 个仍能形成多数派。副本数决定“能容忍几个独立故障”,副本的故障域分布决定“能容忍哪些相关故障”。前者是数量问题,后者是放置问题。
Q4 一个团队在联盟链场景用了 Raft(3 节点),说“我们也用了共识,所以安全”。问题在哪?
▸ 答案:Raft 的故障模型是崩溃-恢复,它假设节点只会“不说话”,不会“说谎”。联盟链里节点属于不同组织,可能被攻陷后伪造交易、选择性广播、对不同的对等节点说不同的话——这是拜占庭故障,Raft 的多数派机制完全防不住(恶意节点可以正常参与投票,同时篡改业务数据)。正确做法是用 BFT 协议(3f+1 副本、2f+1 quorum、签名与交叉校验),并配合审计与可验证日志。
Q5 为什么 “时序故障”(如消息乱序/时钟漂移)比 “崩溃故障” 更难对付?
▸ 答案:因为崩溃是显式的(连接断开、心跳消失),可以被检测并交给超时机制;时序故障是隐式的——消息到了、进程活着,但顺序错了或时钟不一致,系统不会报错,只会算错。所以时序问题要靠 “因果/逻辑时钟 + 幂等 + 版本号” 在协议层消除,而不是靠监控发现。
Q6 同一系统里可以同时存在多种故障模型吗?
▸ 答案:可以,而且通常如此:进程崩溃按 crash-stop 处理、网络按遗漏/分区处理、时钟按漂移处理。关键是分层写清假设:每层信任下面哪一层的保证,以及 “如果假设不成立,最坏会怎样”。写不清假设的系统,换环境就会出 “没人能解释” 的故障。