Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

13 项目二:小型复制 KV/一致性读

本章回答

三个 Python 进程如何用简化 Raft 选出一个 leader,把一次 put 复制到多数派并提交?客户端超时重试时,(client_id, seq) 怎样保证状态机只应用一次?linearizable、lease、stale 三条读路径各自看到了什么,为什么分区期间旧 leader 的读必须被拒绝?

读完你会

能画出三节点选举与日志复制的时间线;能解释 prevLogIndex/prevLogTerm、冲突截断、当前 term 提交规则和 no-op 的作用;能用 kv_client.py 与 /status 复现一次分区、杀主和恢复;能写出一个检查「读后写」与重复 seq 的一致性检查器,并说清它没有证明什么。

第 5 章讲了 Raft 的规则,第 3、4 章讲了复制与一致性模型,第 12 章把一个单机幂等服务拆到了状态机、租约和唯一约束。现在把这三条线接起来:projects/replicated-kv/ 用三个 Python 进程、一个 SQLite 文件和一个 HTTP 接口,实现选举、日志复制、提交、线性一致读和客户端去重。

这个项目仍然故意做小。它没有成员变更、没有日志压缩、没有快照安装,也没有真实网络分区;它只有三个节点、两种 RPC(RequestVote 与 AppendEntries)、三条读路径和一个 (client_id, seq) 去重表。小不是为了绕过难点,而是为了让你能在一屏内看到term、日志索引、commitIndex、appliedIndex、lease 五个数字如何互相约束。把这五个数字看清,换成 etcd、TiKV 或 MySQL Group Replication,协议名不同,约束是一样的。

本章按「验收 → 不变量 → 架构 → 选举 → 复制 → 客户端 → 读 → 持久化 → 代码 → 测试 → 故障注入 → 检查器 → 复盘」组织。每一节都给出可执行命令或可核对输出;不要只读代码。

13.1 目标与验收:三条命令与六个测试

先定义「做完」。不是「三个进程能启动」,而是下面六条同时成立:

#验收项命令通过标准
1写入并线性一致读回python3 -m unittest -v test_kv.py6 个测试全过
2旧 leader 分区后读失败bash fault_inject.sh --partition <leader>线性一致读返回 503,多数派选出新 leader
3杀 leader 后不丢已提交值python3 -m unittest -v test_kv.FailoverTest.test_kill_leader_then_new_leader_keeps_committed_value新 leader 选出且旧值仍在
4同 (client_id, seq) 重复写只应用一次test_3_duplicate_put_same_seq第二次 duplicate=true,版本不变
5三节点 dedup 表一致测试内直接查三个 SQLite每个节点该 client_id 只有 1 行
6恢复后不重复应用重启节点后查 kv 与 dedup版本不倒退,dedup 不重复

这六条对应六个可观察结果。项目后面所有代码、测试和脚本,都是为了让这六条成立;如果某条不成立,先回到对应小节,不要靠「再跑一次」掩盖时序问题。

三节点复制:日志、matchIndex 与 commitIndex leader 把日志复制到两个 follower;多数派 matchIndex 达到 3 后 commitIndex 推进到 3,状态机应用。 三节点复制:日志、matchIndex 与 commitIndex 客户端 put k=v Leader A term=2, commit=2 Follower B match=2 Follower C match=3 append AppendEntries AppendEntries ack 2 ack 3 日志索引 1 已提交 2 已提交 3 已提交 4 未提交 commitIndex = 3:多数派(A、C)match ≥ 3 状态机 apply 到 3;读请求至少等待 applied ≥ readIndex 若 leader 崩溃:新 leader 必须包含 index 3 的日志(选举限制)。
读图:提交不是「leader 写了」而是「多数派持久化了」。图中 follower B 只到 2,但 A+C 已到 3,所以 3 可以提交;如果 leader 在提交前崩溃,新 leader 必须带上已提交的日志。

读这张图要抓住三件事:提交不是 leader 写了,而是多数派持久化了;commitIndex 推进后还要等状态机 apply;选举限制保证新 leader 一定包含已提交日志。三者缺一不可:

  • 只有复制,没有多数派提交:leader 崩溃后,客户端见过成功响应的写可能丢失;
  • 只有 commitIndex,没有 apply:读请求可能看到比 commitIndex 旧的 kv 表;
  • 只有投票,没有日志新旧检查:一个落后节点也可能当选,已提交值被覆盖。

13.1.1 非目标

不做原因生产对应
成员变更固定三节点足够展示 quorum;联合共识是另一章的内容etcd 的 learner/成员变更 API
日志压缩与快照教学项目运行时间短;快照要处理安装与截断Raft snapshot + InstallSnapshot
真实网络分区不引入 root、iptables、netem;用应用层 RPC 阻断tc netem、安全组、交换机 ACL
多 key 事务只支持单 key put/get;事务会引入 MVCC/2PCPercolator、Spanner
鉴权与 TLS本机回环,默认信任边界内mTLS、token、审计

这些非目标不是「以后再说」的借口,而是本章的边界。读完后你应该能判断:换到生产,哪些地方必须补,哪些不变量可以原样保留。

13.2 不变量与故障模型

Raft 的论文有五条安全性,但落到这个项目,最重要的是下面五条。它们不是口号,每一条都能在代码里找到对应位置。

定义 13.2.1 · 项目二的五条不变量
  1. 选举安全:同一 term 最多一个 leader;每个节点每个 term 最多投一票,且投票持久化。
  2. 日志匹配:如果两个日志在相同 index 有相同 term,则该 index 及之前的日志完全相同;prevLogIndex/prevLogTerm 是它的检查点。
  3. 提交规则:leader 只直接提交当前 term 的日志项;旧 term 的日志项通过提交当前 term 的项间接提交。
  4. 去重:状态机对同一 (client_id, seq) 最多应用一次;重复写返回第一次的结果。
  5. ReadIndex:线性一致读必须确认多数派仍认这个 leader,再等 last_applied >= readIndex,才能读本地状态机。

这五条里,第 1、2、3 条保证「已提交的写不会丢」,第 4 条保证「重复的写不会变成两个副作用」,第 5 条保证「读不会退回旧值」。它们分别对应选举、复制、提交、客户端、读路径;任何一条单独看都不够。

13.2.1 故障模型

故障项目里怎么出现协议应该怎么应对项目没有覆盖的
进程崩溃kill leader,或测试里 terminate()重新选举;持久化 term/vote/log磁盘损坏、半写
网络分区/admin/partition 阻断 RPC少数派不能提交;旧 leader 停止服务读真实丢包、延迟、乱序
消息延迟本机回环,延迟很低超时、重试、随机选举跨机房 RTT 与抖动
消息重复客户端重试、RPC 重发(client_id, seq) 去重重复 RPC 的所有边界
节点暂停没有注入 GC/STWlease 可能过期;ReadIndex 仍安全进程暂停、时钟跳变
客户端超时响应丢失、leader 切换同 seq 重试;查询状态客户端崩溃后的状态恢复

项目默认非拜占庭:节点不会伪造消息,但会崩溃、会暂停、会丢包。这个假设很重要;如果节点会说谎,投票和日志匹配都不够,需要另一套协议。

崩溃与分区的区别

崩溃是「进程不在了」,分区是「进程还在,但另一半听不到它」。前者可以靠重启恢复;后者更危险,因为旧 leader 仍然认为自己活着,仍然能读本地 SQLite。第 13.13 节会看到:linearizable 读会拒绝,lease 读却可能在一个时间窗内返回旧值。

13.3 架构:三个进程、两种 RPC、一个状态机

三个节点各自是一个 raft_node.py 进程,监听 7101/7102/7103;每个进程既是 Raft 节点,也是 HTTP 服务端。客户端只跟 leader 打交道,通过 307 跟随重定向。

                       ┌─────────────────────────────┐
                       │  kv_client.py               │
                       │  put / get / status         │
                       │  307 跟随,最多 3 跳          │
                       └──────────────┬──────────────┘
                                      │ HTTP
              ┌───────────────────────┼───────────────────────┐
              │                       │                       │
              ▼                       ▼                       ▼
     ┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
     │ node 1 :7101    │     │ node 2 :7102    │     │ node 3 :7103    │
     │ role/term       │     │ role/term       │     │ role/term       │
     │ log[]           │     │ log[]           │     │ log[]           │
     │ commit/applied  │     │ commit/applied  │     │ commit/applied  │
     │ SQLite          │     │ SQLite          │     │ SQLite          │
     └────────┬────────┘     └────────┬────────┘     └────────┬────────┘
              │  RequestVote / AppendEntries(双向 HTTP)       │
              └───────────────────◀───┼───▶───────────────────┘
                                      │
                         多数派 = 2(三节点中的任意两台)

这张图里有三个容易忽略的点:

  1. RPC 是双向的:leader 向 follower 发 AppendEntries,follower 也会向 leader 发 RequestVote(选举时)。不存在单独的「从节点只读」通道。
  2. 每个节点都有完整日志和状态机:不是分片,不是主从只读副本;三个节点都能在选举后成为 leader。
  3. 客户端不参与 quorum:客户端只负责跟随 307、复用 (client_id, seq);它不知道 commitIndex,也不应该猜。

