DDIA · 分布式故障与共识

Ch.9-10: 最难的论证链 — 网络不可靠 ⇒ 超时是唯一手段 ⇒ 暂停/时钟不可信 ⇒ fencing token ⇒ 共识给出全序日志

分布式系统第一定理式推理链: 每一步都是被迫的 ① partial failure 部分节点坏了其他正常 + 结果非确定 — 分布式的定义性特征 ② 六种情形不可区分 丢包/延迟/乱序/对端死/ 对端忙/对端慢 异步网络里无法分辨 ③ timeout 唯一手段 超时≠死亡: 可能只是慢 宣告死亡须多数派同意 没有"正确值", 按分布自适应 ④ 暂停与时钟不可信 GC 停顿: 你暂停期间 世界继续, 你可能已被废 lease 也不够 → 要单调令牌 ⑤ fencing token 锁服务发单调递增编号 存储拒绝旧 token 的写 epoch/term 就是它 (共识) 结论: "他死了"只能是怀疑 (φ 积累/多数派), "我的写有效"必须由单调编号背书 — 这是全章的骨架 system model: timing(同步/部分同步/异步) × fault(crash-stop / crash-recovery / byzantine) — BFT 需 >2/3 正确节点, 内网系统几乎不用 safety (坏事永不发生) vs liveness (好事最终发生): eventual consistency 是 liveness, 不承诺时限 共识骨架 (Raft/Paxos/Zab 通用): epoch 选举 + 多数派 ① 选举 (epoch=term) 每期至多一个 leader ② quorum 投票 多数同意才上任 ③ 日志复制 leader 按序追加 ④ 多数确认 → 提交 日志序号 = fencing token 冲突裁决: 高 epoch 胜 旧 leader 带旧 term 来写 → 被拒绝 = fencing token 在共识里的形态 "外包"共识: ZooKeeper / etcd / Chubby 提供: 线性一致 CAS · lease · watch(键变化推送) · ephemeral node 用途: 选主 / 元数据 / 分区分配 — 小数据强一致, 别当数据库用 时钟、线性一致与 CAP 的正确读法 墙上时钟 time-of-day 可回跳 (NTP/闰秒), 跨机器 读数不可直接比较 ✗ 测时长 ✗ 排序事件 单调时钟 monotonic 只前进, 绝对值无意义 测 duration/timeout 用它 ✓ 算 elapsed 的唯一选择 CAP 正确读法: P 是必选项, 分区发生时在 C/A 间选 PACELC 补后半句: 无分区时也要在 延迟(L) 与 一致(C) 间取舍 — 不分区≠免费 线性一致 (recency) ≠ 可串行化 (隔离) ≠ 因果一致 (见结果先见原因) 全序广播 ≡ 等价于单值共识 消息可靠投递 + 人人以相同顺序读出 = 共享日志 状态机复制的地基: 所有输入过同一全序日志 → 副本状态一致 (Ch.13)

不可区分性是起点

  • • 请求慢≠节点死, 六种情形网络里无法分辨
  • • timeout 是唯一手段, 但超时≠死亡
  • • 宣告死亡要多数派, 不能单方面
  • • GC 暂停让你"死了还活着"

fencing 是答案

  • • 锁服务发单调递增 token
  • • 存储拒绝旧 token 的写 (zombie 防护)
  • • 共识的 epoch/term 就是 fencing
  • • Paxos/Raft 统一骨架: 选举+多数派+日志

共识的正确用法

  • • ZK/etcd: 选主/元数据/lease/watch
  • • 小数据强一致, 不是数据库
  • • 全序广播 ≡ 共享日志 ≡ 状态机复制
  • • 线性一致读要 quorum, 有成本

💡 一句话理解

这一章是一个被迫的推理链: 网络不可靠 → "对面到底死没死"永远无法确定, 只能怀疑 (timeout + 多数派裁决); 进程随时可能暂停 (GC), 暂停完的自己不知道世界已经前进 → 所以"我有锁"不算数, 要有单调递增的编号 (fencing token) — 存储只认最新的号。共识 (Raft/Paxos) 就是把这套编号机制制度化的算法: epoch 选举 + 多数派投票 + 全序日志, 产出的日志序号本身就是 fencing token。时钟呢? 墙钟会撒谎 (回跳/漂移), 测时长只能用单调时钟, 跨机器排序事件只能靠逻辑时钟。

