
简介《etcd实战及其原理分析.pdf》面向需要掌握分布式键值存储的开发者与运维人员围绕 etcd v3.5 展开从基础概念、典型应用场景到集群搭建、客户端命令与权限管理均有梳理并进一步剖析读请求、写请求、Raft 选举与日志复制、MVCC 更新查询删除等核心流程适合用于系统入门、面试复盘与生产实践查漏。资源包共 1 个 PDF 文件大小约 4.55MB内容按介绍、实战、原理三部分组织目录层次清晰便于按主题检索和对照学习。目前已有 450 人学习可帮助读者把部署操作与一致性机制串联起来形成较完整的 etcd 知识框架。1. 三节点集群里为什么会读出旧值一次 get 背后的一致性缺口etcd 给人的第一印象是强一致很多人据此推断只要 put 返回成功集群里任何一台机器都能立刻读到新值。这个推断只对了一半。默认的线性读确实能兜住但如果你为了降延迟改用串行读或者恰好连到一台被磁盘 I/O 拖住的节点读到的就可能是上一个版本的旧数据。缺口不在协议层而在写已提交和状态机已应用之间那段异步窗口Leader 已经把日志复制到多数派可某个 Follower 的 apply 循环还堵在队列里。搞懂这段窗口才能明白 ReadIndex 到底在补什么、为什么它要多花一次网络往返也才能判断自己业务里哪些读可以安全地省掉这笔开销。这也是 etcd 读多写少场景下最容易被忽略的一层成本。2. 五层架构与写请求接力一次 put 从 Quota 到 WAL 再到 Apply 的顺序2.1 分层模型为什么 Raft 层不知道 key 长什么样etcd 的架构自上而下切成 Client 层、API 网络层、Raft 算法层、功能逻辑层、存储层。这条切分线不是画着好看的它对应的是三组完全不同的关注点Client 层管的是请求发给谁、发失败了怎么办提供 v2 和 v3 两套客户端库内置负载均衡和节点故障自动转移API 网络层管的是用什么协议说客户端到服务端 v2 走 HTTP/1.x、v3 走 gRPC同时用 grpc-gateway 兼容 HTTP/1.x服务端节点之间则另走一套 HTTP 协议专门传递 Raft 消息。真正体现设计功力的是后三层。Raft 算法层只认日志条目——一个带 Term 和 Index 的字节数组它不知道里面装的是PutRequest还是配置变更功能逻辑层KVServer、MVCC、Auth、Lease、Compactor只认业务语义——key 是否合法、租约是否存在、这个用户有没有权限存储层WAL、Snapshot、boltdb只认字节——怎么落盘、怎么重放、怎么在崩溃后恢复到一致状态。这样切的好处是可替换性和可测试性。Raft 层可以单独用内存网络做单元测试不需要真实磁盘存储层可以脱离共识算法验证 WAL 重放的正确性。常见的实现思路是层与层之间只通过明确的数据结构通信任何一层都不去窥探另一层的内部状态。代价是跨层调用要走 channel 和 goroutine多了一层调度开销这也是 etcd 在高并发下写入延迟不如单机 KV 的原因之一。2.2 写请求的五棒接力Quota 预检、KVServer 封装、WAL 落盘、Apply 回放、MVCC 写入一次etcdctl put username fox从客户端发出到最终可见穿过的模块顺序是固定的任何一环出问题都会表现成不同形态的故障。先把顺序摆清楚第一棒是 Quota 模块。它在请求进入 Raft 之前就做拦截检查后端数据库当前大小加上未提交事务的预估增量是否超过--quota-backend-bytes。一旦超限集群会置上 NOSPACE 告警此时只允许读和删除所有写请求返回etcdserver: mvcc: database space exceeded。把配额检查放在最前面是有意为之——如果让写请求先进 Raft 再发现空间不够那些已经复制到多数派的日志条目就得靠额外的补偿逻辑回滚代价高得多。第二棒是 KVServer 模块。它做三件事校验 key 非空、检查绑定的 Lease 是否真实存在、走 Auth 鉴权。全部通过后把请求序列化成一个InternalRaftRequest交给 Raft 层。注意这一步还没有任何持久化动作请求此时只存在于内存里。第三棒是 WAL 模块。Leader 把日志条目追加到本地 WAL 文件并执行 fsync然后才并行广播给其他节点。这个先落盘再广播的顺序是关键如果先广播后落盘Leader 在广播完成后崩溃新 Leader 可能已经基于这些条目提交而原 Leader 重启后本地什么都没有只能靠快照补齐恢复路径会复杂很多。第四棒是 Apply 模块。当多数派节点都持久化成功该条目的 Index 被标记为已提交etcdserver 的 apply 循环从 Raft 模块取出已提交条目先更新consistentIndex再调用applierV3.Apply把操作应用到状态机。这一步是异步的也正是第 1 章那个一致性缺口的来源。第五棒是 MVCC 模块。它在一个 boltdb 事务里同时完成两件事往内存 B 树 treeIndex 里插入新的 revision 映射往 boltdb 的 key bucket 里写入编码后的版本记录。// 写请求在 Raft 层眼里就是一个 EntryType 区分普通提案与配置变更 type Entry struct { Term uint64 // 任期号Leader 换届时递增用于选举与安全性判断 Index uint64 // 日志序号全局单调递增Raft 靠它定位第几条日志 Type EntryType // EntryNormal 表示业务写请求EntryConfChange 表示成员变更 Data []byte // 序列化后的 InternalRaftRequest可能包含 Put/Delete/Txn }Term和Index这两个字段贯穿整个 Raft 层。Term回答这条日志是第几届 Leader 提的Index回答它在日志序列里的第几位。选举时比较日志新旧用的就是先比Term、Term相同再比Index的字典序。代码里Data保持[]byte而不是具体结构体正是 2.1 节说的分层隔离——Raft 层不该关心业务字段。2.3 为什么 WAL 必须先于状态机崩溃恢复的唯一真相WAL 的名字已经说明了它的定位Write-Ahead Log先写日志再改数据。etcd 里改数据指的是 boltdb 事务提交写日志指的是 WAL 条目落盘加 fsync。这个顺序保证了任何时刻崩溃都能从最后一次快照 快照之后的所有 WAL 条目重放出一致的状态。设想反过来会怎样如果先提交 boltdb 再写 WAL某个节点可能在本地已经应用了第 100 条日志但这条日志因为网络分区没能同步给多数派。该节点重启后本地数据里有了其他节点都没有的写入Raft 的日志匹配原则被破坏只能靠强制回滚本地数据来修复——而 boltdb 根本没有提供按 Index 回滚的接口。重启恢复的路径通常是这样的先加载最新快照拿到快照对应的Index然后从 WAL 里找出所有Index大于它的条目逐条重放重放过程中同样走 Apply → MVCC 那条链路。所以 WAL 条目里必须携带完整的Entry信息不能只存个 key-value 对——否则重放时无法重建Term和Index的对应关系。注意WAL 的 fsync 频率直接决定写入延迟的下限。机械盘上单次 fsync 可能到几十毫秒etcd 的写吞吐会被硬生生压住。这也是为什么生产环境要求 SSD而且不建议和其他 I/O 密集进程共用磁盘。2.4 boltdb 里的 key 长什么样revision 编码与 treeIndex 的分工MVCC 模块把数据分两处存内存里的 treeIndex 是一棵 B 树负责用户 key → 该 key 的所有 revision 列表的正向查找boltdb 负责存真正的 value。两份数据靠 revision 关联。boltdb 的 key bucket 里主键不是用户 key而是 revision 的 16 字节编码——高 8 字节是主 revision低 8 字节是子 revision同一个事务内多次写入时用来区分。这样设计之后按 revision 顺序遍历就是天然的物理顺序扫描压缩时删除 revision 小于某个值的数据变成一次范围删除效率极高。// boltdb 中 key 的编码方式主 revision 在前保证按字节序排列即按版本序排列 func newRevBytes(rev int64) []byte { b : make([]byte, revBytesLen) // 固定 16 字节高位在前 binary.BigEndian.PutUint64(b, uint64(rev)) // 主 revision 占前 8 字节 return b // 后 8 字节留给同一事务内的子 revision } // value 部分是 protobuf 序列化的结构用户 key 反而存在 value 里 type KeyValue struct { Key []byte // 用户 key此处冗余存储避免回表 CreateRevision int64 // 首次创建时的全局 revision ModRevision int64 // 最后一次修改时的全局 revision Version int64 // 该 key 自身的修改次数删除后重建从 1 重新开始 Value []byte // 业务数据 Lease int64 // 绑定的租约 ID0 表示未绑定 }为什么把用户 key 冗余在 value 里因为按 revision 遍历时如果不带 key就无法知道这条记录属于谁。CreateRevision和Version的区分也很容易搞混前者是全局计数器的值后者是这个 key 被改过几次删除再创建会重置。这两个字段在实现从某个历史版本读和key 是否被重建过时各自派上用场。3. 读请求链路串行读与 ReadIndex 线性读的代价差在哪3.1 Client 侧的 Round-robin为什么多填几个 endpoint 就能扛住节点故障etcdctl get hello --endpointsip1:2379,ip2:2379,ip3:2379里的 endpoint 列表不是摆设。clientv3 库对每个请求用 Round-robin 轮询选一个节点建立的是长连接连接复用省掉了 TCP 和 TLS 握手开销。轮询而不是随机是为了让三个节点的负载尽量均匀长连接而不是短连接是因为 etcd 的请求本身很小握手成本占比会很高。故障转移的逻辑藏在连接层当某个 endpoint 连续失败客户端会把它标记为不可用并临时从轮询列表里摘掉过一段时间再试探性放回。这个机制解释了为什么生产环境必须配置全部节点地址——只填一个那个节点宕机时客户端就只能干等超时。但轮询带来一个副作用你没法控制请求落到 Leader 还是 Follower。写请求落到 Follower 会被转发给 Leader多一跳读请求则取决于用哪种读模式。这就引出了下面两节的差异。3.2 拦截器非侵入式地把 metrics、慢查询、Leader 检查串成一条链Range RPC 到达 server 后先经过一层拦截器interceptor。它借鉴了 gRPC 的中间件模型在 handler 执行前后各提供一次 hook把一堆横切关注点从业务代码里剥出来请求耗时和错误码统计、来源 IP 记录、Debug 日志、Learner 节点的接口与参数限制。两个基于拦截器实现的能力值得单独说。一是执行操作前集群必须有 Leader——没有 Leader 时直接快速失败而不是让请求在内部排队等到超时这对客户端重试策略很友好。二是慢查询日志3.5 版本里请求延迟超过阈值时会打印一条带来源 IP 的告警日志。这条日志在排查某个客户端把集群拖慢时几乎是唯一线索因为 etcd 本身不记录每个请求的完整上下文。拦截器链执行完毕后才真正调用 KVServer 的 Range 接口。这种设计的代价是每个请求多几次函数调用和类型断言收益是新增一个通用能力比如限流、审计时不需要改任何一个业务 handler。我一般会把这层理解成etcd 的可观测性总闸。3.3 串行读与线性读一条命令的两种语义串行读serializable read的实现非常直接跳过 Raft直接读本地状态机里的 MVCC 数据。延迟最低因为不涉及任何节点间通信但它只保证读到的是本节点已经 apply 的数据不保证是最新数据。线性读linearizable read要求读到集群当前已提交的最新值。它的实现不能靠读 Leader 本地状态机糊弄过去——Leader 可能因为网络分区已经失去了多数派支持自己还不知道此时它本地读到的数据可能已经被新 Leader 覆盖。所以线性读必须确认我现在还是 Leader以及我要读的那个位置已经提交。两种模式的选择在客户端就能指定etcdctl get默认走线性读。对配置中心这类读多写少、偶尔容忍秒级 stale 的场景串行读能把读延迟压到本地磁盘读的水平对分布式锁、选主这类必须读到最新状态的场景只能用线性读。3.4 ReadIndex 的实现细节一次心跳确认加一次本地等待线性读的核心机制叫 ReadIndex思路是把确认 Leader 身份和等待数据追平拆成两步避免让整个读请求走一遍完整的日志复制。// 线性读的三段式取 ReadIndex → 等本地 apply 追平 → 走 MVCC 读 func (r *readIndex) Read(ctx context.Context, req *pb.RangeRequest) (*pb.RangeResponse, error) { // 第一步向 Leader 请求当前已提交的最大 Index。 // Leader 需要先和 quorum 做一轮心跳确认自己仍是 Leader否则直接拒绝。 readIndex, err : r.requestCurrentIndex(ctx) if err ! nil { return nil, err // 常见错误etcdserver: request timed out } // 第二步阻塞等待本地状态机的 apply 进度追上 readIndex。 // 这一步是异步 apply 带来的必要补偿也是慢磁盘时读延迟飙升的根源。 if err : r.waitAppliedIndex(readIndex); err ! nil { return nil, err } // 第三步此时本地数据一定不旧于 readIndex可以安全地读 MVCC。 return r.kv.Range(ctx, req) }三个参数值得展开。requestCurrentIndex里的 quorum 确认是 ReadIndex 的成本大头——3 节点集群需要至少 2 个节点响应心跳。如果 Leader 在任期开始时还没提交过任何条目它必须先提交一个空条目no-op才能确认自己的 commitIndex 可信否则可能读到上一任期的残留状态。waitAppliedIndex是纯本地等待不占网络但它把读延迟和磁盘 fsync 速度绑在了一起apply 循环被慢 I/O 堵住时读请求会在这一步卡住表现出来就是集群没报错但 get 越来越慢。etcd 在这个机制上还做了两处优化多个并发的线性读请求会合并成一次 ReadIndex 调用batcher避免每个请求都触发一轮心跳等待 apply 的 goroutine 唤醒时间加了随机抖动防止大量请求在同一时刻被同时唤醒造成惊群。这两处优化在压测时能拉开数倍的吞吐差距。4. Raft 的 Term 与 IndexLeader 选举和日志复制怎么给一次写操作投票4.1 三种角色与 Term从 Follower 到 Candidate 的那次超时Raft 里每个节点在任意时刻处于三种角色之一Leader 处理所有写请求并广播日志Follower 被动接收日志和心跳Candidate 是选举期间的过渡态。角色切换的触发条件是心跳超时——Follower 在--election-timeout默认 1000ms内没收到 Leader 心跳就自增 Term、投自己一票、向所有节点发 RequestVote。为什么每个节点的选举超时要随机化如果所有 Follower 同时超时、同时发起选举选票会被瓜分谁都拿不到多数派然后进入下一轮同时超时形成活锁。etcd 的做法是在选举超时上叠加一个随机抖动让某个节点大概率先超时并拉到票。这是一条典型的血泪经验分布式系统里所有定时器都应该带抖动。Term 是这里的逻辑时钟。任何两个节点通信时都会带上自己的 Term收到更大 Term 的一方无条件降级为 Follower 并更新本地 Term。这个规则保证了同一 Term 内最多只有一个 Leader——因为当选需要多数派投票而每个节点在一个 Term 内只投一票。4.2 日志复制提案如何变成 committedLeader 收到写请求后把它作为新条目追加到本地日志末尾然后并行向所有 Follower 发 AppendEntries。Follower 收到后先做一致性检查条目里的prevLogIndex和prevLogTerm必须和本地日志对应位置匹配不匹配就拒绝并要求 Leader 回退重试。这个检查保证了日志不会出现中间断了一截却往后接的情况。当多数派节点成功持久化该条目Leader 就把自己的 commitIndex 推进到该条目的 Index并在下一次心跳里把这个信息带给 FollowerFollower 随之推进自己的 commitIndex。只有 commitIndex 之前的条目才允许被应用到状态机——这是 Raft 里已提交和已写入本地日志的分界线也是第 2 章 apply 循环的输入来源。有个容易被忽略的约束Leader 只能通过当前 Term 的条目被多数派确认来间接提交之前 Term 的条目不能直接根据旧 Term 条目的副本数判断提交。原因是旧 Term 的条目可能在被复制到多数派后又被新 Leader 覆盖。这条规则叫提交限制论文里有详细反例实现时漏掉会造成已提交数据丢失这种最严重的一致性事故。4.3 安全性为什么日志不够新的节点当不上 LeaderRequestVote 的投票条件除了这个 Term 我还没投过票还有一条候选人的日志必须至少和我一样新。比较方式是先比最后一条日志的 TermTerm 相同再比 Index候选人两项都不小于投票者才给票。这条规则是 Raft 安全性的基石。它保证了新选出的 Leader 一定包含所有已提交的条目——因为已提交意味着多数派都有而候选人要赢也必需多数派支持两个集合必然相交相交节点不会把票投给日志比自己旧的候选人。它和 ReadIndex 也有直接联系。4.2 节说过 Leader 上任后要先提交一个 no-op 条目正是为了让 commitIndex 反映当前 Term 的真实进度。没有这一步新 Leader 的 commitIndex 可能停留在上一任期基于它做线性读就会读到一个看起来已提交、实际可能被覆盖的位置。4.4 Quorum 与节点数3、5、7 背后的写放大集群节点数容忍故障数写请求需要的确认数相对写成本312基准523约 1.5 倍734约 2 倍945约 2.5 倍Quorum 的定义是(n1)/2向上取整。表里的规律很清楚节点数每加 2容错能力加 1写成本也跟着加 1。所以节点越多越可靠是个误解——超过 7 个节点后写延迟和 WAL 落盘次数的增长开始明显影响吞吐而容错收益只是线性的。偶数节点更没意义4 个节点和 3 个节点能容忍的故障数一样白白多一份复制开销。选 3 还是 5取决于你对同时坏两台这件事的容忍度。单机房部署 3 节点通常够用跨机房部署要考虑机房间网络抖动会同时影响多个节点这时候 5 节点更稳。还有一个常被忽略的成本节点数增加后Leader 的 WAL fsync 次数不变只写本地但网络带宽和 Follower 的 apply 压力线性增长慢节点拖慢整体读延迟的概率也随之上升。4.5 三套编号各管一段Term、Index、Revision 别混用这是最容易翻车的地方。Term 是选举时代只在 Raft 层流转用户永远看不到Index 是日志位置也在 Raft 层但会通过快照和 WAL 暴露在运维指标里Revision 是 MVCC 的全局数据版本对用户可见每次 put 或 delete 都让它加 1etcdctl get -wjson返回的revision就是它。三者数值上通常不相等增长速度也不同一次事务里改 100 个 keyIndex 只加 1Revision 可能加 100。把 Revision 当成 Index 去做 ReadIndex 等待或者拿 Term 判断数据新旧都会得到错误结论。判断这个 key 是不是最新的看ModRevision是否等于全局revision才是对的。5. 排查常见问题etcd 集群里那些看起来正常的坑5.1 单机多实例起不来第二个节点现象想在本机跑一个三节点集群练手第一条命令启动正常第二条命令直接报端口占用退出或者起来了但member list里只有一个成员。原因--listen-peer-urls的默认值是 2380三个实例都用默认值必然冲突。客户端端口 2379 也一样。解决每个实例显式指定不同的两套端口peer 用 12380/22380/32380client 用 12379/22379/32379并且--initial-cluster里的地址要和--initial-advertise-peer-urls完全一致。用 goreman 之类的进程管理工具把三条启动命令写进一个 profile 文件比手敲三条命令可靠得多。集群起不来时第一件事是看每个实例的日志里有没有bind: address already in use。5.2 扩容时新节点加不进去现象集群已经有 3 个节点想加第 4 个新节点启动后一直重连、反复重启member list里看不到它。原因新节点沿用了建集群时的--initial-cluster-state new。这个参数的含义是我要从零引导一个新集群而扩容场景需要的是existing——表示我是来加入一个已存在集群的。用错之后新节点会尝试用自己的--initial-cluster列表去组建一个平行集群当然组不起来。解决把新节点的--initial-cluster-state改成existing同时--initial-cluster里要包含全部节点老的加新的的 peer 地址。如果新节点已经用错误参数启动过它的数据目录里可能残留了脏状态先清空--data-dir再重启。这条踩坑记录值得记住new和existing不是新集群/老集群的意思而是引导模式/加入模式。5.3 开启鉴权后整个集群锁死现象执行etcdctl auth enable成功之后所有命令都返回etcdserver: user name is empty包括原本能用的读写命令集群彻底不可操作。原因鉴权开启后所有请求都必须携带凭证而 root 用户如果事先没创建就没有任何账号能通过认证。etcd 不会给你留后门这个状态几乎不可逆。解决顺序必须是先user add rootroot 用户自带全部权限不需要额外授权再auth enable。开启之后所有命令行操作都要带--userroot --password...。这是一条典型的后悔药问题——如果真锁死了只能停掉整个集群、清空数据目录重建。所以我一般会在开启鉴权前先用user add root加auth enable在一个测试集群上完整走一遍。5.4 服务注册的 key 会莫名其妙消失现象服务启动时往 etcd 写了注册信息运行一段时间后发现其他服务查不到它但进程还活着日志里也没报错。原因注册 key 绑定了租约Lease客户端没有起 keep-alive 循环或者 keep-alive 的 channel 被关闭后没做处理。租约到期时 etcd 会自动删除所有关联的 key这个删除在客户端侧是静默的。解决拿到租约 ID 后必须起一个KeepAlivegoroutine 持续续租并监听返回的 channel——channel 关闭意味着续租失败网络抖动、etcd 节点切换都可能触发此时要重新申请租约并重写 key。另一个常见疏漏是租约 TTL 设得太短客户端一次 GC 停顿就可能导致续租超时。5.5 日志里反复出现 request timed out读延迟持续走高现象应用侧看到context deadline exceededetcd 服务端日志里大量etcdserver: request timed out但 CPU 和内存看起来都不高。原因九成是磁盘 fsync 延迟。WAL 写入和 apply 都要落盘磁盘一旦出现长尾延迟apply 进度落后线性读在waitAppliedIndex那一步就会卡住。集群规模大、key 多的时候boltdb 的 freelist 和页分裂也会放大这个问题。解决先看etcd_disk_wal_fsync_duration_seconds和etcd_disk_backend_commit_duration_seconds两个指标p99 超过 10ms 就要怀疑磁盘。etcd 对磁盘的要求比一般服务高得多共享云盘、网络盘、被其他进程抢占 IOPS 的盘都是隐患。如果确认是磁盘问题换本地 SSD 是最直接的办法实在换不了考虑减少写入频率、开启压缩降低数据量。6. MVCC 与压缩Revision 账本的设计取舍MVCC 在 etcd 里的实现方式本质是用空间换可回溯。treeIndex 在内存里维护每个用户 key 的 revision 列表boltdb 按 revision 顺序存所有历史版本读历史数据时先查 treeIndex 定位 revision再按 revision 编码去 boltdb 取值。这套结构让etcdctl get --rev8这种历史查询变成一次 O(log n) 加一次点查非常优雅。但优雅是有代价的只要不清理历史版本会无限堆积。一个被高频更新的 key每改一次就在 boltdb 里多一条记录旧记录的磁盘空间不会自己还回来。所以必须做压缩Compaction——按--auto-compaction-retention定期丢弃某个 revision 之前的所有版本。压缩之后早于该 revision 的历史读取会直接报错mvcc: required revision has been compacted这是历史已不可追溯的明确信号。设计取舍就落在这里保留更长的历史窗口意味着更大的磁盘占用和更慢的启动速度压缩得太激进又会让依赖历史读的 watcher 恢复、审计类业务失效。etcd 的默认策略是保留最近若干小时这个窗口对服务发现够用对审计不够。还有一个坑压缩只删逻辑数据boltdb 文件大小不会立刻变小。想真正释放磁盘得执行 defrag而 defrag 会阻塞该节点并可能触发 Leader 切换必须逐个节点做。验证方式很简单看endpoint status里的dbSize和dbSizeInUse两个字段——前者是文件大小后者是实际使用量差距大就说明该整理。从那以后我每次给集群配压缩策略都会先在测试环境跑一遍写满 → 压缩 → defrag的完整流程确认dbSizeInUse真的降下来了才敢上线而不是只看命令返回成功。希望这套思路能帮你少走点弯路。本文还有配套的精品资源点击获取