入门解决「怎么活」,深入解决「怎么不丢」
上一篇的选举与复制解决的是集群「活着」的问题;这一篇抠的是「活得不丢、不乱」。Raft 论文用五条安全性性质收束这件事,对做业务的人,真正需要内化的是两条提交红线和一套脑裂免疫逻辑。先看最容易想错的那条——「过半 ACK 就提交」其实是有条件的。
红线一:只提交当前任期的日志
直觉版本是「日志复制到过半就 commit」,但直接这么实现会丢数据。用一个经典时间线(Raft 论文 Figure 8 的故事)还原:
t1 S1 当 Leader(Term=2),写日志 e2.2,复制到 S2,但还没过半
t2 S1 掉线;S5 凭「日志不比自己新」的例外当选(Term=3)
—— S5 没有 e2.2,但它的日志更新(Term 更高),合法
t3 S5 写自己的日志 e3.2,又掉线
t4 S1 回来重新当选(Term=4),继续把 e2.2 复制到过半(S1,S2,S3)
← 按直觉此刻 e2.2 已「过半」,但它属于旧任期 Term=2,仍不许提交
t5 若此刻允许提交 e2.2,然后 S1 再掉线、S5 当选(Term=5)
S5 会用 e3.2 覆盖 e2.2 —— 一条「已提交」的数据就这么丢了Raft 的红线因此写成:commitIndex 只能在当前任期的日志上推进;旧任期日志必须「搭车」——等当前任期有新日志提交时,一并提交。t4 的 S1 想提交 e2.2,得先写一条 Term=4 的新日志让它过半,借新日志的提交把 e2.2 一起抬过线。这条红线略显迂回,却换来一个铁一般的不变量:凡是宣称已提交的数据,任何未来 Leader 都无法覆盖。
日志新旧的精确判定
选举纪律里「日志不比自己新就拒绝投票」,「新旧」的判定规则要一字不差:先比最后一条日志的 Term,Term 大者新;Term 相同再比日志索引,长(大)者新。两步比较杜绝了两个方向的漏洞:Term 大但日志短的不会被误判落后,索引长但 Term 旧的也冒充不了新——选举永远选出「见多识广」的那一位,数据落后者连提名资格都没有。
「不可能丢」的数学直觉
把红线一、投票纪律、log matching 三块拼起来,可以得到一个不需要形式化证明的直觉:已提交意味着过半节点留了底稿;任何新 Leader 的当选也要过半投票;两个「过半」必有交集——交集里的节点既见过那条日志,又有投票权。而它只会投给「日志不比自己新」的候选人,于是新 Leader 必然见过那条已提交日志,当选后按 log matching 保留它。多数派交集 + 选举纪律,把「已提交数据不丢」从概率变成了数学。这正是第 7 篇 Redis 丢锁与 ZAB 共识的分水岭所在。
读请求与脑裂
写要走 quorum,读呢?一个 Leader 读写状态可能落后(它刚被罢免自己还不知道),直接读会违反线性一致。etcd 提供两个档位:ReadIndex——Leader 先向多数派确认「我还是现任」再读,不写日志、一次往返;Lease Read——Leader 靠心跳租约自信自己还在任,零往返直读,快但依赖心跳时序。锁场景里「我还有锁吗」这类判断走 ReadIndex 档位,比 ZK 的 sync 命令语义更精确。至于脑裂:少数派侧的旧 Leader 继续发号施令,但过半 ACK 凑不齐,一条日志都提交不了;多数派侧选出新 Leader 正常服务——分裂只冻结旧的一侧,不毒化新的一侧,分区恢复后旧 Leader 看到 Term 落后自动退位(第 9 篇 epoch 退位的同款剧情)。
| 机制 | 作用 | 护栏对象 |
|---|---|---|
| 只提交当前任期 | 防止旧日志被新 Leader 覆盖 | 已提交数据不丢 |
| 日志新旧判定 | 落后者没有被选举权 | Leader 数据完整性 |
| log matching | 日志强制连续无分叉 | 副本一致性 |
| ReadIndex / Lease | 读不违反线性一致 | 读的正确性 |
| 多数派交集 | 脑裂只冻结一侧 | 分区安全 |
协议课到此结业。下一篇回到应用层,看 etcd 怎么用 Raft 的两个产物——租约(Lease)和全局 revision——把分布式锁做得比 ZK 更简洁:一个 lease 管生死,一个 revision 排公平。
评论 (0)