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.py | 6 个测试全过 |
| 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 不重复 |
这六条对应六个可观察结果。项目后面所有代码、测试和脚本,都是为了让这六条成立;如果某条不成立,先回到对应小节,不要靠「再跑一次」掩盖时序问题。
读这张图要抓住三件事:提交不是 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/2PC | Percolator、Spanner |
| 鉴权与 TLS | 本机回环,默认信任边界内 | mTLS、token、审计 |
这些非目标不是「以后再说」的借口,而是本章的边界。读完后你应该能判断:换到生产,哪些地方必须补,哪些不变量可以原样保留。
13.2 不变量与故障模型
Raft 的论文有五条安全性,但落到这个项目,最重要的是下面五条。它们不是口号,每一条都能在代码里找到对应位置。
- 选举安全:同一 term 最多一个 leader;每个节点每个 term 最多投一票,且投票持久化。
- 日志匹配:如果两个日志在相同 index 有相同 term,则该 index 及之前的日志完全相同;
prevLogIndex/prevLogTerm是它的检查点。 - 提交规则:leader 只直接提交当前 term 的日志项;旧 term 的日志项通过提交当前 term 的项间接提交。
- 去重:状态机对同一
(client_id, seq)最多应用一次;重复写返回第一次的结果。 - 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/STW | lease 可能过期;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(三节点中的任意两台)
这张图里有三个容易忽略的点:
- RPC 是双向的:leader 向 follower 发
AppendEntries,follower 也会向 leader 发RequestVote(选举时)。不存在单独的「从节点只读」通道。 - 每个节点都有完整日志和状态机:不是分片,不是主从只读副本;三个节点都能在选举后成为 leader。
- 客户端不参与 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-entries | leader | 复制日志/心跳 |
POST | /admin/partition | 故障注入 | 阻断与指定 peer 的 RPC |
POST | /admin/heal | 故障注入 | 清除阻断 |
/admin/* 没有鉴权,只适合本机教学;生产环境暴露它等于把集群交给任何人。
13.3.2 三个线程与一把锁
| 线程 | 周期 | 做什么 | 共享状态 |
|---|---|---|---|
_tick_loop | 20 ms | follower 检查选举超时;leader 发心跳、检查 quorum ack | role、election_deadline、last_quorum_ack |
_apply_loop | 20 ms | 把 commit_index 之前的日志应用到 SQLite | last_applied、kv、dedup |
| HTTP handler | 每请求 | 处理投票、复制、客户端读写 | 全部,靠 Node.lock(RLock)保护 |
Node.lock 是粗粒度锁:选举、复制状态、日志数组、lease 都在它下面。这样做的好处是不会出现「日志和 commitIndex 各改各的」;代价是吞吐受限,且持锁时不能做慢 I/O。项目里 _post 和 SQLite 写入都在锁外或短临界区,读者可以对照代码看边界。
13.3.3 运行参数
| 参数 | 默认值 | 含义 | 调大/调小的后果 |
|---|---|---|---|
--election-ms | 500 | 选举超时基准;实际随机 0.5–1.5 倍 | 太小易误选;太大故障恢复慢 |
--heartbeat-ms | 100 | leader 心跳间隔 | 太大 follower 易超时;太小 RPC 多 |
--lease-ms | 800 | leader lease 时长 | 太长分区期间旧主读窗口大;太短读容易 lease_expired |
quorum | 2 | (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、选举超时后变 candidate | term 更小的 RPC |
| candidate | term+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。
三节点的两个多数派必然相交({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 的五个关键字段
| 字段 | 作用 | 如果错了 |
|---|---|---|
term | leader 的任期 | follower 可能接受旧 leader |
prev_log_index | 新 entry 前一条的 index | 无法定位前缀 |
prev_log_term | 前一条的 term | 只看 index 会接受冲突日志 |
entries | 从 prev+1 开始的日志 | 空洞、重复、顺序错 |
leader_commit | leader 已知的提交位置 | follower 提交滞后或越界 |
follower 的处理顺序是:
- 如果
term < current_term,拒绝; - 如果
term > current_term,更新 term、退回 follower; - 如果
prev_log_index > last_log_index,拒绝,并返回自己的last_log_index; - 如果
prev_log_index > 0且log[prev-1].term != prev_log_term,拒绝,并返回prev-1; - 对
entries逐条:如果 index 已存在且 term 相同,跳过;如果 term 不同,从该 index 截断;如果 index 不连续,拒绝; - 追加新 entry;
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),返回 503no_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:返回 503leadership_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 留下的旧值? 项目给了三条路径,代价和安全性各不相同。
13.7.1 三条路径
consistency | 谁可以服务 | 机制 | 失败时 | 安全性 |
|---|---|---|---|---|
linearizable | 仅 leader | 记录 commit_index 作为 readIndex;向多数派发 AppendEntries 确认 leadership;等 applied >= readIndex;读本地 kv | 503 no_quorum / apply_lag | 分区下安全;多一次 RTT |
lease | 仅 leader | lease_until > now 时直接读 commit_index 对应的状态机;等 applied | 503 lease_expired / apply_lag | 依赖 lease 与时钟;分区窗口内可能旧读 |
stale | 任意节点 | 直接读本地 last_applied 的 kv | 200,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
两步缺一不可:
- 确认 leadership:向多数派发一次 AppendEntries,收到同 term 的成功响应。如果旧 leader 被分区,它拿不到多数派,返回 503;如果它已经看到更高 term,
_step_down会让role != leader,也返回 503。 - 等 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 什么时候写盘
| 事件 | 写入 | 是否在回复前 |
|---|---|---|
| 收到更高 term | current_term、清空 voted_for | 是 |
| 投票给候选人 | voted_for | 是 |
| 追加日志 | log 一行 | 是(leader 复制前) |
| 推进 commit | commit_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__ 的顺序是:
_init_db():建表(IF NOT EXISTS);_load():从meta读 term/vote/commit/applied,从log读日志;_reset_election_deadline():给一个随机选举超时;- 启动
_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_leader | term、voted_for、votes | 拿不到 quorum,等下一轮 |
| 写 | handle_put → _append_log → _replicate_peer → handle_append → _advance_commit → _apply_entry | next_index、match_index、commit_index | 202/503;客户端同 seq 重试 |
| 读 | handle_get → _read_index → _replicate_peer → _wait_applied → SELECT kv | read_index、last_applied | 503 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_put | commit 超时且仍 leader | 返回 pending | 202 |
handle_put | commit 超时且失去 leadership | 返回 leadership_lost | 503 |
handle_get | 非 leader 且 linearizable/lease | _redirect() | 307 或 503 |
handle_get | ReadIndex 拿不到 quorum | no_quorum | 503 |
handle_get | applied 追不上 | apply_lag | 503 |
handle_append | prev 不匹配 | 返回 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_linearizable | put k1,立刻 linearizable get | 200,值 v1,stale=false | 基本写读闭环 |
test_2_stale_read_from_follower | 向 follower 轮询 stale get | 最终读到 v2,stale=true | follower 能追上,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 | 三节点指向同一个 id | follower 指向不存在的 id;leader 指向别人 |
commit_index | 单调不减;leader 与多数派一致 | 倒退(除非重启加载旧库);落后很多 |
last_applied | 接近 commit_index | 长期小于 commit,说明 apply 卡住 |
log_len | >= commit_index | 大于 commit 是未提交尾巴;异常增长要查选举/重复写 |
last_log_term | leader 通常等于 term(no-op 之后) | 远小于 term,可能还没提交当前 term |
lease_remaining_ms | leader > 0;follower 0 | leader 长期 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 检查哪两件事
- 读后写:如果一条写 W 在时间上已经返回,之后才开始的一条非 stale 读 R 读同一个 key,那么 R 看到的版本不能早于 W。
- 重复身份:同一个
(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 拿不到多数派 ack | 503 no_quorum,或 step down 后 307/503 | 安全;拒绝比返回旧值好 |
lease | lease 未过期时直接读本地 | 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 做的正是这条时间线:
- 向 leader 写
kp=vp,确认 200; - 隔离 leader 与另外两个节点;
- 等 1.2 秒,向旧 leader 发 linearizable get,断言 503;
- 等新 leader,向新 leader 发 linearizable get,断言 200 且值仍为
vp; - 解除隔离,再读一次,断言值仍是
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 做过公开分析,把选举、线性一致读和故障注入放在一起检验。这里不引用具体数字,只引用结论层面的教训:
- 默认路径要安全:强一致读不能靠调用方记得加参数;默认拒绝旧读,显式选择才降级。
- 读路径也要故障注入:只测「leader 崩溃后能选新 leader」不够,还要测「旧 leader 还能不能服务读」。
- lease 是性能优化,不是安全证明:它把正确性押在时钟和租约上;分区/暂停时要有退路。
| etcd | 项目二 | 差异 |
|---|---|---|
| 默认线性一致读 | 默认 consistency=linearizable | 两者默认都安全;项目允许显式降级 |
| ReadIndex + 等 apply | _read_index + _wait_applied | 项目复用 AppendEntries 当心跳 |
| serializable read | consistency=stale | 项目明确标 stale=true |
| lease read | consistency=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
三次调用的结果(节选):
| 调用 | duplicate | version | 日志 | 状态机 |
|---|---|---|---|---|
第一次 (c1,7) | false | 2 | 新增 idx=2 | 应用一次 |
第二次 (c1,7) | true | 2 | 不新增 | 不应用 |
第三次 (c1,8) | false | 3 | 新增 idx=3 | 再应用一次 |
对这个项目的 put 来说,第三次只是把同一个 value 又写了一遍,kv 的值不变;如果命令是 incr、append 或 transfer,第三次就是第二次副作用。去重键保护的是逻辑请求,不是 key;换 seq 等于告诉服务端「这是一个新请求」。
13.15.2 为什么 apply 时还要去重
考虑这个时序:
- 旧 leader 追加了
(c1,7),已提交但响应丢失; - 新 leader 当选,但还没 apply 到这条 entry;
- 客户端重试
(c1,7)到新 leader; - 新 leader 的
handle_put查 dedup,查不到(还没 apply),于是又追加一条(c1,7); - 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 本章陷阱清单
- 把 307 当失败,换节点后换新 seq 重发:新 seq 是新逻辑请求,去重救不了;307 应跟随,重试应复用原 seq。
- 不传
client_id/seq:handle_put不强制这两个字段,缺省时 dedup 静默关闭,重复写会变成多条日志。 - 同 seq 不同 value 以为会覆盖:项目直接返回第一次结果,第二次 value 被忽略;生产应加指纹并返回 409。
- 只在
handle_put查 dedup:新 leader 还没 apply 时查不到,仍会追加重复 entry;正确性靠_apply_entry的唯一约束。 - 把
lease读当线性一致读:默认lease_ms=800大于election_ms的上界 750,分区时有旧读窗口;强一致用linearizable。 - 以为
stale读会失败:它返回 200 和stale=true,可能found=false;调用方必须自己判断。 - 把
commit_index当last_applied:commit 只表示多数派持久化,状态机可能还没 apply;读之前要等 applied。 - 直接提交旧 term 的日志:多数派复制不等于可提交;必须当前 term 的 entry 先提交,旧 entry 才能间接提交。
- 只看
prev_log_index不看prev_log_term:同 index 不同 term 是冲突日志,必须截断;只比 index 会接受错误历史。 - 以为 no-op 只是占位:no-op 让新 leader 建立当前 term 的提交点,并间接提交旧 term 已提交项;没有它,commitIndex 可能长期不动。
- 把
/admin/*暴露到生产:/admin/partition和/admin/heal没有鉴权,任何人都能切断集群。 - 把三节点本机测试当跨机房验证:没有真实延迟、丢包、时钟跳变、磁盘故障;而且
/admin/partition是应用层拒绝而非丢包,隔离节点的高 term 仍可能通过错误响应泄漏,heal 后出现选举抖动;参数要按生产重测。
13.17 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 说出五条不变量 | 选举安全、日志匹配、提交规则、去重、ReadIndex | 13.2 |
| 画出三节点架构 | 三个进程、两种 RPC、一个状态机、quorum=2 | 13.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_peers | 13.11 |
| 复现分区与杀主 | fault_inject.sh --partition、--kill-leader、--heal | 13.11 |
| 解释旧主读 | 为什么 linearizable 503、lease 可能旧、stale 明确旧 | 13.13 |
| 做一次一致性检查 | 能写出读后写与重复 seq 的检查器,并说明它没证明什么 | 13.12 |
| 说出 etcd 的对应 | ReadIndex、serializable、lease 的取舍 | 13.14 |
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 read | 13.4–13.7、13.14 | 完整 | 只引用公开设计事实,不复制论文或 etcd 源码 |
| 本书项目代码 | projects/replicated-kv/ | 13.1–13.12 | 完整 | 所有代码为本书编写,6 个测试实测 |
下一站:项目二证明了「三节点可以选主、复制、线性一致读」,但还没有回答「集群怎么在线扩容、日志怎么压缩、跨机房延迟怎么影响选举」。进入 A.4 交付自检与回归清单,把本章的验收命令、故障注入和检查器固化成交付前的回归清单。