文档版本 v3.7-DRAFT 处于 草稿 状态。如需获取最新的稳定版文档,请参阅 v3.6。
etcd 持久化存储文件
本文档解释了 etcd 持久化存储格式:命名、内容以及允许开发者检查这些文件的工具。未来应随着存储模型的变化持续扩展本文档。本文档面向 etcd 开发者,以帮助他们满足数据恢复需求。
前提条件
以下文章为本文档提供了有用的背景信息:
- etcd 数据模型概述:https://etcd.io/docs/v3.6/learning/data_model
- Raft 概述:https://raft.github.io/raft.pdf(特别是“5.3 日志复制”部分)。
概述
长期存在的文件
| 文件名 | 高层用途 |
|---|---|
./member/snap/db | 用于存储所有已应用数据、成员授权信息和元数据的bbolt b+树。它记录了最后一个已应用的 WAL 日志索引("consistent_index")。 |
./member/snap/0000000000000002-0000000000049425.snap ./member/snap/0000000000000002-0000000000061ace.snap | 对旧版 v2 存储的定期快照,包含:
从 etcd v3 开始,该内容与 /snap/db 文件中的内容重复。 这些文件会定期(30 秒)被清理,并保留最近的 |
/member/snap/000000000007a178.snap.db | 如果副本落后太多,将从 etcd 领导者处下载一个完整的 bbolt 快照。 其内容类型与 ( 该文件用于以下两种场景:
在恢复完成后,该文件不会被立即删除(整个内容会被写入到 ./member/snap/db 文件中)。系统会定期(每 30 秒)清理这些文件,并保留最多 |
./member/wal/000000000000000f-00000000000b38c7.wal ./member/wal/000000000000000e-00000000000a7fe3.wal ./member/wal/000000000000000d-000000000009c70c.wal | Raft 的预写日志(Write Ahead Logs),包含 Raft 接受的最近事务、周期性快照或 CRC 记录。 保留最近的 如果快照生成频率过低,可能会存在超过 |
./member/wal/0.tmp (或 .../1.tmp) | 为下一个预写日志文件预分配的空间,用于避免因 WAL 日志空间不足而导致 Raft 停滞,同时又无法及时发出告警的情况。 |
临时文件
在 etcd 内部处理过程中,可能会遇到一些短暂存在的临时文件:
| 文件 | 高层用途 |
|---|---|
./member/snap/0000000000000002-000000000007a178.snap.broken | 当快照文件无法被加载时,会被重命名为 'broken' 状态。 尝试加载最新文件的操作发生在 etcd 被启动时。 或者在执行 etcdctl 的备份/迁移命令期间。 |
./member/snap/tmp071677638(随机后缀) | 副本上创建的临时(bbolt)文件,用于响应来自领导者的 msgSnap 请求,即根据领导者要求通过指定快照恢复存储。 在成功完整获取内容后,该文件会被重命名为: 参见 etcd/issues/12837。已在 etcd 3.5 中修复。 |
/member/snap/db.tmp.071677638(随机后缀) | 在碎片整理过程中用于存放 backend 内容(/member/snap/db)副本的临时文件。碎片整理成功完成后,该文件会被重命名为 /member/snap/db,替换原始的 backend 文件。 在 etcd 服务器启动时,这些文件会被修剪。 |
bbolt b+树:member/snap/db
该文件包含主要的 etcd 内容,应用于 Raft 日志的特定位置(参见 consistent_index)。
物理结构
更优的 bolt 存储在物理上被组织为一棵B+树。B 树的物理页从不就地修改1。相反,内容会被复制到一个新页面(从空闲页列表中重新分配),一旦没有正在打开的事务可能访问旧页面,该旧页面就会被加入空闲页列表。得益于这一机制,一个打开的只读事务可以看到存储的一致历史状态。读写事务是独占的,并会阻塞所有其他读写事务。
大值存储在多个连续的页面上。页面回收过程结合需要分配不同大小的连续页面区域,可能导致 bbolt 存储的碎片逐渐增加。
bbolt 文件不会自动缩小。只有在执行碎片整理过程中,文件才会被重写为一个新文件,该新文件在其末尾保留一些空闲页缓冲区,并具有截断后的尺寸。
逻辑结构
bbolt 存储被划分为多个桶(bucket)。每个桶中以字典序存储键值对(byte[] -> value byte[])。以下列表展示了 etcd(截至版本 3.5)所使用的桶及其对应的键。
| 桶 | 键 | 示例值 | 描述 |
|---|---|---|---|
| alarm | rpcpb.Alarm: {MemberID, Alarm: NONE|NOSPACE|CORRUPT} | nil | 表示某个成员节点中已诊断出问题。 |
| auth | "authRevision" | ""(空)或 BigEndian.PutUint64 | 任何角色或用户的更改都会在事务提交时递增此字段。 该值仅用于授权过程中的乐观锁。 |
| authRoles | [roleName] 字符串形式 | authpb.Role 序列化结果 | |
| authUsers | [userName] 字符串形式 | authpb.User 序列化结果 | |
| cluster | "clusterVersion" | "3.5.0"(字符串) | 共识达成的通用存储版本的次版本号。 |
| "downgrade" | JSON:{ "target-version": "3.4.0", "enabled": true/false } | 持久化最近一次 自 v3.5 起 | |
| 键 | [revisionId] 使用 bytesToRev{main,sub} 编码 键值删除操作会以 't' 结尾进行序列化(作为“墓碑”Tombstone) | mvccpb.KeyValue 序列化后的协议缓冲区结构(key, create_rev, mod_rev, version, value, lease id) | |
| 租约 | leasepb.Lease 序列化后的协议缓冲区结构(ID, TTL, RemainingTTL) | 注意:LeaseCheckpoint 仅扩展了 RemainingTTL,原始的 TTL 来自 Grant 请求。 注意 2:我们以秒为单位持久化 TTL(从不确定的“当前时间”起算)。服务器崩溃重启循环不会释放租约!!! | |
| 成员 | 以十六进制字符串形式表示的 [memberId]:"8e9e05c52164694d" | 序列化为字符串的 JSON 格式的 Member 结构:{
"id":10276657743932975437,
"peerURLs":[
"http://localhost:2380"],
"name":"default",
"clientURLs": ["http://localhost:2379"]
} | 达成一致的集群成员信息。 |
| members_removed | 以十六进制字符串形式表示的 [memberId]:"8e9e05c52164694d" | []byte("removed") | 所有已移除成员的 ID。用于验证一个已被移除的成员不会以相同的 ID 再次被添加。 该字段目前(3.4 版本)仅从 V2 存储中读取,不会从 V3 中读取。参见 https://github.com/etcd-io/etcd/pull/12820 |
| 元数据 | "consistent_index" | uint64 字节(大端序) | 表示最后一条已应用的 WAL 条目在 BoltDB 存储中的偏移量。 |
| "scheduledCompactRev" | bytesToRev{main,sub} 编码(16 字节) | 用于在崩溃后重新初始化压缩操作(如果崩溃发生在提交压缩请求之后)。 | |
| "finishedCompactRev" | bytesToRev{main,sub} 编码(16 字节) | 最近一次成功完成压缩时的版本号(https://github.com/etcd-io/etcd/blob/ae7862e8bc8007eb396099db4e0e04ac026c8df5/server/mvcc/kvstore_compaction.go#L54) | |
| "confState" | 自 etcd 3.5 起 | ||
| "term" | 自 etcd 3.5 起 | ||
| "storage-version" |
工具
bbolt
bbolt 提供了一个命令行工具,可用于查看文件内容。
使用示例:
列出指定 bbolt 文件中的所有存储桶(buckets):
% go run go.etcd.io/bbolt/cmd/bbolt buckets ./default.etcd/member/snap/db
读取特定的键/值对:
% go run go.etcd.io/bbolt/cmd/bbolt get ./default.etcd/member/snap/db cluster clusterVersion
etcd-dump-db
etcd-dump-db 可用于列出 v3 版本 etcd 后端(bbolt)的内容。
% go run go.etcd.io/etcd/v3/tools/etcd-dump-db list-bucket default.etcd
alarm
auth
...
更多示例请见:https://github.com/etcd-io/etcd/tree/master/tools/etcd-dump-db
WAL:预写日志(Write ahead log)
预写日志(Write ahead log)是 Raft 持久化存储的一部分,用于保存提案(proposals)。首先,领导者(leader)将提案存储在其日志中,然后(并发地)通过 Raft 协议将其复制给跟随者(followers)。每个跟随者在向领导者确认复制完成之前,必须先将提案持久化到自己的 WAL 中。
etcd 中使用的 WAL 日志与标准 Raft 模型有两个主要区别:
- 它不仅持久化带索引的日志条目,还包含 Raft 快照(轻量级)和硬状态(hard-state)。因此,成员的整个 Raft 状态可以仅从 WAL 日志中恢复。
- 它是仅追加(append-only)的。条目不会就地覆盖,而是在文件后面追加新的条目(相同索引),后追加的条目会取代之前的条目。
文件名
WAL 日志文件的命名遵循以下模式:
"%016x-%016x.wal", seq, index
示例:./member/wal/0000000000000010-00000000000bf1e6.wal
因此,文件名中包含以十六进制编码的:
- WAL 日志文件的序列号
- 文件中第一个条目或快照的索引。特别是第一个文件“0000000000000000-0000000000000000.wal”包含初始快照记录,其索引为 0。
物理内容
WAL 日志文件包含一系列“帧(Frames)”。每个帧包含:
- 小端序2 编码的 uint64,表示序列化后的 walpb.Record(3)的长度。
- 填充:若干个 0 字节,使得整个帧的大小为 8 的倍数(对齐)
- 序列化的 walpb.Record 数据:
- 类型 - 一个整型编码的枚举,决定下方数据字段的解析方式
- 数据 - 根据类型而定,通常是序列化的 protobuf 数据
- crc - 自 WAL 日志创建以来,该副本上所有日志记录中“数据”字段组合而成的 RC-32 校验和(不包括类型字段)。请注意,CRC 校验和包含所有记录(即使这些记录未被 Raft 提交)。
当当前文件大小超过 64*10^6 字节时,会“切分”文件(即创建新文件)。
逻辑内容
逻辑层中的预写日志文件包含:
Raftpb.Entry:由 Raft 领导者复制的最近提案。其中一些提案被视为“已提交”,其余的则可能在逻辑上被覆盖。Raftpb.HardState(term,commit,vote):定期(非常频繁)记录关于日志条目索引的信息,该条目已被“提交”(复制到大多数服务器),因此保证不会被更改或覆盖,并可应用到后端(v2、v3)。其中还包含“term”(指示是否发生过与选举相关的变更)以及 vote —— 当前副本在当前 term 中投票给的成员。walpb.Snapshot(term, index):定期的 Raft 状态快照(不包含数据库内容,仅包含日志索引和 Raft term)- v2 存储内容保存在独立的 *.store 文件中。
- v3 存储内容维护在 bbolt 文件中,一旦条目被应用,就会隐式形成快照。
- crc32 校验和记录(位于每个文件的开头),用于恢复对文件剩余部分的 CRC 校验。
etcdserverpb.Metadata(node_id, cluster_id)—— 用于标识该日志所代表的集群和副本。
每个 WAL 日志文件由以下部分按顺序构成:
CRC-32 帧(从所有先前文件累计的运行 CRC,第一个文件为 0)。
元数据帧(集群 ID 和副本 ID)
仅针对初始 WAL 文件:
- 空快照帧(索引:0,任期:0)。此帧的作用是保证一个不变性:所有条目都“紧随”一个快照之后。
对于非初始(第二个及以后)的 WAL 文件:
- HardState 帧。
条目、HardState 和快照记录的混合
WAL 日志可能包含相同索引的多个条目。这种情况可能出现在 Raft 论文 图 7 所描述的情形中。etcd 的 WAL 日志仅支持追加,因此通过追加具有相同索引的新条目来覆盖旧条目。
特别是在读取 WAL 时,逻辑会用新条目覆盖旧条目。因此,只有 entry.index <= HardState.commit 的条目的最后一个版本才可被视为最终版本。索引大于 HardState.commit 的条目仍可能被更改。
WAL 日志中的“任期”(terms)应是单调递增的。
WAL 日志中的“索引”(indexes)应满足以下条件:
- 从某个快照开始
- 在此快照之后,只要处于同一“任期”内,索引就应顺序增长
- 如果任期发生变化,索引可以减小,但新值必须高于最新的 HardState.commit。
- 可以在任意 index >= HardState.commit 处发生新的快照,从而开启一个新的索引序列。

工具
etcd-dump-logs
可以使用 etcd-dump-logs 工具读取 etcd 的 WAL 日志:
% go install go.etcd.io/etcd/v3/tools/etcd-dump-logs@latest
% go run go.etcd.io/etcd/v3/tools/etcd-dump-logs --start-index=0 aname.etcd
请注意:
- 该工具仅显示条目(Entries),而不显示 WAL 日志文件中的所有记录(如快照、HardStates)。
- 该工具会自动应用“覆盖”操作。如果某个条目被更新的同索引条目覆盖,则工具只会打印最终值。
- 该工具还会打印未提交的条目(来自日志尾部),但不提供关于 HardState.commitIndex 的信息,因此无法判断这些条目是否已最终确定。
(Store V2 的)快照:member/snap/{term}-{index}.snap
文件名:
member/snap/{term}-{index}.snap
文件名是在 此处 生成的 ("%016x-%016x.snap"),包含两个十六进制编码的部分:
- term(任期)—— 快照生成时的 Raft 任期(两次选举之间的周期)
- index(索引)—— 快照生成时最后已应用的提案的索引
创建
*.snap 文件由 Snapshotter.SaveSnap 方法创建。
有两个触发器控制这些文件的创建:
- 每隔大约 --snapshotCount=(默认为 100,000)个已应用的提案,就会创建一个新文件。这是一个近似值,因为我们可能会批量接收提案,并且仅在批处理结束时才考虑进行快照,最终快照过程是异步调度的。该标志名称(--snapshotCount)具有误导性,因为它会导致最后一个快照索引与最后一个已应用提案索引之间的索引值差异。
- Raft 请求副本从快照中恢复。当副本通过网络(msgSnap 消息)接收快照时,它还会将其检查点(轻量级)写入 WAL 日志。这保证了在 WAL 日志的尾部始终存在一个有效的快照及其后的日志条目,从而避免了 WAL 日志中可能出现的连续性缺失问题。
目前这些文件大致3与 WAL 日志中的快照条目一一对应。随着 store v2 的停用,我们预计这些文件将完全停止写入(可选:3.5.x 版本,强制:3.6.x 版本)。
内容
该文件包含序列化后的 snapdb.snapshot proto (uint32 crc, bytes data),
其中 'data' 字段保存了 Raftpb.Snapshot:
(bytes data, SnapshotMetadata{index, term, conf} metadata),
最终嵌套的 data 字段包含序列化为 JSON 的 store v2 内容。
具体包括:
- 任期
- 索引
- 成员信息:
/0/members/8e9e05c52164694d/attributes -> {"name":"default","clientURLs":["[http://localhost:2379](http://localhost:2379)"]}/0/members/8e9e05c52164694d/RaftAttributes -> "{"peerURLs":["http://localhost:2380"]}"
- 存储版本:/0/version-> 3.5.0
工具
protoc
以下命令允许你在 etcd 根目录下执行时查看文件内容:
cat default.etcd/member/snap/0000000000000002-0000000000049425.snap |
protoc --decode=snappb.snapshot \
server/etcdserver/api/snap/snappb/snap.proto \
-I $(go list -f '{{.Dir}}' github.com/gogo/protobuf/proto)/.. \
-I . \
-I $(go list -m -f '{{.Dir}}' github.com/gogo/protobuf)/protobuf
类似地,你可以提取 'data' 字段并解码为 'Raftpb.Snapshot'
etcd 3.4 中 *.snap 文件内 Store V2 内容的 JSON 序列化示例:
{
"Root":{
"Path":"/",
"CreatedIndex":0,
"ModifiedIndex":0,
"ExpireTime":"0001-01-01T00:00:00Z",
"Value":"",
"Children":{
"0":{
"Path":"/0",
"CreatedIndex":0,
"ModifiedIndex":0,
"ExpireTime":"0001-01-01T00:00:00Z",
"Value":"",
"Children":{
"members":{
"Path":"/0/members",
"CreatedIndex":1,
"ModifiedIndex":1,
"ExpireTime":"0001-01-01T00:00:00Z",
"Value":"",
"Children":{
"8e9e05c52164694d":{
"Path":"/0/members/8e9e05c52164694d",
"CreatedIndex":1,
"ModifiedIndex":1,
"ExpireTime":"0001-01-01T00:00:00Z",
"Value":"",
"Children":{
"attributes":{
"Path":"/0/members/8e9e05c52164694d/attributes",
"CreatedIndex":2,
"ModifiedIndex":2,
"ExpireTime":"0001-01-01T00:00:00Z",
"Value":"{\"name\":\"default\",\"clientURLs\":[\"http://localhost:2379\"]}",
"Children":null
},
"RaftAttributes":{
"Path":"/0/members/8e9e05c52164694d/RaftAttributes",
"CreatedIndex":1,
"ModifiedIndex":1,
"ExpireTime":"0001-01-01T00:00:00Z",
"Value":"{\"peerURLs\":[\"http://localhost:2380\"]}",
"Children":null
}
}
}
}
},
"version":{
"Path":"/0/version",
"CreatedIndex":3,
"ModifiedIndex":3,
"ExpireTime":"0001-01-01T00:00:00Z",
"Value":"3.5.0",
"Children":null
}
}
},
"1":{
"Path":"/1",
"CreatedIndex":0,
"ModifiedIndex":0,
"ExpireTime":"0001-01-01T00:00:00Z",
"Value":"",
"Children":{
}
}
}
},
"WatcherHub":{
"EventHistory":{
"Queue":{
"Events":[
{
"action":"create",
"node":{
"key":"/0/members/8e9e05c52164694d/RaftAttributes",
"value":"{\"peerURLs\":[\"http://localhost:2380\"]}",
"modifiedIndex":1,
"createdIndex":1
}
},
{
"action":"set",
"node":{
"key":"/0/members/8e9e05c52164694d/attributes",
"value":"{\"name\":\"default\",\"clientURLs\":[\"http://localhost:2379\"]}",
"modifiedIndex":2,
"createdIndex":2
}
},
{
"action":"set",
"node":{
"key":"/0/version",
"value":"3.5.0",
"modifiedIndex":3,
"createdIndex":3
}
}
]
}
}
}
}
变更
本节保留用于描述不同 etcd 版本之间引入的文件格式变更。