分布式事务里的 TC、元数据、全局状态常常放在 Raft 复制的存储里。Raft 的“提交”到底是什么条件?少数节点挂掉会怎样?多数丢了会怎样?为什么旧任期的日志不能靠数副本数提交?这篇用真实的 etcd 和一个手写的日志模拟,把这些问题逐个跑出来。
写入之前: 节点 | leader | raft term | raftIndex(日志最后一条) | raftAppliedIndex(已应用) ------+--------+-----------+-------------------+---------------------- t24n0 | | 2 | 8 | 8 t24n1 | | 2 | 8 | 8 t24n2 | 是 | 2 | 8 | 8 对 leader 写了 5 次之后: 节点 | leader | raft term | raftIndex(日志最后一条) | raftAppliedIndex(已应用) ------+--------+-----------+-------------------+---------------------- t24n0 | | 2 | 13 | 13 t24n1 | | 2 | 13 | 13 t24n2 | 是 | 2 | 13 | 13 leader 是 t24n2;每个节点的 raftIndex 都涨了 5:每次写入是一条 Raft 日志,leader 把它复制给 follower,多数(3 个里的 2 个)落盘之后才算提交、才应用到状态机、才回复客户端
leader 追加日志、复制给 follower,多数节点落盘后才提交、应用、回复客户端;三个节点的 raftIndex 同步增长。
杀掉 follower t24n0(kill -9)之后,对 leader 写 100 次:中位数 7.83 ms、p99 15.51 ms(杀之前 7.74 ms、p99 17.75 ms);leader 仍是 t24n2 杀掉 follower 期间又写了 50 次之后: 节点 | leader | raft term | raftIndex(日志最后一条) | raftAppliedIndex(已应用) ------+--------+-----------+-------------------+---------------------- t24n0 | (不可达) | - | - | - t24n1 | | 2 | 263 | 263 t24n2 | 是 | 2 | 263 | 263 重启 t24n0 之后 164 ms 它的 raftAppliedIndex 追上了 leader 追上之后: 节点 | leader | raft term | raftIndex(日志最后一条) | raftAppliedIndex(已应用) ------+--------+-----------+-------------------+---------------------- t24n0 | | 2 | 264 | 264 t24n1 | | 2 | 264 | 264 t24n2 | 是 | 2 | 264 | 264
杀掉 1 个 follower,写入延迟几乎不变;它回来后由 leader 补日志。
对唯一存活的 t24n2 写 key = new(客户端超时 3 秒):TimeoutError,等了 3.0 s 没有结果 —— 提交不了 线性一致读 key:TimeoutError(等了 3.0 s,拿不到多数的确认) 可串行化读(serializable,只读本节点自己的状态):key = old(那条没提交的 new 没有生效) 再过 2 秒,t24n2 报告的 leader 字段 = 0(0 表示没有 leader:它联系不上多数,自己让出了 leader) 重启一个节点 t24n0,多数恢复,选出 leader = t24n2;此刻线性一致读 key = new 客户端那次写入虽然超时了,但它已经是 leader 日志里的一条:多数恢复之后被提交了。所以“超时”不等于“没写成功”,只表示“不知道”
只剩 1 个节点时写入、线性一致读都超时,leader 让出角色;可串行化读只读本地。多数恢复后,之前超时的那条写入被提交——超时只代表不知道。
规则 A:只要复制到多数就提交(没有“当前任期”的限制)
(a) S1(任期 2 的 leader)把位置 2 的条目复制给了 S2,S3、S4、S5 还没有
(b) S1 宕机,S5 在任期 3 当选(S3、S4 投票,它们的日志不比 S5 新),在位置 2 追加了自己的条目(任期 3),没来得及复制
(c) S5 宕机,S1 重启并在任期 4 当选(S2、S3 投票),继续复制位置 2 的旧条目(任期 2):现在 S1、S2、S3 都有了它
按“复制到多数就提交”的规则:S1 把位置 2(任期 2)标成已提交、应用给客户端
(d) S1 宕机,S5 重启在任期 5 竞选:能不能当选 = True(S2、S3、S4 要检查 S5 的日志是否“不比自己旧”)
S5 当选,按 AppendEntries 把自己的日志(位置 2 的任期是 3)复制给大家,覆盖了 S1、S2、S3 上位置 2 的任期 2 条目
结果:S5 当选了;已经提交的位置 2 条目现在在 S1、S2、S3 上的任期是 [3, 3, 3] —— 已经提交 / 应用过的条目被覆盖了(任期 2 变成 3)
规则 B:Raft 的规则(只有当前任期的条目才靠数副本数提交,旧任期的条目随它们一起提交)
(a) S1(任期 2 的 leader)把位置 2 的条目复制给了 S2,S3、S4、S5 还没有
(b) S1 宕机,S5 在任期 3 当选(S3、S4 投票,它们的日志不比 S5 新),在位置 2 追加了自己的条目(任期 3),没来得及复制
(c) S5 宕机,S1 重启并在任期 4 当选(S2、S3 投票),继续复制位置 2 的旧条目(任期 2):现在 S1、S2、S3 都有了它
按 Raft 的规则:任期 4 的 leader 不能靠数副本数提交上一任期的条目;S1 要先在任期 4 追加一条自己的条目(位置 3),复制到多数后,才连同前面的一起提交
S1 的任期 4 条目(位置 3)复制到了 S2、S3,多数达成,位置 2 与 3 一起提交
(d) S1 宕机,S5 重启在任期 5 竞选:能不能当选 = False(S2、S3、S4 要检查 S5 的日志是否“不比自己旧”)
结果:S5 没能当选;已经提交的位置 2 条目现在在 S1、S2、S3 上的任期是 [2, 2, 2] —— 没有被覆盖图 8:靠数副本数提交上一任期的条目,会被后来者覆盖;Raft 规定只有当前任期的条目才靠数副本数提交,旧条目随之一起提交。
配置 | 写入延迟中位数 | p99 -----------------------------------+---------+-------- 不加延迟 | 8.1 ms | 17.6 ms 一个 follower(t24n0)的 peer 地址 +20 ms | 7.0 ms | 12.1 ms 两个 follower 的 peer 地址各 +20 ms | 31.0 ms | 39.0 ms 两个 follower:+20 ms 和 +60 ms | 31.5 ms | 37.7 ms leader(t24n2)的 peer 地址 +20 ms | 28.2 ms | 36.1 ms 三个节点的 peer 地址都 +20 ms | 52.7 ms | 60.6 ms 只有一个 follower 慢时,leader 加上另一个 follower 就是多数,延迟几乎不变;两个 follower 都慢时,延迟随较快的那个上升(+20 和 +60 的配置与两个都 +20 差不多,说明等的是较快的那个,不是较慢的)。代理是按“被连接的一方”加延迟的,每种配置具体延迟了 AppendEntries 还是确认的路径,我没有逐条验证,只看写入延迟的变化
提交延迟取决于最快的多数,慢节点不拖累:一个 follower +20 ms 延迟不变,两个都慢才升高。