前面我们已经讲解了etcd的基本知识, 如果是新手可以先看https://blog.csdn.net/josnsense/article/details/155889106?spm=1001.2014.3001.5502这个文档,在来看本文档

一etcd权限管理
1.1用户管理
# 创建用户
2 etcdctl user add fox
3 # 删除用户
4 etcdctl user del fox
5 #修改密码
6 etcdctl user passwd fox
7 #查看所有用户
8 etcdctl user list
9 #查看指定用户及绑定角色
10 etcdctl user get fox
1.2 角色管理
1 #创建角色
2 etcdctl role add test
3 #给角色赋权
4 etcdctl grant‐permission role_name [read|write|readwrite] /name
5 #回收角色赋权
6 etcdctl role revoke‐permission role_name /name
7 #删除角色
8 etcdctl role del test
9 #查询角色列表
10 etcdctl role list
11 #查询指定角色的权限
12 etcdctl role get test
1 #用户绑定角色
2 etcdctl user grant‐role 用户名 角色名
3 #回收用户绑定权限
4 etcdctl user revoke‐role 用户名 角色名
5 # 开启权限
6 etcdctl user add root
7 #root用户存在时才能开启权限控制
8 etcdctl auth enable
9 # 查看用户列表
10 etcdctl user list ‐‐user=root
11 # 权限使用
12 etcdctl ‐‐user='用户名' ‐‐password='密码' get /name
1.3 集群权限管理
1.3.1 root用户
root用户
1 #root用户自带所有权限,因此只需创建该用户,开启认证即有所有权限
2 etcdctl ‐‐endpoints
http://127.0.0.1:12379,http://127.0.0.1:22379,http://127.0.0.1:32379 user a
dd root
3 # 开启身份验证
4 etcdctl ‐‐user='root' ‐‐password='123456' ‐‐endpoints http://127.0.0.1:12
379,http://127.0.0.1:22379,http://127.0.0.1:32379 auth enable
5 # 操作
6 etcdctl ‐‐user='root' ‐‐password='123456' put name fox
7 etcdctl ‐‐user='root' ‐‐password='123456' get nam
普通用户
1 # 创建普通用户
2 etcdctl ‐‐user='root' ‐‐password='123456' ‐‐endpoints http://127.0.0.1:12
379,http://127.0.0.1:22379,http://127.0.0.1:32379 user add fox
3 # 创建角色
4 etcdctl ‐‐user='root' ‐‐password='123456' ‐‐endpoints http://127.0.0.1:12
379,http://127.0.0.1:22379,http://127.0.0.1:32379 role add test
5 # 用户绑定角色
6 etcdctl ‐‐user='root' ‐‐password='123456' ‐‐endpoints http://127.0.0.1:12
379,http://127.0.0.1:22379,http://127.0.0.1:32379 user grant‐role fox test
7
8 # 给角色赋权
9 etcdctl ‐‐user='root' ‐‐password='123456' ‐‐endpoints http://127.0.0.1:12
379,http://127.0.0.1:22379,http://127.0.0.1:32379 role grant‐permission tes
t readwrite /name
二etcd核心原理分析
2.1 etcd 概念词汇表
Raft: etcd所采用的保证分布式系统强一致性的算法。
Node:一个Raft 状态机实 例。
Member :一个etcd实例。它管理着一个Node,并且可以为客户端请求提供服 务。
Cluster:由多个Member构成的,遵循Raft一致性协议的etcd集群。Peer:对同一 个etcd集群中另外一个 Member的叫法。
Client :凡是连接etcd 服务器请求服务的,譬如,获取key-value、写数据或 watch更新的程序,都统称为Client。Proposal :一个需要经过Raft一致性协议的请求,例如,写请求或配置更新请 求。
Quorum : Raft协议需要的、能够修改集群状态的、活跃的etcd集群成员数量称 为Quorum(法定人数)。通俗地讲,即etcd集群成员的半数以上。etcd使用仲裁机制, 若集群中存在几个节点,那么集群中有(n+1)/2个节点达成一致,则操作成功。建议的最优节点数量为3,5,7。大多数用户场景中,一个包含7个节点的集群是足够的。 更多的节点(比如9,11等)可以最大限度地保证数据安全,但是写性能会受影响,因 为需要向更多的集群写入数据。
WAL:预写式日志,etcd 用于持久化存储的日志格式。
Snapshot : etcd集群状态在某一时间点的快照(备份),etcd为防止WAL文件过 多而设置的快照,用于存储etcd 的数据状态。
Proxy: etcd的一种模式,为etcd集群提供反向代理服务。
Leader: Raft算法中通过竞选而产生的处理所有数据提交的节点。Follower :竞 选失败的节点作为Raft 中的从属节点,为算法提供强一致性保证。
Candidate :当Follower超过一定的时间还接收不到Leader 的心跳时转变为 Candidate开始竞选。
Term:某个节点从成为Leader到下一次竞选的时间,称为一个Term。
Index: WAL日志数据项编号。Raft中通过Term和 Index来定位数据。Key:用户定义的用于 存储和获取用户定义数据的标识符。
Key space:键空间,etcd集群内所有键的集合。
Revision: etcd集群范围内64位的计数器,键空间的每次修改都会导致该计数器 的增加。
Modification Revision:一个key最后一次修改的revision。
Lease :一个短时的(会过期),可续订的契约(租约),当它过期时,就会删除与 之关联的所有键。
Transition :事务,一个自动执行的操作集,要么一块成功,要么-一块失败。
Watcher :观察者,etcd最具特色的概念之一。客户端通过打开一个观察者来获 取一个给定键范围的更新。
Key Range :键范围,一个键的集合,这个集合既可以是只有一个 key或者是在 一个字典区间,例如(a, b],或者是大于某个key的所有key.
Endpoint:指向etcd服务或资源的URL。
Compaction : etcd的压缩(Compaction)操作,丢弃所有etcd的历史数据并且 取代一个给revision之前的所有key。压缩操作通常用于重新声明etcd后端数据库 的存储空间。其与Raft 的日志压缩是一个原理。key version :键版本,即一个键从创建开始的写(修改)次数,从1开始。一个不 存在或已删除的键版本是0。其与revision的概念不同。
2.2 etcd读请求执行流程
etcd是典型的读多写少存储,在我们实际业务场景中,读一般占据2/3以上的请求。一个读
请求从client通过Round-robin负载均衡算法,选择一个etcd server节点,发出 gRPC请
求,经过etcd server的 KVServer模块、线性读模块、MVCC的treelndex和 boltdb模块紧
密协作,完成了一个读请求。

