☰
etcd v3.5实战与原理:集群部署、读写链路与MVCC排障
2026/10/28 2:09:23 网站建设 项目流程

简介:面向后端开发、运维人员和分布式架构学习者的一份etcd专题PDF,围绕v3.5版本把环境搭建、客户端操作、权限管理与核心原理串联起来,适合需要理解强一致键值存储、服务发现和分布式协调的读者。压缩包内只有1个PDF文件,大小约4.55MB,方便在桌面端直接阅读和检索。正文先从etcd的应用场景、与ZooKeeper的差异及五层架构讲起,再覆盖单实例、单机多实例和多节点集群部署,etcdctl常用命令与用户、角色权限配置;原理部分沿读请求和写请求两条链路展开,涉及串行读、线性读、ReadIndex、Quota、WAL、Apply、Raft选举与日志复制,以及MVCC的treeIndex、更新、查询和删除机制。目录按介绍、实战、原理三部分组织,章节递进清晰,既可通读也可按主题查阅。已有448人学习,读者可据此建立从部署实操到一致性原理的知识框架,用于面试复盘、方案选型和线上排错参考。

1. 一份把 etcd v3.5 从装到原理拆开的 PDF

第一次在生产环境用 etcd,我踩的坑不是「不会用」,而是「用错了以为没问题」。三节点集群明明健康,etcdctl get却读到旧值;lease 续期看着正常,服务剔除却延迟十几秒;auth enable之后所有客户端集体报错。这些问题的答案不在 API 文档里,而在读链路、写链路和 MVCC 的实现细节里。这份《etcd实战及其原理分析》把 v3.5 的部署、etcdctl、租约、权限,和后面的 KVServer、拦截器、ReadIndex、WAL、Apply、treeIndex、boltdb 串成一条线,读起来像一份能边看边敲的实验手册。它适合正在做服务发现、配置中心或分布式锁的工程师,尤其是卡在「会用但解释不清为什么」这个阶段的人。

2. 环境搭建:单机三实例到三节点集群,三条路径的边界在哪

etcd 的部署方式看着多,其实就三档:单实例、单机多实例、多节点集群。选哪档不取决于「哪个更高级」,而取决于你要验证什么。单实例验证 API 和语义,单机多实例验证 Raft 的多数派与选举,多节点集群验证网络延迟和真实故障切换。这份资料把三档都写了,但三档之间的差别不只是节点数量,端口、集群 token、初始化状态这几个参数一旦搞混,集群会以「看起来起来了但其实没组网」的方式失败——进程在跑,member list只有自己一个。

2.1 单实例起步:二进制包与端口的默认值

单实例是最省事的入口,但有两个默认值必须先记住:客户端请求监听在 2379,集群内部 peer 通信用 2380。后面所有的多实例实验,本质就是把这两个端口复制成多份。

# 下载 v3.5.5 的 linux-amd64 二进制包 wget https://github.com/etcd-io/etcd/releases/download/v3.5.5/etcd-v3.5.5-linux-amd64.tar.gz tar -zxvf etcd-v3.5.5-linux-amd64.tar.gz cd etcd-v3.5.5-linux-amd64/ # 前台启动,先看日志里的 advertise 地址是否和本机网卡一致 ./etcd # 后台启动,日志落到 /tmp/etcd.log nohup ./etcd >/tmp/etcd.log 2>&1 & # 服务端和客户端大版本必须对齐,否则 API 行为会不一致 ./etcd --version # 放进 PATH,后续直接敲 etcdctl cp etcd etcdctl /usr/local/bin

这段命令里,--version那一步别省。etcdctl 默认使用 v3 API,但如果你连的是一个老集群,或者环境变量里残留了ETCDCTL_API=2,命令会走到另一套 API 上,报错信息还不一定指向版本问题。

读写验证只需要两条:

etcdctl --endpoints=localhost:2379 put username fox etcdctl --endpoints=localhost:2379 get username