13.3.1 HTTP 接口

方法路径谁调用作用
GET/healthz测试/运维进程存活,返回节点 id
GET/status客户端/测试/排障角色、term、commit、applied、lease、blocked_peers
POST/client/put客户端写一个 key;leader 才接受
GET/client/get?key=&consistency=客户端三种一致性读
POST/request-vote候选节点拉票
POST/append-entriesleader复制日志/心跳
POST/admin/partition故障注入阻断与指定 peer 的 RPC
POST/admin/heal故障注入清除阻断

/admin/* 没有鉴权,只适合本机教学;生产环境暴露它等于把集群交给任何人。

13.3.2 三个线程与一把锁

线程周期做什么共享状态
_tick_loop20 msfollower 检查选举超时;leader 发心跳、检查 quorum ackrole、election_deadline、last_quorum_ack
_apply_loop20 ms把 commit_index 之前的日志应用到 SQLitelast_applied、kv、dedup
HTTP handler每请求处理投票、复制、客户端读写全部,靠 Node.lock(RLock)保护

Node.lock 是粗粒度锁:选举、复制状态、日志数组、lease 都在它下面。这样做的好处是不会出现「日志和 commitIndex 各改各的」;代价是吞吐受限,且持锁时不能做慢 I/O。项目里 _post 和 SQLite 写入都在锁外或短临界区,读者可以对照代码看边界。

13.3.3 运行参数

参数默认值含义调大/调小的后果
--election-ms500选举超时基准;实际随机 0.5–1.5 倍太小易误选;太大故障恢复慢
--heartbeat-ms100leader 心跳间隔太大 follower 易超时;太小 RPC 多
--lease-ms800leader lease 时长太长分区期间旧主读窗口大;太短读容易 lease_expired
quorum2(peers+1)//2+1三节点固定 2;五节点为 3

测试里把三个值改成 350/80/600,是为了让 6 个测试在几秒内跑完;默认值用于手工演示。参数不是「随便调」:lease_ms 与 election_ms 的关系直接决定 lease 读的安全性,第 13.7 节会展开。

13.4 选举:term、随机超时与投票限制

先看一次选举的时间线。三个节点刚启动,都是 follower,current_term=0,各自在 [0.5, 1.5] × election_ms 后超时。

node 1                 node 2                 node 3
  │                      │                      │
  │ follower             │ follower             │ follower
  │ election deadline    │ election deadline    │ election deadline
  │ 最早到期              │                      │
  ├── term=1, voted=1 ───▶│                      │
  │   RequestVote         │                      │
  │◀── vote_granted=true ─┤                      │
  ├── RequestVote ────────┼─────────────────────▶│
  │◀── vote_granted=true ─┼──────────────────────┤
  │ votes={1,2,3} >= 2   │                      │
  ├── become_leader       │                      │
  │   append no-op(idx=1) │                      │
  ├── AppendEntries ─────▶│                      │
  ├── AppendEntries ──────┼─────────────────────▶│
  │◀── success match=1 ───┤                      │
  │◀── success match=1 ───┼──────────────────────┤
  │ commit_index=1        │                      │

图里有两个细节:候选人的票先投给自己;拿到多数派后立刻追加 no-op,而不是等第一个客户端写。no-op 的作用在第 13.5 节讲,这里先记住:新 leader 需要一条当前 term 的日志来建立提交点。

13.4.1 角色与 term

角色会做什么会拒绝什么
follower响应投票、接受 AppendEntries、选举超时后变 candidateterm 更小的 RPC
candidateterm+1、投自己、并行拉票同 term 的第二个 candidate 的票
leader追加日志、复制、推进 commit、服务读不响应投票(同 term 已有 leader)

term 是逻辑时钟,不是「谁的时间戳大」。每次选举 term+1;节点看到更高 term 就无条件更新并退回 follower。term 只用于选举和拒绝旧消息,不用来判断日志是否最新;日志新旧由 (last_log_term, last_log_index) 决定。

13.4.2 随机超时:把冲突摊开

_reset_election_deadline() 的实现是:

lo, hi = self.election_ms * 0.5, self.election_ms * 1.5
self.election_deadline = mono() + random.uniform(lo, hi) / 1000.0

如果三个节点同时超时,它们会同时 term+1、同时投自己、同时拉票,很可能谁都不够多数派,然后下一轮再撞。随机区间的目的不是「公平」,而是降低同时竞选的概率;它不能消除 split vote,只能让系统在期望几轮内收敛。

Raft 论文用固定的 150–300 ms(或类似区间),工程上常按网络 RTT 的倍数设置。太小,网络抖动就会触发误选;太大,leader 崩溃后恢复慢。项目的 500 ms 基准对应本机回环,生产要按 p99 RTT 和 GC 停顿调。

13.4.3 投票规则:三张否决票

handle_vote 的决策可以压成三张否决票:

否决条件代码里的检查为什么
term 更小if term < self.current_term: deny旧 term 的候选人没有资格
这个 term 已经投给别人voted_for not in (None, cand)一 term 一票,防止两个 leader
候选人日志不够新last_log_term < self.last_log_term,或 term 相同但 last_log_index < self.last_log_index保证新 leader 包含已提交日志

三条都通过才投票。投票前先把 voted_for 写进 SQLite 的 meta 表;如果先回复再持久化,节点在回复后崩溃、重启后可能再次投票,同一 term 出现两张票。

一段简化后的代码:

def handle_vote(self, body: dict) -> dict:
    term = int(body.get("term", 0))
    if term < self.current_term:
        return {"term": self.current_term, "vote_granted": False}
    if term > self.current_term:
        self._step_down(term)
    cand = int(body.get("candidate_id", 0))
    up_to_date = (
        int(body.get("last_log_term", 0)) > self.last_log_term
        or (int(body.get("last_log_term", 0)) == self.last_log_term
            and int(body.get("last_log_index", 0)) >= self.last_log_index)
    )
    granted = False
    if self.voted_for in (None, cand) and up_to_date:
        self.voted_for = cand
        self._persist_meta()          # 先持久化,再回复
        self._reset_election_deadline()
        granted = True
    return {"term": self.current_term, "vote_granted": granted}

这段代码没有检查「候选人是不是自己」;候选人会给自己投票,走的是 _start_election 里的 votes = {self.id},不经过 handle_vote。

为什么一 term 一票就够了

三节点的两个多数派必然相交({1,2} 与 {2,3} 共享 2)。如果每个节点在 term T 只投一票,两个候选人不可能同时拿到两个多数派;所以同一 term 最多一个 leader。这个论证不依赖时钟、不依赖网络延迟,只依赖「投票持久化 + 多数派相交」。

13.4.4 选举限制:为什么落后节点不能当选

假设 node 3 落后,只应用到 index 5;node 1 和 node 2 已经提交到 index 7。node 3 超时后 term+1 拉票:

  • node 1 的 last_log_index=7,node 3 只有 5,拒绝;
  • node 2 同理拒绝;
  • node 3 最多拿到自己一票,不够 2,选举失败。

反过来,如果 node 3 的日志是 (term=3, index=7),node 1 是 (term=2, index=7),node 3 的 last term 更大,node 1 会投票——因为 term 更大的日志意味着它包含了旧 term 的所有已提交项(Raft 的 log matching + leader completeness)。

这条规则是「已提交值不丢」的第二道闸门。第一道是多数派提交,第二道是选举限制;缺了任何一道,旧 leader 崩溃后都可能选出一个没有最新日志的节点。

13.4.5 什么时候会没有 leader

现象原因处理
三个 candidate 反复竞选随机超时撞车/split vote等下一轮;检查网络与 election_ms
一个 candidate 拿不到票日志落后或 term 太旧先补齐日志;不要手工改 term
leader 频繁 step down收不到多数派 ack、或看到更高 term查分区、last_quorum_ack、隔离节点的 term
客户端一直 503 no_leader没有多数派,或选举被阻断先恢复网络,再查 /status

_tick_loop 里还有一条「check quorum」的简化:leader 超过 2 × election_ms 没有收到成功复制,就 _step_down()。它让旧 leader 在分区后尽快停止服务写,但它不是 lease,不能阻止旧 leader 在窗口内继续读;第 13.13 节会看到这个区别。

13.5 日志复制与提交:prevLogIndex、冲突截断与当前 term 规则

选举只解决「谁来写」,复制解决「写到哪里才算数」。Raft 的日志是一个从 1 开始的数组,每个 entry 有 idx、term、command;leader 的 next_index[peer] 是下一次要发给该 peer 的起点,match_index[peer] 是已确认复制的最高 index。

13.5.1 一次 put 的复制路径

client                leader A              follower B           follower C
  │ POST /client/put     │                     │                     │
  ├─────────────────────▶│ role=leader?        │                     │
  │                      │ append idx=4,term=2 │                     │
  │                      ├─ AppendEntries ────▶│                     │
  │                      ├─ AppendEntries ─────┼────────────────────▶│
  │                      │◀─ success match=4 ──┤                     │
  │                      │  A+B = 2 = quorum   │                     │
  │                      │  commit_index=4     │                     │
  │                      │  apply idx=4        │                     │
  │◀─ 200 + version=4 ───┤                     │                     │

注意顺序:leader 先追加本地日志,再并行复制;拿到多数派 match_index >= 4 后才推进 commit_index;_apply_loop 把 entry 应用到 SQLite 的 kv 表;最后才返回 200。客户端看到 200 时,值已经在 leader 的状态机里,且多数派持久化了日志。

13.5.2 AppendEntries 的五个关键字段

字段作用如果错了
termleader 的任期follower 可能接受旧 leader
prev_log_index新 entry 前一条的 index无法定位前缀
prev_log_term前一条的 term只看 index 会接受冲突日志
entries从 prev+1 开始的日志空洞、重复、顺序错
leader_commitleader 已知的提交位置follower 提交滞后或越界

follower 的处理顺序是:

  1. 如果 term < current_term,拒绝;
  2. 如果 term > current_term,更新 term、退回 follower;
  3. 如果 prev_log_index > last_log_index,拒绝,并返回自己的 last_log_index;
  4. 如果 prev_log_index > 0 且 log[prev-1].term != prev_log_term,拒绝,并返回 prev-1;
  5. 对 entries 逐条:如果 index 已存在且 term 相同,跳过;如果 term 不同,从该 index 截断;如果 index 不连续,拒绝;
  6. 追加新 entry;
  7. commit_index = min(leader_commit, last_log_index)。

第 3、4 步是「日志匹配」检查。只比 index 不比 term 是不够的:两个节点可能在同一个 index 上有不同 term 的 entry(旧 leader 未提交的尾巴),只看 index 会把冲突日志当成一致。

13.5.3 冲突截断:删掉未提交的尾巴

假设 node 3 在 term 2 是 leader,追加了 index 6、7,但只复制到 node 3 自己就分区了;node 1 和 node 2 在 term 3 选出新 leader,日志只到 index 5。恢复后,新 leader 给 node 3 发 prev_log_index=5, prev_log_term=3:

  • node 3 的 index 6 是 term 2,与 leader 的 index 6(term 3)冲突;
  • node 3 从 index 6 开始截断,删掉 6、7;
  • 接受新 leader 的 6、7。

被截断的 entry 从未提交,所以删除是安全的。已提交的 entry 永远不会被截断,因为它已经在多数派上,而新 leader 的选举限制保证它包含这些 entry。

_truncate_from(idx) 同时删 SQLite 的 log 表和内存 self.log:

def _truncate_from(self, idx: int) -> None:
    conn = self._conn()
    try:
        conn.execute("DELETE FROM log WHERE idx>=?", (idx,))
    finally:
        conn.close()
    self.log = self.log[:idx - 1]

如果进程在 DELETE 之后、内存截断之前崩溃,重启时 _load() 从 SQLite 重新读日志,内存会回到正确状态;顺序反过来(先改内存再删库)则可能留下「内存没有、磁盘有」的尾巴。持久化代码的顺序要和恢复路径一起看,不能只看正常运行。

13.5.4 提交规则:为什么只直接提交当前 term

_advance_commit() 从后往前找最大的 n,满足「自己 + 足够多的 follower 的 match_index >= n」,并且 log[n-1].term == current_term:

def _advance_commit(self) -> None:
    if self.role != "leader":
        return
    for n in range(self.last_log_index, 0, -1):
        count = 1 + sum(1 for p in self.peers
                        if self.match_index.get(p, 0) >= n)
        if count < self.quorum:
            continue
        if self.log[n - 1]["term"] != self.current_term:
            continue          # 旧 term 的 entry 不能直接提交
        if n > self.commit_index:
            self.commit_index = n
            self._persist_meta()
        return

为什么不能直接提交旧 term 的 entry?因为「多数派复制了」不等于「未来不会选出另一个 leader 覆盖它」。Raft 的 Figure 8 给出了经典反例:一个旧 term 的 entry 在多数派上,但另一个节点可能在更晚的 term 当选并覆盖它。只有当前 term 的 entry 提交后,它之前的旧 term entry 才随之一并提交。

这条规则和 no-op 配合:

  • 新 leader 当选后追加一条当前 term 的 no-op;
  • no-op 复制到多数派后提交;
  • no-op 之前的旧 term entry 被间接提交;
  • 状态机按顺序 apply,旧 entry 不会跳过。

如果新 leader 不追加 no-op,它可能没有任何当前 term 的 entry 可提交,旧 term 的日志就只能一直悬着;读请求也会看到一个不确定的 commitIndex。

13.5.5 复制失败时如何回退

_replicate_peer() 在失败时执行:

match = int(resp.get("match_index", 0))
self.next_index[peer] = max(1, min(ni - 1, match + 1))
  • 如果 follower 的日志太短,返回 match_index=last_log_index,leader 跳到 match+1,一次跳过一大段;
  • 如果 prev_log_term 不匹配,follower 返回 prev-1,leader 只回退一格,下一次再试。

第二种情况在长日志上会很慢:如果冲突点在很前面,leader 需要多次 RTT 才能回退到匹配点。生产实现常用「按 term 二分」或「返回冲突 term 的第一个 index」来加速;项目为了代码短,只回退一格。这是教学简化,不是性能建议。

13.5.6 commitIndex 与 appliedIndex 不是一回事

指标含义谁推进读之前要等吗
commit_index多数派已持久化的最高日志leader 的 _advance_commit,follower 的 leader_commit线性一致读要等 applied >= readIndex
last_applied状态机已经应用到 SQLite 的最高日志_apply_loop是;否则读到旧 kv
log_len本地日志长度append/truncate不等于 commit;可能含未提交尾巴

在 /status 里,正常情况下三者满足 last_applied <= commit_index <= log_len。last_applied < commit_index 是短暂的 apply lag;如果持续很久,说明 apply 线程卡住或 SQLite 写入失败。log_len > commit_index 是未提交尾巴,正常;分区恢复后会被截断或提交。

13.6 客户端协议:307 跟随与 (client_id, seq) 去重

客户端只做三件事:找到 leader、带上身份、在超时后复用身份。kv_client.py 的 follow() 处理第一件,handle_put 的 dedup 处理第二件,seq 的生成规则处理第三件。

13.6.1 307 跟随

follower 收到写请求时返回:

HTTP/1.1 307 Temporary Redirect
Location: http://127.0.0.1:7103
Content-Type: application/json

{"error":"not_leader","leader_id":3,"term":12}

307 与 302 的区别是:307 要求客户端保持原方法和 body 重发。kv_client.py 的 follow() 最多跟 3 跳,超过返回 508 too_many_redirects:

def follow(base, method, path, body=None, timeout=2.0, hops=3):
    current = base.rstrip("/")
    for _ in range(hops):
        status, payload, headers = request(current, method, path, body, timeout)
        if status == 307:
            loc = headers.get("location")
            if not loc:
                return status, payload, current
            current = loc.rstrip("/")
            continue
        return status, payload, current
    return 508, {"error": "too_many_redirects"}, current

三个边界:

  • 如果 follower 不知道 leader(leader_id=None),返回 503 no_leader,不是 307;客户端应退避,而不是换一个新 seq 重发。
  • 如果 leader_id 指向自己(异常状态),_redirect() 会返回 307 到自己;follow() 可能在同一地址上打转,直到 3 跳后 508。生产实现应避免这种自指。
  • follow() 只跟随 307,不自动重试 503/超时;重试策略在调用方。kv_client.py 的 put 只发一次,超时后需要人工/脚本重跑。

13.6.2 身份:client_id 与 seq

kv_client.py 的默认 client_id 是 cli-<8位随机十六进制>,seq 默认 1。每次运行 put,如果没有显式传 --client-id,都会生成新的 client_id;这意味着两次命令行调用不会被当成同一个逻辑请求。做重试实验时必须显式固定这两个值:

# 第一次:写入并记住 (c1, 7)
python3 kv_client.py --key foo --value bar --client-id c1 --seq 7 put

# 同键重试:服务端应返回 duplicate=true,版本不变
python3 kv_client.py --key foo --value bar --client-id c1 --seq 7 put

# 新逻辑请求:seq 变成 8,服务端会再写一条日志
python3 kv_client.py --key foo --value bar --client-id c1 --seq 8 put

一次实际输出(节选):

{
  "http": 200,
  "served_by": "http://127.0.0.1:7101",
  "response": {
    "key": "foo",
    "value": "bar",
    "version": 2,
    "duplicate": true,
    "served_by": 1,
    "term": 1
  }
}

第二次响应的 version 还是 2,duplicate=true,没有 index 字段;第一次响应会有 index=2。index 是这次追加的日志位置,version 是第一次应用时的日志位置;去重命中时,服务端返回的是 dedup.result_json,所以只有 version。

13.6.3 去重表:主键就是身份

CREATE TABLE dedup (
  client_id      TEXT NOT NULL,
  seq            INTEGER NOT NULL,
  result_json    TEXT NOT NULL,
  applied_index  INTEGER NOT NULL,
  PRIMARY KEY (client_id, seq)
);

handle_put 在追加日志前先查一次:

existing = self._get_dedup(client_id, seq)
if existing is not None:
    return 200, {**existing, "duplicate": True,
                 "served_by": self.id, "term": self.current_term}, {}

但这只是「提议前去重」,不是最终保障。真正的保障在 _apply_entry:应用每条 put 时,先查 dedup,命中就跳过;没命中才写 kv 并插入 dedup。为什么两层都要?

  • 提议前查,能避免大部分重复日志,减少日志膨胀;
  • apply 时查,能覆盖「新 leader 还没 apply 完就收到重试」「两个 entry 同时带着同一个 seq」的竞态。

如果只在提议前查,两个并发请求可能都查不到、都追加,最后状态机应用两次;如果只在 apply 时查,日志里会有大量重复项,但状态机仍然只应用一次。正确性靠 apply 时的唯一约束,性能靠提议前的快速路径。

13.6.4 三种重复语义

场景服务端行为结果是否安全
同 (client_id, seq)、同 value返回 duplicate=true 与原结果kv 不变,日志不新增安全
同 (client_id, seq)、不同 value仍返回第一次结果,duplicate=true第二次 value 被静默忽略项目未做 409,是已知边界
不同 seq、同 key/value当成新请求追加日志version 增加,kv 值相同对 put 幂等;对 incr/append 会重复副作用
缺 client_id 或 seq不查 dedup、不写 dedup每次都是新写去重静默失效

第三行很重要:这个项目的 put 是覆盖写,所以「重复写同一个 value」只多一条日志、值不变;如果换成 incr、append、transfer,同样的重试就会变成两次副作用。去重保护的是「同一逻辑请求」,不是「同一个 key」。

第四行是接口层的坑:handle_put 只校验 key/value,不强制 client_id/seq;不传时 _apply_entry 的 if client_id is not None and seq is not None 直接跳过 dedup。生产接口应把这两个字段设为必填,或在缺失时拒绝。

13.6.5 202 与 503 不是成功

handle_put 在两种情况不返回 200:

  • _wait_commit 超时,但角色仍是 leader:返回 202 + {"status":"pending","index":...};
  • _wait_commit 超时,且已经不是 leader:返回 503 leadership_lost。

202 表示「日志已追加,但还没确认提交」;客户端不能当成功,也不能换新 seq,应该用同一个 seq 重试或轮询。kv_client.py put 把 202 也当成功(return 0 if status in (200, 202)),因为它只是演示客户端;生产客户端要区分 200 与 202,并对 202 做轮询。

503 可能是 no_leader、leadership_lost、no_quorum、apply_lag。它们的共同点是「这次请求没有完成」;不同点是「能不能换 leader 重试」。leadership_lost 可以跟 307 到新 leader;no_leader 要先等选举;apply_lag 可以稍后重试同一请求。

13.7 一致性读:linearizable、lease、stale

写路径只关心「多数派提交」,读路径要回答另一个问题:我怎么知道现在读到的值,不是某个旧 leader 留下的旧值? 项目给了三条路径,代价和安全性各不相同。

ReadIndex:线性一致读为什么要一次心跳 leader 记录当前 commitIndex 作为 readIndex,向多数派确认自己仍是 leader,再等 applied ≥ readIndex。 ReadIndex:线性一致读为什么要一次心跳 客户端 Leader Follower 多数派 GET k (linearizable) heartbeat(确认 leadership) ack(多数派) readIndex = commit 记录当前提交位置 等待 applied ≥ readIndex 返回 200 + value 若心跳拿不到多数派 → 拒绝读,不返回旧值 lease read 省掉心跳,但依赖时钟;ReadIndex 不依赖时钟,代价是多一次 RTT。
读图:如果 leader 已经被分区隔离,它本地仍有数据,直接读会返回旧值。ReadIndex 的心跳确认「多数派还认我」,然后再等状态机追上;两步缺一不可。

13.7.1 三条路径

consistency谁可以服务机制失败时安全性
linearizable仅 leader记录 commit_index 作为 readIndex;向多数派发 AppendEntries 确认 leadership;等 applied >= readIndex;读本地 kv503 no_quorum / apply_lag分区下安全;多一次 RTT
lease仅 leaderlease_until > now 时直接读 commit_index 对应的状态机;等 applied503 lease_expired / apply_lag依赖 lease 与时钟;分区窗口内可能旧读
stale任意节点直接读本地 last_applied 的 kv200,stale=true;可能 found=false明确允许旧读;不能用于强一致

三条路径都返回 read_index、applied_index、served_by、term、leader_id。这些字段不是装饰:排障时先看 read_index 与 applied_index 是否相等,再看 served_by 是不是你以为的节点。

13.7.2 ReadIndex 的两步

_read_index() 的实现可以简化成:

def _read_index(self):
    with self.lock:
        if self.role != "leader":
            return None
        ri = self.commit_index
        term = self.current_term
        peers = list(self.peers)
    acks = 1
    for p in peers:
        if p in self.blocked_peers:
            continue
        resp = self._replicate_peer(p)   # 复用 AppendEntries 作为心跳
        if resp and resp.get("term") == term and resp.get("success"):
            acks += 1
    with self.lock:
        if self.role != "leader" or self.current_term != term:
            return None
        if acks >= self.quorum:
            self.last_quorum_ack = mono()
            return ri
    return None

两步缺一不可:

  1. 确认 leadership:向多数派发一次 AppendEntries,收到同 term 的成功响应。如果旧 leader 被分区,它拿不到多数派,返回 503;如果它已经看到更高 term,_step_down 会让 role != leader,也返回 503。
  2. 等 apply 追上:ri 是发起读时的 commit_index,但状态机可能还没应用到那里;_wait_applied(ri) 等到 last_applied >= ri,再读 kv。如果只做第一步,可能读到一个比 commit_index 旧的 kv 表。

为什么 ri 要在确认 leadership 之前记录?因为读的线性化点可以放在「确认多数派」那一刻:所有在 readIndex 之前提交的写都已经在本地日志里,确认之后新提交的写可以排在这条读之后。把 ri 记在确认之后也不一定错,但会要求更强的同步;项目选择前者,和 etcd 的 ReadIndex 思路一致。严格实现还应把「当前 term 已有一条已提交日志」作为 ReadIndex 的前置条件;项目在 _become_leader 追加 no-op,但 _read_index 没有显式检查这一点,依赖 no-op 很快提交。

13.7.3 lease 读:省一次 RTT,换一个假设

lease 路径不向多数派发心跳,只看本地 lease_until:

elif consistency == "lease":
    with self.lock:
        if self.role != "leader" or mono() >= self.lease_until:
            return 503, {"error": "lease_expired"}, {}
        ri = self.commit_index
    if not self._wait_applied(ri, timeout=1.0):
        return 503, {"error": "apply_lag", "read_index": ri}, {}

lease_until 在每次成功的 AppendEntries 响应后延长 lease_ms。它的安全论证是:如果 leader 还能在 lease 内服务读,那么多数派在 lease 到期前不会选出新 leader;只要 lease 没有过期,本地读就不会落后于新 leader 的写。

这个论证有两个前提:

  • 时钟假设:lease 用单调时钟计算,进程暂停/GC 可能让实际时间超过 lease;项目没有处理进程暂停。
  • lease 必须短于选举超时:否则 follower 可以在旧 leader 的 lease 到期前选出新 leader,旧 leader 仍会服务读。

项目的默认值是 lease_ms=800、election_ms=500(随机 250–750 ms)。800 > 750,所以存在一个时间窗:新 leader 已经选出并提交了新写,旧 leader 的 lease_until 还没到,lease 读仍会返回旧值。测试里用的是 600/350(随机 175–525 ms),窗口更大。这不是说 lease 读一定错,而是说:这个项目的 lease 路径是演示,不是线性一致的替代品;需要强一致时用 linearizable。README 也把 lease 列为「依赖单调时钟与本地假设」的已知边界。

13.7.4 stale 读:把旧值标出来

stale 路径不检查角色,直接 ri = last_applied,读本地 kv,返回 stale=true。它可能:

  • 读到一个 follower 还没 apply 的 key,返回 found=false;
  • 读到一个旧版本,version 小于 leader 的最新版本;
  • 在 leader 切换期间读到前任的旧值。

这些都不是 bug,而是契约:调用方明确选择了「可以旧」。如果业务把它当强一致读用,后果由调用方承担。测试 test_2_stale_read_from_follower 就是等 follower 追上后,断言 stale=true 且值正确;它没有断言 follower 一定落后,因为「旧」是允许的,不是保证。

13.7.5 读路径的选择

业务建议理由
余额、库存、配置开关linearizable读到旧值会导致错误决策
高频只读、可容忍秒级旧值stale任意节点可服务,扩展性好
读多写少、能接受 lease 假设lease省一次 RTT;但要先验证 lease < election
调试/对账stale + 版本比对先看各节点 applied_index,再决定是否重读

这张表没有「默认选 lease」。默认应该是 linearizable;只有当你能量化「旧读的代价」并且验证过 lease 假设时,才降到 lease/stale。

13.8 持久化与恢复:SQLite 里到底存了什么

Raft 的正确性依赖「投票、term、日志在回复 RPC 之前已经持久化」。项目用 SQLite 存四张表:

CREATE TABLE meta (
  key TEXT PRIMARY KEY,
  value TEXT NOT NULL
);
CREATE TABLE log (
  idx INTEGER PRIMARY KEY,
  term INTEGER NOT NULL,
  command TEXT NOT NULL
);
CREATE TABLE kv (
  key TEXT PRIMARY KEY,
  value TEXT NOT NULL,
  version INTEGER NOT NULL,
  client_id TEXT,
  seq INTEGER
);
CREATE TABLE dedup (
  client_id TEXT NOT NULL,
  seq INTEGER NOT NULL,
  result_json TEXT NOT NULL,
  applied_index INTEGER NOT NULL,
  PRIMARY KEY (client_id, seq)
);

meta 里存四个键:current_term、voted_for、commit_index、last_applied。log 存日志;kv 和 dedup 是状态机。四张表在同一个 node-<id>.db 文件里,WAL 模式,synchronous=FULL。

13.8.1 什么时候写盘

事件写入是否在回复前
收到更高 termcurrent_term、清空 voted_for是
投票给候选人voted_for是
追加日志log 一行是(leader 复制前)
推进 commitcommit_index是(返回客户端前)
apply 一条先写 kv/dedup,再写 last_applied是(两条事务,中间可崩溃)
截断冲突删除 log 从 idx 起是(接受新日志前)

「回复前持久化」是安全性的底线。如果节点先回复投票、再写 voted_for,它在两者之间崩溃重启,可能在同一 term 投第二票;如果 leader 先返回 200、再写 commit_index,客户端可能看到一个尚未持久化的成功。

13.8.2 连接与 PRAGMA

每次操作新开一个连接,避免跨线程共享 sqlite3.Connection:

def _conn(self) -> sqlite3.Connection:
    conn = sqlite3.connect(self.db_path, timeout=10, isolation_level=None)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA journal_mode=WAL")
    conn.execute("PRAGMA synchronous=FULL")
    conn.execute("PRAGMA busy_timeout=5000")
    return conn
  • WAL:读写不互相阻塞,崩溃恢复更好;
  • synchronous=FULL:提交时 fsync,牺牲吞吐换持久性;
  • busy_timeout=5000:写锁冲突时等 5 秒,避免瞬时并发直接报错;
  • isolation_level=None:关闭 Python 的隐式事务,由代码显式 BEGIN IMMEDIATE 控制边界。

代价是每次操作都建连接、设 PRAGMA,开销不小;教学项目可以接受。生产会用连接池,但必须保证「同一个连接不被多线程同时使用」以及「事务边界清晰」。

13.8.3 重启恢复的顺序

Node.__init__ 的顺序是:

  1. _init_db():建表(IF NOT EXISTS);
  2. _load():从 meta 读 term/vote/commit/applied,从 log 读日志;
  3. _reset_election_deadline():给一个随机选举超时;
  4. 启动 _tick_loop 与 _apply_loop。

_load() 之后,节点不会主动重放日志;它只是恢复内存状态。_apply_loop 会从 last_applied + 1 开始,一直 apply 到 commit_index。如果崩溃发生在「apply 了 kv 但还没写 last_applied」,重启后会重放同一条 entry:

  • kv 的 upsert 是幂等的,同一个 version 再写一次不会产生新版本;
  • dedup 的插入是 INSERT OR IGNORE,同一 (client_id, seq) 不会重复;
  • 所以重放不会产生重复副作用。

如果崩溃发生在「追加日志之后、commit 之前」,重启后 commit_index 不包含这条 entry;它可能被新 leader 截断,也可能在新 leader 上重新提交。两种结果都安全,因为客户端还没看到成功。

13.8.4 崩溃窗口

崩溃位置磁盘状态重启后风险
收到投票后、持久化前voted_for 未写可能再投一次同 term 两票;项目先写后回,避免
追加日志前无 entry重新接收无
追加日志后、复制前本地有未提交 entry等待/被截断无(未提交)
多数派复制后、commit 前多数派有 entry,commit 未推进新 leader 可能提交或覆盖客户端未看到成功
commit 后、返回前已提交客户端 unknown,重试同 seq去重返回原结果
apply 后、last_applied 前kv 已写,meta 未写重放同一条幂等 upsert,无重复
返回后已提交、已 apply正常无

这张表说明:日志、meta、状态机虽然在同一个 SQLite 文件里,却不是跨表原子提交;项目靠顺序、幂等和恢复兜底,不能保证「外部副作用 + 本地状态」原子。项目没有外部副作用;如果 put 要调用下游,必须把 version 或 (client_id, seq) 作为下游幂等键,思路同第 12 章。

13.8.5 没有做的恢复

  • 快照:日志不压缩,重启要重放全部日志;长期运行会越来越慢。
  • 日志校验与磁盘满:没有 checksum,SQLite 只做页级校验;写入失败会抛异常,HTTP 层没有统一映射,可能表现为连接断开。
  • 半写/断电:依赖 SQLite 的 WAL + FULL;没有额外的 fsync 目录。
  • 跨节点修复:没有主动反熵;落后 follower 只能靠 leader 的 AppendEntries 追上。

这些不是「不重要」,而是本章的边界。生产实现要把它们补上,尤其是快照和磁盘满;否则「重启能恢复」只是一句在空库上成立的话。

13.9 代码走读:从 tick 到 apply 的三条路径

raft_node.py 有 700 多行,但结构不复杂。先看骨架:

raft_node.py
├─ Node
│  ├─ 持久化        _conn / _init_db / _load / _persist_meta
│  ├─ 日志          _append_log / _truncate_from / last_log_index
│  ├─ 计时          _reset_election_deadline / _tick_loop / _step_down
│  ├─ 选举          _start_election / _ask_vote / _become_leader
│  ├─ 复制          _replicate_all / _replicate_peer / _advance_commit
│  ├─ 读            _read_index / _wait_commit / _wait_applied
│  ├─ RPC           handle_vote / handle_append
│  ├─ 状态机        _apply_loop / _apply_entry
│  └─ 客户端        handle_put / handle_get / _redirect / status
├─ Server           ThreadingHTTPServer
├─ Handler          /healthz /status /client/* /request-vote /append-entries /admin/*
└─ main             argparse、信号、serve_forever

13.9.1 三条主路径

路径调用链关键状态出错时
选举_tick_loop → _start_election → _ask_vote → _become_leaderterm、voted_for、votes拿不到 quorum,等下一轮
写handle_put → _append_log → _replicate_peer → handle_append → _advance_commit → _apply_entrynext_index、match_index、commit_index202/503;客户端同 seq 重试
读handle_get → _read_index → _replicate_peer → _wait_applied → SELECT kvread_index、last_applied503 no_quorum/apply_lag

选举路径的入口是 _tick_loop:每 20 ms 检查一次,follower 到期就 _start_election,leader 到心跳间隔就 _replicate_all。_start_election 在锁内完成 term+1、投自己、持久化,然后为每个 peer 起一个线程发 RequestVote;每个线程收到响应后回到锁内检查 term 和票数。_become_leader 也在这个锁内追加 no-op、初始化 next_index/match_index、设置 lease。

写路径的入口是 handle_put。它先检查角色,再查 dedup,然后追加日志、触发复制、等待 commit。等待用 Condition,不是忙等:_wait_commit 每 50 ms 醒一次,检查 commit_index 和角色;_advance_commit 推进后 notify_all。如果等待超时但仍是 leader,返回 202;如果已经不是 leader,返回 503。

读路径的入口是 handle_get。linearizable 先 _read_index,再 _wait_applied,最后查 kv;lease 只检查 lease_until 和 applied;stale 直接读本地。三条路径最后都走同一个 SELECT key,value,version FROM kv,但前面的门槛不同。

13.9.2 五个容易看错的函数

函数看起来像实际在做什么看错的后果
_replicate_peer只发日志同时承担心跳、回退 next_index、推进 commit、延长 lease以为读路径不会改状态
_read_index只读会发 AppendEntries,可能推进 commit以为读是纯本地操作
_advance_commit找到多数派就提交还要求 entry 是当前 term旧 term 日志被直接提交,破坏安全性
handle_append只追加还会截断冲突、更新 term/leader、推进 follower commit以为 follower 的日志只增不减
_apply_entry只写 kv还负责 dedup 的最终判定以为提议前去重就够了

13.9.3 错误路径

位置异常/条件处理客户端看到
handle_put非 leader_redirect()307 或 503
handle_put同 seq 已应用返回原结果200 duplicate=true
handle_putcommit 超时且仍 leader返回 pending202
handle_putcommit 超时且失去 leadership返回 leadership_lost503
handle_get非 leader 且 linearizable/lease_redirect()307 或 503
handle_getReadIndex 拿不到 quorumno_quorum503
handle_getapplied 追不上apply_lag503
handle_appendprev 不匹配返回 match_index 帮助回退leader 内部处理
handle_vote日志不够新/已投票拒绝candidate 内部处理

项目没有统一的异常映射:SQLite 写失败、JSON 解析失败、连接断开,可能表现为 500、连接重置或空响应。教学项目可以接受;生产要在 Handler 外层加 try/except,把内部错误映射成稳定的 5xx,并记录 trace id。

13.9.4 并发与线程

_replicate_all() 为每个 peer 起一个线程,_read_index() 又在请求线程里同步调用 _replicate_peer()。这意味着同一时刻可能有多个 AppendEntries 在飞;next_index 和 match_index 都在锁内更新,响应回来时还会检查 term,所以旧 term 的响应不会覆盖新状态。

代价是线程数随「心跳频率 × peer 数」增长。三节点、100 ms 心跳,大约每秒 20 个短线程;本机可以接受,生产会用固定 worker 池和每 peer 一个发送队列。这不是 Raft 协议的一部分,而是工程的实现选择;协议只要求消息最终送达、幂等处理。

13.10 测试:六个场景与它们的边界

测试入口:

cd projects/replicated-kv
python3 -m unittest -v test_kv.py

一次实测输出:

test_kill_leader_then_new_leader_keeps_committed_value ... ok
test_partition_leader_linearizable_read_fails_then_new_leader ... ok
test_1_put_get_linearizable ... ok
test_2_stale_read_from_follower ... ok
test_3_duplicate_put_same_seq ... ok
test_4_read_index_after_write ... ok

----------------------------------------------------------------------
Ran 6 tests in 4.126s

OK

13.10.1 六个测试分别证明什么

测试动作断言证明了
test_1_put_get_linearizableput k1,立刻 linearizable get200,值 v1,stale=false基本写读闭环
test_2_stale_read_from_follower向 follower 轮询 stale get最终读到 v2,stale=truefollower 能追上,stale 明确标记
test_3_duplicate_put_same_seq同 (c-k3,7) 写两次第二次 duplicate=true,version 相同;三节点 dedup 各 1 行去重在 leader 和状态机都生效
test_4_read_index_after_write连续 5 次 put 后立刻 get每次读到刚写的值ReadIndex 能等到 apply
test_kill_leader...put kk 后 terminate leader新 leader 仍读到 vv已提交值不丢
test_partition...put kp 后隔离 leader旧 leader 读 503;新 leader 读到 vp;heal 后仍 vp分区下旧主读被拒,恢复不破坏值

test_3 里有一个容易忽略的细节:它不只查 leader 的 dedup,而是轮询三个节点的 SQLite,直到每个节点的 dedup 都只有 1 行。因为 follower 的 apply 可能晚于 leader,测试要等复制追上;这也说明「去重」不是 leader 内存里的一个集合,而是每个状态机自己的表。

13.10.2 测试没有覆盖什么

  • lease 读的分区窗口:测试只断言 linearizable 读 503,没有在旧 leader 的 lease 未过期时读旧值;
  • 202 与同 seq 不同 value:测试的 put 都在 2 秒内提交,没有制造「仍 leader 但 commit 超时」;项目对同 seq 不同 value 静默返回旧值,也没有断言 409;
  • 缺 client_id/seq:测试从不省略,去重关闭的路径没覆盖;
  • 崩溃重启:terminate() 后不再启动同一节点,没有验证 SQLite 恢复;
  • 真实网络与多 key 并发:本机回环,没有延迟、丢包、乱序;也没有验证不同 key 的写交错与同 key 不同 client 的 last-writer-wins。

把这些边界写清楚,比「6 个测试全过」更有价值。测试通过只说明这 6 条路径在当下时序成立,不说明协议在所有故障下正确。

13.10.3 加一个测试:缺 seq 时去重失效

可以这样加一个测试,专门暴露接口层的坑:

def test_missing_seq_disables_dedup(self):
    body = {"key": "k5", "value": "v5", "client_id": "c-k5"}
    status, payload, _ = follow(self.leader_base, "POST", "/client/put", body)
    self.assertEqual(status, 200, payload)
    self.assertFalse(payload["duplicate"])

这个测试会通过,而且它通过本身就是一个警告:服务端没有强制身份字段,去重可以被客户端静默绕过。生产接口应把 client_id/seq 设为必填,或在缺失时返回 400。

13.11 故障注入与观测:分区、杀主与证据链

这一节把「怎么知道它真的按协议工作」落到命令上。先看 /status,再做故障注入;不要只看测试的绿色 OK。

13.11.1 先看 status

cd projects/replicated-kv
./run_cluster.sh start
python3 kv_client.py status
curl -sS http://127.0.0.1:7101/status | python3 -m json.tool

kv_client.py status 会依次访问三个 base,输出每个节点的 /status。一次实际输出(节选,省略 peers 与节点自身的 base):

{
  "base": "http://127.0.0.1:7101",
  "http": 200,
  "status": {
    "node": 1,
    "role": "leader",
    "term": 1,
    "leader_id": 1,
    "commit_index": 1,
    "last_applied": 1,
    "log_len": 1,
    "last_log_term": 1,
    "quorum": 2,
    "lease_remaining_ms": 739,
    "blocked_peers": []
  }
}
字段正常异常信号
role恰好一个 leader,其余 follower两个 leader 同 term;全部 candidate
term三节点最终一致长期分歧;隔离节点 term 飞涨
leader_id三节点指向同一个 idfollower 指向不存在的 id;leader 指向别人
commit_index单调不减;leader 与多数派一致倒退(除非重启加载旧库);落后很多
last_applied接近 commit_index长期小于 commit,说明 apply 卡住
log_len>= commit_index大于 commit 是未提交尾巴;异常增长要查选举/重复写
last_log_termleader 通常等于 term(no-op 之后)远小于 term,可能还没提交当前 term
lease_remaining_msleader > 0;follower 0leader 长期 0,lease 读会 503
blocked_peers空分区注入未清除;故障演练后忘了 heal

正常/异常的分界不是单个数字,而是三节点放在一起看:一个节点 commit_index=5、另一个 commit_index=3,可能只是复制滞后;如果持续几分钟,才是问题。一个节点 log_len=100、commit_index=3,说明它有一条很长的未提交尾巴,常见于反复选举或旧 leader 分区。

13.11.2 分区与杀主

bash fault_inject.sh --dry-run
bash fault_inject.sh --partition 3
bash fault_inject.sh --heal
bash fault_inject.sh --kill-leader

--dry-run 只打印命令,不注入。--partition 3 会向节点 3 的 /admin/partition 发送 {"peers":[1,2],"enabled":true};节点 3 的 blocked_peers 变成 {1,2},它不再向 1/2 发 RPC,1/2 发来的 RPC 也会因为 from_id 在 blocked_peers 里被拒绝。效果是双向阻断,但实现是「应用层拒绝」,不是「网络丢包」。

一次分区后的线性一致读(旧 leader 仍可达):

{
  "http": 503,
  "served_by": "http://127.0.0.1:7101",
  "response": {"error": "no_leader", "term": 5}
}

具体 error 取决于时序:如果旧 leader 还没 step down,_read_index 拿不到 quorum,返回 no_quorum;如果它已经 step down 且不知道新 leader,返回 no_leader;如果它知道新 leader,返回 307。三种都说明「这次读没有完成」,但客户端动作不同:no_quorum 等恢复,no_leader 等选举,307 跟随。

--kill-leader 的实际输出(节选):

[fault] kill leader node=2 pid=48213
[fault] 新 leader = 3

脚本随后对 failover 做一次 linearizable get;这个 key 通常不存在,所以返回 200 found=false。脚本只证明「新 leader 选出来了」,不证明「已提交值还在」;后者由 test_kill_leader_then_new_leader_keeps_committed_value 断言。

13.11.3 直接查 SQLite

sqlite3 .run/node-1.db "SELECT idx,term,command FROM log ORDER BY idx;"
sqlite3 .run/node-1.db "SELECT client_id,seq,applied_index FROM dedup;"
sqlite3 .run/node-1.db "SELECT key,value,version FROM kv;"

期望:

  • log 的 idx 连续;term 单调不减;command 里能看到 noop 与 put;
  • dedup 的 (client_id,seq) 唯一;同一个 seq 不应出现两行;
  • kv.version 不超过 commit_index,且等于最后一次应用该 key 的日志 index。

如果 dedup 出现两行同一个 (client_id,seq),说明主键被绕过(例如手工建表时漏了 PRIMARY KEY);如果 kv.version 大于 commit_index,说明 apply 越过了提交点,属于严重 bug。

观测的盲区

/status 是节点自己的视角,不是全局真相。旧 leader 在分区期间仍会报告 role=leader、term 也可能很高;不能只看单个节点的 /status 就下结论。至少要看多数派一侧的节点,再用客户端读验证。

13.12 一致性检查器:把「读到旧值」变成可判定的失败

测试是固定的六个场景;真实排障需要从一次运行的历史里判断「这条读是否违反了实时序」。完整的线性一致性判定很复杂(需要搜索所有可能的线性化点),但针对这个项目,可以先做一个实时序检查器:它不证明全部线性一致性,只检查两类高频错误。

13.12.1 检查哪两件事

  1. 读后写:如果一条写 W 在时间上已经返回,之后才开始的一条非 stale 读 R 读同一个 key,那么 R 看到的版本不能早于 W。
  2. 重复身份:同一个 (client_id, seq) 不能对应两个不同的 value;检查器看到 value 冲突就报警。

第一类检查是 sound 的:只要违反,就一定不是线性一致;第二类检查针对去重契约。它不检查「并发的两个写谁先」,也不检查跨 key 的事务,所以通过不等于线性一致。

13.12.2 历史格式

每条事件一行 JSON,时间用同一个单调时钟:

{"op":"put","key":"k","value":"v1","client_id":"c1","seq":1,"start":0.000,"end":0.030,"ok":true,"duplicate":false,"version":2}
{"op":"get","key":"k","version":2,"start":0.040,"end":0.045,"ok":true,"stale":false}
{"op":"put","key":"k","value":"v2","client_id":"c2","seq":1,"start":0.050,"end":0.080,"ok":true,"duplicate":false,"version":3}
{"op":"get","key":"k","version":2,"start":0.090,"end":0.095,"ok":true,"stale":false}

最后一条读发生在 v2 返回之后,却看到 version 2;检查器应报错。

13.12.3 一个可运行的检查器

把下面的脚本存成 /tmp/check_history.py,它只读历史文件,不改项目:

#!/usr/bin/env python3
"""实时序检查器:读后写 + 重复 (client_id, seq)。"""
import json
import sys


def check(events):
    problems = []
    writes = []
    seen = {}
    for e in sorted(events, key=lambda x: x["start"]):
        if e["op"] == "put" and e.get("ok"):
            ident = (e.get("client_id"), e.get("seq"))
            if None not in ident:
                if ident in seen and seen[ident]["value"] != e["value"]:
                    problems.append(
                        f"seq {ident} reused for {seen[ident]['value']} and {e['value']}")
                seen.setdefault(ident, e)
            if not e.get("duplicate"):
                writes.append(e)
    for r in sorted(events, key=lambda x: x["start"]):
        if r["op"] != "get" or not r.get("ok") or r.get("stale"):
            continue
        done = [w for w in writes
                if w["key"] == r["key"] and w["end"] <= r["start"]]
        if not done:
            continue
        newest = max(done, key=lambda w: w["version"])
        if r.get("version") is None or r["version"] < newest["version"]:
            problems.append(
                f"get {r['key']} at {r['start']:.3f} saw {r.get('version')}, "
                f"but put finished at {newest['end']:.3f} with version {newest['version']}")
    return problems


if __name__ == "__main__":
    events = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8")
              if line.strip()]
    found = check(events)
    print(f"events={len(events)} problems={len(found)}")
    for p in found:
        print("-", p)
    sys.exit(1 if found else 0)

正常历史(把最后一条读的 version 改成 3):

$ python3 /tmp/check_history.py history.jsonl
events=4 problems=0

而上面那份原始历史(最后一条读在 v2 返回后仍看到 version 2)会输出:

events=4 problems=1
- get k at 0.090 saw 2, but put finished at 0.080 with version 3

13.12.4 检查器没有证明什么

  • 并发写的顺序:两个写重叠时,检查器不知道哪个先线性化;它只按返回时间做约束。
  • 跨 key 原子性:项目没有多 key 事务,检查器也不检查。
  • stale 读:检查器跳过 stale=true 的读;它们本来就被允许旧。
  • 完整的线性一致性:要枚举读的线性化点,或用 WG/Jepsen 的算法;这个脚本只是第一道筛子。
  • 故障注入下的历史:它需要你先把事件记录下来;项目本身不产出这份 JSONL。

这份检查器的价值在于:它把「我觉得读到了旧值」变成一条可复现的失败记录。排障时先跑它,再决定要不要上完整判定。

13.13 失败模式:分区期间的旧主读

这是本章最重要的失败模式。分区期间,旧 leader 的进程还活着,SQLite 里还有数据,/status 还会返回 role=leader;如果读路径不确认 quorum,它就会把旧值返回给客户端。

13.13.1 时间线

t0  A 是 leader;k=v1 已提交到 A+B(index=4)
t1  A 与 B、C 之间分区;A 仍认为自己是 leader
t2  B、C 选出 C;C 提交 k=v2(index=5),A 不知道
t3  客户端向 A 发 GET consistency=linearizable
      A: _read_index 向 B/C 要 ack,全部失败 → 503 no_quorum
t4  客户端向 A 发 GET consistency=lease
      A: lease_until 还没过期 → 读本地 kv → 返回 v1(旧值)
t5  A 超过 2×election_ms 没有 quorum ack → step_down
t6  客户端再向 A 发 GET → 307 到 C,或 503 no_leader
t7  分区恢复;C 的 AppendEntries 截断 A 的未提交尾巴

第 t4 步是危险点。lease 读不向多数派确认,只看本地 lease_until;如果新 leader 已经在旧 lease 到期前选出并提交,旧 leader 仍然会服务读。默认参数下 lease_ms=800、election_ms=500(随机 250–750 ms),新 leader 可以在旧 lease 到期前产生;测试参数 600/350 的窗口更大。所以这个项目的 lease 读不能当作线性一致读使用。

13.13.2 三条读路径在分区下的表现

路径旧 leader 的行为返回值安全性
linearizable_read_index 拿不到多数派 ack503 no_quorum,或 step down 后 307/503安全;拒绝比返回旧值好
leaselease 未过期时直接读本地200 + 旧值不安全窗口;依赖 lease < election 与时钟
stale任意节点本地读200 + 旧值 + stale=true契约允许;调用方自负

注意 linearizable 也不是「绝对不会返回旧值」:它要求 _read_index 成功,且 _wait_applied 追上。如果 _read_index 成功但 leader 在返回前被推翻,读仍然可以线性化在确认 quorum 的那一刻;只要它返回的是 readIndex 对应的状态,就是合法的。真正危险的是「没有确认 quorum 就读」。

13.13.3 测试证据

test_partition_leader_linearizable_read_fails_then_new_leader 做的正是这条时间线:

  1. 向 leader 写 kp=vp,确认 200;
  2. 隔离 leader 与另外两个节点;
  3. 等 1.2 秒,向旧 leader 发 linearizable get,断言 503;
  4. 等新 leader,向新 leader 发 linearizable get,断言 200 且值仍为 vp;
  5. 解除隔离,再读一次,断言值仍是 vp。

它没有测 lease 读,也没有测「旧 leader 在 lease 窗口内返回 v1」;这是测试的空白,不是协议的安全保证。你可以手工补一个实验:在 t4 时刻对旧 leader 发 --consistency lease,观察它是否返回旧值;如果返回 200,说明 lease 窗口真实存在。

13.13.4 怎么修

措施作用代价
默认只用 linearizable分区时拒绝读,不返回旧值多一次 RTT;leader 不可用时读不可用
lease < election timeout降低旧 lease 窗口需要保守参数和时钟假设
check quorum / 自停旧 leader 尽快停止服务仍有一个检测窗口
fencing token挡住旧 leader 对下游的写需要资源端配合;读路径不适用
把 stale 明确标注让调用方选择不能修复强一致需求

没有一种措施能「既零延迟又强一致」。ReadIndex 用一次 RTT 换安全;lease 用时钟假设换延迟;stale 用正确性换可用性。选哪条,取决于业务能不能承受旧值。

13.14 真实案例:etcd 的 ReadIndex

etcd 是 Raft 的工业实现之一。它公开的读路径设计和这个项目高度对应:

  • 线性一致读是默认:客户端默认的读要经过 leader,不能随便从 follower 本地读;
  • ReadIndex:leader 记录当前的 commit index 作为 readIndex,向多数派确认自己仍然是 leader,然后等状态机 apply 到 readIndex,再返回;
  • serializable read:显式选择后,可以从本地状态机读,可能旧;它不是默认;
  • lease read:一种省心跳的快速路径,依赖 leader lease 与时钟假设;不是所有配置/版本都默认开启。

失败链正是第 13.13 节的时间线:分区 → 旧 leader 仍有本地数据 → 客户端读到旧值;ReadIndex 在返回前再确认多数派,确认不了就失败,而不是返回旧值。

Jepsen 在 2020 年对 etcd 3.4.3 做过公开分析,把选举、线性一致读和故障注入放在一起检验。这里不引用具体数字,只引用结论层面的教训:

  1. 默认路径要安全:强一致读不能靠调用方记得加参数;默认拒绝旧读,显式选择才降级。
  2. 读路径也要故障注入:只测「leader 崩溃后能选新 leader」不够,还要测「旧 leader 还能不能服务读」。
  3. lease 是性能优化,不是安全证明:它把正确性押在时钟和租约上;分区/暂停时要有退路。
etcd项目二差异
默认线性一致读默认 consistency=linearizable两者默认都安全;项目允许显式降级
ReadIndex + 等 apply_read_index + _wait_applied项目复用 AppendEntries 当心跳
serializable readconsistency=stale项目明确标 stale=true
lease readconsistency=lease项目的 lease 参数关系不满足安全条件
成员变更/快照未实现项目的已知边界
事实边界

本节只引用 etcd 公开的 ReadIndex/lease read 设计思路与 Jepsen 的公开分析,不引用 etcd 源码,也不编造延迟、QPS 或事故数字。Jepsen 报告:https://jepsen.io/analyses/etcd-3.4.3;Raft 论文:https://raft.github.io/raft.pdf。更多文献见 A.3 论文、规范与公开复盘索引。

13.15 重试导致重复副作用:客户端重试写

第 12 章讲的是「服务端执行了,但响应丢了」;这一章讲的是「写已经复制并提交了,但客户端没看到 200」。两者的修复思路相同:重试必须携带同一个逻辑身份。

13.15.1 同一把钥匙的三种结局

# 第一次:客户端 c1 的逻辑请求 seq=7
python3 kv_client.py --key pay --value 100 --client-id c1 --seq 7 put

# 响应丢失后,同键重试
python3 kv_client.py --key pay --value 100 --client-id c1 --seq 7 put

# 错误做法:换一个新 seq
python3 kv_client.py --key pay --value 100 --client-id c1 --seq 8 put

三次调用的结果(节选):

调用duplicateversion日志状态机
第一次 (c1,7)false2新增 idx=2应用一次
第二次 (c1,7)true2不新增不应用
第三次 (c1,8)false3新增 idx=3再应用一次

对这个项目的 put 来说,第三次只是把同一个 value 又写了一遍,kv 的值不变;如果命令是 incr、append 或 transfer,第三次就是第二次副作用。去重键保护的是逻辑请求,不是 key;换 seq 等于告诉服务端「这是一个新请求」。

13.15.2 为什么 apply 时还要去重

考虑这个时序:

  1. 旧 leader 追加了 (c1,7),已提交但响应丢失;
  2. 新 leader 当选,但还没 apply 到这条 entry;
  3. 客户端重试 (c1,7) 到新 leader;
  4. 新 leader 的 handle_put 查 dedup,查不到(还没 apply),于是又追加一条 (c1,7);
  5. apply 线程先应用第一条,写入 dedup;再应用第二条,命中 dedup,跳过。

结果:日志里有两条 (c1,7),状态机只应用一次。提议前去重是快速路径,apply 时去重是正确性保障。如果没有第 5 步,客户端重试就会变成两次写;如果只有第 5 步,日志会膨胀,但不影响正确性。

13.15.3 四种重试姿势

客户端行为服务端看到结果评价
同 (client_id,seq)、同 value重复身份返回原结果,duplicate=true正确
同 (client_id,seq)、不同 value重复身份返回第一次结果,静默忽略新值项目未做 409;生产要拒绝
新 seq、同 value新请求新日志、新版本;put 值相同对 put 无害;对 incr 重复
缺 client_id/seq无身份每次都是新写去重失效,最危险

13.15.4 结论

这个项目实现的是「至少一次投递 + 状态机幂等」,不是「网络层 exactly-once」。客户端可能重试多次,服务端可能收到多个物理请求,甚至日志里出现重复 entry;只要 (client_id, seq) 不变,状态机就只应用一次。正确性的锚点是身份,不是重试次数,也不是 HTTP 连接。

13.16 本章陷阱清单

12 条高频错误
  1. 把 307 当失败,换节点后换新 seq 重发:新 seq 是新逻辑请求,去重救不了;307 应跟随,重试应复用原 seq。
  2. 不传 client_id/seq:handle_put 不强制这两个字段,缺省时 dedup 静默关闭,重复写会变成多条日志。
  3. 同 seq 不同 value 以为会覆盖:项目直接返回第一次结果,第二次 value 被忽略;生产应加指纹并返回 409。
  4. 只在 handle_put 查 dedup:新 leader 还没 apply 时查不到,仍会追加重复 entry;正确性靠 _apply_entry 的唯一约束。
  5. 把 lease 读当线性一致读:默认 lease_ms=800 大于 election_ms 的上界 750,分区时有旧读窗口;强一致用 linearizable。
  6. 以为 stale 读会失败:它返回 200 和 stale=true,可能 found=false;调用方必须自己判断。
  7. 把 commit_index 当 last_applied:commit 只表示多数派持久化,状态机可能还没 apply;读之前要等 applied。
  8. 直接提交旧 term 的日志:多数派复制不等于可提交;必须当前 term 的 entry 先提交,旧 entry 才能间接提交。
  9. 只看 prev_log_index 不看 prev_log_term:同 index 不同 term 是冲突日志,必须截断;只比 index 会接受错误历史。
  10. 以为 no-op 只是占位:no-op 让新 leader 建立当前 term 的提交点,并间接提交旧 term 已提交项;没有它,commitIndex 可能长期不动。
  11. 把 /admin/* 暴露到生产:/admin/partition 和 /admin/heal 没有鉴权,任何人都能切断集群。
  12. 把三节点本机测试当跨机房验证:没有真实延迟、丢包、时钟跳变、磁盘故障;而且 /admin/partition 是应用层拒绝而非丢包,隔离节点的高 term 仍可能通过错误响应泄漏,heal 后出现选举抖动;参数要按生产重测。

13.17 本章出口检查

检查项达标线没达标去哪
说出五条不变量选举安全、日志匹配、提交规则、去重、ReadIndex13.2
画出三节点架构三个进程、两种 RPC、一个状态机、quorum=213.3
解释选举限制能说出 (last_log_term, last_log_index) 如何否决落后候选人13.4
解释日志匹配prevLogIndex/prevLogTerm、冲突截断、回退规则13.5
解释当前 term 提交为什么旧 term 的 entry 不能直接提交;no-op 的作用13.5
解释 307 与去重Location、follow() 上限、(client_id,seq) 两层去重13.6
区分三条读路径linearizable/lease/stale 的机制、失败码、安全性13.7、13.13
读懂 /status能指出 leader、term、commit、applied、lease、blocked_peers13.11
复现分区与杀主fault_inject.sh --partition、--kill-leader、--heal13.11
解释旧主读为什么 linearizable 503、lease 可能旧、stale 明确旧13.13
做一次一致性检查能写出读后写与重复 seq 的检查器,并说明它没证明什么13.12
说出 etcd 的对应ReadIndex、serializable、lease 的取舍13.14
即时自测 · 13

Q1 新 leader 为什么要在当选后追加一条 no-op?如果没有 no-op,commit_index 和读路径会受什么影响?

Q2 follower 收到 prev_log_index=5, prev_log_term=2,但它本地 index 5 的 term 是 3。它会返回什么?leader 如何回退?

Q3 为什么 linearizable 读要「先确认多数派,再等 applied」,只做其中一步会怎样?

Q4 旧 leader 被分区,但 lease_remaining_ms 还有 300 ms;客户端用 lease 读,可能看到什么?为什么 linearizable 不会这样?

Q5 客户端第一次 put 超时,服务端其实已提交;客户端用同一个 (client_id,seq) 重试,日志和状态机各会发生什么?如果换成新 seq 呢?

答案

A1 no-op 在当前 term 建立一条可提交的日志,提交后把之前旧 term 的已提交项一并带过,状态机才能安全推进 commit_index。没有 no-op,新 leader 在第一条当前 term 写提交之前,commit_index 可能停在旧位置,读路径会读到更旧的状态或 503;no-op 把这个窗口尽量缩短。

A2 它发现 prev_log_term 不匹配,返回 success=false 和 match_index=prev-1=4。leader 把 next_index 从 6 调到 min(6-1, 4+1)=5;下一次 prev=4,如果 index 4 匹配,就从 index 5 开始覆盖冲突项,否则继续回退。

A3 只确认多数派、不等 applied:leader 可能仍是 leader,但本地 kv 还没应用到 readIndex,读到旧值。只等 applied、不确认多数派:旧 leader 可能已经被分区,本地状态再新也可能落后于新 leader 的写。两步分别解决「我还是不是 leader」和「我的状态机够不够新」。

A4 可能返回 200 和旧值。lease 读只看本地 lease_until,不向多数派确认;如果新 leader 在旧 lease 到期前已选出并提交,旧 leader 仍会服务读。linearizable 会先 _read_index 向多数派要 ack,拿不到就 503,不会返回旧值。

A5 同 seq 重试:handle_put 查 dedup,命中则返回 200 duplicate=true 和原 version;日志不新增,状态机不重复应用。如果新 leader 还没 apply,可能先追加一条重复 entry,但 _apply_entry 的 dedup 会让第二次跳过,仍然只应用一次。换新 seq:服务端当成新逻辑请求,新增日志并再应用一次;对 put 是覆盖,对 incr/transfer 就是第二次副作用。

母本对应

母本章节本书落点程度说明
谢希仁《计算机网络》第 8 版第 5 章 运输层、第 6 章 应用层13.3、13.6背景只借用「请求经过网络与 HTTP」的视角;TCP/HTTP 细节不重写
张彦飞《深入理解 Linux 网络》第 4–7 章(TCP、socket、连接)13.3、13.9背景连接/线程模型只引用,不展开内核路径
《Python 网络编程》socket/HTTP 服务、并发服务器13.9、13.10精选借用标准库 HTTP 服务的工程风格;代码全部重写
《Linux 系统与内核》第 4 篇4.4–4.11(网络与协议栈)13.3、13.8交叉引用明确不重复 TCP/socket/epoll/HTTP
本书自编 + Raft 论文/etcd 文档Raft、ReadIndex、lease read13.4–13.7、13.14完整只引用公开设计事实,不复制论文或 etcd 源码
本书项目代码projects/replicated-kv/13.1–13.12完整所有代码为本书编写,6 个测试实测

下一站:项目二证明了「三节点可以选主、复制、线性一致读」,但还没有回答「集群怎么在线扩容、日志怎么压缩、跨机房延迟怎么影响选举」。进入 A.4 交付自检与回归清单,把本章的验收命令、故障注入和检查器固化成交付前的回归清单。