思考:通过etcdctl执行如下命令etcd是如何工作的?
1 etcdctl get hello ‐‐ endpoints 192.168.65.210 : 2379 , 192.168.65.211 : 2379 , 19
2.168.65.212 : 2379
3.2.1 Client
3.2.1.1 首先,etcdctl 会对命令中的参数进行解析。
"get"是请求的方法,它是 KVServer 模块的 API;
"hello"是我们查询的 key 名;
"endpoints"是我们后端的 etcd 地址。通常,生产环境下中需要配置多个
endpoints,这样在 etcd 节点出现故障后,client 就可以自动重连到其它正常的节 点,从而保证请求的正常执行。
3.2.1.2在解析完请求中的参数后,etcdctl 会创建一个 clientv3 库对象,使用 KVServer 模块
的 API 来访问 etcd server。
etcd clientv3 库采用的负载均衡算法为 Round-robin。针对每一个请求,Round-robin
算法通过轮询的方式依次从 endpoint 列表中选择一个 endpoint 访问 (长连接),使 etcd
server 负载尽量均衡。
3.2.1.3 KVServer 与 拦截器
client 发送 Range RPC 请求到了 server 后就进入了 KVServer 模块。
etcd 通过拦截器以非侵入式的方式实现了许多特性,例如:丰富的 metrics、日志、请求
行为检查、所有请求的执行耗时及错误码、来源 IP 等。拦截器提供了在执行一个请求前后
的 hook 能力,除了 debug 日志、metrics 统计、对 etcd Learner 节点请求接口和参数限
制等能力,etcd 还基于它实现了以下特性:
要求执行一个操作前集群必须有 Leader;
请求延时超过指定阈值的,打印包含来源 IP 的慢查询日志 (3.5 版本)。
server 收到 client 的 Range RPC 请求后,根据 ServiceName 和 RPC Method 将请求转
发到对应的 handler 实现,handler 首先会将上面描述的一系列拦截器串联成一个拦截器
再执行,在拦截器逻辑中,通过调用 KVServer 模块的 Range 接口获取数据。
3.2.1.4 串行读与线性读
etcd 为了保证服务高可用,生产环境一般部署多个节点,多节点之间的数据由于延迟等关
系可能会存在不一致的情况。
当 client 发起一个写请求后分为以下几个步骤:
1)Leader 收到写请求,它会将此请求持久化到 WAL 日志,并广播给各个节点;
只有 Leader 节点能处理写请求。
2)若一半以上节点持久化成功,则该请求对应的日志条目被标识为已提交;
3)etcdserver 模块异步从 Raft 模块获取已提交的日志条目,应用到状态机 (boltdb 等)。 此时若client 发起一个读取 hello的请求,假设此请求直接从状态机中读取,如果连接到的
是C节点,若C节点磁盘I/O出现波动,可能导致它应用已提交的日志条目很慢,则会出现更
新hello为world的写命令,在client读 hello 的时候还未被提交到状态机,因此就可能读取
到旧数据
所以 在多节点etcd集群中,各个节点的状态机数据一致性存在差异。 而我们不同业务场景
的读请求对数据是否最新的容忍度是不一样的,有的场景它可以容忍数据落后几秒甚至几分
钟,有的场景要求必须读到反映集群共识的最新数据。根据业务场景对数据一致性差异的接
受程度。
etcd 中有两种读模式
1)串行 (Serializable) 读:直接读状态机数据返回、无需通过 Raft 协议与集群进行交互,
它具有低延时、高吞吐量的特点,适合对数据一致性要求不高的场景。
2)线性读: etcd默认读模式是线性读, 需要经过 Raft 协议模块,反应的是集群共识,因
此在延时和吞吐量上相比串行读略差一点,适用于对数据一致性要求高的场景。
3.2.1.5 ReadIndex
在 etcd 3.1 时引入了 ReadIndex 机制,保证在串行读的时候,也能读到最新的
数据。 具体流程如下:

当收到一个线性读请求时,它首先会从Leader获取集群最新的已提交的日志索 引(committed index),如上图中的流程二所示。
Leader收到ReadIndex请求时,为防止脑裂等异常场景,会向Follower节点发
送心跳确认,一半以上节点确认Leader身份后才能将已提交的索引(committed
index)返回给节点C(上图中的流程三)。
节点则会等待,直到状态机已应用索引 (applied index)大于等于Leader的已提
交索引时(committed Index)(上图中的流程四),然后去通知读请求,数据已赶上
Leader,你可以去状态机中访问数据了(上图中的流程五)。
以上就是线性读通过ReadIndex机制保证数据一致性原理 ,当然还有其它机制也能实现线性
读,如在早期etcd 3.0中读请求通过走一遍Raft 协议保证一致性,这种Raft log read机制
依赖磁盘IO,性能相比 ReadIndex较差。
总体而言,KVServer模块收到线性读请求后,通过架构图中流程三向Raft模块发起
ReadIndex请求,Raft模块将Leader最新的已提交日志索引封装在流程四的ReadState结构
体,通过channel层层返回给线性读模块,线性读模块等待本节点状态机追赶上Leader进
度,追赶完成后,就通知KVServer模块,进行架构图中流程五,与状态机中的 MVCC模块
进行进行交互了。
3.2.1.6MVCC
流程五中的多版本并发控制(Multiversion concurrency control)模块是为了解决etcd v2不
支持保存key的历史版本、不支持多key事务等问题而产生的。它核心 由内存树形索引模块
(treelndex)和嵌入式的KV持久化存储库 boltdb 组成。 boltdb是个基于B+ tree实现的
key-value键值库,支持事务,提供Get/Put等简易API给etcd操作。
etcd MVCC 具体方案如下:
每次修改操作,生成一个新的版本号 (revision),以版本号为 key, value 为用
户 key-value 等信息组成的结构体存储到 blotdb。
读取时先从 treeIndex 中获取 key 的版本号,再以版本号作为 boltdb 的 key,
从 boltdb 中获取其 value 信息。
3.2.1.7treelndex
treelndex模块是基于Google开源的内存版btree 库实现的,treeIndex模块只会保存用户
的 key和相关版本号信息,用户 key 的value数据存储在boltdb里面,相比ZooKeeper和
etcd v2全内存存储,etcd v3对内存要求更低。
简单介绍了etcd如何保存 key的历史版本后,架构图中流程六也就非常容易理解了,它需要
从treelndex模块中获取 hello这个 key对应的版本号信息。treeIndex模块基于 B-tree快速
查找此 key,返回此 key对应的索引项keyIndex即可。索引项中包含版本号等信息。
3.2.1.8buffer
在获取到版本号信息后,就可从boltdb模块中获取用户的key-value数据了。 不过并不是所
有请求都---定要从 boltdb 获取数据。etcd出于数据一致性、性能等考虑,在访问boltdb 前,首先会从一个内存读事务 buffer中,二分查找你要访问key是否在 buffer里面,若命中
则直接返回。
3.2.1.9boltdb
若buffer未命中,此时就真正需要向boltdb模块查询数据了 ,进入了流程七。
我们知道MySQL通过 table 实现不同数据逻辑隔离,那么在boltdb是如何隔离集群元数据
与用户数据的呢?答案是bucket。boltdb里每个 bucket类似对应MySQL 一个表,用户的
key数据存放的 bucket名字的是 key,etcd MVCC元数据存放的 bucket是 meta。
因boltdb使用B+ tree来组织用户的key-value数据,获取 bucket key对象后,通过boltdb
的游标Cursor可快速在B+ tree找到 key hello对应的value数据,返回给client。
到这里,一个读请求之路执行完成。
3.3 etcd写请求执行流程
etcd 一个写请求执行流程又是怎样的呢?
1 etcdctl put hello world ‐‐endpoints 192.168.65.210:2379

执行流程:
1)首先 client 端通过负载均衡算法选择一个 etcd 节点,发起 gRPC 调用;
2)然后 etcd 节点收到请求后经过 gRPC 拦截器、Quota 模块后,进入
KVServer 模块; 3)KVServer 模块向 Raft 模块提交一个提案,提案内容为"大家好,请使用
put 方法执行一个 key 为 hello,value 为 world 的命令"。
4)随后此提案通过 RaftHTTP 网络模块转发、经过集群多数节点持久化后,状
态会变成已提交;
5)etcdserver 从 Raft 模块获取已提交的日志条目,传递给 Apply 模块
6)Apply 模块通过 MVCC 模块执行提案内容,更新状态机。
与读流程不一样的是写流程还涉及 Quota、WAL、Apply 三个模块。etcd 的 crash-safe
及幂等性也正是基于 WAL 和 Apply 流程的 consistent index 等实现的。
3.3.1 Quota 模块
Quota 模块 主要用于检查下当前 etcd db 大小加上你请求的 key-value 大小之和是否超过
了配额 (quota-backend-bytes)。
如果超过了配额,它会产生一个告警(Alarm)请求,告警类型是 NO SPACE,并通过
Raft 日志同步给其它节点,告知 db 无空间了,并将告警持久化存储到 db 中。最终, 无论
是 API 层 gRPC 模块还是负责将 Raft 侧已提交的日志条目应用到状态机的 Apply 模块,
都拒绝写入,集群只读。
常见的 "etcdserver: mvcc: database space exceeded" 错误就是因为Quota 模块检测到
db 大小超限导致的。
哪些情况会触发这个错误:
一方面默认 db 配额仅为 2G ,当你的业务数据、写入 QPS、Kubernetes 集群
规模增大后,你的 etcd db 大小就可能会超过 2G。
另一方面 etcd 是个 MVCC 数据库,保存了 key 的历史版本,当你未配置压缩
策略的时候,随着数据不断写入,db 大小会不断增大,导致超限。
解决办法:
1)首先当然是调大配额,etcd 社区建议不超过 8G。
如果填的是个小于 0 的数,就会禁用配额功能,这可能会让
db 大小处于失控,导致性能下降,不建议你禁用配额。
2)检查 etcd 的压缩(compact)配置是否开启、配置是否合理。
压缩时只会给旧版本Key打上空闲(Free)标记,后续新的数
据写入的时候可复用这块空间,db大小并不会减小。
如果需要回收空间,减少 db 大小,得使用碎片整理
(defrag), 它会遍历旧的 db 文件数据,写入到一个新的 db 文
件。但是它对服务性能有较大影响,不建议你在生产集群频繁使
用。 调整后还需要手动发送一个取消告警(etcdctl alarm disarm)的命令,以消除所有告警,
否则因为告警的存在,集群还是无法写入。
3.3.2 KVServer 模块
通过流程二的配额检查后,请求就从 API 层转发到了流程三的 KVServer 模块的 put 方
法。
KVServer 模块主要功能为
1)打包提案:将 put 写请求内容打包成一个提案消息,提交给 Raft 模块
2)请求限速、检查:不过在提交提案前,还有限速、鉴权和大包检查。
3.3.3Preflight Check
为了保证集群稳定性,避免雪崩,任何提交到 Raft 模块的请求,都会做一些简单的限速判
断。
3.3.3.1限速
如果 Raft 模块已提交的日志索引(committed index)比已应用到状态机的日志索引
(applied index)超过了 5000,那么它就 返回一个"etcdserver: too many requests"错
误给 client。
3.3.3.2鉴权
然后它会尝试去获取请求中的鉴权信息,若使用了密码鉴权、请求中携带了 token, 如果
token 无效,则返回"auth: invalid auth token"错误给 client。
3.3.3.3大包检查
其次它会检查你写入的包大小是否超过默认的 1.5MB, 如果超过了会返回"etcdserver:
request is too large"错误给给 client。