🧠 必知必会 必考 & 必会

partial failure
部分节点坏了其他正常 + 结果非确定 (同一命令时好时坏) — 单机编程从没有过的组合, 是分布式一切麻烦的源头。
# 同一个请求, 三次三种结果:
#   ① 成功  ② 网络丢了  ③ 节点处理完但响应丢了
# 单机世界: 要么成功要么抛异常, 状态确定
不可区分性
丢包/延迟/乱序/对端死/对端忙/对端慢 — 异步网络中六种情形外部观测完全相同: 都表现为"没收到回音"。
send(req) → 等 3s → 没回音
# 是网断了? 对端 GC? 对端死了? 对端慢?
# 观测者无法区分 — 只能"怀疑"
timeout 与多数派
超时是处理"不知道"的唯一手段, 但超时≠死亡。宣告节点死亡影响重大 (触发选主), 必须多数派同意 — 不可能同时出现两个冲突的多数。
# minority 宣布 leader 死了并选新主?
# 多数派说: 他没死 → 两个 leader = 脑裂
# 规则: 重大决策 = 多数派投票
墙上钟 vs 单调钟
time-of-day 可回跳 (NTP 校准/闰秒), 只适合"报时间"; monotonic 只前进, 绝对值无意义, 测时长唯一合法。
# 错:
start = time.time(); ...; print(time.time()-start)
# NTP 回跳 → elapsed 为负!
# 对:
t0 = time.monotonic(); ...; time.monotonic()-t0
时钟漂移与 NTP
石英钟每天漂几秒级 (200ppm), 靠 NTP 校准; 公网 NTP 误差 35ms~1s, 且 NTP 服务器本身可能错 — 跨机器时间戳不可直接比较。
# 机器 A: 10:00:00.100 (NTP 校过)
# 机器 B: 09:59:59.900 (慢 200ms)
# "A 的事件晚于 B" — 用墙钟判断 = 赌博
TrueTime
Spanner 把时钟读数变成置信区间 [earliest, latest]: 提交前等待区间过尽, 换取外部一致性 — 硬件 (GPS+原子钟) 是前提。
tt = truetime.now()        # → [t-2ms, t+2ms]
commit_wait(tt.latest)     # 等到区间过尽再提交
# 普通机房没有原子钟 — 别照抄
process pause
GC STW/VM 挂起可任意长: 暂停期间世界继续走, 你的锁可能已被转移 — "我在临界区里"不是护身符。
acquire(lock)          # 拿到锁
# ← GC 停顿 10s;  期间锁过期, 另一进程接管
write(storage)         # 醒来继续写 — zombie 写!
lease 与 fencing
lease = 带超时的锁; 但"检查 lease→执行写"之间仍可能暂停 — 唯一可靠解: 锁服务发单调递增 fencing token, 存储拒绝旧 token。
lock(svc) → token = 33       # 每次授予递增
write(storage, data, token=33)
# zombie 拿着 token=32 来写 → 存储: 拒绝 (33 已见)
safety / liveness
safety: 坏事永不发生 (无前提); liveness: 好事最终发生 (有前提)。最终一致性是 liveness — 不承诺多久, 不承诺顺序。
# safety: "不会有两个 leader" (永远)
# liveness: "最终会选出 leader" (无分区时)
# FLP: 异步+无超时下 liveness 无法保证 → 超时解锁
线性一致性
写一旦完成, 所有后续读必见新值 — 单对象 recency 语义。成本: 读也要过 quorum, 延迟高; 与可串行化 (多对象隔离) 是两个维度。
set(x=1) 完成 ──────────────→
get(x)          # 必须 =1 (任何副本/任何客户端)
# 非"最终一致"的读可能 =0 — 那是因果/最终一致
CAP / PACELC
CAP: 分区 (P) 必然存在, 分区时在 C/A 间二选一, "三选二"是误导; PACELC 补充: 无分区时也要在 延迟 与 一致 间取舍。
# 网络永远会有分区 → P 是既定事实
# 分区时: 拒绝写 (保C) or 照常服务 (保A)
# 无分区: 同步复制=延迟↑, 异步=可能旧 (ELC)
Lamport / HLC
Lamport: (counter, nodeID), 生成前自增、见大抬升 — 给出与因果一致的全序, 但无法揭示并发; HLC = 物理刻度 + 逻辑追赶 (CockroachDB)。
# 节点A: ts=(1,A) 发消息 → 节点B 收到
# B: max(自己, 收到)+1 → (2,B) — 因果被编码进序
# (1,A) 与 (2,B) 可排序;  并发时按 nodeID 决胜
全序广播 ≡ 共识
可靠投递 + 人人以相同顺序读出 = 共享日志; 等价于"就一系列值达成共识" — 状态机复制的地基, Kafka 单分区的本质。
# 所有副本按 [m1, m2, m3] 同序应用 → 状态一致
# 每条消息的 offset 就是全序位置 (也是 fencing)