--endpoints在不指定时默认是127.0.0.1:2379。单机实验可以偷懒,但只要端口改过,就必须显式写,否则客户端会去连默认端口然后超时。日志是排查启动问题的第一现场,/tmp/etcd.log里会打出 member ID、cluster ID、advertise URL,这三个值在后面组集群时能直接对上。

2.2 goreman 拉起单机三实例:2380 端口的冲突先解决

单机多实例最容易翻车的地方是 peer 端口。三个 etcd 进程如果都监听 2380,第二个就会启动失败,日志里写的是bind: address already in use。这份资料的做法是把 peer 端口改成 12380、22380、32380,客户端端口改成 12379、22379、32379,用 goreman 统一管理。

先规划端口和数据目录:

实例名client 端口peer 端口数据目录
infra11237912380/tmp/etcd1
infra22237922380/tmp/etcd2
infra33237932380/tmp/etcd3

数据目录这一列在原始 profile 里没写,但必须补。三个实例共用默认数据目录会互相覆盖 WAL 和 snapshot,表现出来的现象是「集群能起来,但重启之后数据来回跳」,很难查。我一般会在 profile 里显式加--data-dir。

安装 goreman 并写 profile:

# goreman 依赖 Go 环境 go install github.com/mattn/goreman@latest # local-cluster-profile 内容如下,三行分别对应三个实例 cat > local-cluster-profile <<'EOF' etcd1: etcd --name infra1 --data-dir /tmp/etcd1 \ --listen-client-urls http://127.0.0.1:12379 \ --advertise-client-urls http://127.0.0.1:12379 \ --listen-peer-urls http://127.0.0.1:12380 \ --initial-advertise-peer-urls http://127.0.0.1:12380 \ --initial-cluster-token etcd-cluster-1 \ --initial-cluster 'infra1=http://127.0.0.1:12380,infra2=http://127.0.0.1:22380,infra3=http://127.0.0.1:32380' \ --initial-cluster-state new etcd2: etcd --name infra2 --data-dir /tmp/etcd2 \ --listen-client-urls http://127.0.0.1:22379 \ --advertise-client-urls http://127.0.0.1:22379 \ --listen-peer-urls http://127.0.0.1:22380 \ --initial-advertise-peer-urls http://127.0.0.1:22380 \ --initial-cluster-token etcd-cluster-1 \ --initial-cluster 'infra1=http://127.0.0.1:12380,infra2=http://127.0.0.1:22380,infra3=http://127.0.0.1:32380' \ --initial-cluster-state new etcd3: etcd --name infra3 --data-dir /tmp/etcd3 \ --listen-client-urls http://127.0.0.1:32379 \ --advertise-client-urls http://127.0.0.1:32379 \ --listen-peer-urls http://127.0.0.1:32380 \ --initial-advertise-peer-urls http://127.0.0.1:32380 \ --initial-cluster-token etcd-cluster-1 \ --initial-cluster 'infra1=http://127.0.0.1:12380,infra2=http://127.0.0.1:22380,infra3=http://127.0.0.1:32380' \ --initial-cluster-state new EOF

--initial-cluster里的 peer URL 必须和每台机器上的--initial-advertise-peer-urls完全一致,这是组网成功的第一条件。--initial-cluster-token用来隔离集群,同一台机器上跑多套实验时靠它区分,写错会导致节点互相不认。

启动和验证:

goreman -f ./local-cluster-profile start # 任意一个节点都能看到三个 member etcdctl --endpoints=localhost:12379 member list # 停一个、重启一个,观察 leader 是否切换 goreman run stop etcd2 goreman run restart etcd2 # 写请求打到指定节点,任然会被转发或由 leader 处理 etcdctl --endpoints http://localhost:22379 put user fox

停掉 etcd2 之后,三节点剩两个,仍然是多数派,集群可读写;这时候如果再停一个,只剩一个节点,就选不出 leader,写请求会卡住直到超时。这个实验值得手动做一遍,比看十遍 Raft 说明都直观。

2.3 静态配置与动态发现:三节点集群的两条引导路径