3.3.4 Propose
通过检查后会生成一个唯一的 ID,将此请求关联到一个对应的消息通知 channel(用于接
收结果),然后向 Raft 模块发起(Propose)一个提案(Proposal)
向 Raft 模块发起提案后,KVServer 模块会等待此 put 请求,等待写入结果通过消息通知
channel 返回或者超时。 etcd 默认超时时间是 7 秒 (5 秒磁盘 IO 延时 +2*1 秒竞选超时
时间), 如果一个请求超时未返回结果,则可能会出现你熟悉的 etcdserver: request
timed out 错误。
3.3.5WAL 模块
Raft 模块收到提案后,如果当前节点是 Follower,它会转发给 Leader, 只有 Leader 才能
处理写请求。
Leader 收到提案后,通过 Raft 模块输出待转发给 Follower 节点的消息和待持久化的日志
条目,日志条目则封装了我们上面所说的 put hello 提案内容。
etcdserver 从 Raft 模块获取到以上消息和日志条目后,作为 Leader,它会将 put 提案消
息广播给集群各个节点,同时需要把集群 Leader 任期号、投票信息、已提交索引、提案内
容持久化到一个 WAL(Write Ahead Log)日志文件中,用于保证集群的一致性、可恢复
性,也就是我们图中的流程五模块。
3.3.6WAL 日志结构
WAL 日志结构如下:

WAL 文件它由多种类型的 WAL 记录顺序追加写入组成,每个记录由类型、数据、循环冗
余校验码组成。不同类型的记录通过 Type 字段区分,Data 为对应记录内容,CRC 为循环
校验码信息。
WAL 记录类型目前支持 5 种,分别是文件元数据记录、日志条目记录、状态信息记录、
CRC 记录、快照记录
1)文件元数据记录,包含节点 ID、集群 ID 信息,它在 WAL 文件创建的时候 写入;
2)日志条目记录:包含 Raft 日志信息,如 put 提案内容;
3)状态信息记录,包含集群的任期号、节点投票信息等,一个日志文件中会有
多条,以最后的记录为准;
4)CRC 记录,包含上一个 WAL 文件的最后的 CRC(循环冗余校验码)信息,
在创建、切割 WAL 文件时,作为第一条记录写入到新的 WAL 文件, 用于校验数据
文件的完整性、准确性等;
5)快照记录,包含快照的任期号、日志索引信息,用于检查快照文件的准确
性。
3.3.7 WAL 持久化
首先会将 put 请求封装成一个 Raft 日志条目,Raft 日志条目的数据结构信息如下:
1 type Entry struct {
2 Term uint64 `protobuf:"varint,2,opt,name=Term" json:"Term"`
3 Index uint64 `protobuf:"varint,3,opt,name=Index" json:"Index"`
4 Type EntryType `protobuf:"varint,1,opt,name=Type,enum=Raftpb.EntryTyp
e" json:"Type"`
5 Data []byte `protobuf:"bytes,4,opt,name=Data" json:"Data,omitempty"`
6 }
7
它由以下字段组成:
Term 是 Leader 任期号,随着 Leader 选举增加;
Index 是日志条目的索引,单调递增增加;
Type 是日志类型,比如是普通的命令日志(EntryNormal)还是集群配置变更
日志(EntryConfChange);
Data 保存我们上面描述的 put 提案内容。
具体持久化过程如下:
1)它首先先将 Raft 日志条目内容(含任期号、索引、提案内容)序列化后保存
到 WAL 记录的 Data 字段, 然后计算 Data 的 CRC 值,设置 Type 为 Entry
Type, 以上信息就组成了一个完整的 WAL 记录。
2)最后计算 WAL 记录的长度,顺序先写入 WAL 长度(Len Field),然后写
入记录内容,调用 fsync 持久化到磁盘,完成将日志条目保存到持久化存储中。
3)当一半以上节点持久化此日志条目后, Raft 模块就会通过 channel 告知
etcdserver 模块,put 提案已经被集群多数节点确认,提案状态为已提交,你可以执
行此提案内容了。
4)于是进入流程六,etcdserver 模块从 channel 取出提案内容,添加到先进 先出(FIFO)调度队列,随后通过 Apply 模块按入队顺序,异步、依次执行提案内 容。
3.3.8 Apply 模块
Apply 模块主要用于执行处于 已提交状态的提案,将其更新到状态机。
Apply 模块在执行提案内容前,首先会判断当前提案是否已经执行过了,如果执行了则直
接返回,若未执行同时无 db 配额满告警,则进入到 MVCC 模块,开始与持久化存储模块
打交道。
如果执行过程中 crash,重启后如何找回异常提案,再次执行的呢?
主要依赖 WAL 日志,因为提交给 Apply 模块执行的提案已获得多数节点确认、持久化,
etcd 重启时,会从 WAL 中解析出 Raft 日志条目内容,追加到 Raft 日志的存储中,并重
放已提交的日志提案给 Apply 模块执行。

