2. etcd 架构回顾:Raft 协议、WAL 日志、快照与后端存储的关系
在深入压测场景之前,必须重新梳理 etcd 的核心架构。磁盘 IO 高导致的读写延迟抖动,本质上是 etcd 内部各组件对磁盘写入频率与写入模式不匹配的结果。本节从 Raft 协议、WAL 日志、快照(Snapshot)和后端存储(Backend/boltdb)四个关键模块出发,厘清它们之间的数据流与依赖关系。
2.1 整体数据流与模块关系
etcd 的写入路径可以概括为:客户端请求 → Raft 共识层 → WAL 日志持久化 → 状态机应用 → 后端 boltdb 存储。下图展示了这一核心链路:
压测启示: 磁盘 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 写入流程:
- 序列化 Raft 日志条目为 protobuf 格式。
- 追加到当前 WAL 文件的内存缓冲区。
- 调用
file.Sync()(即 fsync)将缓冲区数据刷入磁盘。 - 返回写入成功给 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 事务提交流程:
- 开始读写事务。
- 将一批键值对写入 B+ 树的内存页面。
- 提交事务时,将脏页面写入磁盘文件(通过
pwrite或write)。 - 调用
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 高导致读写延迟抖动的根本链路是:
- WAL 的频繁 fsync 对磁盘 IO 延迟最敏感。
- 快照生成时的 IO 突发会抢占 WAL 的磁盘带宽。
- boltdb 的批量提交在积压严重时也会加剧 IO 竞争。
- Raft 协议依赖 WAL 的快速响应,一旦 WAL 延迟升高,整个集群的提交延迟随之恶化。