真到多机部署,原始资料列了三种方式:静态配置、etcd 动态发现、DNS 动态发现。静态配置的前提是集群节点个数和各节点地址已知,适合线下环境;动态发现解决的是「搭集群之前还不知道各节点 IP」的情况,用一个发现令牌把所有新成员拉到一起。

静态配置的关键参数表:

参数作用常见错误
--name节点名集群内重复,导致发现失败
--initial-cluster初始成员列表与实际 peer URL 不一致
--initial-cluster-statenew 或 existing加入已有集群却写了 new
--listen-client-urls客户端监听地址写成公网地址,本机起不来
--advertise-client-urls通告给客户端的地址通告成 127.0.0.1,其它机器连不上
--listen-peer-urlspeer 监听地址和 client 端口混用
--initial-advertise-peer-urls通告给 peer 的地址同上

静态配置下,三台机器各自的启动参数只有--name、两个 client URL 和两个 peer URL 不同,--initial-cluster三台必须一模一样。下面以一台为例:

etcd --name infra1 \ --data-dir /data/etcd \ --listen-client-urls http://10.20.0.11:2379 \ --advertise-client-urls http://10.20.0.11:2379 \ --listen-peer-urls http://10.20.0.11:2380 \ --initial-advertise-peer-urls http://10.20.0.11:2380 \ --initial-cluster-token etcd-cluster-1 \ --initial-cluster 'infra1=http://10.20.0.11:2380,infra2=http://10.20.0.12:2380,infra3=http://10.20.0.13:2380' \ --initial-cluster-state new

另外两台把infra1、10.20.0.11换成自己的名字和 IP,--initial-cluster保持原样。三台全部启动后,在任意一台执行etcdctl --endpoints=10.20.0.11:2379 member list,应该能看到三条记录,且只有一个 leader。

动态发现用发现服务先建一个令牌:

# size 必须等于实际节点数,这个令牌只能用于引导一次 curl 'https://discovery.etcd.io/new?size=3' # 启动时用 --discovery 替换 --initial-cluster etcd --name etcd1 \ --data-dir /data/etcd \ --listen-client-urls http://10.20.0.11:2379,http://127.0.0.1:2379 \ --advertise-client-urls http://10.20.0.11:2379 \ --listen-peer-urls http://10.20.0.11:2380 \ --initial-advertise-peer-urls http://10.20.0.11:2380 \ --discovery https://discovery.etcd.io/<token>

动态发现有两个边界要记住:令牌绑定的 size 和实际启动节点数不一致时,集群会一直等;发现协议只用于引导阶段,运行时的成员变更要靠member add/member remove,不能拿它当扩缩容工具。

3. etcdctl 与租约:把 put/get/watch/lease 跑通成一整套

etcdctl 的命令不多,但每个命令背后的语义比表面复杂。尤其是get返回的那几个 revision 字段,第一次看 JSON 输出很容易晕:为什么全局 revision 已经到 30,这个 key 的 version 才 2?为什么删除再重建之后 version 又从 1 开始?这一章把键操作、watch、lease 和权限四块串起来,都是在后面读链路之前必须建立的直觉。

3.1 键操作与 revision 语义:get 返回的字段到底看什么

基础增删改查:

etcdctl put /test/name fox # 以 16 进制返回,排查二进制 value 时用 etcdctl get /test/name --hex # 范围查询,左闭右开 etcdctl get /test/name /test/name3 # 前缀查询,配合 --limit 控制返回数量 etcdctl get --prefix /test etcdctl get --prefix /test --limit 3 # 按字典序读大于等于 name2 的 key etcdctl get --from-key name2 # 读 JSON 元信息 etcdctl get /test/name -w=json # 读取历史版本,--rev 指定全局 revision etcdctl get --rev=8 /test/name # 删除,--prev-kv 返回被删掉的 value etcdctl del /aaa/bbb etcdctl del --prev-kv name1

-w=json返回的字段里,cluster_id和member_id标识集群和节点,revision是集群当前全局数据版本号,任一 key 的 put 或 delete 都会让它加 1,raft_term是当前主节点任期。create_revision是 key 创建时的全局 revision,mod_revision是最后一次修改时的全局 revision,version是这个 key 自己的修改次数。

