Day 46 Hard Consensus Raft / Paxos Coordination

分布式共识与协调 — 让 5 台机器对「一件事」达成一致Distributed Consensus & Coordination: Raft/Paxos, Leader Election, Locks, Quorum & Split-Brain

问题场景 + 需求约束

你要给一个跨 3 个可用区的分布式数据库做控制面:谁是当前唯一的 primary(负责写)?某个分片迁移任务此刻能不能启动?服务发现里 db-shard-7 的地址是什么?这些问题都要求所有节点看到同一个答案,且答案不会因为网络分区裂成两份——这就是分布式协调,业界的标准答案是 etcd / ZooKeeper / Chubby 这类「共识服务」。

具体设计目标:一个 5 节点的强一致协调服务(类似 etcd)——

高层架构

graph TD
    C["客户端
kubelet / 数据库节点"] subgraph RSM["共识组 (Replicated State Machine)"] L["Leader
term=7"] F1["Follower A"] F2["Follower B"] F3["Follower C"] F4["Follower D"] end SM["状态机 KV
apply committed log"] C -->|"①写请求"| L L -->|"②AppendEntries
复制日志"| F1 L -->|"②"| F2 L -->|"②"| F3 L -->|"②"| F4 F1 -.->|"③ack"| L F2 -.->|"③ack"| L L -->|"④多数派 ack → commit"| SM SM -.->|"⑤apply 后返回"| C classDef leader fill:#2a1530,stroke:#ff7ab6,color:#e8eef5 classDef follower fill:#1a2530,stroke:#64c8ff,color:#e8eef5 classDef sm fill:#1a1a30,stroke:#ffb450,color:#e8eef5 classDef client fill:#0e2030,stroke:#5eead4,color:#e8eef5 class L leader class F1,F2,F3,F4 follower class SM sm class C client

写请求先进 Leader 的日志,复制到多数派(3/5)后才 commit 并 apply 到状态机——「多数派确认」是整个系统安全性的地基

所有写操作被序列化成一条复制日志(replicated log),每个节点按相同顺序 apply 到状态机 → 得到相同结果,这就是 Replicated State Machine 模型。Leader 负责定序,Follower 只是复制。共识算法(Raft / Paxos / ZAB)解决的核心问题只有一个:在节点会宕机、网络会丢包和分区的前提下,让所有活着的节点对「日志的第 N 条是什么」达成不可推翻的一致

关键技术点

1. 共识的本质 — 为什么必须是「多数派」Quorum

核心 trade-off:多数派 quorum 用「牺牲一半以上节点才不可用」换来「任意两次决议必然有交集,因此不可能矛盾」。这是 CP 系统的数学地基,代价是可用性上限被 quorum 卡死

【原理】共识要同时满足:Agreement(所有节点认同同一值)、Validity(认同的值是某人真提过的)、Termination(最终能决定)。关键机制是多数派交集:5 节点里任意两个多数派(各 ≥3)必然至少共享 1 个节点,那个节点「记得」上一次的决议,从而阻止出现两个冲突的已提交值。这就是为什么 2f+1 节点只能容忍 f 个故障——你需要活着的多数派来形成新决议,同时保证它与历史决议相交。

Trade-off(三大算法族):
算法特点牺牲什么
Multi-Paxos理论最早、最通用、可乱序提交极难理解/实现正确,工程细节论文没讲
Raft强 leader、日志连续、可理解性优先只允许顺序提交,leader 是写吞吐瓶颈
ZABZooKeeper 专用,主备广播 + 恢复与 ZK 语义耦合,通用性差
# Raft 日志复制核心(Leader 侧,伪代码)
def on_client_write(cmd):
    entry = LogEntry(term=current_term, cmd=cmd)
    log.append(entry)                       # 先进自己的日志
    replicate_to_followers(entry)           # 并行 AppendEntries
    # 等待多数派(含自己)确认
    if acked_count(entry) >= majority:       # 3/5
        commit_index = entry.index          # 提交点前移
        apply_to_state_machine(entry)       # 才真正生效
        return OK
    # 拿不到多数派 → 阻塞/超时,绝不 commit

# Follower 收到 AppendEntries:任期落后就拒绝,保证不倒退
def on_append_entries(req):
    if req.term < current_term: return REJECT   # 旧 leader
    if not log_matches(req.prev_index, req.prev_term): return REJECT  # 日志不连续
    log.append(req.entries); return ACK
现实案例:etcd(Kubernetes 的大脑)用 Raft 存全集群元数据;Google ChubbyBurrows, OSDI 2006)用 Paxos,为 GFS/BigTable 做 leader election;ZooKeeper 用 ZAB;Kafka KRaft(KIP-500)用 Raft 变体的 quorum controller 取代了 ZooKeeper,Kafka 4.0 已彻底移除 ZK 依赖。

