Kubernetes控制平面组件:etcd(二)

云原生学习路线导航页(持续更新中)

本文将继续对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
  • 典型场景

    bash 复制代码
    # 查看 key 的历史版本
    etcdctl get --rev=<specific_revision> <key>

1.3.2.版本压缩机制

  • 必要性

    • 防止长期运行的 Key 占用过多存储空间
    • 避免单个 Key 频繁修改导致数据库膨胀
  • 操作方法

    bash 复制代码
    # 执行版本压缩(保留最近 N 个版本)
    etcdctl compact <revision_number>
    
    # 碎片整理(需停机维护)
    etcdctl defrag
  • 注意事项

    • Compact 后旧版本不可访问
    • Defragment 会影响服务可用性

2.etcd 存储键值对 字段解释

sh 复制代码
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.整体结构

json 复制代码
{
  "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 字段详解(单条记录)

json 复制代码
{
  "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 接口
  • 交互流程
    1. 对请求进行预处理:
      • 配额检查 :检查存储空间是否足够(ETCD_SPACE_QUOTA
      • 限流:基于令牌桶算法控制 QPS
      • 认证鉴权:校验用户 RBAC 权限
      • 请求合规性:校验请求大小(默认限制 1.5MB,过大的资源会直接影响Etcd的心跳数据包大小,拖慢心跳速度,所以etcd默认最大资源限制1.5MB,我们在设计k8s资源时要注意不能太大)
    2. 通过后,将请求转发至 KV Server

3.2.2.KV Server

  • 职责
    • 协调读写请求的分发
    • 管理事务逻辑
  • 交互流程
    1. 接收 HTTP Server 的写入请求(如 put key=foo value=bar

    2. 构造提案(Proposal)

      go 复制代码
      type Proposal struct {
          Op:    Put,          // 操作类型
          Key:   "foo",        // 键
          Value: "bar",        // 值
          Lease: 0,            // 租约(可选)
      }
    3. 调用 Raft 模块的 Propose() 方法提交提案,该提案就会被传给一致性模块

    4. 当超过半数的节点达成一致后,KV Server会控制该数据 移到Raft Log的committed log中,并更新自己的 matchIndex

    5. 最后 KV Server 会发起该提案的 Apply,将数据应用到状态机MVCC

  • matchIndex 是用于标识有多少数据已经被半数确认,是一个递增的数字

3.2.3.Raft 模块

  • 核心组件

    • Leader 节点:处理提案,发起日志复制
    • Follower 节点:接收并持久化日志
  • RaftLog

    • etcd每个节点在内存里都会预留一块空间,叫做 raftLog
    • RaftLog包括三部分:unstable log、committed log、applied log
  • 交互流程

    1. Propose 阶段
      • Leader 将提案写入 Memory Store(unstable log),然后返回响应给KV Server
      • KV Server 会同时做两件事情:
        • 通过 RPC 发送 AppendEntries 请求给所有 Follower(通过心跳
        • 将提案写入 WAL日志,目的是将提案进行持久化。WAL 日志本身也是不稳定的,但是会有后台线程定期将数据序列化到磁盘进行持久化
    2. Replicate 阶段
      • Follower 将日志写入本地 unstable logWAL 日志
      • 返回 AppendEntriesResponse 确认
    3. Commit 阶段
      • 当收到超过半数节点的确认后,Leader:
        • 将日志标记为 committed
        • 更新 commit index
    4. Apply 阶段
      • 在Apply之前,数据还没有提交到状态机,此时get数据是拿不到的

      • 通过 Ready 结构体通知 KV Server 可提交到状态机MVCC

      • 当数据成功写入MVCC后,就会被移到 applied Log

        go 复制代码
        type Ready struct {
            CommittedEntries: []Entry,  // 已提交的日志条目
            Messages:         []Message, // 待发送的 RPC 消息
        }
  • 注意

    • 如果客户端把请求发到了Follower,Follower会把请求转给leader,由leader发起提案

3.2.4.WAL(Write-Ahead Log)

  • 职责
    • 将Raft Log 的数据序列化为二进制后,进行持久化,防止宕机丢失
    • 记录所有状态变更操作。但注意WAL日志并非状态机,数据apply之前,在这里的数据依旧可能是不稳定的
  • 交互流程
    1. 日志格式

      go 复制代码
      type WALEntry struct {
          Term    uint64    // 当前任期
          Index   uint64    // 日志索引
          Type    EntryType // 日志类型(Normal/ConfChange)
          Data    []byte    // 序列化后的提案数据
      }
    2. 写入策略

      • 同步写入 :每次提案均触发 fsync(高可靠性)
      • 批量写入:合并多个提案后写入(高性能模式)

3.2.5.MVCC 模块

MVCC:multiversion concurrent control 多版本并发控制,在尽量不加锁的前提下实现一个多版本数据存储。

etcd 使用 TreeIndex(内存索引) 和 BoltDB(持久化存储) 协同工作,实现键值存储的多版本并发控制。

3.2.5.1.TreeIndex:内存中的键版本索引
  • 核心作用

    • 快速定位键的历史版本 :通过内存中的 B-tree 索引,支持高效的范围查询(如 etcdctl get --prefix /key)和版本遍历。
    • 管理键的生命周期:记录键的创建、修改、删除事件,实现 MVCC 的版本隔离。
  • 数据结构详解

    go 复制代码
    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 能够准确追踪键的生命周期,确保查询操作的正确性。
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 中
      go 复制代码
      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 表示无租约)
      }
  • 数据组织方式

    • 按 Revision 顺序存储

      • 由于etcd将全局Revision,作为BoltDB Key的 Main主版本号,所以 BoltDB 所有的键值对 都按 Revision 排序写入 BoltDB
      • 确保数据在磁盘上的写入顺序与逻辑操作顺序一致。
    • 例如:

      bash 复制代码
      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}(删除标记)
3.2.5.3.TreeIndex 与 BoltDB 的协作流程
3.2.5.3.1.写入键 Put /a "v1"
  • 假设当前全局 Revision 为 1000

  • TreeIndex 更新

    • 检查 /a 是否已存在。若不存在,创建新的 KeyIndexGeneration

      go 复制代码
      KeyIndex{
         Key:      []byte("/a"),
         Modified: Revision{1000},
         Generations: []Generation{
             {
                 Version:   1,
                 Created:   Revision{1000},
                 Revs:      []Revision{1000},
             },
         },
      }
  • BoltDB 写入

    • 将键值对按 Revision 写入 BoltDB:

      go 复制代码
      Key: 1000_0 → Value: KeyValue{
          Key:            []byte("/a"),
          Value:          []byte("v1"),
          CreateRevision: 1000,
          ModRevision:    1000,
          Version:        1,
          Lease:          0,
      }
3.2.5.3.2.修改建 Put /a "v2"
  • 此时全局 Revision:1001

  • TreeIndex 更新

    • 找到 /aKeyIndex,更新 Modified 和当前 Generation

      go 复制代码
      KeyIndex{
          Key:      []byte("/a"),
          Modified: Revision{1001},
          Generations: []Generation{
              {
                  Version:   2,
                  Created:   Revision{1000},
                  Revs:      []Revision{1000, 1001},
              },
          },
      }
  • BoltDB 写入

    • 将新的键值对按 Revision 写入 BoltDB:

      go 复制代码
      Key: 1001_0 → Value: KeyValue{
          Key:            []byte("/a"),
          Value:          []byte("v2"),
          CreateRevision: 1000,
          ModRevision:    1001,
          Version:        2,
          Lease:          0,
      }
3.2.5.3.3.删除建 Delete /a
  • 此时全局 Revision:1002

  • TreeIndex 更新

    • TreeIndex 会记录删除操作的 Revision,并将其添加到当前 Generation 的 Revs 列表中。因此删除并不能直接体现出来,只是在查询时找到最新版本1002,发现在BoltDB中Value已经是空,就知道该数据是被删除了

    • 删除操作会结束当前的 Generation,表示该键的生命周期已经终止。下次重建将会创建新的Generation

    • 找到 /aKeyIndex,标记删除并结束当前Generation

      go 复制代码
      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:

      go 复制代码
      Key: 1002_0 → Value: KeyValue{
          Key:            []byte("/a"),
          Value:          []byte(""), // 空值表示删除
          CreateRevision: 1000,
          ModRevision:    1002,
          Version:        2,
          Lease:          0,
      }
    • 删除操作结束后,BoltDB最终的数据存储如下:

      go 复制代码
      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 是否已存在。

      • 若不存在,创建新的 KeyIndexGeneration
      • 发现 /aKeyIndex 已经存在,就找到KeyIndex,通过最新版本1002知道该数据已经被删除
      • 因此为其创建新的 Generation
      go 复制代码
      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:

      go 复制代码
      Key: 1003_0 → Value: KeyValue{
          Key:            []byte("/a"),
          Value:          []byte("v3"),
          CreateRevision: 1003,
          ModRevision:    1003,
          Version:        1,
          Lease:          0,
      }
  • 重建工作完成后,BoltDB数据的真实存储

    bash 复制代码
    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

      go 复制代码
      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 读取数据

    • 从 BoltDB 读取 1003_0,返回:

      go 复制代码
      KeyValue{
          Key:            []byte("/a"),
          Value:          []byte("v3"),
          CreateRevision: 1003,
          ModRevision:    1003,
          Version:        1,
          Lease:          0,
      }
3.2.5.3.6.查询 /a 的历史值(Revision=1001) Get /a --rev=1001
  • TreeIndex 查询 KeyIndex

    • 从 TreeIndex 查找 /a 的 KeyIndex,,确认 Revision 1001 存在于第一个 Generation

      go 复制代码
      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 读取数据

    • 从 BoltDB 读取 1001_0,返回:

      go 复制代码
      KeyValue{
          Key:            []byte("/a"),
          Value:          []byte("v2"),
          CreateRevision: 1000,
          ModRevision:    1001,
          Version:        2,
          Lease:          0,
      }
3.2.5.3.7.查询 /a 的历史值(Revision=1002,删除标记) Get /a --rev=1002
  • TreeIndex 查询 KeyIndex

    • 从 TreeIndex 查找 /a 的 KeyIndex,,确认 Revision 1002 存在于第一个 Generation

      go 复制代码
      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 读取数据

    • 从 BoltDB 读取 1002_0,返回:

      go 复制代码
      KeyValue{
          Key:            []byte("/a"),
          Value:          []byte(""), // 空值表示删除
          CreateRevision: 1000,
          ModRevision:    1002,
          Version:        2,
          Lease:          0,
      }
3.2.5.3.8.范围查询前缀 / 的所有键(查最新版本) Range / --prefix
  • 流程

    • 从 TreeIndex 找到所有键(目前只有 /a)。
    • 获取 /a 的最新 Revision:1003。
    • 从 BoltDB 读取 1003_0,返回:
  • TreeIndex 查询所有 前缀为/ 的 KeyIndex

    go 复制代码
    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 读取数据

    • 从 BoltDB 读取 /a 的最新 Revision:1003_0,返回:

      go 复制代码
      KeyValue{
          Key:            []byte("/a"),
          Value:          []byte("v3"),
          CreateRevision: 1003,
          ModRevision:    1003,
          Version:        1,
          Lease:          0,
      }
3.2.5.3.9.范围查询前缀 / 的所有键(查历史版本) Range / --prefix --rev=1001
  • 流程

    • 从 TreeIndex 找到所有键(目前只有 /a)。
    • 获取 /a 在 Revision 1001 时的值。
    • 从 BoltDB 读取 1001_0,返回。
  • TreeIndex 查询所有 前缀为/ 的 KeyIndex

    go 复制代码
    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 读取数据

    • 从 BoltDB 读取 /a 的最新 Revision:1001_0,返回:

      go 复制代码
      KeyValue{
          Key:            []byte("/a"),
          Value:          []byte("v2"),
          CreateRevision: 1000,
          ModRevision:    1001,
          Version:        2,
          Lease:          0,
      }
3.2.5.3.10.压缩(Compact)
  • 操作:压缩到 Revision 1002

  • TreeIndex 清理

    • 删除已压缩的 Generation(第一个 Generation)。

    • 更新后的 KeyIndex:

      go 复制代码
      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:日志复制与提交

Client Leader Follower1 Follower2 MVCC put key=foo value=bar Propose(写入 unstable log) AppendEntries RPC AppendEntries RPC ACK(确认日志写入) ACK(确认日志写入) 标记日志为 committed Apply 到状态机 返回成功 Client Leader Follower1 Follower2 MVCC

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。
  • 示例

    • 创建资源时:

      yaml 复制代码
      apiVersion: v1
      kind: Pod
      metadata:
        name: my-pod
        resourceVersion: "1000"  # 对应 etcd 的 ModRevision
    • 更新资源时:

      yaml 复制代码
      apiVersion: v1
      kind: Pod
      metadata:
        name: my-pod
        resourceVersion: "1001"  # 对应 etcd 的新 ModRevision
  • 我们找个k8s的pod资源验证一下

    • 找一个pod,从yaml中得知 metadata.resourceVersion == 80581574

      yaml 复制代码
      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

      shell 复制代码
      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
      }

3.5.2.Kubernetes Watch 机制与增量同步

  • Watch 机制:

    • Kubernetes 客户端可以通过 Watch API 监听资源的变化。客户端可以指定一个 resourceVersion,从该版本开始接收后续的变更事件。
  • 增量同步:

    • 客户端首次启动时,会通过 List API 获取全量资源列表,并记录最新的 resourceVersion。
    • 后续通过 Watch API 监听变更,从记录的 resourceVersion 开始接收增量事件。
  • 示例:

    bash 复制代码
    # 首次 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,表示资源已被修改。
    • 具体实现:

      go 复制代码
      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 的类型

    1. Key Watch:监听单个键的变化。
    2. Range Watch :监听某个前缀下的所有键的变化(类似于前缀查询)。

2.2.Watch 的实现细节

携带revision revision <= 当前版本 revision > 当前版本 客户端Watch请求 revision检查 加入unsynced组 从BoltDB加载历史数据 数据就绪后移入synced组 直接加入synced组

  • Revision 与 Watcher Group
    • 每个 Watch 请求可以携带一个 Revision 参数,表示从哪个版本开始监听
    • 通过对 Revision 参数的值key当前版本值 的比较,可以将 Watch 请求分为 Sync Group 和 Unsync Group
  • Sync Group
    • 如果 Revision 参数的值 > key当前版本值 ,表示监听未来数据,放入 Sync Group,并持续监听未来的变更
  • Unsync Group
    • 如果 Revision 参数的值 <= key当前版本值 ,表示监听历史数据,放入 Unsync Group。
    • 因为有数据未同步,需要先从 BoltDB 加载历史数据进行同步
    • 历史数据加载完成后,请求会被转移到 Sync Group,并开始监听未来的变更

2.3.Watch 的工作流程

  1. 客户端发起 Watch 请求

    • 指定监听的键(Key)或键范围(Range)。
    • 可选的 Revision 参数,表示从哪个版本开始监听。
  2. etcd 处理 Watch 请求

    • 比较 Revision 和当前 Store 的 Revision
    • 根据比较结果,将请求放入 Sync Group 或 Unsync Group。
  3. Sync Group 的处理

    • 直接返回当前数据。
    • 持续监听未来的变更,并将变更事件推送给客户端。
  4. Unsync Group 的处理

    • 从 BoltDB 加载历史数据。
    • 加载完成后,将请求转移到 Sync Group,并开始监听未来的变更。
相关推荐
终端行者2 小时前
k8s之Ingress服务接入控制器
云原生·容器·kubernetes
学Linux的语莫7 小时前
k8s的nodeport和ingress
网络·rpc·kubernetes
aashuii13 小时前
k8s通过NUMA亲和分配GPU和VF接口
云原生·容器·kubernetes
Most6619 小时前
kubesphere安装使用
kubernetes
Kentos(acoustic ver.)20 小时前
云原生 —— K8s 容器编排系统
云原生·容器·kubernetes·云计算·k8s
哈里谢顿1 天前
Kubernetes 简介
kubernetes
__Smile°1 天前
k8s-MongoDB 副本集部署
云原生·容器·kubernetes
Jy_06221 天前
k8s 中的 deployment,statefulset,daemonset 控制器的区别
云原生·容器·kubernetes
码字的字节1 天前
深入解析HBase如何保证强一致性:WAL日志与MVCC机制
hadoop·hbase·wal·mvcc
超龄超能程序猿1 天前
图片查重从设计到实现(2)Milvus安装准备etcd介绍、应用场景及Docker安装配置
docker·etcd·milvus