这里有个高频误区:version和revision不是一回事。revision是集群级的计数器,跨 key 共享;version是单 key 级的写次数,key 被删除后重新创建,version会从 1 重新开始,而revision继续增长。做增量同步时如果拿version当全局游标用,数据会错乱。

3.2 watch 与 lease:服务发现最常用的两把工具

watch 是基于长连接的推送,不是轮询。开两个终端就能验证:

# 终端 A:监听 /test/name etcdctl watch /test/name # 终端 B:改一次值 etcdctl put /test/name fox

终端 A 会立刻打印新值。做服务发现时,watch 配合前缀监听,能拿到某个服务目录下所有实例的上下线事件。

lease 的用法是「先授租约,再把 key 挂上去」:

# 授予 30 秒 TTL 的租约,返回一个 lease ID etcdctl lease grant 30 # 把 key 绑定到租约,租约过期后 key 会被自动删除 etcdctl put --lease=705b8474d1acd63b name fox # 应用侧续期,保持租约存活 etcdctl lease keep-alive 705b8474d1acd63b # 查询剩余时间和关联的 key etcdctl lease timetolive 705b8474d1acd63b etcdctl lease timetolive --keys 705b8474d1acd63b # 主动撤销,所有关联 key 一起删除 etcdctl lease revoke 705b8474d1acd63b

服务发现的典型组合是三步:注册时put --lease,健康检查由客户端后台跑keep-alive,下线时revoke或者等 TTL 自然过期。相比临时节点方案,lease 的好处是批量绑定和多 key 关联,一个服务实例的所有元信息可以挂到同一个租约上,撤销一次全部清掉。

keep-alive这条命令是阻塞的,实际工程里不会在 shell 里手动跑,而是客户端库在后台维持一条续期流。如果这条流断了没有重连,租约到期后 key 被删,服务发现就会误判实例下线。这类问题用lease timetolive --keys定期抽查最有效。

3.3 权限管理:root 用户和 auth enable 的顺序代价

权限这块,原始资料给的是用户管理、角色管理、绑定、开启认证四段命令。顺序错了会非常难受,所以单独说清楚。

# 1. 先建 root 用户,root 自带全部权限 etcdctl user add root # 2. 建普通用户和角色 etcdctl user add fox etcdctl role add test # 3. 给角色赋权,路径是前缀匹配 etcdctl role grant-permission test readwrite /name # 4. 用户绑定角色 etcdctl user grant-role fox test # 5. 最后开启认证 etcdctl auth enable # 6. 开启后所有操作都要带凭据 etcdctl --user='root' --password='123456' user list etcdctl --user='fox' --password='...' get /name

auth enable必须在 root 用户已创建之后执行。如果先 enable 再想建 root,集群会拒绝所有未认证请求,只能停服务、清数据目录重来,生产环境里这一下就是事故。我一般把「root 建了吗」当成开启认证前的唯一检查项。

role grant-permission的路径要按前缀设计。/name只覆盖/name这一个 key,/name/才覆盖/name/下面的所有子 key。微服务场景里通常按服务名前缀授权,比如/svc/order/,而不是给单个实例 key 授权。

多节点集群下,所有 etcdctl 命令都要带完整的 endpoints 列表,认证操作也一样:

etcdctl --user='root' --password='123456' \ --endpoints http://10.20.0.11:2379,http://10.20.0.12:2379,http://10.20.0.13:2379 \ user add fox

4. 读请求链路:串行读、线性读与 ReadIndex 差在哪

读请求占 etcd 实际流量的大头,原始资料里说读一般超过三分之二。但「读」在 etcd 里不是一个动作,而是两个:串行读和线性读。前者读本地状态机,快但可能旧;后者走 ReadIndex,向 leader 确认当前提交位置,再等本地应用到那个位置才返回。搞不清这两者的区别,就会出现「集群健康但读到旧值」的经典问题。

4.1 从 etcdctl get 到 Range RPC:一次读请求路过哪些模块