🏭 生产实战 real world

场景 1 · 自适应超时: 用 RTT 分布代替拍脑袋

固定 1s 超时在抖动网络下要么误杀要么等死 — φ 积累思想: 按历史分布算"怀疑度"。

import statistics

rtts = load_recent_rtt(window=100)      # 最近 100 次 RTT
mu, sd = statistics.mean(rtts), statistics.pstdev(rtts)

def phi(elapsed):                        # 怀疑度
    return (elapsed - mu) / max(sd, 0.001)

# phi > 4 → 怀疑 (≈ 万分之几的误判), 动态且自适应
# 对比固定 timeout=1s: 早高峰 RTT 本来就 900ms → 全误杀

Akka/Cassandra 的 phi-accrual 即此思想; 固定超时是它的退化解。

场景 2 · 分布式锁的 zombie 写: fencing token 修复

GC 停顿 10 秒后"醒来"的持锁者继续写库, 把新持锁者的数据覆盖了。

# 修复: 锁服务每次授予发单调 token, 存储校验
token = lock_service.acquire("job-7")   # → 33 (递增)

# 存储: 记录见过的最大 token, 小的拒绝
UPDATE jobs SET result = ?, lock_token = ?
 WHERE id = 7 AND lock_token >= (SELECT max_seen FROM fence);
# zombie 持 token=32 来写 → 0 行受影响 ✓

# token 哪来? etcd 事务递增 / Raft 日志序号

要点: fencing 需要存储端配合校验 — 只有"库自己挡"才算数。

场景 3 · etcd 选主: lease + watch 十行实现

调度器多实例部署只有一个能干活 — 用 etcd 租约选主, 会话断自动交接。

# etcdctl 演示
lease=$(etcdctl lease grant 10)          # 10s 租约
etcdctl put /leader/worker7 "me" \
  --lease=$lease                          # 抢占 (事务防覆盖)
etcdctl keep-alive $lease                 # 心跳续约

# 备用实例: watch /leader/ → key 消失(租约过期)即接管
etcdctl watch /leader/ --prefix
# 崩溃 → 续约停 → 10s 后 key 消失 → 备位上任 (自动)

注意: 接管者应递增 epoch (fencing), 老 leader 复活时写被拒。

场景 4 · 线性一致读: etcd 的三个读选项

"读主就行"不够 — 读到旧主的数据仍然不线性一致, 要用对读模式。

etcdctl get /cfg/feature_x --consistency="l"   # 线性一致
# 实现: ReadIndex — 读前确认自己还是 leader 且
#       等待 apply index 追上 (不落盘, 比 quorum 写便宜)

# 代价: l 读 ≈ 一次 RTT;  s(串行) 读走本地, 可能旧
# 路由开关读用 s, 领导权/配额判断用 l — 按需分配

原则: 每个读操作显式声明要多"新" — 默认值不该背这个锅。

场景 5 · 需要拜占庭容错吗: 内网系统几乎不需要

评审有人提议"上 BFT 更安全" — 先问节点会不会说谎。

# 判定清单:
#   节点会恶意伪造数据吗? 内网自研服务 → 不会 (crash 即可)
#   参与方互不信任?       区块链/联盟链 → 会 (需要 BFT)
# BFT 代价: >2/3 正确节点 + 数倍通信开销 + 复杂度

# 结论: 支付/订单等内部系统用 crash-recovery 模型
# (Raft/2PC 家族), BFT 留给互不信任的开放网络

把 system model 写进设计文档: timing=部分同步, fault=crash-recovery。

场景 6 · CAP 落地决策: 分区时降级读旧

跨 DC 网络分区, 强一致写全挂 — 预案必须是"事先写好的", 不是临场拍的。

