bigywater.tech
分布式事务 · 24

Raft 日志复制:提交条件

分布式事务里的 TC、元数据、全局状态常常放在 Raft 复制的存储里。Raft 的“提交”到底是什么条件?少数节点挂掉会怎样?多数丢了会怎样?为什么旧任期的日志不能靠数副本数提交?这篇用真实的 etcd 和一个手写的日志模拟,把这些问题逐个跑出来。

7.74 → 7.83 ms
杀掉 1 个 follower 前后,3 节点 etcd 的写入延迟中位数(没有变化)
3.0 s 超时 → 之后 new
只剩 1 个节点时那次写入客户端超时;多数恢复后线性一致读到的却是 new
[3, 3, 3]
手写模拟图 8:规则 A 下已提交的位置 2 条目被覆盖后,S1、S2、S3 上它的任期

提交 = 多数落盘

实测 · 本机执行输出
  写入之前:
  节点    | 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 延迟不变,两个都慢才升高。

分步演示

Raft 提交
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / MySQL 26.7.0 与 PostgreSQL 16.15(Homebrew,每个实验自己 initdb、自己起实例,端口 9150 以上,只监听 127.0.0.1)/ Python 3.14.8 + PyMySQL 1.2.3 + psycopg 3.3.6 / Redis 8.10.2 / Kafka 4.3.1 / RabbitMQ 4.3.6 / etcd 3.7.2(按各篇注明用到哪些);不连任何外部服务,不碰本机已有的数据库实例;故障用 kill -9 进程、让连接断开或在代码里的指定位置抛异常来制造;计时用 time.perf_counter,数字每次运行会有小幅抖动。本篇第一、二、三、五节用真实的 etcd 3.7.2(3 个节点,heartbeat-interval 100 ms、election-timeout 1000 ms),通过 etcd 的 HTTP/JSON 网关读写,kill -9 杀进程;peer 地址经过我加的一层延迟代理(只在第五节用来加延迟);第四节是我按 Raft 论文图 8 手写的 5 节点日志模拟(任期、投票的“日志不比自己旧”检查、AppendEntries 覆盖),不是 etcd
    • 没有验证:etcd 里同一场景(图 8)的真实表现;磁盘 fsync 对提交延迟的影响(mac 上 fsync 偏快);选举超时的随机化、PreVote、CheckQuorum 的细节(第三节 leader 让出角色是观察结果,没有读源码确认机制);成员变更与 learner;网络分区(这里只杀进程和加延迟);第三节“那条超时的写入被提交”只在持有它的节点赢得选举时成立,另一种情形没有造出来
    京ICP备2026067502号-1京公网安备11010502063487号