以一条命令为例:

etcdctl get hello \ --endpoints 10.20.0.11:2379,10.20.0.12:2379,10.20.0.13:2379

客户端先解析参数,get对应 KVServer 的 Range 接口,hello是 key,endpoints 是后端地址列表。然后 clientv3 库建立连接,用轮询的方式从三个 endpoint 里选一个发 gRPC 请求,选中的连接会保持长连接复用。

请求到达 server 后,先过拦截器链,再进 KVServer 的 Range 处理逻辑,然后根据读模式决定是直接读状态机,还是走 ReadIndex。最后落到 MVCC 模块,由内存里的 treeIndex 定位 revision,再去 boltdb 取 value。整条链路可以对照下表理解:

阶段模块做的事
客户端etcdctl / clientv3参数解析、轮询选 endpoint、发 Range RPC
接入拦截器指标、日志、慢查询、leader 检查
逻辑KVServer鉴权、决定串行读还是线性读
一致性ReadIndex向 leader 确认 commit index
存储MVCC treeIndex + boltdb定位 revision、读 value

生产环境配多个 endpoint 的意义在于故障自动转移:某个节点挂了,客户端重连时轮询到下一个健康节点,请求不至于直接失败。只配一个 endpoint 的客户端在节点重启期间会持续报错。

4.2 拦截器与串行读/线性读:一致性级别是你自己选的

拦截器是 etcd 用非侵入方式挂特性的一套机制,它在每个请求前后提供 hook,用来做 metrics 统计、debug 日志、请求耗时和来源 IP 记录、慢查询日志、Learner 节点请求限制,以及「执行操作前集群必须有 leader」这类检查。这些能力都不需要改业务逻辑,全部收敛在拦截器里。

真正影响读结果的是读模式:

  • 串行读直接读本地状态机,不经过 leader 确认,延迟低,但在 follower 上可能读到落后于最新提交的数据。
  • 线性读走 ReadIndex,先确认 leader 身份和当前 commit index,再等本地状态机应用到该 index,返回的一定是最新已提交数据。

etcdctl 默认走线性读。原始资料里举的例子很典型:客户端连到 C 节点,C 节点的磁盘 I/O 抖动导致 apply 变慢,写请求已经把hello改成world并且多数派提交了,但 C 的状态机还没应用这条日志,此时串行读就会读到旧值。

在 Go 客户端里切换读模式靠clientv3.WithSerializable()这个 OpOption,常见做法是只在对一致性不敏感的统计类查询上开串行读,配置、锁、服务发现这类场景一律线性读。用 etcdctl 时也可以显式指定,但多数人不需要改默认值。

提示:串行读不是「更差的线性读」,它是一个有明确适用边界的选项。把它当默认值的替代品,等于主动放弃一致性保证。

4.3 ReadIndex 与 Raft:线性读凭什么读到最新

线性读的核心是 ReadIndex。leader 收到读请求后,先记录当前的 commit index,然后向多数派发一轮心跳确认自己仍然是 leader,确认通过后,等本地状态机 apply 到刚才记录的 index,再把结果返回。这一套流程保证了两件事:读到的数据一定是已提交的,且一定是当前 leader 认可的最新提交位置。

这套机制依赖 Raft 的三个基础能力:

Leader 选举靠 term 和随机超时。follower 在超时时间内没收到心跳就转成 candidate,term 加一,向其它节点拉票,拿到多数票就成为 leader。随机超时是为了避免所有 follower 同时竞选,把选票打散。

日志复制是 leader 收到写请求后先写本地 WAL,再并行发给所有 follower,多数派持久化成功即视为提交,然后异步 apply 到状态机。提交位置由 commit index 标记。

安全性保证已提交的日志不会被覆盖,选举时 candidate 的日志必须至少和多数派一样新才有资格当选。这三条合起来,才让 ReadIndex 的「确认 leader 身份」有意义。

节点数建议是 3、5、7。3 节点容忍 1 台故障,5 节点容忍 2 台,7 节点容忍 3 台。节点越多读的可用性越高,但写要等更多节点持久化,写性能会下降。原始资料里提到大多数场景 7 节点已经足够,再往上收益递减。

