Ch.9-10: 最难的论证链 — 网络不可靠 ⇒ 超时是唯一手段 ⇒ 暂停/时钟不可信 ⇒ fencing token ⇒ 共识给出全序日志
这一章是一个被迫的推理链: 网络不可靠 → "对面到底死没死"永远无法确定, 只能怀疑 (timeout + 多数派裁决); 进程随时可能暂停 (GC), 暂停完的自己不知道世界已经前进 → 所以"我有锁"不算数, 要有单调递增的编号 (fencing token) — 存储只认最新的号。共识 (Raft/Paxos) 就是把这套编号机制制度化的算法: epoch 选举 + 多数派投票 + 全序日志, 产出的日志序号本身就是 fencing token。时钟呢? 墙钟会撒谎 (回跳/漂移), 测时长只能用单调时钟, 跨机器排序事件只能靠逻辑时钟。
# 同一个请求, 三次三种结果: # ① 成功 ② 网络丢了 ③ 节点处理完但响应丢了 # 单机世界: 要么成功要么抛异常, 状态确定
send(req) → 等 3s → 没回音 # 是网断了? 对端 GC? 对端死了? 对端慢? # 观测者无法区分 — 只能"怀疑"
# minority 宣布 leader 死了并选新主? # 多数派说: 他没死 → 两个 leader = 脑裂 # 规则: 重大决策 = 多数派投票
# 错: start = time.time(); ...; print(time.time()-start) # NTP 回跳 → elapsed 为负! # 对: t0 = time.monotonic(); ...; time.monotonic()-t0
# 机器 A: 10:00:00.100 (NTP 校过) # 机器 B: 09:59:59.900 (慢 200ms) # "A 的事件晚于 B" — 用墙钟判断 = 赌博
tt = truetime.now() # → [t-2ms, t+2ms] commit_wait(tt.latest) # 等到区间过尽再提交 # 普通机房没有原子钟 — 别照抄
acquire(lock) # 拿到锁 # ← GC 停顿 10s; 期间锁过期, 另一进程接管 write(storage) # 醒来继续写 — zombie 写!
lock(svc) → token = 33 # 每次授予递增 write(storage, data, token=33) # zombie 拿着 token=32 来写 → 存储: 拒绝 (33 已见)
# safety: "不会有两个 leader" (永远) # liveness: "最终会选出 leader" (无分区时) # FLP: 异步+无超时下 liveness 无法保证 → 超时解锁
set(x=1) 完成 ──────────────→ get(x) # 必须 =1 (任何副本/任何客户端) # 非"最终一致"的读可能 =0 — 那是因果/最终一致
# 网络永远会有分区 → P 是既定事实 # 分区时: 拒绝写 (保C) or 照常服务 (保A) # 无分区: 同步复制=延迟↑, 异步=可能旧 (ELC)
# 节点A: ts=(1,A) 发消息 → 节点B 收到 # B: max(自己, 收到)+1 → (2,B) — 因果被编码进序 # (1,A) 与 (2,B) 可排序; 并发时按 nodeID 决胜
# 所有副本按 [m1, m2, m3] 同序应用 → 状态一致 # 每条消息的 offset 就是全序位置 (也是 fencing)
固定 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 即此思想; 固定超时是它的退化解。
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 需要存储端配合校验 — 只有"库自己挡"才算数。
调度器多实例部署只有一个能干活 — 用 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 复活时写被拒。
"读主就行"不够 — 读到旧主的数据仍然不线性一致, 要用对读模式。
etcdctl get /cfg/feature_x --consistency="l" # 线性一致 # 实现: ReadIndex — 读前确认自己还是 leader 且 # 等待 apply index 追上 (不落盘, 比 quorum 写便宜) # 代价: l 读 ≈ 一次 RTT; s(串行) 读走本地, 可能旧 # 路由开关读用 s, 领导权/配额判断用 l — 按需分配
原则: 每个读操作显式声明要多"新" — 默认值不该背这个锅。
评审有人提议"上 BFT 更安全" — 先问节点会不会说谎。
# 判定清单: # 节点会恶意伪造数据吗? 内网自研服务 → 不会 (crash 即可) # 参与方互不信任? 区块链/联盟链 → 会 (需要 BFT) # BFT 代价: >2/3 正确节点 + 数倍通信开销 + 复杂度 # 结论: 支付/订单等内部系统用 crash-recovery 模型 # (Raft/2PC 家族), BFT 留给互不信任的开放网络
把 system model 写进设计文档: timing=部分同步, fault=crash-recovery。
跨 DC 网络分区, 强一致写全挂 — 预案必须是"事先写好的", 不是临场拍的。
# 分区检测: 跨 DC 心跳丢失 10s if partition_detected: feature_flags["allow_writes"] = False # 保 C: 拒写 serve_stale_reads() # 保可用读 banner("部分功能降级中") # 或反向: 电商大促保 A — 双方各自收单 + 事后对账补偿 # 关键: 两种预案都演练过 (呼应韧性页)
PACELC 对照: 无分区时同步复制=延迟+3ms (EL 选 C), 异步=可能旧 (选 L)。
监控里函数耗时为负数 — 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()
一句话: 墙上钟回答"现在几点", 单调钟回答"过了多久" — 别混。
数据中心 B 的机器快 200ms, LWW 永远赢 — 逻辑时钟替代物理时间戳。
# 事故: 同 key 并发写, B 的旧数据 (时钟快) 胜出 # LWW: max(wall_clock) → B 赢 → A 的新值丢失 # 修复: HLC (混合逻辑时钟) # 物理部分提供"大概顺序", 逻辑计数器保因果 # 见到更大物理时间 → 抬高自己; 并发用计数器决胜 # CockroachDB 即用 HLC — NTP 仍需要, 但偏移不再致命
复盘结论模板: "任何跨机器的物理时间戳比较, 都是潜在事故。"
事件溯源要求"人人以相同顺序重放" — 全序广播从哪来?
# 方案 A: Kafka 单分区 = 全序日志 (分区内有序) producer.send(topic="cart-events", partition=0, ev) # 所有消费者按 offset 顺序读 → 状态机复制成立 # 方案 B: etcd/Raft 日志 — 小规模、强一致场景 etcdctl txn ... # 原子写入, revision 全序 # 方案 C: 库内逻辑日志 (CDC) — 顺序即提交顺序
衔接: 全序日志 + 确定性 fold = 状态机复制 = Ch.13 一切派生的地基。
三个节点同时发起选举, 互相撞票, 谁也选不上 — 随机超时打破对称。
# Raft 选举超时: 基础值 + 随机抖动 election_timeout = 150ms + rand(0, 150ms) # 节点 A: 187ms B: 243ms C: 161ms # → C 先超时先拉票, 大概率成功 — 对称被打破 # 现实映射 FLP: 异步系统里确定性算法无法保证 # termination (选主终会完成); 引入"随机性/超时"即解
配套: timeout 基线要远大于 RTT (如 10×), 否则正常抖动也触发重选。
# 错: timeout = 1s 写死 # 对: phi > 4 判怀疑 / p99×2 动态
monotonic/nanoTime/Since。 # 错: datetime.now() 相减 # 对: time.monotonic() 相减
# 错: if a.ts > b.ts: a 发生在后 # 对: happens-before / HLC 排序
# 错: "NTP 同步了, 时间可信" # 对: 监控偏移 + 决策不依赖绝对时间
# 错: 23:59:60 处理逻辑缺失 # 对: chrony smeared + 任务幂等
# 错: setnx 拿锁 → 直接写库 # 对: token 随写下发, 库校验单调
# 错: if lease_valid(): write() # 对: write(token) 由存储裁决
# 错: 任一节点可发起 failover # 对: quorum 授权 + epoch 递增
# 错: 异步副本读余额 # 对: 读己之写/线性一致读
# 错: 全部 --consistency=l # 对: 按语义分级 l / s
# 错: "我们是 CA 系统" # 对: 分区预案: 保 C 拒写 or 保 A 收单
# 错: 分区 = 全站 500 # 对: 只读降级 + 关键路径预案
# 错: "线性一致库不会 write skew" # 对: strict serializability 才兼得
# 错: (2,B)>(1,A) ⇒ B 知道 A # 对: version vector 判并发 → sibling
# 错: 订单写 etcd # 对: etcd 选主, 订单进 Raft 复制库
# 错: etcd 存用户会话几百万条 # 对: etcd 存 leader/配置, 会话进 Redis
# 错: lease grant 2s, GC 一下就丢主 # 对: 10s lease + keep-alive 3s
# 错: timeout=300ms 三节点一致 # 对: 300ms + rand(0,150ms)
# 错: n=5, w=2, r=2 # 对: w=r=3 (majority)
# 错: 内部支付链路上 PBFT # 对: Raft + 签名防伪 (传输层)