简介:《etcd实战及其原理分析》是一份面向后端开发、运维及分布式系统学习者的PDF技术资料,基于etcd v3.5梳理从入门到原理的完整知识框架,可用于理解强一致键值存储、服务发现、分布式锁、队列与主备选举等典型场景。压缩包共1个PDF文件,大小约4.55MB,内容按“介绍—实战—核心原理”组织,便于集中查阅。资料涵盖单实例、单机多实例与多节点集群部署,etcdctl常用命令和权限管理,并深入读请求、写请求执行流程,以及Raft选举与日志复制、MVCC的更新、查询、删除机制,另涉及WAL、Snapshot和boltdb存储层。已有450人学习或下载,适合希望把部署操作与一致性原理结合起来的读者查漏补缺。
1. etcd 这本书值不值得翻:从一条etcdctl get hello说起
etcd 这个名字,很多人第一次认真看是在控制面告警里:etcdserver: request timed out、mvcc: database space exceeded,或者 Raft 日志里 term 一路往上跳。平时它安静地躺在控制面,出问题时整个 apiserver 跟着一起趴下。这份 v3.5 线的实战手册把内容切得很干脆:前半段讲它是什么、和 ZooKeeper 差在哪,中间是一整套能照着敲的环境搭建、etcdctl 命令和 RBAC 配置,后半段把读请求、写请求、Raft、MVCC 拆到模块级别。刚接手控制面运维的同学、要拿 etcd 做服务注册和分布式锁的业务开发、以及想把一致性协议从头捋一遍的人,都能从对应章节直接切进去,不用从第一页读到最后一页。
2. 环境搭建:单机、单机多实例到三节点集群的三条起法
环境这一块最容易出现“教程里能跑、我这里报错”的情况。原因往往不是 etcd 本身,而是端口、--initial-cluster里的地址和实际监听的地址对不上,或者># 下载 v3.5.5 的 linux-amd64 二进制包(官方 release 页面的命名规则) wget https://example.com/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/ # 前台启动,观察启动日志里的 name / listen-client-urls / listen-peer-urls ./etcd # 生产或长驻场景改为后台启动,日志重定向到文件 nohup ./etcd >/tmp/etcd.log 2>&1 & # 确认版本,避免客户端与服务端大版本不匹配 ./etcd --version # 放进 PATH,后续直接调用 etcd / etcdctl cp etcd etcdctl /usr/local/bin
这段脚本的逻辑很直白:解压出来的目录里同时带了etcd(服务端)和etcdctl(客户端),先在前台跑一次是为了让启动日志直接打在终端上,肉眼确认name、># 写入一个 key,服务端返回 OK 表示 WAL 落盘成功 etcdctl --endpoints=localhost:2379 put username fox # 读回来,默认输出只带 value etcdctl --endpoints=localhost:2379 get username # 带 -w json 可以看到 revision、raft_term 等元数据 etcdctl --endpoints=localhost:2379 get username -w json
参数上要留意--endpoints。不写这个参数时 etcdctl 默认连127.0.0.1:2379,容器里跑或者改了监听地址就会连不上,报context deadline exceeded。这个报错信息没有任何指向性,新手很容易怀疑是服务端没起来,实际只是客户端连错了地址。
2.2 goreman 拉起单机多实例:端口规划是核心
想观察 Leader 选举、日志复制、节点下线这三种行为,最少需要三个 member。租三台机器成本太高,本地起三个进程是最经济的做法。麻烦点在于 etcd 默认所有实例都用 2379/2380,同机启动必然端口冲突,所以要把每个实例的两类端口错开:客户端端口用 12379/22379/32379,peer 端口用 12380/22380/32380。
手工敲三行超长命令不现实,进程管理工具 goreman 是常见选择,它读一个 profile 文件,按行启动进程,还能单独 stop/restart 某一个。
# goreman 依赖 Go 环境,没有的话先装 Go go install github.com/mattn/goreman@latest goreman helpprofile 文件的格式是进程名: 启动命令,每行一个进程。下面这份是手册里给的三节点本地集群写法,我加了注释说明每一段在干什么:
# local-cluster-profile # 三个实例共用同一个 cluster-token,否则会被认为是不同集群 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三行里最容易错的是--initial-cluster。它的格式是节点名=peer地址,节点名必须和本行的--name完全一致,peer 地址必须和对应节点的--initial-advertise-peer-urls一致。只要有一处笔误,节点就会卡在启动阶段反复重试,日志里出现cannot fetch cluster information from file或者一直在waiting for the initial cluster,看起来像网络问题,实际是配置错了。
启动和常用操作:
# 按 profile 启动全部三个进程 goreman -f ./local-cluster-profile start # 从任意一个节点查集群成员,三个成员都应为 started 状态 etcdctl --endpoints=localhost:12379 member list # 停掉其中一个进程,模拟节点下线 goreman run stop etcd2 # 再拉起来,观察它重新加入后的日志追赶过程 goreman run restart etcd2 # 写数据时显式指定落到哪个节点,验证 Leader 转发 etcdctl --endpoints=http://localhost:22379 put user foxmember list的输出里有两列要盯住:isLearner和状态。正常成员不是 learner,状态是started;如果某个成员一直显示unstarted,基本可以断定它的 peer 端口没通,或者--initial-cluster里的地址写错了。第三行向 etcd2 写数据,如果 etcd2 恰好是 Follower,它会把请求转发给 Leader,返回的响应里仍然带OK,这一步可以用来验证转发链路是否正常。
2.3 静态配置三节点集群:先已知,再启动
生产环境多数用静态配置,前提是两个条件同时满足:集群节点数提前确定,每个节点的地址提前确定。云上用固定内网 IP 的场景基本都适合这种方式,缺点是扩缩容要改配置重启,所以很多团队会配合配置管理工具批量下发。
假设三台机器地址分别是 10.0.0.11、10.0.0.12、10.0.0.13,节点名 infra1/infra2/infra3。三份启动参数只有--name和四个 URL 相关字段不同,其余完全一致:
# 10.0.0.11 上执行 etcd --name infra1 \ --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 # 10.0.0.12 上执行:只改 name 和本机地址 etcd --name infra2 \ --listen-client-urls http://10.0.0.12:2379 \ --advertise-client-urls http://10.0.0.12:2379 \ --listen-peer-urls http://10.0.0.12:2380 \ --initial-advertise-peer-urls http://10.0.0.12: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 # 10.0.0.13 上执行 etcd --name infra3 \ --listen-client-urls http://10.0.0.13:2379 \ --advertise-client-urls http://10.0.0.13:2379 \ --listen-peer-urls http://10.0.0.13:2380 \ --initial-advertise-peer-urls http://10.0.0.13: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两个参数需要单独解释。--advertise-client-urls是告诉集群“客户端应该用这个地址访问我”,它和--listen-client-urls可以不同:监听可以绑0.0.0.0或同时绑内网加回环,但对外广播必须是其他节点和客户端真正能访问到的地址。如果只监听 127.0.0.1 而广播内网 IP,健康检查会显示节点正常,客户端却连不上。--initial-cluster-state在首次引导时是new,如果填成existing,节点会去尝试加入一个已存在的集群,三台都这么填就会互相等待,整个集群起不来。
启动后从任意节点验证:
# 用一个 endpoint 就能看到全部成员,说明集群已形成 etcdctl --endpoints=10.0.0.11:2379 member list # 客户端配置多个 endpoint,任一节点故障后自动切换 etcdctl --endpoints=10.0.0.11:2379,10.0.0.12:2379,10.0.0.13:2379 get user客户端侧配置多个 endpoint 是必须做的。etcdctl 和 clientv3 采用轮询(Round-robin)方式从列表里选一个建立长连接,某个节点挂掉后会重连到下一个,这个行为不需要业务代码额外实现,配好列表就够了。
2.4 动态发现:只在引导阶段有效
节点 IP 不固定的场景(比如自动扩缩容、容器化部署)用静态配置就很别扭,这时走动态发现:先向一个发现服务申请令牌,各节点带着同一个令牌启动,由发现服务协调成员列表。
# 申请一个 size=3 的发现令牌,返回的 URL 就是令牌 curl https://discovery.etcd.io/new?size=3 # 三个节点都用同一个 --discovery 参数启动,其余字段按本机填写 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,http://127.0.0.1:2379 \ --advertise-client-urls http://10.0.0.11:2379 \ --discovery https://discovery.etcd.io/<token>这里有两个硬性约束。第一,一个令牌只能引导一个集群,用过一次就作废,重搭集群必须重新申请,否则节点会一直报discovery: cluster ID mismatch。第二,每个成员的--name必须不同,重名会导致发现失败。手册里明确写了发现协议只用于引导阶段,不能用于运行时重配置或集群监控,这一点经常被误解成“动态发现的集群可以随便加节点”,实际上扩缩容仍然要走member add。
三种方式的适用边界可以简单归一下:
| 部署方式 | 前提条件 | 适用场景 | 主要限制 |
|---|---|---|---|
| 单实例 | 无 | 本地开发、API 验证 | 无高可用,宕机即不可用 |
| 单机多实例 | 端口可自由分配 | 学习 Raft、验证故障转移 | 机器级故障会全挂,不能当生产 |
| 静态配置集群 | 节点数与地址已知 | 固定 IP 的云主机、物理机 | 扩缩容需改配置 |
| 动态发现 | 可访问发现服务 | 地址不固定的自动化部署 | 令牌一次性,仅引导阶段 |
3. etcdctl 与权限体系:键操作、租约、RBAC 的落地顺序
命令行工具是 etcd 最容易被低估的部分。很多人只用到put和get,实际上 etcdctl 覆盖了键操作、历史版本读取、watch、租约、用户角色权限这一整套能力,业务代码里要实现的逻辑,先用 etcdctl 手动跑通一遍,能省掉大量调试时间。这一章按“键操作 → 租约 → 权限”的顺序推进,这个顺序也是实际项目中落地的顺序:先把数据结构定下来,再考虑过期和回收,最后才收权限。
3.1 键操作与 revision 六个字段的读法
etcd 的键空间是扁平的,但用前缀可以模拟目录层级。常用操作覆盖了增删改查和范围读取:
# 设置或更新 etcdctl put /test/name fox # 读取指定 key etcdctl get /test/name # 以十六进制返回,排查不可见字符时用得上 etcdctl get /test/name --hex # 半开区间读取:左闭右开,[name, name3) etcdctl get /test/name /test/name3 # 前缀读取,配合 --limit 限制返回条数 etcdctl get --prefix /test etcdctl get --prefix /test --limit 3 # 按字典序读取大于等于 name2 的所有 key etcdctl get --from-key name2 # 读取历史版本:--rev 指定全局 revision etcdctl get --rev=8 /test/name # 删除单个 key 或区间 etcdctl del /test/name etcdctl del /test/name /test/name3 # 删除时返回被删的值,用于确认删对了 etcdctl del --prev-kv name1这些命令里,--rev最值得单独说。etcd 的 revision 是集群级别的 64 位计数器,任何一次 put 或 delete 都会让它自增,跟具体是哪个 key 无关。revision=1 是保留值,所以用户数据的第一个版本从 2 开始。想读某个 key 的历史值,必须给出当时的全局 revision,用-w json就能看到这个数。
返回的 JSON 里有六个字段,理解它们才能正确使用 MVCC:
| 字段 | 含义 | 使用场景 |
|---|---|---|
cluster_id | 集群唯一 ID | 确认客户端连的是不是目标集群 |
member_id | 当前响应的节点 ID | 定位请求落到了哪个 member |
revision | 服务端当前全局版本号 | 做 watch 起点、判断数据新鲜度 |
raft_term | 当前 Leader 任期号 | 排查 Leader 是否频繁切换 |
create_revision | 该 key 创建时的全局 revision | 判断 key 是否被删后重建 |
mod_revision | 该 key 最后一次修改的全局 revision | 做乐观锁的版本比对 |
version | 该 key 自身的修改次数,从 1 开始 | 判断 key 生命周期内的写次数 |
version和revision是两个概念,这一点新手经常混。version只跟单个 key 走,key 删掉重建后从 1 重新计数;revision是全局的,只增不减。做乐观并发控制时要用mod_revision比较,而不是version,因为version在删除重建之后会归零,会导致误判。
3.2 租约:TTL、续期与撤销的完整闭环
租约是 etcd 实现服务发现和分布式锁的基础。key 绑定到租约上,租约到期,所有绑定的 key 一起被删除。这个机制比“业务自己起定时任务扫过期 key”可靠得多,因为过期判定发生在服务端,不依赖客户端时钟。
# 授予一个 TTL 为 30 秒的租约,返回租约 ID etcdctl lease grant 30 # 把 key 绑定到租约上 etcdctl put --lease=705b8474d1acd63b name fox # 查询租约剩余时间 etcdctl lease timetolive 705b8474d1acd63b # 同时列出该租约绑定的所有 key etcdctl lease timetolive --keys 705b8474d1acd63b # 主动续期,客户端应周期性调用 etcdctl lease keep-alive 705b8474d1acd63b # 主动撤销,绑定在该租约上的 key 会被立刻删除 etcdctl lease revoke 705b8474d1acd63blease keep-alive在前台是持续运行的,会按 TTL 的一定比例自动续期。业务代码里的典型写法是:启动时申请租约,把服务地址作为 key 写进去并绑定租约,然后开一个 goroutine 或线程持续 keep-alive;进程挂掉,keep-alive 停止,租约到期,key 自动消失,服务发现侧通过 watch 立刻感知下线。整条链路不需要任何“心跳上报”的自定义协议。
有两个参数值得调整。租约 TTL 不宜太短,网络抖动时续期失败会导致服务被误判下线,实践中 10 到 30 秒比较常见;也不宜太长,进程异常退出后要等很久才会被清理。keep-alive 的间隔一般由客户端库自动决定,不需要手动设置,但要确保客户端进程不会被 GC 或调度长时间挂起。
3.3 权限管理:root 用户必须先建,auth enable 最后开
etcd 的权限模型是用户、角色、权限三级:权限授予角色,角色授予用户。开启认证的顺序不能错,一旦顺序反了,很容易把自己锁在门外。正确顺序是:先建 root 用户,再建普通用户和角色,最后才auth enable。
# 第一步:创建用户(会提示输入密码) etcdctl user add fox # 查看用户列表、查看指定用户绑定的角色 etcdctl user list etcdctl user get fox # 修改密码、删除用户 etcdctl user passwd fox etcdctl user del fox # 第二步:创建角色并授权 etcdctl role add test etcdctl role grant-permission test readwrite /name etcdctl role get test etcdctl role list # 回收权限、删除角色 etcdctl role revoke-permission test /name etcdctl role del test # 第三步:用户绑定角色 etcdctl user grant-role fox test etcdctl user revoke-role fox test # 第四步:必须先把 root 建出来,才能开启认证 etcdctl user add root etcdctl auth enable # 开启后所有操作都要带身份 etcdctl --user=root --password='123456' get /name etcdctl --user=fox --password='123456' get /namerole grant-permission的权限粒度只有三档:read、write、readwrite,作用对象是 key 或 key 前缀。它不支持“只允许读某个前缀下的部分 key”这种细粒度规则,所以设计 key 结构时就要把权限边界考虑进去,用前缀把不同业务隔离到不同目录。比如/svc/order/和/svc/user/分别对应两个角色,比所有业务共用一个根目录再想细分要现实得多。
集群模式下开启认证要额外注意 endpoint 的写法。如果客户端只配了一个节点,auth enable期间该节点正好在做 Leader 切换,命令会失败,但认证状态可能已经在集群内生效,这时再执行普通操作就会报etcdserver: user name is empty。稳妥做法是把三个 endpoint 都写进命令里:
# 多 endpoint 开启认证,避免单节点抖动导致半开状态 etcdctl --endpoints=http://127.0.0.1:12379,http://127.0.0.1:22379,http://127.0.0.1:32379 \ --user=root --password='123456' auth enable # 用普通用户验证权限边界:只能操作授权过的前缀 etcdctl --endpoints=http://127.0.0.1:22379 \ --user=fox --password='123456' put /name fox # 越权访问应返回 permission denied,这一步一定要主动验证 etcdctl --endpoints=http://127.0.0.1:22379 \ --user=fox --password='123456' put /other/name fox最后这条越权验证经常被跳过。授权完成不等于权限生效,如果角色绑定写错了对象,普通用户实际拿到了 root 级别权限,从功能上完全看不出来,只有主动去访问一个不该有权限的 key 才能暴露。
3.4 watch:监听与断线重连的起点
watch是 etcd 最有特色的能力,服务发现、配置热更新、分布式协调全都建立在它上面。命令行用法很直接:
# 终端 A:监听指定 key etcdctl watch /test/name # 终端 B:写入新值,终端 A 立刻输出事件 etcdctl put /test/name fox2 # 从指定 revision 开始监听,用于补上断线期间漏掉的事件 etcdctl watch /test/name --rev=8 # 监听整个前缀 etcdctl watch --prefix /test--rev是断线重连的关键。客户端连接断开后重连,如果从当前 revision 开始监听,断线期间发生的事件就永久丢失了。正确做法是记住上次处理到的最新 revision,重连时用--rev=上次+1补上。但这里有个前提:该 revision 的历史数据还没被压缩(compaction)掉,如果已经被压缩,服务端会返回required revision has been compacted,这时只能退化为全量重新拉取。所以 watch 的消费端必须同时实现“增量补”和“全量重建”两条路径,只做前者迟早会遇到补不回来的情况。
4. 读写两条链路:拦截器、ReadIndex、WAL 与 MVCC 的协作
前面都是操作层面的东西,到了原理这块,很多人的理解停留在“etcd 用 Raft 保证一致性”这一句。这句话没错,但没法用来定位问题。真正有用的是把一次读和一次写拆成具体的模块调用顺序,知道每一步在哪卡住会表现成什么现象。手册把读链路和写链路分开讲,这个拆法是对的,因为两者的瓶颈点完全不同:读主要卡在是否走 Raft、是否要等 Leader 确认,写主要卡在 WAL 落盘和 Apply 速度。
4.1 读链路:串行读、线性读与 ReadIndex 的取舍
先看一次etcdctl get hello从客户端到数据的完整路径。客户端这边,etcdctl 解析参数,get对应 KVServer 的 Range 接口,endpoints是候选地址列表。clientv3 用轮询方式从列表里挑一个建长连接,这一步决定了请求落到哪个 member 上。
请求到达服务端后,先经过拦截器(Interceptor)。拦截器以非侵入的方式挂了一批通用能力:metrics 采集、请求日志、执行耗时和错误码记录、来源 IP 标记、慢查询日志,以及一条很关键的检查——执行操作前集群必须有 Leader。这条检查意味着,如果集群刚丢了 Leader,所有请求会在拦截器层直接被拒,不会走到存储层。排查时如果看到大量请求在极短时间内失败,先看raft_term有没有跳变,比直接翻存储层日志高效得多。
穿过拦截器之后,才是读的一致性策略问题。etcd 有两种读:
串行读(Serializable)不走 Raft,直接从本地状态机读,延迟低,代价是可能读到旧数据。原因是多节点状态下,各节点的状态机应用日志的速度不一样:Leader 收到写请求,先写 WAL 再广播,过半节点持久化成功后才算提交,然后各节点异步把已提交日志应用到状态机。如果某个 Follower 磁盘 I/O 抖动,应用得慢,此时从它上面串行读就可能读到上一次的值。
线性读(Linearizable)是默认行为,保证读到最新已提交的数据。实现上并不需要把读请求也写进 Raft 日志(那样代价太高),而是用 ReadIndex:收到读请求后,向 Leader 确认当前已提交的最大日志 index,等本地状态机应用到该 index 之后再返回结果。这样只用一次轻量的心跳往返,就换来了强一致的读。
这个差别直接对应到参数和现象上。默认线性读在跨机房、高延迟链路下会增加一次 RTT,如果业务能容忍短暂不一致(比如展示类配置),显式用串行读能明显降低 P99 延迟。但要注意,串行读在读多写少的场景下很容易掩盖数据不一致的问题:业务读到旧值,排查时会怀疑写请求失败了,实际是读走了串行路径。
4.2 写链路:Quota、WAL、Apply、MVCC 五个环节
写请求的处理顺序比读复杂,每一环都有独立的失败表现:
- Quota 模块做前置检查。etcd 有存储配额(默认 2GB,可通过
--quota-backend-bytes调整),写入前先判断后端数据库大小是否超限。超限后集群进入只读状态,所有写请求返回etcdserver: mvcc: database space exceeded,读请求仍然正常。 - KVServer 模块接收请求并做参数校验,包括 key 长度、value 大小、租约是否存在。
- 请求被封装成 Proposal 交给 Raft 层,Leader 把它写入 WAL 并广播给 Follower。
- 过半节点持久化成功后,该日志条目被标记为已提交。
- Apply 模块异步取出已提交的日志条目,应用到状态机,也就是写入 MVCC 和 boltdb。
这条链路上最慢的一环通常是第 3 步的 fsync。WAL 要求落盘才算持久化,如果磁盘 IOPS 不够或者和别的写密集服务共用一块盘,Leader 的写延迟会直接上去,表现为业务侧写请求超时。经验值是给 etcd 单独挂 SSD,并且不要和数据面服务混部。
第 5 步的 Apply 是异步的,这就解释了 4.1 里提到的状态机滞后问题。Apply 慢的时候,etcd_server_apply_duration_seconds这类指标会明显抬升,同时etcd_debugging_mvcc_db_total_size_in_bytes和已提交 index 的差距会拉大。看 Raft 层的已提交 index 和状态机的 applied index 之间的差值,是判断“是网络慢还是应用慢”的最快办法。
4.3 MVCC:treeIndex 加 boltdb 的双层结构
MVCC 是 etcd 3.x 相对 2.x 最大的变化,也是理解 revision 语义的基础。它的存储分成两层:内存里的 treeIndex 和后端 boltdb。
treeIndex 是一棵 B-tree,key 是用户定义的键名,value 是这个 key 从创建到现在的所有 revision 列表,最新版本排在最前面。它不存具体数据,只做“键名 → revision 列表”的索引。boltdb 里存的是真正的键值对,但它的 key 不是用户键名,而是 revision 编码后的字节串,value 则是包含 key 名、创建 revision、版本号等信息的结构体。
更新一个 key 的过程是这样的:先按 key 名在 treeIndex 里找到已有 revision 列表,把新的 revision 插到最前面;然后生成一条以新 revision 为主键的记录写进 boltdb;最后更新全局 revision 计数器。查询时反过来:按 key 名查 treeIndex 拿到最新的 revision,再用这个 revision 去 boltdb 里取具体数据。删除操作不真删数据,而是在 boltdb 里写入一条标记为删除的记录,treeIndex 对应 key 的 revision 列表清空,但历史数据仍然保留在 boltdb 里。
这就引出了 compaction。历史版本一直保留,数据库会持续膨胀,所以需要定期压缩,丢弃某个 revision 之前的全部历史。压缩之后,--rev读取落在压缩点之前的版本会失败,watch 从压缩点之前开始也会报错。常见做法是开启自动压缩,--auto-compaction-mode=periodic --auto-compaction-retention=1h表示保留一小时内的历史,对多数业务够用;如果业务依赖较长时间的历史回溯(比如审计类场景),就要把保留时间放长,并同步调大配额。
这里有一个血泪经验:压缩只是释放了逻辑空间,boltdb 的物理文件不会自动缩小,碎片整理(defrag)需要单独做,而且要逐个节点做,defrag 期间该节点会阻塞。手册里把 compaction 和 defrag 分开讲,就是因为这两件事经常被当成一件事,结果压完了发现磁盘还是满的。
4.4 从链路反推的几个配置点
把两条链路串起来,能反推出几个实际部署时该确认的配置:
| 观察点 | 对应环节 | 异常表现 | 建议动作 |
|---|---|---|---|
raft_term频繁变化 | Raft 选举 | Leader 反复切换,写请求抖动 | 检查节点间网络与磁盘延迟 |
| 已提交 index 与 applied index 差值大 | Apply 模块 | 读延迟高、串行读读到旧值 | 检查状态机写入瓶颈与 boltdb 锁竞争 |
| 后端 DB 大小逼近配额 | Quota 模块 | 写入报 database space exceeded | 开启自动压缩并做 defrag |
| 慢查询日志频繁出现 | 拦截器 | 请求耗时超阈值,带来源 IP | 从来源 IP 反查具体业务调用 |
拦截器里那条慢查询日志(3.5 版本提供)特别值得开。它会把超过阈值的请求连同来源 IP 一起打出来,业务侧超时投诉的时候,直接按 IP 就能定位到是哪个服务在打大量慢请求,比从服务端指标反推快得多。
5. 排查与避坑:五条把集群搞到不可用的操作
etcd 的故障有个共同特征:表征和根因离得很远。客户端报超时,根因可能是磁盘写满;某个节点起不来,根因可能是上一次启动的># 1. 先看集群成员和健康状态 etcdctl --endpoints=http://10.0.0.11:2379,http://10.0.0.12:2379,http://10.0.0.13:2379 \ endpoint health --cluster # 2. 看每个节点的角色、term、DB 大小、是否 learner etcdctl --endpoints=http://10.0.0.11:2379 endpoint status --cluster -w table # 3. 看告警列表,NOSPACE 之类会直接列出来 etcdctl --endpoints=http://10.0.0.11:2379 alarm list # 4. 看某个 key 的元数据,确认 revision 是否符合预期 etcdctl --endpoints=http://10.0.0.11:2379 get /svc/order/1 -w json
四步之内基本能定位到是 Raft 层、存储层还是配额层的问题。跳过前三步直接翻应用日志,是新手最常见的低效路径。
6. 进阶:用租约加 watch 拼一个能自证的服务注册小样
原理看完,最好有一份能跑起来、能证明自己确实理解了的代码。服务注册是最合适的练手对象,因为它同时用到租约、watch 和 MVCC 的 revision 语义,而且失败模式很直观——把进程 kill 掉,看注册信息几秒内消失。下面用 Go 的 clientv3 写一个最小实现:
package main import ( "context" "fmt" "log" "time" clientv3 "go.etcd.io/etcd/client/v3" ) func main() { // 多 endpoint 配置,单点故障时客户端自动切换 cli, err := clientv3.New(clientv3.Config{ Endpoints: []string{"http://10.0.0.11:2379", "http://10.0.0.12:2379"}, DialTimeout: 5 * time.Second, }) if err != nil { log.Fatal(err) } defer cli.Close() ctx := context.Background() // 申请 10 秒租约,TTL 太短会被网络抖动误判下线 lease, err := cli.Grant(ctx, 10) if err != nil { log.Fatal(err) } // 写入服务地址并绑定租约,key 用前缀隔离业务 key := "/svc/order/10.0.0.21:8080" if _, err = cli.Put(ctx, key, "alive", clientv3.WithLease(lease.ID)); err != nil { log.Fatal(err) } // keep-alive 放在独立协程,避免主业务阻塞导致续期中断 ch, err := cli.KeepAlive(ctx, lease.ID) if err != nil { log.Fatal(err) } go func() { for resp := range ch { if resp == nil { // 续期通道关闭说明租约已失效,应主动退出等待重建 log.Println("keep-alive closed, exiting") return } fmt.Println("ttl renewed, deadline:", resp.TTL) } }() // 另起协程监听整个前缀,从当前 revision 开始 go func() { watchCh := cli.Watch(ctx, "/svc/order/", clientv3.WithPrefix()) for resp := range watchCh { for _, ev := range resp.Events { fmt.Printf("event type=%s key=%s value=%s rev=%d\n", ev.Type, ev.Kv.Key, ev.Kv.Value, ev.Kv.ModRevision) } } }() // 模拟业务运行,进程退出后租约到期 key 自动消失 select {} }代码里有三处参数值得针对性调整。Grant的 10 秒是 TTL,决定了进程崩溃后多久被判定下线,实践里按业务对故障感知的敏感度取 10 到 30 秒;取太小会在网络抖动时误删注册信息,取太大则故障感知迟钝。WithLease保证 key 与租约绑定,这一步漏了的话 key 就变成永久存在,进程挂了也不会消失。Watch用WithPrefix监听整个服务目录,配合ModRevision就能实现增量同步;生产实现还要在此基础上补上断线重连和 revision 续接,也就是 3.4 里那套逻辑。
验证方法很简单:把程序跑起来,用etcdctl get --prefix /svc/order/能看到注册信息;kill掉进程,等 10 秒左右再查,key 已经消失;在另一个终端挂一个etcdctl watch --prefix /svc/order/,能看到 PUT 和 DELETE 两类事件依次出现。整条链路上没有任何自定义心跳协议,可靠性完全来自 etcd 的租约机制加上 Raft 的一致性保证。
有个习惯我后来一直保留:任何要接入 etcd 的服务,上线前先在这个小样上把“注册 → 续期 → 进程杀掉 → 事件感知”四个动作手动走一遍,确认 TTL 和重连逻辑符合预期再进真实环境。这一步通常只要十分钟,但能挡掉后面绝大多数“服务明明活着却被判下线”的玄学问题。希望帮到你。
本文还有配套的精品资源,点击获取