5. 写链路与 MVCC 排查:Quota 拒绝、WAL 堆积与 Apply 卡住

写请求比读请求长得多,一条put要穿过 Quota 校验、KVServer 处理、Raft 提案、WAL 持久化、Apply 应用、MVCC 落盘六个环节。这条链路上任何一环出问题,现象都可能是「写不进去」或者「写进去了但读不到」。这一章先把五段流水线拆开,再讲 MVCC 的 treeIndex 和 boltdb 怎么配合,最后落到五条实际踩过的坑。

5.1 写请求五段流水线:从 Quota 到 MVCC

第一段是 Quota 模块。etcd 对后端数据库有配额限制,默认大小由--quota-backend-bytes控制,写请求进来先检查当前 dbSize 是否超限,超了直接拒绝并触发 NOSPACE 告警。

第二段是 KVServer。做鉴权、租约检查、key 合法性校验,然后把写请求包装成 Raft 提案。

第三段是 WAL。leader 把提案写进预写日志并落盘,这是崩溃恢复的依据。WAL 没落盘之前,任何「写成功」的返回都不可信。

第四段是 Apply。etcdserver 模块异步从 Raft 拿到已提交的日志条目,应用到状态机。这一步是批量、异步的,所以 commit 和 apply 之间有时间差,这也是串行读可能读到旧值的根源。

第五段是 MVCC。生成新的 revision,更新 treeIndex,把 value 写进 boltdb。

用 JSON 输出能直接看到 revision 的变化:

etcdctl put /kv/demo v1 -w=json etcdctl put /kv/demo v2 -w=json etcdctl get /kv/demo -w=json

第一次 put 之后create_revision和mod_revision相等,第二次 put 之后mod_revision增加而create_revision不变,version从 1 变 2。如果两次 put 之间集群里还有别的 key 被写过,revision的增量会大于 1,这不是异常。

5.2 treeIndex 与 boltdb:MVCC 的更新、查询、删除

MVCC 由两部分组成:内存里的 treeIndex 和磁盘上的 boltdb。treeIndex 是一棵 B 树,key 是用户 key,value 是这个 key 的 revision 列表;boltdb 里存的 key 是 revision,value 是用户数据。两者通过 revision 关联。

更新一个 key 时,MVCC 生成新 revision,在 treeIndex 的版本列表末尾追加,然后在 boltdb 写入一条以新 revision 为主键的新记录。旧记录不删,所以历史版本可查。

查询时,如果指定了--rev,treeIndex 找到小于等于该 revision 的最大版本,再去 boltdb 按这个 revision 取 value;不指定就用当前最新 revision。

删除不是物理删除,而是写入一条 tombstone 记录,treeIndex 里把对应版本标记为已删除。历史数据仍然占空间,直到压缩。

压缩和碎片整理是配套动作:

# 压缩到指定 revision,之前的版本不可再查 etcdctl compact 100 # 回收 boltdb 的碎片空间,逐节点执行,执行期间该节点会阻塞 etcdctl defrag --endpoints=10.20.0.11:2379

compact只标记可回收,真正释放文件空间要靠defrag。defrag是阻塞操作,在线集群要逐节点做,不能一次全上。

5.3 五条踩坑记录:现象、原因、解决

第一条:写请求突然全部失败,报etcdserver: mvcc: database space exceeded。现象是集群健康检查正常,但所有 put 都返回这个错误。原因是后端 dbSize 超过了--quota-backend-bytes,Quota 模块开始拒绝写。解决分两步:先etcdctl alarm list确认 NOSPACE 告警,然后etcdctl compact加etcdctl defrag回收空间,最后etcdctl alarm disarm解除告警。根因通常是没配定期压缩,历史 revision 一直在涨。

第二条:lease 续期看着正常,服务发现还是把实例剔除了。现象是实例进程活着,lease timetolive也能查到剩余时间,但 watch 的客户端收到了删除事件。原因是续期流断过,客户端没有重连逻辑,租约到期后 key 被删。解决是在客户端加 keep-alive 通道的异常监听,通道关闭就重建租约并重新注册,不能只依赖一次keep-alive调用。