2. Leader Election 与脑裂防护 — term 单调 + 多数派 = 旧王无法复辟

核心 trade-off:用「任期号 term + 多数派选举」保证任意时刻最多一个能提交的 leader;代价是选举期间(几百 ms)整个集群写不可用。

【原理】每次选举产生一个单调递增的 term(Paxos 叫 ballot、ZAB 叫 epoch)。候选人拿到多数派选票才当选,而多数派交集保证一个 term 内不可能选出两个 leader。网络分区时,小分区(≤2 节点)凑不齐多数派选不出新 leader,也 commit 不了任何写;大分区选出 term 更高的新 leader。旧 leader 即使还活着、还以为自己是 leader,它的 AppendEntries 带着旧 term 会被所有人 REJECT——这就是脑裂被数学消灭的机制:可以有两个「自称 leader」,但只有一个能凑齐多数派提交。

Trade-off(如何防旧 leader 造成危害):
# 存储端用 fencing token 拒绝旧 leader(关键:单调校验)
current_token = 0
def write(payload, token):
    global current_token
    if token < current_token:          # 更小 = 旧 leader / 旧租约
        raise StaleLeaderError          # 拒绝!哪怕它自认还是 leader
    current_token = token
    persist(payload)
现实案例:ZooKeeper 用 zxid(epoch+counter)做全序与 fencing;Chubby 提供 sequencer(本质就是 fencing token)让下游校验;etcd 的 lease + revision 机制让 leader election 结果可被下游验证。Kleppmann 在《DDIA》第 8/9 章反复强调:分布式锁没有 fencing token 就是错的

3. 分布式锁的正确姿势 — TTL 不够,fencing token 才是命根

核心 trade-off:基于共识服务的锁(ZK/etcd)比 Redis Redlock 慢,但换来唯一性可证明;纯 TTL 锁在 GC/网络延迟面前必然出双主。

【原理】经典幻觉:客户端 A 拿到锁(TTL=10s),干活时突发 15s STW GC 停顿;期间锁过期、B 拿到锁开始写;A 醒来不知道自己已失去锁继续写 → 两客户端同时写同一资源。TTL 永远堵不住,因为「检查锁」和「用锁」之间总有时间差。唯一正解:锁服务发一个单调递增的 fencing token,客户端每次操作把 token 带给下游,存储拒绝比已见过更小的 token——醒来的 A 拿旧 token,写直接被打回。

Trade-off(三种锁实现):
ZooKeeper 临时顺序节点etcd leaseRedis Redlock
唯一性保证强(共识 + session)强(Raft + lease)有争议(Kleppmann 质疑)
fencing tokenzxid 自带revision 自带无内建,需自造
失效检测session 心跳,断连自动释放lease keepaliveTTL 过期
延迟较高(走共识)较高(走共识)低(内存)
# ZooKeeper 风格:临时顺序节点 = 天然公平锁 + fencing
def acquire_lock(zk):
    my = zk.create("/lock/req-", ephemeral=True, sequential=True)  # 得到 req-000007
    while True:
        kids = sorted(zk.children("/lock"))
        if my == kids[0]:                    # 我序号最小 → 持锁
            return seq_num(my)               # 序号即 fencing token(单调递增)
        prev = predecessor(kids, my)         # 只 watch 前一个节点,避免惊群
        zk.watch(prev)                       # 前者释放/session 断 → 唤醒我
        wait()
现实案例:大量系统用 ZooKeeper 临时顺序节点做公平锁与 leader election(HBase HMaster、早期 Kafka controller)。Kleppmann 的经典文章 《How to do distributed locking》 系统性论证了「Redlock 缺 fencing token → 不能用于保护有副作用的操作」,是这个主题的必读辩论。

4. 线性化读的代价 — 「从 Leader 本地读」是经典 bug

