写入路径(第 7 篇)走 Raft;读在 etcd 上刻意做了分流:默认 线性一致 读不走日志,而用 ReadIndex 确认 Leader 并等待 applied index;Serializable 读完全本地;Lease 相关 RPC(KeepAlive、TimeToLive)在 Leader 上不经 Raft 更新/查询 lessor 状态。
混用三种语义是排障「读到旧 resourceVersion」「锁 Lease 已过期但 TTL 仍显示正数」的第一分叉。
本篇在系列中的位置
篇目 核心内容 第 7 篇 · 写入路径 Txn、quota、alarm 第 8 篇 · 读路径与一致性 ReadIndex、Serializable、Lease read 第 9 篇 · Watch watchableStore、ErrCompacted 系列目录 关键问题、阅读路径
版本锚定:etcd v3.5.33(tag
v3.5.33)。机制对齐server/etcdserver/v3_server.go(Range、linearizableReadLoop)、api/etcdserverpb/rpc.proto、raft/raft.go(ReadOnlyOption)。无真实集群则不粘贴 follower 与 leader 读对比的伪造延迟表。
一、Range 默认:线性一致读
RangeRequest.serializable 默认为
false。非 Serializable 的 Range 在
EtcdServer.Range 中先调用
linearizableReadNotify,再
applyV3Base.Range 读本地 MVCC:
// server/etcdserver/v3_server.go @ v3.5.33(节选)
if !r.Serializable {
err = s.linearizableReadNotify(ctx)
// ...
}
get := func() { resp, err = s.applyV3Base.Range(ctx, nil, r) }客户端默认即线性一致(etcdctl get key,不加
--consistency=s)。语义:读到的是已
apply 且不超过 ReadIndex 确认 commit
点的状态——不是「最新 commit 但未
apply」的数据。
二、ReadIndex 循环
Dedicated goroutine
linearizableReadLoop
批量服务线性读:
sequenceDiagram
participant req as Range or linearizable Txn
participant loop as linearizableReadLoop
participant raft as Raft ReadIndex
participant apply as applyWait
participant mvcc as MVCC Range
req->>loop: readwaitc signal
loop->>raft: sendReadIndex
raft-->>loop: readState.Index equals commit index
loop->>apply: Wait until applied ge index
loop->>req: readNotifier notify
req->>mvcc: Range local
步骤(v3_server.go):
- 合并多个并发读请求到同一
readNotifier; requestCurrentIndex→r.ReadIndex(ReadOnlySafe路径,见下节);- 若
appliedIndex < confirmedIndex,阻塞于applyWait.Wait(confirmedIndex); notify唤醒所有等待的 Range/Txn 读。
边界情况:
- Leader
切换:
ErrLeaderChanged,客户端应重试到其他 member; - 新 term 首条 commit 前:重发
ReadIndex(
firstCommitInTermNotifier); - ReadIndex
超时:重试;
slow_read_index_total计数堆积。
这与 distributed/13 的 ReadIndex 工程补丁衔接;本篇钉 etcdserver 如何等待 apply,不重复 Raft 状态机全书。
三、Serializable 读与 follower 风险
RangeRequest.serializable = true(etcdctl get --consistency=s)跳过
linearizableReadNotify,直接在本地 member 上
Range。proto
注释写明:延迟更低、吞吐更高,可能
stale——因 follower 的 applied index
可落后于 Leader。
| 模式 | 网络 | 一致性 | 典型场景 |
|---|---|---|---|
| 默认 linearizable | ReadIndex RTT + apply wait | 线性一致 | 控制面强一致读、Txn compare |
| Serializable | 无额外 Raft 往返 | 成员本地序 | 缓存预热、可容忍 lag 的统计 |
工程判断:K8s apiserver 对 etcd 的
Get/List 默认应走 linearizable(通过最新 RV);自行用
clientv3 WithSerializable() 接 follower
端点做读扩展,不等于 apiserver 语义。
只读 Txn:isTxnSerializable 要求
success/failure 分支全部为 Serializable
Range 才跳过线性读;否则仍
linearizableReadNotify。
四、Lease read:两类「租约读」
「Lease read」在 etcd 语境下需分列,避免与 KV Lease 字段混淆:
4.1 Raft ReadOnlyLeaseBased(库能力,非 etcd 默认)
go.etcd.io/raft/v3 提供两种
ReadOnlyOption(raft/raft.go):
| 选项 | 机制 | etcd v3.5.33 |
|---|---|---|
ReadOnlySafe(默认) |
ReadIndex + quorum heartbeat ack | 使用(raft.Config 未改
ReadOnlyOption) |
ReadOnlyLeaseBased |
依赖 Leader lease,跳过 quorum ack | 未启用;需
CheckQuorum |
ReadOnlyLeaseBased
在时钟漂移无界时不安全(raft 注释);etcd 生产路径走
ReadOnlySafe。讨论「Lease read
优化」时须标明是 Raft 层可选模式,不是
v3.5.33 默认行为。
4.2 Lease RPC:不经 Raft 的 lessor 读/续租
LeaseKeepAlive(LeaseRenew)与
LeaseTimeToLive 在 Leader
上直接访问 lessor,不写 Raft
日志:
LeaseRenew:ensureLeadership()+waitAppliedIndex()后Renew;LeaseTimeToLive:waitAppliedIndex()后lessor.Lookup,若le.Demoted()返回ErrLeaderChanged。
Follower 收到请求会 forward 到 Leader(gRPC proxy 路径)。语义:TTL 以 Leader 上 lessor 为准,不是 MVCC key 的 ModRevision;Leader 切换时存在 Promote/Demote 窗口(第 10 篇)。
KeepAlive 不延长 Raft log,但依赖 Leader
身份;分区 Follower 返回
ErrNotPrimary,客户端应切换 endpoint。
五、读路径与 treeIndex / bbolt
线性读等待 apply 之后,Range 与 Serializable
在 MVCC
层相同:treeIndex.Revisions +
bbolt UnsafeRange(第 5–6
篇)。ConcurrentReadTx 合并写缓冲,delete 立即
commit 规则保证不会线性读看到「已 delete
仍可见」的违反。
历史读:RangeRequest.revision > 0 读指定
MVCC rev;若 < compactMainRev 返回
ErrCompacted(Watch 与
apiserver relist 见第 9、13 篇)。
六、选型口令与开放问题
| 需求 | 推荐 |
|---|---|
| 控制面当前状态 | 默认 Range / linearizable Txn |
| follower 读扩展 | Serializable + 接受 stale |
| 分布式锁 / CAS | linearizable + ModRevision compare |
| Lease TTL 监控 | TimeToLive 对 Leader;注意切换窗口 |
开放问题(研究台账):
- follower Serializable 读是否应默认禁用(安全 vs 扩展性);
- ReadOnlyLeaseBased 在 NTP 严格同步集群是否值得开放(etcd 未做,时钟仍是 K8s 痛点);
- Lease read 与 Jepsen 锁结论的交集见 distributed/53。
参考资料
规范 / 源码(A)
etcd-io/etcdtagv3.5.33:server/etcdserver/v3_server.go(Range、linearizableReadLoop、LeaseRenew、LeaseTimeToLive)api/etcdserverpb/rpc.proto(RangeRequest.serializable注释)raft/raft.go(ReadOnlySafe/ReadOnlyLeaseBased)
站内对照
- distributed/13 · Raft 深度(ReadIndex)
- distributed/50 · etcd(读模式概览)
论文 / 争论
- Ongaro & Ousterhout, In Search of an Understandable Consensus Algorithm(ReadIndex 动机)
- Jepsen etcd 3.4.3 报告(Lease 锁边界,B 级)
上一篇:写入路径深读
下一篇:Watch 机制
读完这篇,下一步读什么
优先读同系列或同问题的下一篇,把单篇消费变成主题集群。
etcd / 生产内核:Raft、WAL、MVCC 与 Kubernetes 控制面底座
补齐 distributed 百科单篇之上的 etcd 生产内核:单 Raft 组、WAL/快照、Revision MVCC、bbolt 单写者、Watch/Lease、K8s apiserver 耦合,并以排障与相对 TiKV/FoundationDB 的选型收束。
【etcd】生产全景:缺口、五轴坐标系与 16 篇路线
相对 distributed/50、39、13 钉清 etcd 生产内核缺口;定义 Raft/WAL/MVCC/Watch/Lease 五轴排障坐标系,给出 16 篇阅读路线与 K8s 控制面耦合指针。版本锚定 v3.5.33。
【etcd】bbolt 后端:mmap、batch 与单写者约束
钉清 etcd v3.5.33 的 bbolt 后端:bucket 布局、mmap 预映射、100ms/10000 条 batch commit、ConcurrentReadTx 与单写者如何约束 MVCC 读写并发。
【etcd】treeIndex 与 Apply 管道:propose → commit → apply
走读 etcd v3.5.33 从 Raft commit 到 MVCC apply 的串行管道:treeIndex 与 bbolt 分工、consistent index、watchableStore 通知触发点,以及 committed/applied 分列排障。