第三条:auth enable之后所有客户端 401。现象是开启认证后,原本能读写的客户端集体报错。原因是 root 用户没在 enable 之前创建,或者客户端代码里没带 user/password。前者只能停服务清数据目录重来,后者在客户端配置里补上凭据即可。我的习惯是把「root 是否已创建」写进上线检查单。

第四条:三节点集群启动后member list只有自己一个。现象是三个进程都在跑,但每个都认为自己是单节点集群。原因是--initial-cluster里的 peer URL 和实际的--initial-advertise-peer-urls对不上,或者其中一台的--initial-cluster-state写成了existing。解决是把三台的启动参数逐字对齐,特别是 IP 和端口,改完清掉数据目录重新引导。

第五条:读请求偶尔返回旧值,重试又正常。现象是写成功返回之后,紧接着的读拿到的是上一次的值。原因是客户端连到了 follower 且用了串行读,follower 的 apply 还没追上 commit。解决办法是确认客户端读模式,配置、锁这类场景强制线性读;如果确实要用串行读,至少保证读自己刚写的 key 时走线性读。

6. 验证与进阶:用 endpoint status 和 revision 做一次自证

排查完上面这些,还需要一套日常能跑的验证动作。我一般分两层:第一层看集群状态,第二层看单次写的完整链路。

集群状态用endpoint status,它能一次打出每个节点的 raftIndex、raftTerm、dbSize 和 isLeader:

etcdctl endpoint status \ --endpoints=10.20.0.11:2379,10.20.0.12:2379,10.20.0.13:2379 \ --write-out=table etcdctl endpoint health \ --endpoints=10.20.0.11:2379,10.20.0.12:2379,10.20.0.13:2379

三个节点的 raftIndex 差距应该很小,如果某个节点持续落后几千,说明它的 apply 卡住了,要看磁盘 I/O 和 WAL 目录所在盘的负载。dbSize 三个节点应该接近,差距过大通常是某个节点长时间没做 defrag。

单次写的链路验证,我会用一个固定的测试 key 走一遍 put、get、compact、defrag:

# 写入并记录 revision etcdctl put /verify/probe v1 -w=json # 读回来确认 mod_revision 与写入时一致 etcdctl get /verify/probe -w=json # 直接走 v3 gateway,key 需要 base64,/verify/probe 编码后如下 echo -n '/verify/probe' | base64 curl -s http://10.20.0.11:2379/v3/kv/range \ -X POST -d '{"key":"L3ZlcmlmeS9wcm9iZQ=="}'

endpoint status和get -w=json配合,能确认写请求真的穿过了 Raft 并落到了状态机,而不是只看客户端返回的 OK。curl 那条走的是 grpc-gateway,用来确认 HTTP 入口和 gRPC 入口看到的是同一份数据。

进阶一点的做法是把 compact 和 defrag 做成例行任务,按 revision 而不是按时间触发:

动作触发条件注意点
compact每小时一次,压缩到当前 revision 减一个安全窗口压缩后旧版本不可查,依赖历史版本的业务要评估
defrag每天一次,逐节点串行阻塞操作,避开业务高峰
alarm list每次 defrag 前后各一次有 NOSPACE 告警时先 compact 再 disarm

安全窗口我一般留 10 分钟对应的 revision 增量,给还在读历史版本的客户端留缓冲。这个值没有标准答案,取决于业务对历史版本的依赖程度。

最后说一个我自己的教训。早期做服务发现,我只在注册时看了一眼lease timetolive,觉得租约还在就没问题,结果一次网络抖动导致续期流断了十几分钟,key 被删,上游把流量切走,事后查日志才发现 watch 事件早就发出去了。从那以后我每次上线服务发现模块,都强制走一遍「手动 kill 续期协程、观察 key 是否按预期过期、再验证重建逻辑」的流程,不再相信单次命令的输出。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询