核心 trade-off:quorum read 安全但慢;lease read 快但赌时钟;follower read 可扩展但可能读到旧值。三选一,取决于你要不要真正的线性化。

【原理】直觉上「leader 有最新数据,直接读 leader 内存就行」——。一个 leader 可能刚被多数派罢免、选出了新 leader,而它自己还不知道(网络分区把它孤立了)。此时读它的本地状态会返回stale 旧值,破坏线性化。正确做法:读之前 leader 必须确认自己仍是 leader——Raft 的 ReadIndex:记下当前 commit index,向多数派发一轮心跳确认领导权仍在,再返回该 index 处的值。

Trade-off(线性化读三方案):
# Raft ReadIndex:确认领导权后再读,避免 stale leader 读旧值
def linearizable_read(key):
    idx = commit_index                     # 记录当前提交点
    if not confirm_leadership_via_quorum(): # 发一轮心跳,多数派回应才算数
        raise NotLeaderError               # 已被罢免 → 拒绝,转发给真 leader
    wait_until(applied_index >= idx)       # 等状态机追上该 index
    return state_machine.get(key)          # 此刻读到的一定不旧
现实案例:Jepsen 对 etcd/Consul 的分析正是踩在这个坑上——早期 etcd 的「consistent read」直接读本地 leader 状态,Aphyr 测出 约八成历史非线性化(能读到 index 5→4→6 这种倒退),此后 etcd 改用 ReadIndex/quorum read。TiKV、CockroachDB 用 leader lease read 在保证正确性的前提下省掉多数往返。

扩展与优化

常见陷阱 + 面试问题

1. 偶数节点是纯浪费。 4 节点的多数派是 3,和 3 节点一样只容忍 1 故障,却多付一台机器成本、还增大分区时 2-2 僵局概率。共识组永远用奇数(3/5/7)。
2. 「从 leader 本地读」的 stale bug。 面试高频陷阱:候选人说「读直接问 leader」,追问「leader 被分区罢免但自己不知道呢?」——必须 ReadIndex/lease 确认领导权。
3. 分布式锁不带 fencing token。 只靠 TTL/session 就敢去写数据库,GC 停顿一来就双主。能说出「fencing token 单调递增 + 存储端校验」是资深信号。
4. 用 lease 却假设时钟完美。 Leader lease read 的正确性依赖「时钟漂移有上界」;VM 被冻结、NTP 跳变都能击穿它。要么留足安全边际,要么退回 quorum read。
5. 拿共识当高吞吐存储。 每个写都过多数派复制,天然是低吞吐、强一致的控制面工具。别把用户业务数据的高频写扔进 etcd/ZK——它们是协调器,不是主数据库。

其他高频追问:①为什么协调服务不用 2PC?(2PC 协调者单点、参与者会被阻塞,不容错)②5 节点跨 3 AZ 怎么摆才能容忍一个 AZ 挂掉?③Raft 和 Paxos 到底差在哪?④watch 的通知怎么保证不漏(etcd revision + 断点续传)?

深入资源

深入思考(点击展开答案)

1. 多数派是「任意两个 quorum 必相交」的一种实现。Flexible Paxos 说选举 quorum 与复制 quorum 只需相交即可——这揭示了共识的什么本质?能怎么利用?

共识安全性真正需要的不是「多数派」,而是「新决议的 quorum 一定能撞见旧决议的 quorum」——多数派只是保证相交的最简单充分条件(任意两个 >N/2 的集合必相交)。

Flexible Paxos 的洞察:只要 选举 quorum Q1复制 quorum Q2 满足 |Q1| + |Q2| > N(保证相交),就安全。于是可以非对称配置:让复制 quorum 变小(比如 5 节点里只需 2 个 ack 就 commit)来降低写延迟,代价是选举 quorum 必须变大(要 4 个)。这对「写频繁、选举罕见」的负载是净赚——把成本从热路径(每次写)挪到冷路径(偶尔选举)。

推论:还能做「网格 quorum」等拓扑,让跨区复制只需本区多数 + 少量远端。本质是把「相交」约束暴露成可调旋钮。

2. 5 节点要容忍「整个可用区挂掉」,跨 3 个 AZ 该怎么摆?为什么不能 3-1-1 或 2-2-1 随便放?