重启恢复时,如何确保幂等性,防止提案重复执行导致数据混乱呢?
etcd 通过引入一个 consistent index 的字段,来存储系统当前已经执行过的日志条目索
引,实现幂等性。
因为 Raft 日志条目中的索引(index)字段,而且是全局单调递增的,每个日志条目索引
对应一个提案。 如果一个命令执行后,我们在 db 里面也记录下当前已经执行过的日志条
目索引,就可以解决幂等性问题了。当然还需要将执行命令和记录index这两个操作作为原
子性事务提交,才能实现幂等。
3.3.9 MVCC 模块
MVCC 主要由两部分组成,一个是内存索引模块 treeIndex,保存 key 的历史版本号信
息,另一个是 boltdb 模块,用来持久化存储 key-value 数据。
MVCC 模块执行 put hello 为 world 命令时,它是如何构建内存索引和保存哪些数据到 db
呢?
3.3.10treeIndex
MVCC 写事务在执行 put hello 为 world 的请求时,会基于 currentRevision 自增生成新
的 revision 如{2,0},然后从 treeIndex 模块中查询 key 的创建版本号、修改次数信息。这
些信息将填充到 boltdb 的 value 中,同时将用户的 hello key 和 revision 等信息存储到
B-tree,也就是下面简易写事务图的流程一,整体架构图中的流程八。

3.3.11boltdb
MVCC 写事务自增全局版本号后生成的 revision{2,0},它就是 boltdb 的 key,通过它就
可以往 boltdb 写数据了,进入了整体架构图中的流程九。
那么写入 boltdb 的 value 含有哪些信息呢?
写入 boltdb 的 value, 并不是简单的"world",如果只存一个用户 value,索引又是保存
在易失的内存上,那重启 etcd 后,我们就丢失了用户的 key 名,无法构建 treeIndex 模块
了。
因此为了构建索引和支持 Lease 等特性,etcd 会持久化以下信息:
key 名称;
key 创建时的版本号(create_revision)、最后一次修改时的版本号
(mod_revision)、key 自身修改的次数(version);
value 值;
租约信息。
boltdb value 的值就是将含以上信息的结构体序列化成的二进制数据,然后通过 boltdb
提供的 put 接口,etcd 就快速完成了将你的数据写入 boltdb。
注意:在以上流程中,etcd 并未提交事务(commit),因此数据只更新在 boltdb 所管理
的内存数据结构中。
事务提交的过程,包含 B+tree 的平衡、分裂,将 boltdb 的脏数据(dirty page)、元数
据信息刷新到磁盘,因此事务提交的开销是昂贵的。如果我们每次更新都提交事务,etcd 写性能就会较差。
etcd 的解决方案是合并再合并:
首先 boltdb key 是版本号,put/delete 操作时,都会基于当前版本号递增生成新的版本
号,因此属于顺序写入,可以调整 boltdb 的 bucket.FillPercent 参数,使每个 page 填充
更多数据,减少 page 的分裂次数并降低 db 空间。
其次 etcd 通过合并多个写事务请求,通常情况下,是异步机制定时(默认每隔 100ms)
将批量事务一次性提交(pending 事务过多才会触发同步提交), 从而大大提高吞吐量
但是这优化又引发了另外的一个问题, 因为事务未提交,读请求可能无法从 boltdb 获取
到最新数据。
为了解决这个问题,etcd 引入了一个 bucket buffer 来保存暂未提交的事务数据。在更新
boltdb 的时候,etcd 也会同步数据到 bucket buffer。 因此 etcd 处理读请求的时候会优
先从 bucket buffer 里面读取,其次再从 boltdb 读,通过 bucket buffer 实现读写性能提
升,同时保证数据一致性。
3.4 Raft算法
思考: etcd是如何基于Raft来实现高可用、数据强---致性的?
3.4.1 什么是Raft算法
Raft 算法是现在分布式系统开发首选的共识算法。 从本质上说,Raft 算法是通过一切以领
导者为准的方式,实现一系列值的共识和各节点日志的一致。领导者就是 Raft 算法中的霸
道总裁,Raft 算法是强领导者模型,集群中只能有一个"霸道总裁"。
https://raft.github.io/
Raft is a consensus algorithm for managing a replicated log. It produces a result
equivalent to (multi-)Paxos, and it is as efficient as Paxos, but its structure is different from
Paxos;
3.4.2 为什么需要 Raft
回答该问题之前可以思考一下另一个问题: 为什么需要共识算法?
为了解决单点问题,软件系统工程师引入了数据复制技术,实现多副本。而多副本间的数据
复制就会出现一致性问题。所以需要共识算法来解决该问题。
共识算法的祖师爷是 Paxos, 但是由于它过于复杂,难于理解,工程实践上也较难落地,
导致在工程界落地较慢。 Raft 算法正是为了可理解性、易实现而诞生的。它通过问题分
解,将复杂的共识问题拆分成三个子问题,分别是:
Leader选举,Leader故障后集群能快速选出新Leader;
日志复制,集群只有Leader能写入日志,Leader负责复制日志到Follower节点, 并强制Follower节点与自己保持相同;
安全性,一个任期内集群只能产生一个Leader、已提交的日志条目在发生 Leader选举时,一定会存在更高任期的新Leader日志中、各个节点的状态机应用的 任意位置的日志条目内容应---样等。
3.4.3 Leader 选举
当etcd server收到client 发起的put hello写请求后,KV模块会向Raft模块提交一个put提
案,我们知道只有集群Leader才能处理写提案,如果此时集群中无Leader,整个请求就会
超时。
3.4.4 节点状态
首先在 Raft 协议中它定义了集群中的如下节点状态,任何时刻,每个节点肯定处于其中一
个状态:
Follower,跟随者, 同步从 Leader 收到的日志,etcd 启动的时候默认为此状
态;
Candidate,竞选者,可以发起 Leader 选举;
Leader,集群领导者, 唯一性,拥有同步日志的特权,需定时广播心跳给
Follower 节点,以维持领导者身份。
3.4.5term
Raft 协议将时间划分成一个个任期(Term),任期用连续的整数表示,每个任期从一次选
举开始,赢得选举的节点在该任期内充当 Leader 的职责,随着时间的消逝,集群可能会发
生新的选举,任期号也会单调递增。
通过任期号,可以比较各个节点的数据新旧、识别过期的 Leader 等,它在 Raft 算法中充
当逻辑时钟,发挥着重要作用。
3.4.6 选举流程
当 Follower 节点接收 Leader 节点心跳消息超时后,它会转变成 Candidate 节点,并可发
起竞选 Leader 投票,若获得集群多数节点的支持后,它就可转变成 Leader 节点。 etcd 默认心跳间隔时间(heartbeat-interval)是 100ms, 默认竞选超时时间(election
timeout)是 1000ms,
注意:你需要根据实际部署环境、业务场景适当调优,否则就很可能会频繁发生 Leader 选举切
换,导致服务稳定性下降。
我们以Leader crash场景为案例,详细介绍一下etcd Leader选举原理。
假设集群总共3个节点,A节点为Leader,B、C节点为Follower。

