简介:《etcd实战及其原理分析》是一份面向分布式系统开发与运维人员的PDF文档,适合需要掌握一致性键值存储、服务发现与集群协调的初中级工程师查漏补缺。内容以etcd v3.5为基线,先介绍应用场景与分层架构,再落到单实例、单机多实例和多节点集群部署,以及etcdctl常用命令与权限管理。原理部分沿读写请求链路展开,涉及KVServer、拦截器、串行读与线性读、ReadIndex、Quota、WAL、Apply等模块,并专章梳理Raft的Leader选举、日志复制与安全性,以及MVCC的treeIndex、更新、查询和删除机制,便于把命令操作与底层实现对应起来。资源包仅含1个PDF文件,约4.55MB,按介绍、实战、原理三部分编排,目录清晰,适合通读和按主题检索。目前已有450人学习,可作为etcd入门与复盘的参考材料。
1. 从一次"读到旧值"说起:这份 etcd 资料补的是哪块短板
三节点 etcd 集群,客户端 put 返回成功,紧接着 get 却读回旧值——第一次碰到这个现象的人多半以为集群坏了,重启一遍又"好了",然后不再深究。真正的原因通常不在 etcd 本身,而在于这次读走的是串行读还是线性读、连的是 Leader 还是落后节点、那个节点的 Apply 进度落在了哪里。这份《etcd实战及其原理分析.pdf》讲的正是这一段:v3.5 的三种部署形态、etcdctl 的键与租约语义、Auth 权限体系,以及 Range 与写请求的完整链路、Raft 和 MVCC 的实现细节。适合两类人:正要把 etcd 接进配置中心、服务注册或分布式锁的业务开发,和想把读写路径讲清楚、经得起追问的中高级候选人。
2. 把 etcd v3.5 跑起来:单实例、单机多实例、三节点集群三种起法
环境搭建这一节看着像流水账,实际是全篇的地基。绝大多数"etcd 用不起来"的抱怨,本质上都是部署形态选错或者参数含义没吃透:单实例当生产用、单机多实例被 2380 端口撞死、多节点集群 initial-cluster 写错一个地址就永远凑不齐 Quorum。先把这三种形态的边界摆清楚,再动手。
2.1 先选形态:单点调试、单机多实例验证、多节点才谈生产
单实例适合本地开发联调,一个进程、2379 一个客户端口,写完配置就能跑,但它没有 Quorum 概念,进程一挂服务就完全不可用,任何有可用性要求的场景都不能这么上。单机多实例的价值在于用一台机器验证集群行为:Leader 选举、Follower 落后、member 增删这些操作,在单机上就能演练,省机器也省心,代价是三个进程共享同一块磁盘的 IO,测出来的性能数字没有参考价值。
多节点集群才是生产形态。etcd 用仲裁机制,成员数为 n 时,需要 (n+1)/2 个节点达成一致操作才算成功,也就是三节点容忍一个节点故障、五节点容忍两个、七节点容忍三个。节点数不是越多越好:每次写都要把日志复制到多数派并落盘,节点越多,写延迟越高、吞吐越低。社区给的常见建议是 3、5、7 这三个奇数档,七节点对绝大多数业务已经足够,再往上加更多是为了数据冗余而不是性能。
注意:偶数节点不会提升容错能力,四节点的容错能力和三节点一样(都是容忍一个),却要多同步一份数据,纯属浪费。
还有一种容易被忽略的形态是 Proxy 模式,它不参与 Raft、不存数据,只做反向代理把请求转发到集群。这种模式适合客户端不方便维护多个 endpoint 的场景,但代理本身也是单点,现在更常见的做法是让客户端直接拿全量 endpoint 列表做轮询。
2.2 单实例:二进制包解压即用,先认清 2379 和 2380
单实例部署是最快能看到数据的一次。从 etcd 官方 release 页面取对应版本的 linux-amd64 压缩包,解压后目录里就是 etcd 服务端和 etcdctl 客户端两个二进制,不需要额外依赖。
# 解压官方 release 页面下载的对应版本压缩包(版本号按实际替换) tar -zxvf etcd-v3.5.5-linux-amd64.tar.gz cd etcd-v3.5.5-linux-amd64/ # 前台启动,先看日志里打印的 name / listen-client-urls / ready to serve ./etcd # 测试环境常用:后台启动,日志固定落到一个文件,便于 tail nohup ./etcd >/tmp/etcd.log 2>&1 & # 确认版本,以及 2379(客户端)和 2380(节点间)是否都在监听 ./etcd --version ss -lntp | grep -E '2379|2380' # 把两个二进制丢进 PATH,后面就不用带 ./ 了 cp etcd etcdctl /usr/local/bin/这段里最关键的信息是端口分工:2379 面向客户端,客户端 put/get/watch 都打这个口;2380 面向集群内其他成员,承载 Raft 的日志复制和心跳。单实例时 2380 也在监听,只是没有别的节点来连它,很多人看到 2380 开着会误以为"已经集群了",其实只是一个节点在自言自语。
进程起来之后,用 etcdctl 做第一次读写。v3.5 的 etcdctl 默认已经是 v3 API,从老脚本迁过来的人习惯性加export ETCDCTL_API=3,留着也无害,但要知道它不是必须的。
# 写入一个键,成功会返回 OK etcdctl --endpoints=http://127.0.0.1:2379 put username fox # 读回来 etcdctl --endpoints=http://127.0.0.1:2379 get username # 用 json 格式输出,能看到 revision / create_revision / mod_revision / version etcdctl --endpoints=http://127.0.0.1:2379 get username -w json--endpoints这个参数建议从一开始就显式写,别依赖默认值。生产环境里要配全量地址列表,客户端会做故障转移,某个节点挂了自动换下一个,这个习惯从单机阶段就养成,后面迁到集群不用改代码。
2.3 单机多实例:把 2379/2380 错开,用 goreman 一次拉起
单机多实例的第一道坎是端口。三个 etcd 进程如果都用默认的 2379/2380,第二个进程直接启动失败。常见做法是把三组端口错开成 12379/12380、22379/22380、32379/32380,客户端口和节点间口成对偏移,看日志时不容易混。
手动开三个终端挨个起进程太痛苦,goreman 这类进程管理工具可以按一个配置文件批量拉起、单独停止、单独重启。它依赖 Go 环境,装完就能用。
# 安装 goreman(需要本地有 Go 环境) go install github.com/mattn/goreman@latest # 验证安装 goreman help然后写一个集群描述文件,每行一个进程,格式是"进程名: 启动命令"。三个节点的 name、data-dir、两组监听地址必须各不相同,而--initial-cluster和--initial-cluster-token必须完全一致——这两处不一致是最常见的启动失败原因。
# local-cluster-profile:三节点本地集群 etcd1: etcd --name infra1 \ --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 \ --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 \ --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# 按配置文件拉起全部进程 goreman -f ./local-cluster-profile start # 任意连一个节点看成员列表,三个 member 都应该在,且恰好一个 isLeader etcdctl --endpoints=http://127.0.0.1:12379 member list # 单独停止、单独重启某个节点,用来演练故障场景 goreman run stop etcd2 goreman run restart etcd2集群跑起来之后,往非 Leader 节点写一次数据,观察客户端行为:
# 指定 etcd2 的客户端口写入,etcd 会自动转发到 Leader 处理 etcdctl --endpoints=http://127.0.0.1:22379 put user fox这几个参数的语义值得单独记一遍,后面上生产改配置全靠它们:
| 参数 | 作用 | 常见错误 |
|---|---|---|
--name | 节点在本集群内的唯一名字 | 三个节点写成同一个,member 加不进去 |
--initial-cluster | 集群成员名字与 peer 地址的完整列表 | 写成客户端口、漏写某个成员、名字与--name不一致 |
--initial-cluster-state | new表示新建集群,existing表示加入已有集群 | 新集群误写existing,一直等不到成员 |
--listen-client-urls | 客户端请求的监听地址 | 只绑 127.0.0.1,容器外或跨机访问不通 |
--advertise-client-urls | 告诉客户端"用这个地址访问我" | 与 listen 不一致又没打通网络,客户端连接超时 |
--listen-peer-urls | 节点间通信的监听地址 | 与客户端口混用,导致 Raft 消息错乱 |
--initial-advertise-peer-urls | 告诉其他成员"用这个地址找我" | 与 initial-cluster 中的地址写法不一致 |
--initial-cluster-state这个参数是区分场景的关键:新建集群三个节点都写new;如果是往已有集群里加节点,新节点要写existing,并且--initial-cluster里要同时包含老成员和新成员。这一点在扩容时最容易翻车。
2.4 三节点集群:静态配置写死地址,动态发现靠一次性 token
真正上生产时,多节点部署有三种引导方式:静态配置、etcd 动态发现、DNS 动态发现。它们的区别只在于"节点启动时怎么知道其他成员是谁"。
静态配置最简单,前提是搭建之前各节点的 IP 已经确定。三台机器分别启动,每台的--name、两组监听地址、--initial-advertise-peer-urls各不相同,但--initial-cluster和--initial-cluster-token三台完全一致。下面以 10.0.0.11、10.0.0.12、10.0.0.13 为例,第一台的启动命令长这样:
# 节点一,其余两台把 name、IP、端口按各自实际值替换 etcd --name infra1 \ --data-dir /data/etcd \ --listen-client-urls http://10.0.0.11:2379 \ --advertise-client-urls http://10.0.0.11:2379 \ --listen-peer-urls http://10.0.0.11:2380 \ --initial-advertise-peer-urls http://10.0.0.11:2380 \ --initial-cluster-token etcd-cluster-1 \ --initial-cluster 'infra1=http://10.0.0.11:2380,infra2=http://10.0.0.12:2380,infra3=http://10.0.0.13:2380' \ --initial-cluster-state new这里每个节点都要有独立的--data-dir,不能共用目录,否则 WAL 和 boltdb 会被互相覆盖,症状是集群反复重启、member 信息错乱。三台都起来之后,随便连一台执行member list,应该看到三个 member 和一个 Leader。
当各节点 IP 事先不确定(比如弹性伸缩、容器编排场景),静态配置就不适用了,这时用动态发现:先通过发现服务申请一个 token,所有新成员带着同一个 token 启动,由发现服务在引导阶段协调出成员列表。
# 申请一个 size=3 的发现 token,返回一个 URL curl 'https://discovery.etcd.io/new?size=3' # 每个节点用同一个 token 启动,其余参数按实际填写 etcd --name etcd1 \ --data-dir /data/etcd \ --initial-advertise-peer-urls http://10.0.0.11:2380 \ --listen-peer-urls http://10.0.0.11:2380 \ --listen-client-urls http://10.0.0.11:2379 \ --advertise-client-urls http://10.0.0.11:2379 \ --discovery 'https://discovery.etcd.io/<申请到的token>'注意:发现 token 是一次性的,一个 token 只对应一个集群,引导失败也不能拿去引导第二个集群,必须重新申请。而且它只在引导阶段起作用,运行期的扩缩容不能靠它。
三种方式选哪种,取决于你能不能在建集群前确定成员地址:能确定就用静态配置,最可控;不能确定又不想依赖 DNS 就用发现服务;已经有成熟的 DNS 体系可以考虑 DNS 发现。无论哪种,集群起来后第一件事都是member list确认成员数量和 Leader 状态。
3. etcdctl 的键、租约与权限:revision、version、auth 三条容易搞混的线
命令行工具是验证一切判断的手段。这一节把三类最常用的操作拆开:键的区间语义、版本计数器的含义、以及 Auth 开启后的权限模型。前两个决定了你读到的数据对不对,第三个决定了集群开不开得了机——auth enable 的顺序搞错,锁死自己是血泪经验里出现频率最高的一种。
3.1 put/get/del 的区间语义:默认单键,前缀和范围要显式开
etcdctl 的 get 默认只查一个键,要查一批必须显式加参数。这和其他 KV 工具不太一样,新手容易写出"以为查了前缀其实只查了一个 key"的代码。
# 单键读写 etcdctl --endpoints=http://127.0.0.1:12379 put /test/name fox etcdctl --endpoints=http://127.0.0.1:12379 get /test/name # 十六进制输出,排查二进制 value 或不可见字符时用 etcdctl --endpoints=http://127.0.0.1:12379 get /test/name --hex # 范围查询:左闭右开,[name, name3) 包含 name、name2,不包含 name3 etcdctl --endpoints=http://127.0.0.1:12379 get /test/name /test/name3 # 前缀查询,配合 --limit 控制返回条数,避免一次拉回几十万 key etcdctl --endpoints=http://127.0.0.1:12379 get --prefix /test etcdctl --endpoints=http://127.0.0.1:12379 get --prefix /test --limit 3 # 从某个 key 开始按字典序读,不设结束位置表示读到末尾 etcdctl --endpoints=http://127.0.0.1:12379 get --from-key /test/name2 # 删除单个键、删除一个区间 etcdctl --endpoints=http://127.0.0.1:12379 del /test/name etcdctl --endpoints=http://127.0.0.1:12379 del /test/name /test/name3 # 删除时把旧值带回来,做审计或补偿逻辑时有用 etcdctl --endpoints=http://127.0.0.1:12379 del --prev-kv /test/name范围查询的左闭右开语义要记牢:get a c拿到的是a和b,不含c。前缀查询本质上是把前缀转成[prefix, prefix+1)这个区间。--limit在生产排障时非常有用,键空间里几十万个 key 的情况下,不加限制直接拉前缀,客户端可能先被内存撑爆。
删除不是物理删除,这一点在第 4 章的 MVCC 里会展开,但排障时要知道:删完 key 之后用--rev依然能读到删除前的历史版本,磁盘上的数据也不会立刻减少,得靠 compaction 清理。
3.2 revision 与 version:两个计数器各管什么,别拿来互相校验
etcd 有全局的 revision 和单 key 的 version 两个计数器,语义完全不同。revision 是集群范围的 64 位计数器,任何一次 put 或 delete 都会让它自增 1,跟是哪个 key 无关;version 是单个 key 自己的写次数,从 1 开始,key 被删除后重新创建会从 1 重新计。把这两个混为一谈,写出来的重试、幂等逻辑就会出问题。
# 读取当前 key 的完整元信息 etcdctl --endpoints=http://127.0.0.1:12379 get /test/name -w json # 读取第 8 个全局版本时的值,用来回看历史 etcdctl --endpoints=http://127.0.0.1:12379 get --rev=8 /test/name返回的 json 里几个字段值得逐一看:
| 字段 | 含义 | 使用场景 |
|---|---|---|
cluster_id | 集群唯一 ID | 确认客户端连的是不是同一个集群 |
member_id | 处理本次请求的节点 ID | 排障时确认请求落到了哪个 member |
revision | 服务端当前全局版本号 | 做 watch 断点续传、增量同步 |
raft_term | 当前 Leader 的任期号 | 判断是否发生过重新选举 |
create_revision | key 创建时的全局版本 | 判断这个 key 是新建还是覆盖 |
mod_revision | key 最后一次修改的全局版本 | 做乐观锁的 CAS 比较 |
version | key 自身的写次数 | 判断这个 key 被改过几次 |
revision 有一个细节:1 是保留版本号,用户的第一个 key 从 2 开始算。所以看到 create_revision 是 2,说明这是集群里第一个写入的 key,不是异常。
--rev这个参数是 etcd 相比很多 KV 存储的差异点,它让"读历史快照"变成一次普通的读请求,做数据变更审计、事故回溯时不用额外搭一套 binlog。前提是那个 revision 还没被 compaction 掉,压缩之后历史版本就读不到了,会返回版本已压缩的错误。
3.3 watch 与 lease:一次订阅还是持续触发,租约到期谁负责删 key
watch 是 etcd 的招牌能力之一。单次 watch 在键发生变化时输出新值,加--rev可以从指定版本开始补事件,这在客户端断线重连时非常关键——重连后不用全量拉一遍,从上次处理到的 revision+1 继续 watch 就行,中间漏掉的事件会被补发。
# 另开一个终端先挂上 watch etcdctl --endpoints=http://127.0.0.1:12379 watch /test/name # 在原终端改一次值,watch 那边会立刻输出事件并带 revision etcdctl --endpoints=http://127.0.0.1:12379 put /test/name wolf # 从指定历史版本开始补事件,用于断线重连场景 etcdctl --endpoints=http://127.0.0.1:12379 watch --rev=8 /test/nameetcd 的 watch 是稳定的长连接订阅,不是一次性的单次触发,某个 key 删掉重建、前缀下新增 key,都能持续收到事件。做服务注册、配置热更新,靠的就是这条链路。
lease 承担的是存活周期控制的角色。客户端申请一个租约、拿到 lease ID,把 key 绑到这个租约上,之后要么周期性续约,要么就等着到期被自动删除。
# 授予一个 TTL 30 秒的租约,返回 lease ID etcdctl --endpoints=http://127.0.0.1:12379 lease grant 30 # 把 key 绑到租约上,写法和普通 put 一样,只是多一个 --lease etcdctl --endpoints=http://127.0.0.1:12379 put --lease=<leaseID> /test/name fox # 手动续约,进程活着就定期刷 TTL etcdctl --endpoints=http://127.0.0.1:12379 lease keep-alive <leaseID> # 查询租约剩余时间,加 --keys 能一并列出绑在上面的 key etcdctl --endpoints=http://127.0.0.1:12379 lease timetolive <leaseID> etcdctl --endpoints=http://127.0.0.1:12379 lease timetolive --keys <leaseID> # 主动撤销租约,绑定的 key 会被一并删除 etcdctl --endpoints=http://127.0.0.1:12379 lease revoke <leaseID>服务注册就是这套用法的典型:服务启动时申请一个较长 TTL 的租约,把自己的地址写到/services/<name>/<instanceID>并绑定租约,后台起一个 keep-alive 循环;进程崩溃、网络中断导致续约失败,租约到期后 etcd 自动把那个 key 删掉,其他服务 watch 到删除事件就知道这个实例下线了。相比临时节点那种方案,租约的语义更明确,续约失败就是失败,不会出现节点还在但状态不明的情况。
注意:TTL 不要设得太短。网络抖动一次就可能续约失败导致 key 被误删;也不要设太长,实例已经死了很久才被发现,服务发现就失去了意义。实践中按心跳间隔的 3 到 5 倍取值比较稳。
3.4 Auth 权限:user、role、permission 三层,顺序错一步就锁死
etcd 的权限模型是三层结构:用户(user)绑定角色(role),角色绑定权限(permission),权限是"某个 key 范围上的 read/write/readwrite"。开启认证之前,必须先把 root 用户建出来,否则 auth enable 执行成功的那一刻,所有没带用户名密码的请求都会被拒绝,包括你自己接下来想创建用户的那条命令。
# 创建用户、改密码、查列表、删除 etcdctl --endpoints=http://127.0.0.1:12379 user add fox etcdctl --endpoints=http://127.0.0.1:12379 user passwd fox etcdctl --endpoints=http://127.0.0.1:12379 user list etcdctl --endpoints=http://127.0.0.1:12379 user del fox # 创建角色、给角色赋权、收回权限、删除角色、查看角色 etcdctl --endpoints=http://127.0.0.1:12379 role add test etcdctl --endpoints=http://127.0.0.1:12379 role grant-permission test readwrite /test/name etcdctl --endpoints=http://127.0.0.1:12379 role revoke-permission test /test/name etcdctl --endpoints=http://127.0.0.1:12379 role del test etcdctl --endpoints=http://127.0.0.1:12379 role list etcdctl --endpoints=http://127.0.0.1:12379 role get test # 用户绑定角色、解绑 etcdctl --endpoints=http://127.0.0.1:12379 user grant-role fox test etcdctl --endpoints=http://127.0.0.1:12379 user revoke-role fox test # 开启认证前必须先建 root,root 自带全部权限 etcdctl --endpoints=http://127.0.0.1:12379 user add root etcdctl --endpoints=http://127.0.0.1:12379 auth enable # 之后所有操作都要带凭据,否则返回认证失败 etcdctl --user=root --password='<你的密码>' --endpoints=http://127.0.0.1:12379 get /test/name集群场景下的顺序更要小心:对三个节点分别建 root、然后带 root 凭据执行 auth enable。切到认证模式之后,普通用户的完整流程是先建用户、再建角色、给角色赋权、最后把用户绑到角色上,四步缺一不可,权限范围只认前缀和区间,/test和/test/name是两个不同的权限范围。
注意:一旦开启 auth 而 root 密码又丢了,恢复手段相当麻烦,常规做法是停掉所有节点、在一个节点上带
--force-new-cluster单节点拉起,重建用户后再恢复集群。上线前把 root 凭据放进配置管理,比事后补救省事得多。
4. 一次 get 背后发生了什么:Range 链路、WAL 写入与 MVCC 的 treeIndex
命令行玩熟之后,遇到"读到的数据不对""写成功但另一台机器读不到"这类问题,光会敲命令就不够了。这一章按读、写两条路径拆开,再把 Raft 和 MVCC 单独拎出来讲清楚,是这份资料里最有含金量的部分。
4.1 读请求:Client 轮询、拦截器、串行读与线性读的分岔
etcd 是典型的读多写少存储,实际业务里读请求通常占到三分之二以上,所以读路径的设计直接决定整体表现。一次etcdctl get hello --endpoints=...从敲下回车到看到结果,大致经过这些环节。
Client 层先解析参数:get对应 KVServer 的 Range 接口,hello是 key,--endpoints是后端地址列表。然后用 clientv3 库创建一个客户端对象,负载均衡算法是 Round-robin,也就是对每个请求按轮询顺序从 endpoint 列表里挑一个,保持长连接,让各节点负载尽量均衡。生产环境一定要配多个 endpoint,某个节点故障时客户端能自动重连到其他节点,单个 endpoint 的配置等于把 etcd 的高可用废掉一半。
请求进到 server 端先经过一串 gRPC 拦截器,这是 etcd 比较巧妙的设计:用非侵入的方式把 metrics 采集、日志、请求耗时、错误码、来源 IP 这些通用能力挂上去,还基于拦截器实现了两个重要特性——执行操作前检查集群是否有 Leader,以及请求耗时超过阈值时打印带来源 IP 的慢查询日志。排障时这条慢查询日志非常好用,能直接定位到是哪个客户端在拖慢整个集群。
进了 KVServer 的 Range 处理逻辑,真正的分岔点出现了:这次读是串行读还是线性读。
串行读(Serializable)直接读本地状态机,不跟 Leader 确认,延迟低、吞吐高,但可能读到旧数据。原因很直白:一个写请求要经过 Leader 落 WAL、广播给 Follower、多数派确认、提交、再异步 Apply 到状态机。如果客户端连的那个 Follower 磁盘 IO 刚好有波动,它的 Apply 进度落后,此时读到的就是写之前的旧值。
线性读(Linearizable)走 ReadIndex 机制:节点收到读请求后,先向 Leader 确认当前已提交的最新日志索引,拿到之后在本地等待自己的 Apply 进度追上这个索引,再读状态机。这样读到的数据一定不旧于请求发起时刻已提交的数据。etcd 的默认读是线性读,就是为了让客户端在任意节点上都能读到一致的数据。
注意:对一致性要求不高的场景(比如读一份几乎不变的静态配置),显式降级成串行读能明显降低读延迟和 Leader 压力;但涉及分布式锁、选主、配置切换这类必须看到最新值的逻辑,不要图省事改成串行读。
线性读的成本在于多了一次与 Leader 的往返确认,所以它比串行读慢。这个代价换来的是"任意节点都能读到一致数据"这个性质,值不值取决于业务。
4.2 写请求:从 Quota 检查到 WAL 落盘,再到 Apply 进 MVCC
写路径比读路径长得多,环节也更多。一次 put 大致经过 Quota、KVServer、WAL、Apply、MVCC 五个模块。
Quota 模块负责后端数据库的体积管控。etcd 的 boltdb 文件大小有上限(--quota-backend-bytes控制,默认在 2 GiB 量级),接近上限时会先发 NOSPACE 告警,超过之后集群整体转为只读,所有写请求被拒绝。这个设计是为了防止磁盘被悄悄写满导致更严重的故障,但代价是:如果没人处理,整个集群就写不进去了。所以监控 dbSize 和 NOSPACE 告警是运维的必修课。
KVServer 模块负责请求校验和鉴权:检查 key 和 value 的长度是否超过限制、当前用户对该 key 范围是否有写权限、请求体是否超过--max-request-bytes。校验通过之后,写请求被包装成一个 Proposal 交给 Raft 模块,带上 prevKV、lease 之类的附加信息。
Raft 层收到 Proposal 后,只有 Leader 能处理:先把日志条目追加到自己的 WAL 并落盘,然后并行广播给所有 Follower;Follower 落盘后回 ACK;当超过半数节点确认落盘,这个条目被标记为已提交。注意这里是"落盘后确认"而不是"收到就确认",这也是 etcd 写入延迟主要来自 fsync 的原因。
已提交的日志条目由 etcdserver 模块异步取出,交给 Apply 模块应用到状态机。Apply 里做的就是 MVCC 的活:写一个新的 revision,更新内存里的 treeIndex,然后把 key、revision、value 写进 boltdb 的一个事务里。这一步做完,客户端那边才收到 put 成功的响应。
整个链路里最容易被忽视的是 Apply 是异步的:日志提交和状态机可见之间存在一个窗口。这解释了为什么写成功后立刻从另一个节点读,偶尔会读不到——如果那个节点 Apply 落后,而客户端又用了串行读,就会看到旧状态。线性读正是为了消除这个窗口而存在的。
4.3 Raft:Leader 选举、日志复制、Term 与 Index 定位
Raft 是 etcd 保证多节点一致的基石,三个核心机制值得掰开:选举、复制、安全性。
选举靠心跳维持。Leader 周期性向 Follower 发心跳(etcd 默认在百毫秒量级),Follower 超过选举超时(默认在秒级,且每个节点随机化)没收到心跳,就认为 Leader 失联,把自己的任期 term 加一、转为 Candidate、向其他节点拉票。拿到超过半数选票的节点成为新 Leader。随机化的选举超时避免了所有 Follower 同时竞选、反复僵持。
日志复制是 Leader 收到写请求后的动作:追加到本地日志、广播 AppendEntries、收集 ACK、多数派确认后提交,然后通知所有节点可以应用。Follower 的日志如果和 Leader 不一致(比如上次 Leader 只写了一半就挂了),Leader 会通过回退索引的方式找到最后一个共同点,用自己较新的日志覆盖对方。这套机制保证所有节点的日志最终一致。
安全性约束有两条很关键。第一条是选举限制:只有日志不落后于其他节点的候选人才可能当选,具体比较方式是先比最后一条日志的 term、term 相同再比 index,更大者更新。这条规则保证了已经提交的日志不会因为选举而丢失。第二条是提交规则:Leader 只能提交当前 term 的日志,不能靠数副本数来提交之前 term 的日志,必须等当前 term 有一条新日志提交之后,之前的日志才被间接提交。
Term 和 Index 是 Raft 里定位日志的两个坐标:term 表示这是第几任 Leader 任期内的日志,index 是在整个日志序列里的位置。排查集群问题时,endpoint status输出的 raftIndex 和 raftTerm 就是这两个值,节点之间 raftIndex 差距过大,通常意味着某个节点 Apply 严重落后,需要重点看它的磁盘。
节点数对 Raft 的影响在这里体现得最直接:写请求要等多数派落盘,节点越多,需要的 ACK 越多,长尾延迟越明显。这也是七节点之后不建议继续扩的原因。
4.4 MVCC:treeIndex 在内存里做索引,boltdb 存真正的版本数据
MVCC 是 etcd 支持无锁读、历史版本查询的基础,它由两部分组成:内存里的 treeIndex 和磁盘上的 boltdb。
treeIndex 是一棵内存 B 树(实际实现是 btree),key 是用户键,value 是这个键对应的所有 revision 组成的数组。它不存数据本身,只存"这个 key 有哪些版本"这份索引。因为全部在内存里,查找是一条 O(log n) 的路径。
boltdb 里存的是另一套映射:revision 到 key/value 的对应关系。每个 revision 对应一条记录,key 是 revision 编码后的字节,value 里包含用户键和实际数据。这种设计把"按 key 找版本"和"按版本取数据"两件事拆到了两个结构上,各自都能做到高效。
更新 key 的流程是:分配一个新 revision(全局计数器加一),把新数据写进 boltdb,然后在 treeIndex 里把这个 revision 追加到对应 key 的版本数组末尾。注意写入 boltdb 和更新 treeIndex 是在同一个事务性流程里完成的,否则崩溃后会出现索引和数据对不上。
查询 key 的流程反过来:先在 treeIndex 里找到这个 key 的版本数组,根据请求的 revision(默认是最新)二分查找出小于等于目标 revision 的最大版本号,再拿这个版本号去 boltdb 里读实际数据。这就是为什么--rev能读到历史值——历史版本并没有被覆盖,一直躺在 boltdb 里,只是索引里能查到而已。
删除 key 不是物理删除,而是写入一条删除标记:分配新 revision,value 为空,标记为 tombstone,同时在 treeIndex 里追加这个 revision。查询时如果最新版本是 tombstone,就返回 key 不存在;但如果指定了更早的 revision,依然能读到删除前的值。
这套机制带来一个副作用:历史版本会无限增长,磁盘只增不减。那就需要压缩(Compaction),把某个 revision 之前的所有历史版本丢掉,只保留每个 key 最新的那个版本。etcd 支持手动etcdctl compact <rev>和自动压缩(--auto-compaction-retention,按小时或按 revision 数保留)。压缩只释放 boltdb 内部的空闲页,文件大小不会自动缩小,还需要etcdctl defrag做一次碎片整理才能真正把磁盘空间还给操作系统。
5. 五个高频故障的排查顺序:端口冲突、Quorum 丢失、NOSPACE、auth 锁死与租约蒸发
部署和原理过一遍,剩下的就是踩坑。下面五条是我在测试环境和类生产环境里反复遇到的,每条按现象、原因、解决三段写,排障时可以直接对照。
5.1 集群起不来,日志一直刷"cannot fetch cluster information"
现象:三个进程都在跑,但member list只能看到一个成员,日志里反复出现拉取集群信息失败或者等待其他成员超时,集群始终没有 Leader。
原因:--initial-cluster与实际 peer 地址对不上,或者端口写成了客户端口。还有一种情况是--initial-cluster-token三个节点不一致,每个节点都认为自己在加入不同的集群,互相不认。第三种是--name与 initial-cluster 里的名字不一致,节点看见列表里有自己,但按名字匹配不上。
解决:三个节点的--initial-cluster、--initial-cluster-token、--initial-cluster-state必须逐字符一致,只有--name和各自的地址不同。确认 initial-cluster 里写的全是 2380 这类 peer 端口而不是 2379。如果已经用错误参数启动过、产生了脏数据,必须清空--data-dir重新来,光改参数重启是没用的,旧数据目录里的成员信息会一直干扰。
5.2 第二个节点起不来,报 address already in use
现象:单机多实例场景下,第一个节点启动正常,第二个节点直接退出,日志里是端口被占用。
原因:etcd 默认客户端口 2379、节点间口 2380,同一台机器上多个实例不做偏移必然冲突。很多人只改了客户端口,忘了 peer 端口,或者反过来。
解决:每个实例的客户端口和 peer 口都要成对偏移,比如 12379/12380、22379/22380、32379/32380,并且--listen-*-urls与--initial-advertise-peer-urls、--initial-cluster里出现的地址必须完全对应。改完端口之后,--data-dir也要跟着用不同目录,否则旧实例的成员信息会混进来。
5.3 集群突然写不进去,客户端报 no space
现象:所有写请求被拒绝,返回etcdserver: mvcc: database space exceeded,读请求还正常,监控里 dbSize 一路涨到接近上限。
原因:历史版本没有压缩,boltdb 文件持续增长,触发了 quota 限制。etcd 一旦进入 NOSPACE 状态会整体转只读,这是保护机制不是 bug。常见诱因是某些 key 被高频更新又没有配置自动压缩。
解决:分三步走。先etcdctl alarm list确认 NOSPACE 告警存在;再对当前 revision 做一次压缩etcdctl compact <当前revision>,然后逐个节点执行etcdctl defrag(注意 defrag 期间该节点会阻塞,要一个一个来,别同时做);最后etcdctl alarm disarm解除告警,写操作才恢复。长期方案是把--auto-compaction-retention打开,并定期检查 dbSize 趋势。
5.4 开启 auth 之后所有命令都返回 authentication failed
现象:执行auth enable成功,紧接着任何 etcdctl 命令都报认证失败,连user list也看不了,整个集群等于锁死。
原因:开启认证之前没有创建 root 用户,或者创建了但密码没记住。auth enable 生效后,没有凭据的请求一律被拒绝,包括管理类命令。
解决:如果 root 用户存在只是密码丢了,先带--user=root --password=试几个可能的密码;确实找不回来,常规恢复手段是停掉所有节点,挑一个节点带--force-new-cluster单节点强制拉起,用这个单节点重建用户体系,再让其他节点重新加入。这个操作有丢数据风险,只在确认无法恢复时使用。预防手段很简单:建 root → 验证 root 能读写 → 再 enable,且把凭据存进配置管理。
5.5 服务注册表里的实例莫名其妙消失
现象:服务注册用的 key 过一段时间就没了,watch 那边收到删除事件,但服务进程明明还活着。
原因:租约的 keep-alive 断了。可能是客户端到 etcd 的网络抖动、也可能是 keep-alive 循环所在的 goroutine 被阻塞、还可能是 TTL 设得太短而客户端续约间隔太长。租约一到期,etcd 不关心业务进程是否存活,绑定在上面的 key 一律删除。
解决:先确认 TTL 和续约间隔的比例,续约间隔不要超过 TTL 的三分之一;再检查客户端有没有处理 keep-alive 返回的 channel 关闭事件,很多实现只起了一个续约循环却从不监听这个 channel,连接断了也不知道,等到 key 消失才发现。另外 watch 断线重连时要带上次处理到的 revision+1,否则断开期间的事件会永久丢失,表现为"某个实例其实早就下线了,但本地状态一直没更新"。
6. 用 endpoint status 和 check perf 给集群做体检:一套能落地的自检顺序
功能跑通不等于可以上线。我现在的习惯是,任何一次参数变更、版本升级、扩容缩容之后,先跑一遍固定顺序的体检,确认集群处于健康状态再谈业务验证。
第一步看成员和健康度:
# 三个节点是否都在,是否恰好一个 Leader,有没有 learner etcdctl --endpoints=http://10.0.0.11:2379,http://10.0.0.12:2379,http://10.0.0.13:2379 member list # 每个 endpoint 的健康检查,返回 healthy 表示能正常处理请求 etcdctl --endpoints=http://10.0.0.11:2379,http://10.0.0.12:2379,http://10.0.0.13:2379 endpoint health # 更详细的状态:dbSize、raftIndex、raftTerm、leader、isLearner etcdctl --endpoints=http://10.0.0.11:2379,http://10.0.0.12:2379,http://10.0.0.13:2379 endpoint status -w <p> <a href="https://download.csdn.net/download/weixin_43109587/87442251" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>