关键:任何单个 AZ 挂掉后,剩余节点仍需凑齐多数派(3/5)

  • 2-2-1 摆法:AZ 各放 2/2/1。挂掉一个 2-节点 AZ → 剩 3 个,正好多数派,可用 ✅。挂掉 1-节点 AZ → 剩 4 个,更没问题。这是正确摆法。
  • 3-1-1 摆法:挂掉那个 3-节点 AZ → 只剩 2 个,<3 不成多数派,集群整体不可写 ❌。把太多票押在一个 AZ = 把单点故障域放大。
  • 2 个 AZ(3-2):挂掉 3-节点 AZ 直接失多数;且两 AZ 间网络分区时,2-节点侧永远选不出 leader。要容忍 AZ 级故障,至少 3 个 AZ。

成本优化:若第三个 AZ 只想放 1 个节点,可用「witness / 无数据仲裁节点」——它只投票不存全量,用低成本换奇数投票权和打破平局的能力。

3. 客户端持锁时发生 30s GC 停顿。为什么加长 lease 或续租都救不了?为什么只有 fencing token 能根治?

加长 lease 不行:你不知道停顿多久,设多长都可能被更长的停顿击穿;而 lease 越长,真正宕机时其他人等待越久,把可用性搞垮。

续租不行:GC 停顿期间客户端整个进程冻结,根本执行不了续租心跳。等它醒来,锁早在别处易主了。

根因:无论怎么调时间,「客户端检查『我还持锁吗』」和「客户端实际执行写」之间永远存在一个时间窗口,停顿可以精准落在这个窗口里。这是时序问题,不是时长问题——加时长只是让窗口更难被命中,不是消除它。

fencing token 为何根治:它把判定权从「客户端不可靠的自我认知」转移到「下游存储的单调校验」。醒来的旧客户端带 token=33 去写,而存储已因新持有者 token=34 把水位抬高,33 < 34 直接被拒。正确性不再依赖任何时间假设——哪怕停顿一小时,旧 token 也永远打不进去。这是把「时间问题」转成「单调序问题」。

4. etcd 默认读要走 quorum(ReadIndex),性能明显更差。follower read 什么情况下才是安全的?CockroachDB 的 leader lease read 又靠什么免掉往返?

为何默认 quorum read:唯有确认「我此刻仍是 leader 且看到了所有已提交写」,才能保证线性化。直接读本地 = 赌自己没被悄悄罢免,Jepsen 已证明这会读到倒退值。

follower read 何时安全:①业务能接受有界 stale(如「至多旧 5 秒」的读),放弃线性化换吞吐;②或 follower 用 read index 机制——向 leader 要一个当前 commit index,等本地 apply 追上该 index 再返回,此时能给出「不早于请求时刻」的线性化读,代价是要等追平(TiKV 的做法)。纯粹「读本地 follower 内存」永远是非线性化的。

leader lease read 免往返的原理:leader 持有一个有时限的领导权租约,只要在租约有效期内,且系统保证「租约到期前不会选出新 leader」(新 leader 选举必须等旧租约过期),那么 leader 就能确信自己仍是唯一 leader,无需每次心跳确认即可本地读。代价:正确性依赖时钟漂移有上界——VM 冻结、NTP 大幅跳变会击穿它,所以要留安全边际(lease 有效期算得比理论值保守)。

5. 共识组的写吞吐为什么无法靠「加机器」扩展,反而可能越加越慢?Multi-Raft 是怎么绕开这堵墙的?

为何加机器不提吞吐、甚至变慢:单组里所有写串行过同一个 leader,它是绝对瓶颈。加 follower 不分担写——反而每条日志要复制给更多节点、leader 出向和 fsync 压力更大、多数派门槛更高(7 节点要 4 ack,尾延迟被最慢者拖累)。所以共识组通常止步 5-7 节点:再大只增容错不增性能。

Multi-Raft 的破局:既然单组不能扩,就开成千上万个组。把 key space 分成许多 range/shard,每个 shard 是一个独立 Raft 组,有自己的 leader、自己的日志、自己的多数派。不同 shard 的 leader 打散到不同物理节点上 → 写吞吐随 shard 数水平扩展,单机同时是若干组的 leader、若干组的 follower。

新代价:跨 shard 原子操作需额外的分布式事务层(2PC over Raft groups);shard 分裂/合并/再平衡成为新复杂度;成千上万组的心跳会淹没网络,于是要心跳合并(同节点间多组心跳批成一条)。CockroachDB、TiKV 是这条路线的代表。