2. etcd 架构回顾:Raft 协议、WAL 日志、快照与后端存储的关系

在深入压测场景之前,必须重新梳理 etcd 的核心架构。磁盘 IO 高导致的读写延迟抖动,本质上是 etcd 内部各组件对磁盘写入频率与写入模式不匹配的结果。本节从 Raft 协议、WAL 日志、快照(Snapshot)和后端存储(Backend/boltdb)四个关键模块出发,厘清它们之间的数据流与依赖关系。

2.1 整体数据流与模块关系

etcd 的写入路径可以概括为:客户端请求 → Raft 共识层 → WAL 日志持久化 → 状态机应用 → 后端 boltdb 存储。下图展示了这一核心链路:

etcd 核心写入路径与模块关系 etcd 核心写入路径与模块关系 客户端请求 Raft 共识层 Leader 选举 · 日志复制 · 提交 WAL 日志 顺序追加写 · fsync 落盘 状态机应用 后端 boltdb 存储 关键观察 • 每次写入必须经过 WAL fsync • boltdb 异步批量提交 • 快照压缩 WAL 空间
压测启示: 磁盘 IO 延迟直接影响 WAL 的 fsync 耗时,进而拖慢整个 Raft 提交周期。这是读写延迟抖动的根本原因之一。

2.2 Raft 协议:共识与日志复制

Raft 是 etcd 的分布式共识引擎,负责在集群节点间复制日志并保证一致性。与磁盘 IO 相关的关键行为包括:

  • Leader 选举: 需要持久化当前任期和投票记录,产生少量 WAL 写入。
  • 日志复制: 客户端写入必须由 Leader 追加到自己的 WAL,然后并行发送给 Follower。Follower 收到后同样需要写入本地 WAL 并 fsync。
  • 提交判定: 当多数节点(quorum)确认日志已持久化后,Leader 才提交该日志项,并应用到状态机。

Raft 日志条目结构如下表所示:

字段 说明 磁盘影响
Term 当前任期号 极小,每次选举写入
Index 日志索引,单调递增 随写入量线性增长
Type 日志类型(普通/配置变更) 无显著影响
Data 序列化的客户端请求 直接决定 WAL 写入量
压测关联: 高并发写入时,Raft 日志条目快速累积,WAL 文件频繁 fsync。如果磁盘 IOPS 不足或存在抖动,Leader 的提交延迟会飙升,甚至触发 Leader 选举风暴。

2.3 WAL 日志:顺序写入与 fsync 瓶颈

WAL(Write-Ahead Log)是 etcd 保证数据不丢失的第一道防线。所有 Raft 日志条目在应用到状态机之前,必须先写入 WAL 并调用 fsync 确保落盘。

WAL 的核心特性:

  • 顺序追加写: 日志文件按顺序追加,理论上对机械硬盘友好,但 SSD 的随机写性能同样受益于顺序模式。
  • 文件分段: 每个 WAL 文件默认 64MB,写满后创建新文件。旧文件在快照完成后可被清理。
  • fsync 频率: 每次 Raft 日志条目写入后都会调用 fsync。这是磁盘 IO 延迟的主要来源。

WAL 写入流程:

  1. 序列化 Raft 日志条目为 protobuf 格式。
  2. 追加到当前 WAL 文件的内存缓冲区。
  3. 调用 file.Sync()(即 fsync)将缓冲区数据刷入磁盘。
  4. 返回写入成功给 Raft 层。
// WAL 写入核心伪代码
func (w *WAL) Save(ents []raftpb.Entry) error {
    // 1. 序列化日志条目
    data := marshalEntries(ents)
    // 2. 追加到文件缓冲区
    if _, err := w.encoder.Write(data); err != nil {
        return err
    }
    // 3. fsync 落盘(关键 IO 操作)
    if err := w.f.Sync(); err != nil {
        return err
    }
    return nil
}
磁盘 IO 抖动放大效应: 当磁盘 IO 延迟从 1ms 抖动到 10ms 时,WAL 的 fsync 耗时增加 10 倍。由于每次写入都同步等待,客户端请求的 P99 延迟会从几毫秒飙升到几十甚至上百毫秒。