# 分区检测: 跨 DC 心跳丢失 10s
if partition_detected:
    feature_flags["allow_writes"] = False   # 保 C: 拒写
    serve_stale_reads()                      # 保可用读
    banner("部分功能降级中")

# 或反向: 电商大促保 A — 双方各自收单 + 事后对账补偿
# 关键: 两种预案都演练过 (呼应韧性页)

PACELC 对照: 无分区时同步复制=延迟+3ms (EL 选 C), 异步=可能旧 (选 L)。

场景 7 · 负 elapsed: 墙钟测时长的翻车与修复

监控里函数耗时为负数 — NTP 在中途把墙钟往回拨了。

# 事故代码:
start = datetime.now()
run_job()
elapsed = datetime.now() - start   # → -00:00:35 !!

# 修复: 单调时钟 (Python)
t0 = time.monotonic()
run_job()
elapsed = time.monotonic() - t0    # 永远 ≥ 0 ✓

# Go: time.Since(start) (内部 monotonic) / Java: System.nanoTime()

一句话: 墙上钟回答"现在几点", 单调钟回答"过了多久" — 别混。

场景 8 · 时钟偏移复盘: LWW 吞掉正确数据 (串联复制页)

数据中心 B 的机器快 200ms, LWW 永远赢 — 逻辑时钟替代物理时间戳。

# 事故: 同 key 并发写, B 的旧数据 (时钟快) 胜出
# LWW: max(wall_clock) → B 赢 → A 的新值丢失

# 修复: HLC (混合逻辑时钟)
#   物理部分提供"大概顺序", 逻辑计数器保因果
#   见到更大物理时间 → 抬高自己;  并发用计数器决胜
# CockroachDB 即用 HLC — NTP 仍需要, 但偏移不再致命

复盘结论模板: "任何跨机器的物理时间戳比较, 都是潜在事故。"

场景 9 · 全序广播落地: 事件溯源的上游保障

事件溯源要求"人人以相同顺序重放" — 全序广播从哪来?

# 方案 A: Kafka 单分区 = 全序日志 (分区内有序)
producer.send(topic="cart-events", partition=0, ev)
# 所有消费者按 offset 顺序读 → 状态机复制成立

# 方案 B: etcd/Raft 日志 — 小规模、强一致场景
etcdctl txn ...           # 原子写入, revision 全序

# 方案 C: 库内逻辑日志 (CDC) — 顺序即提交顺序

衔接: 全序日志 + 确定性 fold = 状态机复制 = Ch.13 一切派生的地基。

场景 10 · 选举活锁: 随机化 election timeout

三个节点同时发起选举, 互相撞票, 谁也选不上 — 随机超时打破对称。

# Raft 选举超时: 基础值 + 随机抖动
election_timeout = 150ms + rand(0, 150ms)
# 节点 A: 187ms  B: 243ms  C: 161ms
# → C 先超时先拉票, 大概率成功 — 对称被打破

# 现实映射 FLP: 异步系统里确定性算法无法保证
# termination (选主终会完成);  引入"随机性/超时"即解

