Kubernetes控制平面组件:etcd(二)
云原生学习路线导航页(持续更新中)
- kubernetes学习系列快捷链接
- Kubernetes架构原则和对象设计(一)
- Kubernetes架构原则和对象设计(二)
- Kubernetes架构原则和对象设计(三)
- Kubernetes控制平面组件:etcd(一)
- kubectl 和 kubeconfig 基本原理
- kubeadm 升级 k8s集群 1.17到1.20
- Kubernetes常见问题解答
- 查看云机器的一些常用配置
本文将继续对Kubernetes控制平面组件 etcd 进行介绍,从etcd的组件架构和组件交互层面,深入理解etcd如何实现 数据存储、数据生命周期、多版本并发控制、故障恢复和watch机制等
- 上节Kubernetes控制平面组件:etcd(一)中,我们详细讲解了Raft协议的原理
- 两大核心功能
- Leader Election(选主)
- Data Replication(数据同步)
- 安全保障
- 通过多数节点投票机制保障数据一致性
- 两大核心功能
- 本节将重点介绍etcd的实现细节,包括 Etcd 的数据存储、数据生命周期、多版本并发控制、故障恢复和watch机制等
1.Etcd的存储机制
1.1.分层设计原则
- 持久化层:
- 必须将数据可靠地写入磁盘(安全性保障)
- 缺点:纯磁盘读写性能较差
- 内存加速层:
- 通过缓存和索引提高访问速度
1.2.关键技术组件
1.2.1.KV Index(内存索引)
- 定位方式:Key-based 查询
- 数据结构:基于 Google B-Tree 的开源 Golang 实现 (github.com/google/btree)
- 特性:
- 天然适合键值数据库场景
- 避免传统关系型数据库复杂的行/列索引
1.2.2.Backend Storage(后端存储)
- 实现引擎:BoltDB
- 工作原理:
- 操作底层 .db 文件
- 采用追加式日志结构合并树(LSM Tree)变种
- 支持事务操作
- 注意:BoltDB 中存储的key是reversion,value是 我们的数据k-v
1.3.多版本控制系统
1.3.1.Revision 机制
-
版本组成:
- Main Revision(全局递增版本号),每次事务进行+1
- Sub-Revision(同一事务内的操作序号),同一个事务内部每次操作+1
-
核心特征:
- 每次 Key 修改生成新版本,非覆盖式
- 允许历史版本追溯
- Watch 机制可指定起始 revision
-
典型场景:
# 查看 key 的历史版本 etcdctl get --rev=<specific_revision> <key>
1.3.2.版本压缩机制
- 必要性
- 防止长期运行的 Key 占用过多存储空间
- 避免单个 Key 频繁修改导致数据库膨胀
- 操作方法
# 执行版本压缩(保留最近 N 个版本) etcdctl compact <revision_number> # 碎片整理(需停机维护) etcdctl defrag
- 注意事项
- Compact 后旧版本不可访问
- Defragment 会影响服务可用性
2.etcd 存储键值对 字段解释
sh-5.0# etcdctl --endpoints=127.0.0.1:2379 get b
{
"header": {
"cluster_id": 6652528222563268894,
"member_id": 4812728548556146573,
"revision": 81695026,
"raft_term": 5
},
"kvs": [{
"key": "Yg==",
"create_revision": 81694561,
"mod_revision": 81694561,
"version": 1,
"value": "VjE="
}],
"count": 1
}
2.1.整体结构
{
"header": { ... }, // 响应的元数据头
"kvs": [ ... ], // 匹配到的键值对列表
"count": 1 // 实际返回的键值对总数
}
2.2.Header 字段详解
字段名 | 类型 | 说明 |
---|---|---|
cluster_id | uint64 | 集群唯一 ID (十六进制转换示例:printf '%x\n' 6652528222563268894 ) |
member_id | uint64 | 处理此请求的成员节点 ID |
revision | int64 | 当前全局数据版本号,代表整个集群的最新提交版本 |
raft_term | uint64 | Raft 共识算法当前的任期编号(Leader 发生变更时会递增) |
2.3.kvs 字段详解(单条记录)
{
"key": "Yg==", // Base64 编码的原 key
"create_revision": 81694561,// 首次创建该k-v时的全局版本号
"mod_revision": 81694561, // 最后一次修改该k-v时的全局版本号
"version": 1, // 该 key 的修改次数计数器
"value": "VjE=" // Base64 编码的值
}
# 解码 key (Base64 -> ASCII)
echo "Yg==" | base64 -d # 输出: b
# 解码 value (Base64 -> ASCII)
echo "VjE=" | base64 -d # 输出: V1
2.4.Count 字段
字段名 | 类型 | 说明 |
---|---|---|
count | int | 实际匹配到的键值对数量(当使用范围查询或前缀查询时可能 >1) |
2.5.关键差异对比
字段 | 区别点 | 示例场景 |
---|---|---|
revision vs raft_term | revision 跟踪数据变化,raft_term 跟踪 leader 任期 | 数据写入会增加 revision,leader 换届会改变 raft_term |
create_revision vs mod_revision | create 只在创建时设置,mod 随每次更新变化 | 新建 key 时二者相等,更新后 mod_revision 变大 |
3.Etcd 节点组件详解
3.1.组件交互全景图
- 每一个etcd节点,内部都包括下面这些模块:
- Http Server模块:接收客户端请求,进行预检查
- KV Server:协调读写请求的分发
- 一致性模块:Raft协议的具体实现,多数投票保证数据一致性
- WAL日志模块:数据提交前的持久化
- MVCC状态机模块:数据查询和存储模块
- 数据写入控制流:
- 客户端发起数据写入请求 → HTTP Server → KV Server → Raft模块 → WAL持久化 → MVCC模块(TreeIndex + BoltDB)
- 数据写入流程完整流程:
- 客户端发送写入请求到etcd,先通过HTTP Server的预检查,然后请求进入KV Server
- KV Server将请求交给Raft一致性模块,进行多数投票
- Leader构造提案发给Follower,同时把数据序列化为二进制写入WAL日志(有异步线程进行持久化,防止数据丢失)
- 当多数投票成功,Leader 的 KV Server会发起数据apply,将数据发给Leader的 MVCC模块进行状态机修改
- 状态机修改成功后,Leader 的 KV Server会修改自己的MatchIndex,并在下一次心跳时将MatchIndex最新值发给Follower,Follower就会更新自己的 MatchIndex 和 状态机
- 内存索引和后端存储都成功,本条数据写入就完成了
3.2.关键组件职责与交互细节
3.2.1.HTTP Server
- 职责:
- 接收客户端请求(PUT/GET)
- 提供 RESTful API 接口
- 交互流程:
- 对请求进行预处理:
- 配额检查:检查存储空间是否足够(
ETCD_SPACE_QUOTA
) - 限流:基于令牌桶算法控制 QPS
- 认证鉴权:校验用户 RBAC 权限
- 请求合规性:校验请求大小(默认限制 1.5MB,过大的资源会严重影响写入性能,所以etcd默认最大资源限制1.5MB,我们在设计k8s资源时要注意不能太大)
- 配额检查:检查存储空间是否足够(
- 通过后,将请求转发至 KV Server
- 对请求进行预处理:
3.2.2.KV Server
- 职责:
- 协调读写请求的分发
- 管理事务逻辑
- 交互流程:
- 接收 HTTP Server 的写入请求(如
put key=foo value=bar
) - 构造提案(Proposal):
type Proposal struct { Op: Put, // 操作类型 Key: "foo", // 键 Value: "bar", // 值 Lease: 0, // 租约(可选) }
- 调用 Raft 模块的
Propose()
方法提交提案,该提案就会被传给一致性模块 - 当超过半数的节点达成一致后,KV Server会控制该数据 移到Raft Log的committed log中,并更新自己的 matchIndex
- 最后 KV Server 会发起该提案的 Apply,将数据应用到状态机MVCC
- 接收 HTTP Server 的写入请求(如
- matchIndex 是用于标识有多少数据已经被半数确认,是一个递增的数字
3.2.3.Raft 模块
- 核心组件:
- Leader 节点:处理提案,发起日志复制
- Follower 节点:接收并持久化日志
- RaftLog
- etcd每个节点在内存里都会预留一块空间,叫做
raftLog
- RaftLog包括三部分:unstable log、committed log、applied log
- etcd每个节点在内存里都会预留一块空间,叫做
- 交互流程:
- Propose 阶段:
- Leader 将提案写入 Memory Store(unstable log),然后返回响应给KV Server
- KV Server 会同时做两件事情:
- 通过 RPC 发送
AppendEntries
请求给所有 Follower(通过心跳) - 将提案写入 WAL日志,目的是将提案进行持久化。WAL 日志本身也是不稳定的,但是会有后台线程定期将数据序列化到磁盘进行持久化
- 通过 RPC 发送
- Replicate 阶段:
- Follower 将日志写入本地 unstable log 和 WAL 日志
- 返回
AppendEntriesResponse
确认
- Commit 阶段:
- 当收到超过半数节点的确认后,Leader:
- 将日志标记为 committed
- 更新 commit index
- 当收到超过半数节点的确认后,Leader:
- Apply 阶段:
- 在Apply之前,数据还没有提交到状态机,此时get数据是拿不到的
- 通过
Ready
结构体通知 KV Server 可提交到状态机MVCC - 当数据成功写入MVCC后,就会被移到 applied Log
type Ready struct { CommittedEntries: []Entry, // 已提交的日志条目 Messages: []Message, // 待发送的 RPC 消息 }
- Propose 阶段:
- 注意:
- 如果客户端把请求发到了Follower,Follower会把请求转给leader,由leader发起提案
3.2.4.WAL(Write-Ahead Log)
- 职责:
- 将Raft Log 的数据序列化为二进制后,进行持久化,防止宕机丢失
- 记录所有状态变更操作。但注意WAL日志并非状态机,数据apply之前,在这里的数据依旧可能是不稳定的
- 交互流程:
- 日志格式:
type WALEntry struct { Term uint64 // 当前任期 Index uint64 // 日志索引 Type EntryType // 日志类型(Normal/ConfChange) Data []byte // 序列化后的提案数据 }
- 写入策略:
- 同步写入:每次提案均触发
fsync
(高可靠性) - 批量写入:合并多个提案后写入(高性能模式)
- 同步写入:每次提案均触发
- 日志格式:
3.2.5.MVCC 模块
MVCC:multiversion concurrent control 多版本并发控制,在尽量不加锁的前提下实现一个多版本数据存储。
etcd 使用 TreeIndex(内存索引) 和 BoltDB(持久化存储) 协同工作,实现键值存储的多版本并发控制。
3.2.5.1.TreeIndex:内存中的键版本索引
- 核心作用
- 快速定位键的历史版本:通过内存中的 B-tree 索引,支持高效的范围查询(如
etcdctl get --prefix /key
)和版本遍历。 - 管理键的生命周期:记录键的创建、修改、删除事件,实现 MVCC 的版本隔离。
- 快速定位键的历史版本:通过内存中的 B-tree 索引,支持高效的范围查询(如
- 数据结构详解
type KeyIndex struct { Key []byte // 键名(如 "/foo/bar") Modified Revision // 最后一次修改的 Revision(如 12345.0) Generations []Generation // 键的“代”历史(解决键的删除与重建问题) } type Generation struct { Version int64 // 同一代内的版本号(从1开始递增) Created Revision // 当前代的创建 Revision Revs []Revision // 当前代的所有修改 Revision(包括删除) }
- KeyIndex.Generations 的作用:解决键的删除与重建问题
- 当一个键被删除后重新创建,会生成新的 Generation。例如:
- Generation 1: 创建(rev=100),修改(rev=101),删除(rev=102)
- Generation 2: 重新创建(rev=200),修改(rev=201)…
- 通过 TreeIndex 的 Generations,etcd 能够准确追踪键的生命周期,确保查询操作的正确性。
- 当一个键被删除后重新创建,会生成新的 Generation。例如:
3.2.5.2.BoltDB:持久化存储引擎
-
BoltDB 和 ETCD 的关系
- ETCD 的真实后端存储是可选的,目前是 Google开发的 基于 B+树 的 BoltDB
-
BoltDB 的 key:
- 存到BoltDB的每条k-v,其实都是作为value存到后端存储中的
- BoltDB实际存放的key,是它自行拼接的
Revision{Main}_{Sub}
结构
-
存储结构
- Key:Revision{Main}_{Sub}(例如 1000_0)
- Main:全局单调递增的事务 ID(每个写操作递增 1)。
- Sub:同一事务内的操作编号(通常为 0,事务批量写时可能大于 0)。
- Value:
- 存进来的k-v就放在 KeyValue 的 key和value 中
type KeyValue struct { Key []byte // 键名(如 "/a") Value []byte // 值(如 "hello") CreateRevision int64 // 创建时的 Revision(对应 Generation.Created) ModRevision int64 // 最后一次修改的 Revision(对应 KeyIndex.Modified) Version int64 // 版本号(对应 Generation.Version) Lease int64 // 关联的租约 ID(0 表示无租约) }
- Key:Revision{Main}_{Sub}(例如 1000_0)
-
数据组织方式
- 按 Revision 顺序存储
- 由于etcd将全局Revision,作为BoltDB Key的 Main主版本号,所以 BoltDB 所有的键值对 都按 Revision 排序写入 BoltDB
- 确保数据在磁盘上的写入顺序与逻辑操作顺序一致。
- 例如:
BoltDB Bucket: Key: 1000_0 → Value: {Key: "/a", Value: "v1", CreateRevision: 1000, ModRevision: 1000, Version: 1} Key: 1001_0 → Value: {Key: "/a", Value: "v2", CreateRevision: 1000, ModRevision: 1001, Version: 2} Key: 1002_0 → Value: {Key: "/a", Value: "", CreateRevision: 1000, ModRevision: 1002, Version: 3}(删除标记)
- 按 Revision 顺序存储
3.2.5.3.TreeIndex 与 BoltDB 的协作流程
3.2.5.3.1.写入键 Put /a "v1"
-
假设当前全局 Revision 为 1000
-
TreeIndex 更新
- 检查
/a
是否已存在。若不存在,创建新的KeyIndex
和Generation
:KeyIndex{ Key: []byte("/a"), Modified: Revision{1000}, Generations: []Generation{ { Version: 1, Created: Revision{1000}, Revs: []Revision{1000}, }, }, }
- 检查
-
BoltDB 写入
- 将键值对按 Revision 写入 BoltDB:
Key: 1000_0 → Value: KeyValue{ Key: []byte("/a"), Value: []byte("v1"), CreateRevision: 1000, ModRevision: 1000, Version: 1, Lease: 0, }
- 将键值对按 Revision 写入 BoltDB:
3.2.5.3.2.修改建 Put /a "v2"
-
此时全局 Revision:1001
-
TreeIndex 更新
- 找到
/a
的KeyIndex
,更新Modified
和当前Generation
:KeyIndex{ Key: []byte("/a"), Modified: Revision{1001}, Generations: []Generation{ { Version: 2, Created: Revision{1000}, Revs: []Revision{1000, 1001}, }, }, }
- 找到
-
BoltDB 写入
- 将新的键值对按 Revision 写入 BoltDB:
Key: 1001_0 → Value: KeyValue{ Key: []byte("/a"), Value: []byte("v2"), CreateRevision: 1000, ModRevision: 1001, Version: 2, Lease: 0, }
- 将新的键值对按 Revision 写入 BoltDB:
3.2.5.3.3.删除建 Delete /a
-
此时全局 Revision:1002
-
TreeIndex 更新
- TreeIndex 会记录删除操作的 Revision,并将其添加到当前 Generation 的 Revs 列表中。因此删除并不能直接体现出来,只是在查询时找到最新版本1002,发现在BoltDB中Value已经是空,就知道该数据是被删除了
- 删除操作会结束当前的 Generation,表示该键的生命周期已经终止。下次重建将会创建新的Generation
- 找到
/a
的KeyIndex
,标记删除并结束当前Generation
:KeyIndex{ Key: []byte("/a"), Modified: Revision{1002}, Generations: []Generation{ { Version: 2, Created: Revision{1000}, Revs: []Revision{1000, 1001, 1002}, }, }, }
-
BoltDB 写入
- 在 etcd 中,空值(Value 字段为空)被用作删除标记。当读取数据时,如果发现某个 Revision 的 Value 为空,则认为该键已被删除。
- 因此删除只是为该key添加了一个空数据KeyValue作为最新版本。实际上所有旧的数据都还在
- 将删除标记按 Revision 写入 BoltDB:
Key: 1002_0 → Value: KeyValue{ Key: []byte("/a"), Value: []byte(""), // 空值表示删除 CreateRevision: 1000, ModRevision: 1002, Version: 2, Lease: 0, }
- 删除操作结束后,BoltDB最终的数据存储如下:
Key: 1000_0 → Value: {Key: "/a", Value: "v1", CreateRevision: 1000, ModRevision: 1000, Version: 1} Key: 1001_0 → Value: {Key: "/a", Value: "v2", CreateRevision: 1000, ModRevision: 1001, Version: 2} Key: 1002_0 → Value: {Key: "/a", Value: "", CreateRevision: 1000, ModRevision: 1002, Version: 2}(删除标记)
3.2.5.3.4.重新创建键 Put /a "v3"
-
假设当前全局 Revision 为 1003
-
TreeIndex 更新
- 检查
/a
是否已存在。- 若不存在,创建新的
KeyIndex
和Generation
。 - 发现
/a
的KeyIndex
已经存在,就找到KeyIndex
,通过最新版本1002知道该数据已经被删除 - 因此为其创建新的
Generation
KeyIndex{ Key: []byte("/a"), Modified: Revision{1003}, Generations: []Generation{ { Version: 2, Created: Revision{1000}, Revs: []Revision{1000, 1001, 1002}, }, { Version: 1, Created: Revision{1003}, Revs: []Revision{1003}, }, }, }
- 若不存在,创建新的
- 检查
-
BoltDB 写入
- 将新的键值对按 Revision 写入 BoltDB:
Key: 1003_0 → Value: KeyValue{ Key: []byte("/a"), Value: []byte("v3"), CreateRevision: 1003, ModRevision: 1003, Version: 1, Lease: 0, }
- 将新的键值对按 Revision 写入 BoltDB:
-
重建工作完成后,BoltDB数据的真实存储
Key: 1000_0 → Value: {Key: "/a", Value: "v1", CreateRevision: 1000, ModRevision: 1000, Version: 1} Key: 1001_0 → Value: {Key: "/a", Value: "v2", CreateRevision: 1000, ModRevision: 1001, Version: 2} Key: 1002_0 → Value: {Key: "/a", Value: "", CreateRevision: 1000, ModRevision: 1002, Version: 2}(删除标记) Key: 1003_0 → Value: {Key: "/a", Value: "v3", CreateRevision: 1003, ModRevision: 1003, Version: 1}
3.2.5.3.5.查询 /a
的最新值 Get /a
- TreeIndex 查询 KeyIndex
- 从 TreeIndex 查找 /a 的 KeyIndex,获取最新 Modified Revision:1003
KeyIndex{ Key: []byte("/a"), Modified: Revision{1003}, Generations: []Generation{ { Version: 2, Created: Revision{1000}, Revs: []Revision{1000, 1001, 1002}, }, { Version: 1, Created: Revision{1003}, Revs: []Revision{1003}, }, }, }
- 从 TreeIndex 查找 /a 的 KeyIndex,获取最新 Modified Revision:1003
- BoltDB 读取数据
- 从 BoltDB 读取 1003_0,返回:
KeyValue{ Key: []byte("/a"), Value: []byte("v3"), CreateRevision: 1003, ModRevision: 1003, Version: 1, Lease: 0, }
- 从 BoltDB 读取 1003_0,返回:
3.2.5.3.6.查询 /a
的历史值(Revision=1001) Get /a --rev=1001
- TreeIndex 查询 KeyIndex
- 从 TreeIndex 查找 /a 的 KeyIndex,,确认
Revision 1001
存在于第一个Generation
KeyIndex{ Key: []byte("/a"), Modified: Revision{1003}, Generations: []Generation{ { Version: 2, Created: Revision{1000}, Revs: []Revision{1000, 1001, 1002}, }, { Version: 1, Created: Revision{1003}, Revs: []Revision{1003}, }, }, }
- 从 TreeIndex 查找 /a 的 KeyIndex,,确认
- BoltDB 读取数据
- 从 BoltDB 读取 1001_0,返回:
KeyValue{ Key: []byte("/a"), Value: []byte("v2"), CreateRevision: 1000, ModRevision: 1001, Version: 2, Lease: 0, }
- 从 BoltDB 读取 1001_0,返回:
3.2.5.3.7.查询 /a
的历史值(Revision=1002,删除标记) Get /a --rev=1002
- TreeIndex 查询 KeyIndex
- 从 TreeIndex 查找 /a 的 KeyIndex,,确认
Revision 1002
存在于第一个Generation
KeyIndex{ Key: []byte("/a"), Modified: Revision{1003}, Generations: []Generation{ { Version: 2, Created: Revision{1000}, Revs: []Revision{1000, 1001, 1002}, }, { Version: 1, Created: Revision{1003}, Revs: []Revision{1003}, }, }, }
- 从 TreeIndex 查找 /a 的 KeyIndex,,确认
- BoltDB 读取数据
- 从 BoltDB 读取 1002_0,返回:
KeyValue{ Key: []byte("/a"), Value: []byte(""), // 空值表示删除 CreateRevision: 1000, ModRevision: 1002, Version: 2, Lease: 0, }
- 从 BoltDB 读取 1002_0,返回:
3.2.5.3.8.范围查询前缀 / 的所有键(查最新版本) Range / --prefix
- 流程
- 从 TreeIndex 找到所有键(目前只有 /a)。
- 获取 /a 的最新 Revision:1003。
- 从 BoltDB 读取 1003_0,返回:
- TreeIndex 查询所有
前缀为/
的 KeyIndexKeyIndex{ Key: []byte("/a"), Modified: Revision{1003}, Generations: []Generation{ { Version: 2, Created: Revision{1000}, Revs: []Revision{1000, 1001, 1002}, }, { Version: 1, Created: Revision{1003}, Revs: []Revision{1003}, }, }, }
- BoltDB 读取数据
- 从 BoltDB 读取 /a 的最新 Revision:1003_0,返回:
KeyValue{ Key: []byte("/a"), Value: []byte("v3"), CreateRevision: 1003, ModRevision: 1003, Version: 1, Lease: 0, }
- 从 BoltDB 读取 /a 的最新 Revision:1003_0,返回:
3.2.5.3.9.范围查询前缀 / 的所有键(查历史版本) Range / --prefix --rev=1001
- 流程
- 从 TreeIndex 找到所有键(目前只有 /a)。
- 获取 /a 在 Revision 1001 时的值。
- 从 BoltDB 读取 1001_0,返回。
- TreeIndex 查询所有
前缀为/
的 KeyIndexKeyIndex{ Key: []byte("/a"), Modified: Revision{1003}, Generations: []Generation{ { Version: 2, Created: Revision{1000}, Revs: []Revision{1000, 1001, 1002}, }, { Version: 1, Created: Revision{1003}, Revs: []Revision{1003}, }, }, }
- BoltDB 读取数据
- 从 BoltDB 读取 /a 的最新 Revision:1001_0,返回:
KeyValue{ Key: []byte("/a"), Value: []byte("v2"), CreateRevision: 1000, ModRevision: 1001, Version: 2, Lease: 0, }
- 从 BoltDB 读取 /a 的最新 Revision:1001_0,返回:
3.2.5.3.10.压缩(Compact)
-
操作:压缩到 Revision 1002
-
TreeIndex 清理:
- 删除已压缩的 Generation(第一个 Generation)。
- 更新后的 KeyIndex:
KeyIndex{ Key: []byte("/a"), Modified: Revision{1003}, Generations: []Generation{ { Version: 1, Created: Revision{1003}, Revs: []Revision{1003}, }, }, }
-
BoltDB 清理:
- 删除 Revision 1000_0、1001_0、1002_0
- 保留 Revision 1003_0
-
注:除了Compact,还可以定期为 Etcd 生成snapshot,以此避免版本过多导致存储占用太大
3.2.5.3.11.总结
- TreeIndex 记录了键的完整生命周期(创建、修改、删除、重建)。
- BoltDB 按 Revision 顺序存储键值对,确保数据持久化和一致性。
- 压缩机制 清理过期数据,释放存储空间。
3.3.关键协作场景
3.3.1.场景 1:日志复制与提交
3.3.2.场景 2:数据版本回滚
- 当客户端指定 --rev 参数时:
- MVCC 模块从 TreeIndex 查找指定 Revision
- 通过 BoltDB 按 Revision 读取历史数据
- 但是读取已提交(committed)的版本,未提交的数据还没有应用到状态机,读取不到
3.4.容错机制补充
3.4.1.Leader 故障恢复
- 新 Leader 选举完成后:
- 从 WAL 中读取所有未提交的日志
- 重新发起提案(重新走 Propose → Commit 流程)
3.4.2.Follower 数据追赶
- 落后节点通过 AppendEntries RPC 获取缺失日志
- 应用所有未提交日志到本地状态机
3.5.与 Kubernetes ResourceVersion 与 ETCD 的关联设计
3.5.1.Kubernetes ResourceVersion 实现
-
Kubernetes 将 metadata.resourceVersion 直接映射到 etcd 的 ModRevision,即 Kubernetes 资源的
metadata.resourceVersion == etcd.ModRevision
-
数据流:
- 当 Kubernetes 创建一个资源时,etcd 会为该资源分配一个 ModRevision,并将其写入 metadata.resourceVersion。
- 当 Kubernetes 更新一个资源时,etcd 会生成一个新的 ModRevision,并更新 metadata.resourceVersion。
- 当 Kubernetes 删除一个资源时,etcd 会标记删除,并保留最终的 ModRevision。
-
示例
- 创建资源时:
apiVersion: v1 kind: Pod metadata: name: my-pod resourceVersion: "1000" # 对应 etcd 的 ModRevision
- 更新资源时:
apiVersion: v1 kind: Pod metadata: name: my-pod resourceVersion: "1001" # 对应 etcd 的新 ModRevision
- 创建资源时:
-
我们找个k8s的pod资源验证一下
- 找一个pod,从yaml中得知
metadata.resourceVersion == 80581574
apiVersion: v1 kind: Pod metadata: creationTimestamp: "2025-02-08T14:55:27Z" generateName: envoy-deployment-555699d88- labels: app: envoy pod-template-hash: 555699d88 manager: kubelet operation: Update time: "2025-02-08T14:55:28Z" name: envoy-deployment-555699d88-gr9qq namespace: default ownerReferences: - apiVersion: apps/v1 blockOwnerDeletion: true controller: true kind: ReplicaSet name: envoy-deployment-555699d88 uid: 003390a5-bd85-4c1b-afc1-b633804becb4 resourceVersion: "80581574" selfLink: /api/v1/namespaces/default/pods/envoy-deployment-555699d88-gr9qq uid: 93f6c401-272c-4f46-9b51-99c6229dcd72 spec: containers: - image: envoyproxy/envoy:v1.19.1 imagePullPolicy: IfNotPresent name: envoy ports: - containerPort: 8080 protocol: TCP resources: {} terminationMessagePath: /dev/termination-log ......
- 进入kubernetes的etcd pod中,执行命令。查看boltdb存储数据,对输出json结果进行格式化,ModRevision果然是 80581574
sh-5.0# etcdctl --endpoints=127.0.0.1:2379 get /registry/pods/default/envoy-deployment-555699d88-gr9qq -wjson { "header": { "cluster_id": 6652528222563268894, "member_id": 4812728548556146573, "revision": 81923826, "raft_term": 5 }, "kvs": [{ "key": "L3JlZ2lzdHJ5L3BvZHMvZGVmYXVsdC9lbnZveS1kZXBsb3ltZW50LTU1NTY5OWQ4OC1ncjlxcQ==", "create_revision": 80581558, "mod_revision": 80581574, "version": 4, "value": "azhzAAoJCgJ2MR........." }], "count": 1 }
- 找一个pod,从yaml中得知
3.5.2.Kubernetes Watch 机制与增量同步
- Watch 机制:
- Kubernetes 客户端可以通过 Watch API 监听资源的变化。客户端可以指定一个 resourceVersion,从该版本开始接收后续的变更事件。
- 增量同步:
- 客户端首次启动时,会通过 List API 获取全量资源列表,并记录最新的 resourceVersion。
- 后续通过 Watch API 监听变更,从记录的 resourceVersion 开始接收增量事件。
- 示例:
# 首次 List 获取全量数据 kubectl get pods --watch --resource-version=1000 # Watch 监听变更 kubectl get pods --watch --resource-version=1001
3.5.3.乐观锁(Optimistic Lock)
-
乐观锁的概念
-
定义:乐观锁是一种并发控制机制,假设冲突发生的概率较低,因此在更新资源时不会加锁,而是在提交更新时检查资源是否被修改。
-
实现方式:通过比较资源的当前版本(resourceVersion)和客户端持有的版本,判断是否发生冲突。
-
-
Kubernetes 中的乐观锁实现
- 更新流程:
- 客户端读取资源的当前状态,并记录 metadata.resourceVersion。
- 客户端修改资源后,尝试更新到服务器。
- 服务器检查资源的当前 ModRevision,是否与客户端提供的 resourceVersion 一致:
- 如果一致,允许更新,并生成新的 ModRevision。
- 如果不一致,返回 409 Conflict,表示资源已被修改。
- 具体实现:
if etcd.kv.ModRevision == clientResourceVersion { allowUpdate() } else { return 409 Conflict }
- 更新流程:
3.6.有了MVCC进行数据持久化,为什么还需要WAL日志?
- 如果etcd只有一个节点,自然只有一个持久化组件就够了,但是Etcd天生就支持分布式,需要协调 Leader、Follower 达到一致(多数投票通过)的状态后数据才能写入状态机
- 而在Leader、Follower尚未达成一致的这段时间里,写的数据只存在于内存。一旦发生节点重启,数据就丢失掉了,因此需要WAL对尚未提交的数据也做持久化
3.7.性能优化点
- Batch 提交:合并多个提案批量写入 WAL
- Pipeline 复制:Leader 不等待前一个 RPC 响应即发送下一个请求
- ReadIndex 优化:通过 ReadIndex 机制实现线性一致读(避免访问 Raft 日志)
3.8.Etcd如何保证数据一致性
-
日志索引(Index):
- 每个日志条目都有一个唯一的索引(Index),表示其在日志中的位置。
- 日志是顺序追加的(Append-Only),确保日志的顺序性和一致性。
-
多数确认机制:
- 只有当日志条目被多数节点确认后,才会被提交(Commit)。
- 未提交的日志条目(如未达到多数确认)不会应用到状态机,因此不会对外可见。
-
示例:
- 假设集群有 3 个成员:Leader(A)、Follower(B)、Follower(C)。
- 日志条目:
- Index 1-5:已提交(多数节点确认)。
- Index 6:Leader(A)和 Follower(B)确认,Follower(C)未确认。
- Index 7:Leader(A)和 Follower(B)确认,Follower(C)未确认。
- Index 8:Leader(A)收到请求,但未同步到任何 Follower。
- 结果:
- Index 1-7:已提交,数据有效。
- Index 8:未提交,数据无效。
-
Raft多数确认机制 决定数据的commit
- 虽然Follower C数据落后,但是fg已经超过2个节点commit,就已经是一个有效数据
- 虽然Leader A存在数据h,但是该数据还没有多数确认,就没有commit,就是无效数据
4.Etcd的Watch机制
2.1.Watch 的基本概念
-
Watch 的作用:
- 监听指定键(Key)或键范围(Range)的变化。
- 当键的值发生变化时,etcd 会向客户端推送变更事件。
-
Watch 的类型:
- Key Watch:监听单个键的变化。
- Range Watch:监听某个前缀下的所有键的变化(类似于前缀查询)。
2.2.Watch 的实现细节
- Revision 与 Watcher Group:
- 每个 Watch 请求可以携带一个
Revision
参数,表示从哪个版本开始监听 - 通过对
Revision 参数的值
与key当前版本值
的比较,可以将 Watch 请求分为 Sync Group 和 Unsync Group
- 每个 Watch 请求可以携带一个
- Sync Group
- 如果
Revision 参数的值
>key当前版本值
,表示监听未来数据,放入 Sync Group,并持续监听未来的变更
- 如果
- Unsync Group
- 如果
Revision 参数的值
<=key当前版本值
,表示监听历史数据,放入 Unsync Group。 - 因为有数据未同步,需要先从 BoltDB 加载历史数据进行同步
- 历史数据加载完成后,请求会被转移到 Sync Group,并开始监听未来的变更
- 如果
2.3.Watch 的工作流程
-
客户端发起 Watch 请求:
- 指定监听的键(Key)或键范围(Range)。
- 可选的
Revision
参数,表示从哪个版本开始监听。
-
etcd 处理 Watch 请求:
- 比较
Revision
和当前 Store 的Revision
。 - 根据比较结果,将请求放入 Sync Group 或 Unsync Group。
- 比较
-
Sync Group 的处理:
- 直接返回当前数据。
- 持续监听未来的变更,并将变更事件推送给客户端。
-
Unsync Group 的处理:
- 从 BoltDB 加载历史数据。
- 加载完成后,将请求转移到 Sync Group,并开始监听未来的变更。