2.4 快照(Snapshot):压缩与 IO 突发

快照是 etcd 用来压缩 WAL 日志、控制磁盘空间占用的机制。当 WAL 文件累积到一定数量(默认 10000 条日志)时,etcd 会触发快照操作。

快照过程:

  • 将当前状态机数据(即 boltdb 中的键值对)序列化为一个快照文件。
  • 记录快照对应的 Raft 日志索引和任期。
  • 删除该索引之前的所有 WAL 文件。

快照对磁盘 IO 的影响:

阶段 IO 类型 延迟特征
快照生成 顺序读(boltdb)+ 顺序写(快照文件) 中等,数据量大时可达秒级
WAL 清理 删除旧文件(元数据操作) 低,但可能触发文件系统锁竞争
快照加载(重启/恢复) 顺序读(快照文件)+ 随机写(boltdb) 高,恢复期间不可服务
压测陷阱: 在持续高写入压测中,快照频繁触发(例如每 30 秒一次)。快照生成时的 IO 突发会与正常 WAL 写入争抢磁盘带宽,导致读写延迟出现周期性抖动。这是压测沙盘中常见的“锯齿形”延迟曲线的成因之一。

2.5 后端存储(boltdb):B+ 树与事务提交

etcd 使用 boltdb(一个嵌入式键值存储引擎)作为持久化状态机。boltdb 将数据组织为 B+ 树,并支持读写事务。

boltdb 的写入特点:

  • 批量提交: etcd 不会每收到一条 Raft 日志就写一次 boltdb,而是将一批已提交的日志条目打包成一个事务,批量写入 boltdb。
  • mmap 映射: boltdb 通过内存映射文件(mmap)访问数据,读操作通常不产生磁盘 IO。
  • 页面管理: B+ 树的页面分裂与合并会产生随机写,但 boltdb 通过预写日志(WAL)和页面回收机制缓解碎片化。

boltdb 事务提交流程:

  1. 开始读写事务。
  2. 将一批键值对写入 B+ 树的内存页面。
  3. 提交事务时,将脏页面写入磁盘文件(通过 pwrite 或 write)。
  4. 调用 fdatasync 确保元数据落盘(可选,取决于配置)。
// boltdb 事务提交核心伪代码
func (tx *Tx) Commit() error {
    // 1. 将脏页面写入文件
    if err := tx.writePages(); err != nil {
        return err
    }
    // 2. 刷新文件元数据(fdatasync)
    if err := tx.db.fdatasync(); err != nil {
        return err
    }
    // 3. 释放锁,通知等待的读事务
    tx.close()
    return nil
}
与 WAL 的对比: WAL 是每次 Raft 日志写入都 fsync,而 boltdb 是批量提交后 fdatasync。因此 boltdb 的磁盘 IO 频率远低于 WAL,但单次 IO 的数据量更大。压测中,如果 boltdb 提交周期过长(例如积压了大量待提交日志),会导致内存压力上升,间接影响读写延迟。

2.6 四者关系总结

下表从磁盘 IO 角度对比四个模块的关键属性:

模块 IO 模式 fsync 频率 IO 大小 延迟敏感度
Raft 协议 逻辑控制,不直接 IO 无 无 高(依赖 WAL 响应)
WAL 日志 顺序追加写 每次写入 小(KB 级) 极高
快照 顺序读 + 顺序写 生成时一次 大(MB ~ GB 级) 中等(突发性)
boltdb 随机写 + 顺序写混合 批量提交时 中(KB ~ MB 级) 中等

从压测角度看,磁盘 IO 高导致读写延迟抖动的根本链路是:

  1. WAL 的频繁 fsync 对磁盘 IO 延迟最敏感。
  2. 快照生成时的 IO 突发会抢占 WAL 的磁盘带宽。
  3. boltdb 的批量提交在积压严重时也会加剧 IO 竞争。
  4. Raft 协议依赖 WAL 的快速响应,一旦 WAL 延迟升高,整个集群的提交延迟随之恶化。