如上Leader选举图左边部分所示,
正常情况下,Leader节点会按照心跳间隔时间,定时广
播心跳消息(MsgHeartbeat消息)给Follower节点,以维持Leader身份。Follower收到后
回复心跳应答包消息(MsgHeartbeatResp 消息)给Leader。
当 Leader 节点异常后,Follower 节点会接收 Leader 的心跳消息超时,当超时时间大于竞
选超时时间后,它们会进入 Candidate 状态。
进入 Candidate 状态的节点,会立即发起选举流程,自增任期号,投票给自己,并向其他
节点发送竞选 Leader 投票消息(MsgVote)。
C 节点收到 Follower B 节点竞选 Leader 消息后,这时候可能会出现如下两种情况:
1) C 节点判断 B 节点的数据至少和自己一样新、B 节点任期号大于 C 当前任
期号、并且 C 未投票给其他候选者,就可投票给 B。这时 B 节点获得了集群多数节
点支持,于是成为了新的 Leader。
2)恰好 C 也心跳超时超过竞选时间了,它也发起了选举,并投票给了自己,那
么它将拒绝投票给 B,这时谁也无法获取集群多数派支持,只能等待竞选超时,开启
新一轮选举。 Raft 为了优化选票被瓜分导致选举失败的问题,引入了随机数,每个
节点等待发起选举的时间点不一致, 优雅的解决了潜在的竞选活锁,同时易于理解。
如果现有 Leader 发现了新的 Leader 任期号,那么它就需要转换到 Follower 节点。A 节
点 crash 后,再次启动成为 Follower,假设因为网络问题无法连通 B、C 节点,这时候根
据状态图,我们知道它将不停自增任期号,发起选举。等 A 节点网络异常恢复后,那么现
有 Leader 收到了新的任期号,就会触发新一轮 Leader 选举,影响服务的可用性。 那如何避免以上场景中的无效的选举呢?
在 etcd 3.4 中,etcd 引入了一个 PreVote 参数(默认 false),可以用来启用
PreCandidate 状态解决此问题。Follower 在转换成 Candidate 状态前,先进入
PreCandidate 状态,不自增任期号, 发起预投票。若获得集群多数节点认可,确定有概率
成为 Leader 才能进入 Candidate 状态,发起选举流程。
因 A 节点数据落后较多,预投票请求无法获得多数节点认可,因此它就不会进入
Candidate 状态,导致集群重新选举。
这就是 Raft Leader 选举核心原理,使用心跳机制维持 Leader 身份、触发 Leader 选举,
etcd 基于它实现了高可用,只要集群一半以上节点存活、可相互通信,Leader 宕机后,就
能快速选举出新的 Leader,继续对外提供服务。
3.4.7日志复制
具体流程如下图所示:

Leader 收到写请求后,生成一个提案并提交给 Raft 模块
Leader 的Raft 模块为此提案生成一个日志条目,并追加到 Raft 日志中,此处
有 WAL持久化。
Leader 将新的日志发送给 Follower,Leader 会维护两个核心字段来追踪各个
Follower 的进度信息,一个字段是 NextIndex, 它表示 Leader 发送给 Follower
节点的下一个日志条目索引。一个字段是 MatchIndex, 它表示 Follower 节点已复
制的最大日志条目的索引。
Follower 收到日志后先进行安全检测,通过检测后将该日志写入自己的 Raft 日
志中,并回复 Leader 当前已复制的日志最大索引。此处也有WAL持久化。
最后 Leader 根据 Follower 的 MatchIndex 信息,找出已经被半数以上的节点
同步的位置,这个位置之前的所有日志条目都可以提交了。
Leader 通过消息告诉 Follower 那些日志条目可以执行提交了
Follower 根据 Leader 的信息从Raft模块中取出对应日志条目内容,并应用到状
态机中。
通过以上流程, Leader 就完成了同步日志条目给 Follower 的任务,一个日志条目被确定
为已提交的前提是,它需要被 Leader 同步到一半以上节点上。 以上就是 etcd Raft 日志复
制的核心原理。
3.4.8 raft 日志
下图是 Raft 日志复制过程中的日志细节图:

在日志图中,最上方的是日志条目序号/索引,日志由有序号标识的一个个条目组成,每个
日志条目内容保存了Leader任期号和提案内容。最开始的时候,A节点是 Leader,任期号
为1,A节点crash 后,B节点通过选举成为新的Leader,任期号为2。
Leader 是如何知道从哪个索引位置发送日志条目给 Follower,以及 Follower 已复制的日
志最大索引是多少呢?
Leader 会维护两个核心字段来追踪各个 Follower 的进度信息,一个字段是 NextIndex,
它表示 Leader 发送给 Follower 节点的下一个日志条目索引。一个字段是 MatchIndex,
它表示 Follower 节点已复制的最大日志条目的索引,比如上面的日志图 1 中 C 节点的已
复制最大日志条目索引为 5,A 节点为 4。
3.4.9安全性
假设当前raft日志条目如下图所示:

