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+1 quorum 设计,因为签名不能防止“被攻陷的节点用合法密钥作恶”。

故障模型怎么选:一张决策表

你的场景 假设的故障 需要的机制
普通微服务 + 数据库 崩溃-恢复 + 遗漏 重试 + 幂等 + 多数派复制
元数据/配置(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 处理、网络按遗漏/分区处理、时钟按漂移处理。关键是分层写清假设:每层信任下面哪一层的保证,以及 “如果假设不成立,最坏会怎样”。写不清假设的系统,换环境就会出 “没人能解释” 的故障。

关联