配套: timeout 基线要远大于 RTT (如 10×), 否则正常抖动也触发重选。

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 超时值拍脑袋 — 固定 1s 在抖动下全误杀或全等死. 原因: 无分布视角. 正解: 按实测 RTT 分布自适应 (phi/分位数)。
# 错: timeout = 1s 写死
# 对: phi > 4 判怀疑 / p99×2 动态
坑 2 · 用墙钟测时长 — NTP 回跳, elapsed 变负. 原因: 时钟语义混淆. 正解: monotonic/nanoTime/Since。
# 错: datetime.now() 相减
# 对: time.monotonic() 相减
坑 3 · 跨机器比较时间戳 — 两台机器差 200ms, 顺序判断全错. 原因: 时钟漂移/偏移. 正解: 逻辑时钟/版本向量定因果。
# 错: if a.ts > b.ts: a 发生在后
# 对: happens-before / HLC 排序
坑 4 · 全信 NTP — NTP 服务器本身可能错, 同步误差秒级. 原因: 把校准当真值. 正解: 时间只做参考, 关键决策用逻辑时钟/共识序。
# 错: "NTP 同步了, 时间可信"
# 对: 监控偏移 + 决策不依赖绝对时间
坑 5 · 忘了闰秒 — 墙钟重复/跳变, 定时任务连跑两次. 原因: 没有 smear. 正解: NTP smear + 业务侧幂等/单调钟。
# 错: 23:59:60 处理逻辑缺失
# 对: chrony smeared + 任务幂等
坑 6 · 锁无 fencing — zombie 写覆盖新持锁者数据. 原因: "拿到锁就安全". 正解: 单调 token + 存储端拒绝旧 token。
# 错: setnx 拿锁 → 直接写库
# 对: token 随写下发, 库校验单调
坑 7 · 信任 lease 窗口 — 检查 lease 后 GC 停顿 10s, 继续写. 原因: 检查与使用之间有缝. 正解: 每次写都带 lease 纪元校验 (fencing)。
# 错: if lease_valid(): write()
# 对: write(token) 由存储裁决
坑 8 · 少数派宣告死亡 — 2/5 节点说 leader 死了就重选. 原因: 无多数派纪律. 正解: 重大决策 majority 同意。
# 错: 任一节点可发起 failover
# 对: quorum 授权 + epoch 递增
坑 9 · 最终一致当强一致用 — 付款后立刻查余额可能旧值. 原因: 语义没对齐用户预期. 正解: 敏感读走 leader/线性一致读。
# 错: 异步副本读余额
# 对: 读己之写/线性一致读
坑 10 · 线性一致读当免费 — 每个读都过 quorum, 延迟翻倍. 原因: 不知道成本. 正解: 区分读等级, 路由开关用串行读。
# 错: 全部 --consistency=l
# 对: 按语义分级 l / s
坑 11 · CAP"三选二"理解 — 以为可以选 P 不要. 原因: 定理表述误读. 正解: P 必然发生, 问题只是分区时 C 还是 A。
# 错: "我们是 CA 系统"
# 对: 分区预案: 保 C 拒写 or 保 A 收单
坑 12 · 分区时硬保 C — 跨 DC 断网拒绝一切写, 全站不可用. 原因: 没有降级预案. 正解: 按业务分级, 部分路径保 A + 事后对账。
# 错: 分区 = 全站 500
# 对: 只读降级 + 关键路径预案
坑 13 · 线性一致≠可串行化 — 以为"读总是最新"等于"事务隔离". 原因: 两个维度混淆. 正解: recency (单对象) 与 isolation (多对象) 分开声明。
# 错: "线性一致库不会 write skew"
# 对: strict serializability 才兼得
坑 14 · Lamport 戳揭并发 — 时间戳大≠因果在后, 并发被强行排序. 原因: 只看数值. 正解: 需要并发判定用版本向量, Lamport 只保全序。
# 错: (2,B)>(1,A) ⇒ B 知道 A
# 对: version vector 判并发 → sibling
坑 15 · 共识当万能 — 每个功能都过 etcd, 吞吐几百 TPS 顶天. 原因: 把共识当 RPC. 正解: 共识只管"选主/元数据/小状态", 业务数据走复制库/Kafka。
# 错: 订单写 etcd
# 对: etcd 选主, 订单进 Raft 复制库
坑 16 · ZK/etcd 当数据库 — 存几百 MB 状态, 快照/重放卡死集群. 原因: 定位不清. 正解: 只存小元数据 (KB 级), 大状态放业务存储。
# 错: etcd 存用户会话几百万条
# 对: etcd 存 leader/配置, 会话进 Redis
坑 17 · session 太短 — lease 2s 网络抖一下就重选, 主频繁漂移. 原因: 怕脑裂设太短. 正解: lease ≈ 10×RTT + 心跳 3 次容忍。
# 错: lease grant 2s, GC 一下就丢主
# 对: 10s lease + keep-alive 3s
坑 18 · 选主超时不随机 — 三节点同刻拉票撞车, 活锁. 原因: 对称行为. 正解: election timeout 加随机抖动。
# 错: timeout=300ms 三节点一致
# 对: 300ms + rand(0,150ms)
坑 19 · quorum 配置错 — 5 节点集群允许 2 节点写 (w=2<majority), 两个"多数"并存. 原因: 参数复制粘贴. 正解: w=r=majority(n), 审计配置。
# 错: n=5, w=2, r=2
# 对: w=r=3 (majority)
坑 20 · 内网误用 BFT — 自家服务互相说谎? 不存在的, 白付 3 倍开销. 原因: 术语赶时髦. 正解: 声明故障模型, crash-recovery 足够时用 Raft 族。
# 错: 内部支付链路上 PBFT
# 对: Raft + 签名防伪 (传输层)