Leader B 在应用日志指令 put hello 为 world 到状态机,并返回给 client 成功后,突然
crash 了,那么 Follower A 和 C 是否都有资格选举成为 Leader 呢?
从日志图 2 中我们可以看到,如果 A 成为了 Leader 那么就会导致数据丢失,因为它并未
含有刚刚 client 已经写入成功的 put hello 为 world 指令。
Raft 算法如何确保面对这类问题时不丢数据和各节点数据一致性呢?
Raft 通过给选举和日志复制增加一系列规则,来实现 Raft 算法的安全性。
3.4.10选举规则
当节点收到选举投票的时候,需检查候选者的最后一条日志中的任期号:
若小于自己则拒绝投票。
如果任期号相同,日志却比自己短,也拒绝为其投票。
这样能保证投票的节点数据至少比当前节点数据新。
3.4.11日志复制规则
在日志图 2 中,Leader B 返回给 client 成功后若突然 crash 了,此时可能还并未将 6 号
日志条目已提交的消息通知到 Follower A 和 C,那么如何确保 6 号日志条目不被新
Leader 删除呢? 同时在 etcd 集群运行过程中,Leader 节点若频繁发生 crash 后,可能
会导致 Follower 节点与 Leader 节点日志条目冲突,如何保证各个节点的同 Raft 日志位置
含有同样的日志条目?
以上各类异常场景的安全性是通过 Raft 算法中的 Leader 完全特性和只附加原则、日志匹
配等安全机制来保证的。
Leader 完全特性: 是指如果某个日志条目在某个任期号中已经被提交,那么这
个条目必然出现在更大任期号的所有 Leader 中。
只附加原则: Leader 只能追加日志条目,不能删除已持久化的日志条目。 因此 Follower C 成为新 Leader 后,会将前任的 6 号日志条目复制到 A 节点。
日志匹配特性:Leader 在发送追加日志 RPC 消息时,会把新的日志条目紧接着 之前的条目的索引位置和任期号包含在里面。Follower 节点会检查相同索引位置的 任期号是否与 Leader 一致,一致才能追加。
它本质上是一种归纳法,一开始日志空满足匹配特性,随后每 增加一个日志条目时,都要求上一个日志条目信息与 Leader 一 致,那么最终整个日志集肯定是一致的。
通过以上的 Leader 选举限制、Leader 完全特性、只附加原则、日志匹配等安全特性,
Raft 就实现了一个可严格通过数学反证法、归纳法证明的高可用、一致性算法,为 etcd 的
安全性保驾护航
3.5 MVCC原理
思考:etcd是如何实现MVCC多版本控制的?
下图是 MVCC 模块的一个整体架构图,整个 MVCC 特性由 treeIndex、Backend/boltdb
组成。
当你执行 put 命令后,请求经过 gRPC KV Server、Raft 模块流转,对应的日志条目被提
交后,Apply 模块开始执行此日志内容。

Apply 模块通过 MVCC 模块来执行 put 请求,持久化 key-value 数据。
MVCC 模块将请求请划分成两个类别,分别是读事务(ReadTxn)和写事务 (WriteTxn)。读事务负责处理 range 请求,写事务负责 put/delete 操作。读写 事务基于 treeIndex、Backend/boltdb 提供的能力,实现对 key-value 的增删改查 功能。
treeIndex 模块基于内存版 B-tree 实现了 key 索引管理,它保存了用户 key 与
版本号(revision)的映射关系等信息。
Backend 模块负责 etcd 的 key-value 持久化存储,主要由 ReadTx、 BatchTx、Buffer 组成,ReadTx 定义了抽象的读事务接口,BatchTx 在 ReadTx 之 上定义了抽象的写事务接口,Buffer 是数据缓存区。
etcd 设计上支持多种 Backend 实现,目前实现的 Backend 是 boltdb。boltdb 是一个基于 B+
tree 实现的、支持事务的 key-value 嵌入式数据库。
3.5.1treeIndex 原理
对于 etcd v2 来说,当你通过 etcdctl 发起一个 put hello 操作时,etcd v2 直接更新内存
树,这就导致历史版本直接被覆盖,无法支持保存 key 的历史版本。
在 etcd v3 中引入 treeIndex 模块正是为了解决这个问题,支持保存 key 的历史版本,提
供稳定的 Watch 机制和事务隔离等能力。
etcd 在每次修改 key 时会生成一个全局递增的版本号(revision)。
然后通过数据结构 B-tree 保存用户 key 与版本号之间的关系;
再以版本号作为 boltdb key,以用户的 key-value 等信息作为 boltdb value, 保存到 boltdb。
根据上述存储逻辑可知,boltdb 中只能通过reversion来查询数据,但是客户端都是通过 key 来查
询的。所以 etcd 在内存中使用 treeIndex 模块 维护了一个kvindex,保存的就是 key-reversion 之
间的映射关系,用来加速查询。 当你发起一个get hello命令时,从treelndex中获取 key 的版本号,然后再通过这个版本
号,从 boltdb获取value信息。boltdb的value是包含用户key-value、各种版本号、lease
信息的结构体。

在treelndex中,每个节点的 key是一个keyIndex结构,etcd就是通过它保存了用户的key
与版本号的映射关系。
1 type keyIndex struct {
2 key []byte //key名称
3 modified revision //最后一次修改key时的etcd版本号
4 generations []generation //generation保存了一个key若干代版本号信息
5 }
keyIndex中包含用户的 key、最后一次修改key时的 etcd版本号、key的若干代
(generation)版本号信息,每代中包含对 key的多次修改的版本号列表。generations表示
一个 key从创建到删除的过程,每代对应 key 的一个生命周期的开始与结束。当你第一次
创建一个key时,会生成第0代,后续的修改操作都是在往第0代中追加修改版本号。当你把
key 删除后,它就会生成新的第1代,一个key不断经历创建、删除的过程,它就会生成多
个代。
1 type generation struct {
2 ver int64 //表示此key的修改次数
3 created revision //表示generation结构创建时的版本号
4 revs []revision //每次修改key时的revision追加到此数组
5 }
6
7
generation结构中包含此 key的修改次数、generation创建时的版本号、对此 key 的修改
版本号记录列表。
1 type revision struct {
2 main int64 // 一个全局递增的主版本号3 sub int64 // 一个事务内的子版本号,从0开始随事务内put/delete操作递增
4 }
revision包含 main和sub两个字段,main是全局递增的版本号,它是个etcd逻辑时钟随着
put/txn/delete 等事务递增。sub是一个事务内的子版本号,从0开始随事务内的
put/delete操作递增。
3.5.2MVCC更新key原理

3.5.1.1 第一步:查询 keyIndex
首先它需要从 treeIndex 模块中查询 key 的 keyIndex 索引信息。keyIndex 中存储了 key
的创建版本号、修改的次数等信息,这些信息在事务中发挥着重要作用,因此会存储在
boltdb 的 value 中。
3.5.1.2第二步:写入 boltdb
其次 etcd 会根据当前的全局版本号(空集群启动时默认为 1)自增,生成 put hello 操作
对应的版本号 revision{2,0},这就是 boltdb 的 key。
boltdb 的 value 是 mvccpb.KeyValue 结构体,它是由用户 key、value、
create_revision、mod_revision、version、lease 组成。它们的含义分别如下:
create_revision 表示此 key 创建时的版本号。在本例中,key hello 是第一次
创建,那么值就是 2。当你再次修改 key hello 的时候,写事务会从 treeIndex 模块
查询 hello 第一次创建的版本号,也就是 keyIndex.generations[i].created 字段,
赋值给 create_revision 字段;
mod_revision 表示 key 最后一次修改时的版本号,即 put 操作发生时的全局版
本号加 1; version 表示此 key 的修改次数。每次修改的时候,写事务会从 treeIndex 模块
查询 hello 已经历过的修改次数,也就是 keyIndex.generations[i].ver 字段,将 ver
字段值加 1 后,赋值给 version 字段。
填充好 boltdb 的 KeyValue 结构体后,这时就可以通过 Backend 的写事务 batchTx 接口
将 key{2,0},value 为 mvccpb.KeyValue 保存到 boltdb 的缓存中,并同步更新 buffer,
如 上图中的流程二所示。
3.5.1.3第三步:更新 treeIndex
然后 put 事务需将本次修改的版本号与用户 key 的映射关系保存到 treeIndex 模块中,也
就是上图中的流程三。
因为 key hello 是首次创建,treeIndex 模块它会生成 key hello 对应的 keyIndex 对象,
并填充相关数据结构。
1 key hello的keyIndex: 2 key: "hello" 3 modified: <2,0> 4 generations: 5 [{ver:1,created:<2,0>,revisions: [<2,0>]} ]key 为 hello,modified 为最后一次修改版本号 <2,0>,key hello 是首 次创建的,因此新增一个 generation 代跟踪它的生命周期、修改记录;
generation 的 ver 表示修改次数,首次创建为 1,后续随着修改操作 递增;
generation.created 表示创建 generation 时的版本号为 <2,0> ;
revision 数组保存对此 key 修改的版本号列表,每次修改都会将将相;
应的版本号追加到 revisions 数组中。
3.5.1.4第四步:持久化
通过以上流程,一个 put 操作终于完成,但是此时数据还并未持久化。
为了提升 etcd 的写吞吐量、性能,数据持久化由 Backend 的异步 goroutine 完成,它通
过事务批量提交,定时将 boltdb 页缓存中的脏数据提交到持久化存储磁盘中。
3.5.3 MVCC查询key原理
执行 get 命令时,,MVCC 模块首先会创建一个读事务对象(TxnRead)。在 etcd 3.4 中
Backend 实现了 ConcurrentReadTx, 也就是并发读特性。 并发读特性的核心原理是创建读事务对象时,它会全量拷贝当前写事务未提交的 buffer 数
据,并发的读写事务不再阻塞在一个 buffer 资源锁上,实现了全并发读。

第一步:查询版本号
首先需要根据 key 从 treeIndex 模块获取版本号(因我们未带版本号读,默认是读取最新
的数据)。treeIndex 模块从 B-tree 中,根据 key 查找到 keyIndex 对象后,匹配有效的
generation,返回 generation 的 revisions 数组中最后一个版本号{2,0}给读事务对象。
第二步:查询 blotdb
读事务对象根据此版本号为 key,通过 Backend 的并发读事务(ConcurrentReadTx)接
口,优先从 buffer 中查询,命中则直接返回,否则从 boltdb 中查询此 key 的 value 信
息。
3.5.4MVCC删除key原理
当执行 del 命令时 etcd 实现的是延期删除模式 ,原理与 key 更新类似。
与更新 key 不一样之处在于:
一方面,生成的 boltdb key 版本号{4,0,t}追加了删除标识(tombstone, 简写 t),boltdb value 变成只含用户 key 的 KeyValue 结构体。
另一方面 treeIndex 模块也会给此 key hello 对应的 keyIndex 对象,追加一个
空的 generation 对象,表示此索引对应的 key 被删除了。
当你再次查询 hello 的时候,treeIndex 模块根据 key hello 查找到 keyindex 对象后,若
发现其存在空的 generation 对象,并且查询的版本号大于等于被删除时的版本号,则会返
回空。
那么 key 打上删除标记后有哪些用途呢?什么时候会真正删除它呢?
一方面删除 key 时会生成 events,Watch 模块根据 key 的删除标识,会生成 对应的 Delete 事件。
另一方面,当你重启 etcd,遍历 boltdb 中的 key 构建 treeIndex 内存树时, 你需要知道哪些 key 是已经被删除的,并为对应的 key 索引生成 tombstone 标 识。
而真正删除 treeIndex 中的索引对象、boltdb 中的 key 是通过压缩 (compactor) 组件异
步完成。
正因为 etcd 的删除 key 操作是基于以上延期删除原理实现的,因此 只要压缩组件未回收历
史版本,我们就能从 etcd 中找回误删的数据。




