--- title: etcd 持久化存储文件 weight: 2650 description: 持久化存储格式与文件参考 categories: [概念] upstream_link: "https://github.com/etcd-io/website/blob/824597935df6e95992ef61c07e3222f4f796ca6c/content/en/docs/v3.7/learning/persistent-storage-files.md" aliases: [/etcd/learning/persistent-storage-files/] --- 本文介绍了 etcd 持久化存储格式:命名规则、内容结构以及可供开发者用于检查存储内容的工具。后续应随着存储模型的变更持续扩展本文内容。本文面向 etcd 开发者,旨在帮助其满足数据恢复需求。 ## 先决条件 {#prerequisites} 以下文章为本文提供了有益的背景信息: * [etcd 数据模型概述](/zh/docs/etcd/learning/data_model/) * [Raft 概述](https://raft.github.io/raft.pdf)(特别是“5.3 日志复制”节)。 ## 概述 {#overview} ### 长期存在的文件 {#long-leaving-files}
文件名 主要用途
./member/snap/db
bbolt b+tree 用于存储所有已应用的数据、成员权限信息及元数据。它知晓最新的已应用 WAL 日志索引("consistent_index")。
./member/snap/0000000000000002-0000000000049425.snap
./member/snap/0000000000000002-0000000000061ace.snap

定期生成的旧版 v2 存储系统快照,包含:

  • 基本成员信息
  • etcd 版本

自 etcd v3 起,其内容与 /snap/db 文件的内容重复。

这些文件会定期(30s)清理,仅保留最近的 --max-snapshots=5 个。

/member/snap/000000000007a178.snap.db

如果副本严重滞后,则从 etcd 领导者下载完整的bbolt 快照。

其内容类型与 ./member/snap/db 文件相同。

该文件用于以下两种场景:

  • 响应领导者从快照恢复的请求。
  • 服务器启动期间,发现最后一个快照(.snap.db 文件),且其索引比当前 snap.db 文件中的 consistent_index 更新时。
请注意:每个副本定期生成的快照只以 *.snap 文件形式输出,而不是 snap.db 文件。因此,无法保证 WAL 日志中的最新快照存在对应的 *.snap.db 文件。但在这种情况下,后端(snap/db)应比快照更新。

恢复完成后,该文件不会被删除(其全部内容会填充到 ./member/snap/db 文件中)。这些文件会定期(30s)进行清理。 此处同样只保留 --max-snapshots=5 个文件。由于这些文件可能达到 O(GBs) 量级,可能导致磁盘空间耗尽。

./member/wal/000000000000000f-00000000000b38c7.wal
./member/wal/000000000000000e-00000000000a7fe3.wal
./member/wal/000000000000000d-000000000009c70c.wal

Raft 的预写日志,包含 Raft 接受的近期事务、定期快照或 CRC 记录。

保留最近的 --max-wals=5 个文件。每个文件的大小为 ~64*10^6 字节。文件超过此硬编码大小时才会被切分,因此实际大小可能略超过该值(所以预分配的 0.tmp 无法完全防止磁盘空间耗尽)。

如果快照生成频率过低,可能会出现超过 --max-wals=5 个文件的情况,因为文件系统级锁会保护这些文件,防止其过早删除。

./member/wal/0.tmp (or .../1.tmp)
为下一个预写日志文件预留的空间。 用于避免因 WAL 日志容量不足而导致 Raft 卡住,且无法触发告警的情况。
### 临时文件 {#temporary-files} 在 etcd 内部处理过程中,可能会遇到多个生命周期较短的文件:
文件 主要用途
./member/snap/0000000000000002-000000000007a178.snap.broken

当快照文件无法加载时,会被重命名为“broken”。

etcd 启动时会尝试加载最新文件。

或在执行 etcdctl 的备份/迁移命令期间。

./member/snap/tmp071677638 (random suffix)

该临时 bbolt 文件由副本创建,用于响应领导者的 msgSnap 请求,按要求从指定快照恢复存储。

完整内容成功获取后,文件将被重命名为 /member/snap/[SNAPSHOT-INDEX].snap.db。若服务器在下载过程中崩溃或被终止,这些文件会保留在磁盘上,且不会自动清理。其体积可能达到数 GB。

参见 etcd/issues/12837。已在 etcd 3.5 中修复。

/member/snap/db.tmp.071677638 (random suffix)

在碎片整理过程中,该临时文件用于保存后端数据库内容(/member/snap/db)的副本。整理成功后,文件会重命名为 /member/snap/db,替换原后端数据库。

在 etcd 服务器启动时,这些文件会被清理。

## bbolt B+ 树:**member/snap/db** {#bbolt-btree-membersnapdb} 该文件包含已应用至 Raft 日志某个特定位置的 etcd 主要内容(参见 [consistent_index](https://github.com/etcd-io/etcd/blob/a1ff0d5373335665b3e5f4cb22a538ac63757cb6/server/etcdserver/cindex/cindex.go#L92))。 ### 物理组织结构 {#physical-organization} Bolt 存储系统在物理上按 [B+树](https://en.wikipedia.org/wiki/B%2B_tree) 组织。B+树的物理页永远不会原地修改[^1]。相反,内容会被复制到新页(从空闲页列表中回收)中,一旦没有正在运行的事务可能访问该旧页,该旧页即被加入空闲页列表。得益于这一机制,打开的只读(RO)事务可观察到存储系统一致的历史状态。读写(RW)事务具有排他性,会阻塞其他所有读写事务。 大值存储在多个连续页上。页回收过程与需分配不同大小的连续页区域的需求相结合,可能导致 bbolt 存储系统出现日益严重的碎片化。 bbolt 文件不会自行缩小。只有在执行碎片整理过程中,文件才能被重写为一个新的文件,该文件末尾保留了一定数量的空闲页,并且大小已被截断。 ### 逻辑组织 {#logical-organization} bbolt 存储系统划分为多个桶。每个桶中以字典序存储键(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
}

保存最近一次 Downgrade RPC 请求所配置的意图。

自 v3.5 版本起

key

[revisionId] 使用 bytesToRev{main,sub} 编码

删除的键值对在序列化时会以 't' 结尾,表示“墓碑”(Tombstone)

mvccpb.KeyValue 序列化 proto(key, create_rev, mod_rev, version, value, lease id)
lease leasepb.Lease 序列化后的 proto(ID、TTL、RemainingTTL)

注意:LeaseCheckpoint 仅扩展 RemainingTTL。TTL 仍来自原始 Grant。

注意 2:我们以秒为单位持久化 TTL(从未定义的“现在”开始计算)。崩溃循环的服务器不会释放租约!

members 以十六进制字符串形式表示的 [memberId]:"8e9e05c52164694d" 以字符串形式序列化的 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

meta "consistent_index" uint64 字节(大端序) 表示最后应用的 WAL 条目在 Bolt DB 存储系统中的偏移量。
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"
### 工具 {#tools} #### bbolt {#bbolt} bbolt 提供了一个命令行工具,可用于检查文件内容。 使用示例: ##### 列出给定 bbolt 文件中的所有桶: {#list-all-buckets-in-given-bbolt-file} ``` % go run go.etcd.io/bbolt/cmd/bbolt buckets ./default.etcd/member/snap/db ``` ##### 读取特定键值对: {#read-a-particular-keyvalue-pair} ``` % go run go.etcd.io/bbolt/cmd/bbolt get ./default.etcd/member/snap/db cluster clusterVersion ``` #### etcd-dump-db {#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](https://github.com/etcd-io/etcd/tree/master/tools/etcd-dump-db) ## WAL:预写日志 {#wal-write-ahead-log} 预写日志(Write Ahead Log)是 Raft 协议的持久化存储,用于存储提案。首先,领导者将提案写入其日志,然后(并发地)通过 Raft 协议将提案复制到跟随者。每个跟随者在向领导者确认复制之前,会先将其提案持久化到自身的 WAL 中。 etcd 中使用的 WAL 日志与标准 Raft 模型存在两方面的差异: * 它不仅持久化索引条目,还持久化 Raft 快照(轻量级)和硬状态。因此,仅通过 WAL 日志即可恢复成员的完整 Raft 状态。 * WAL 只支持追加写入。条目不会原地覆盖;后续追加到文件中的同索引条目会取代先前的条目。 ### 文件名 {#file-names} WAL 日志文件的命名遵循以下模式: ``` "%016x-%016x.wal", seq, index ``` 示例:`./member/wal/0000000000000010-00000000000bf1e6.wal` 因此,文件名包含十六进制编码: * WAL 日志文件的顺序编号 * 文件中第一条条目或快照的索引。 特别地,第一个文件“0000000000000000-0000000000000000.wal”包含索引为 0 的初始快照记录。 ### 物理内容 {#physical-content} WAL 日志文件由一系列“[帧](https://github.com/etcd-io/etcd/blob/a1ff0d5373335665b3e5f4cb22a538ac63757cb6/server/wal/encoder.go#L62)”组成。每个帧包含: 1. 使用 [LittleEndian](https://github.com/etcd-io/etcd/blob/a1ff0d5373335665b3e5f4cb22a538ac63757cb6/server/wal/encoder.go#L120)[^2] 编码的 uint64,表示序列化后 [walpb.Record](https://github.com/etcd-io/etcd/blob/a1ff0d5373335665b3e5f4cb22a538ac63757cb6/server/wal/walpb/record.proto#L11) 的长度(3)。 2. 填充:若干个 0 字节,使整个帧的大小按 8 字节对齐。 3. 序列化后的 [walpb.Record](https://github.com/etcd-io/etcd/blob/a1ff0d5373335665b3e5f4cb22a538ac63757cb6/server/wal/walpb/record.proto#L11) 数据: 1. [type](https://github.com/etcd-io/etcd/blob/aa97484166d2b3fb6afeb4390344e68b02afb566/server/storage/wal/wal.go#L39) - 以整数编码的枚举,用于决定如何解释下述 data 字段 2. data - 由类型决定,通常是序列化后的 Protocol Buffers 数据 3. crc - 自 WAL 日志创建以来,该副本上所有日志记录中所有“data”字段(不包含 type)的 RC-32 校验和。请注意,CRC 计算包含所有记录(即使它们未被 Raft 提交)。 当当前文件超过 64*10^6 字节时,会将其切分并开始写入新文件。 ### 逻辑内容 {#logical-content} 预写日志文件在逻辑层包含: * `Raftpb.Entry: ` 由 Raft 领导者复制的最近提案。其中部分提案被视为“已提交”,其余提案可能被逻辑覆盖。 * `Raftpb.HardState(term,commit,vote): ` 关于日志条目索引的周期性(非常频繁)信息,该索引表示条目已“提交”(复制到多数服务器),因此保证不会被更改或覆盖,并可应用于后端(v2、v3)。该信息还包含“任期”(指示是否存在与选举相关的变更)以及投票信息——当前副本在当前任期中投给的成员。 * `walpb.Snapshot(term, index): ` Raft 状态的周期性快照(不包含数据库内容,仅包含快照日志索引和 Raft 任期) * v2 存储内容存储在独立的 *.store 文件中。 * v3 存储内容保存在 bbolt 文件中,一旦条目被应用,该文件即成为隐式快照。 * crc32 校验和记录(位于每个文件开头),用于恢复对文件剩余部分的 CRC 检查。 * `etcdserverpb.Metadata(node_id, cluster_id)` —— 用于标识日志所代表的集群与副本。 每个 WAL 日志文件按以下顺序构建: 1. CRC-32 帧(从之前所有文件延续的 CRC;第一个文件为 0)。 2. 元数据帧(集群 ID 与副本 ID)。 3. 仅初始 WAL 文件包含: * 空快照帧(索引:0,任期:0)。 此帧用于维持一个不变量:所有条目之前均有一个快照。 对于非初始(第 2 个及后续)WAL 文件: * HardState 帧。 4. 条目、硬状态与快照记录的混合 WAL 日志可能包含同一索引的多个条目。这种情况可能出现在 [Raft 论文](http://web.stanford.edu/~ouster/cgi-bin/papers/raft-atc14.pdf) 图 7 所描述的场景中。etcd 的 WAL 日志仅支持追加写入,因此当新条目以相同索引写入时,原有条目会被覆盖。 特别是在读取 WAL 时,[逻辑会用新条目覆盖旧条目](https://github.com/etcd-io/etcd/blob/release-3.4/wal/wal.go#L448-L462)。因此,仅当条目索引 entry.index <= HardState.commit 时,才可视为最终版本。索引大于 HardState.commit 的条目可能发生变化。 WAL 日志中的“任期”应保持单调递增。 WAL 日志中的“索引”预期满足以下要求: 1. 从某个快照开始 2. 在该任期期间,索引应持续递增 3. 若任期发生变化,索引可能减少,但必须大于最新的 HardState.commit 值 4. 任意索引大于等于 HardState.commit 的新快照都可能发生,从而开启新的索引序列 ![etcd 持久化存储文件](/docs/etcd/learning/img/persistent-storage-files-figure-01.png) ### 工具 {#tools-1} #### etcd-dump-logs {#etcd-dump-logs} etcd 的 WAL 日志可使用 [etcd-dump-logs](https://github.com/etcd-io/etcd/tree/master/tools/etcd-dump-logs) 工具读取: ``` % 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 ``` 请注意: * 该工具仅显示条目,而不显示 WAL 日志文件中的所有记录(如快照、HardState)。 * 该工具会自动应用“覆盖”规则。若某条目被同一索引下更新的条目覆盖,则工具仅输出最终值。 * 该工具还会输出未提交的条目(来自日志尾部),但不包含 HardState.commitIndex 信息,因此无法判断条目是否为最终值。 ## Store V2 快照:**member/snap/{term}-{index}.snap** {#snapshots-of-store-v2-membersnapterm-indexsnap} ### 文件名: {#file-names-1} **member/snap/{term}-{index}.snap** 文件名在 [此处](https://github.com/etcd-io/etcd/blob/ad5b30297a43daeb5ce7311fa606ce4c1f16618f/server/etcdserver/api/snap/snapshotter.go#L78) `("%016x-%016x.snap") ` 生成,采用 2 个十六进制编码的组合形式: * `term` -> 快照生成时的 Raft 任期(两次选举之间的时段) * `index` -> 快照生成时最后一个已应用提案的索引 ### 创建 {#creation} *.snap 文件由 [Snapshotter.SaveSnap](https://github.com/etcd-io/etcd/blob/ad5b30297a43daeb5ce7311fa606ce4c1f16618f/server/etcdserver/api/snap/snapshotter.go#L68) 方法创建。 有两个触发器控制这些文件的创建: * 每处理大约 --snapshotCount=(默认为 100'000)个已应用提案,就会创建一个新文件。这只是近似值:提案可能分批到达,而系统只会在一批处理结束时考虑生成快照,最终的快照过程还会异步调度。 标志名 --snapshotCount 容易产生误解:它控制[最后一次快照索引与最后一次已应用提案索引之间的索引差](https://github.com/etcd-io/etcd/blob/ad5b30297a43daeb5ce7311fa606ce4c1f16618f/server/etcdserver/server.go#L1266)。 * Raft 请求副本从快照恢复。副本通过网络接收快照(msgSnap 消息)时,也会将其以轻量方式记录到 WAL 日志中。这可确保 WAL 日志尾部始终存在一个后接日志条目的有效快照,从而避免 WAL 日志中可能出现的不连续。 目前,这些文件大致[^3]与 WAL 日志中的 Snapshot 条目一一对应。随着 v2 存储系统停用,预计将彻底停止写入这些文件(3.5.x 可选启用,3.6.x 强制执行)。 ### 内容 {#content} 该文件包含序列化后的 [snapdb.snapshot proto](https://github.com/etcd-io/etcd/blob/ad5b30297a43daeb5ce7311fa606ce4c1f16618f/server/etcdserver/api/snap/snappb/snap.proto#L11) `(uint32 crc, bytes data)`, 其中 `data` 字段包含 [Raftpb.Snapshot](https://github.com/etcd-io/etcd/blob/ad5b30297a43daeb5ce7311fa606ce4c1f16618f/raft/raftpb/raft.proto#L31): (字节数据,SnapshotMetadata{index, term, [conf](https://github.com/etcd-io/etcd/blob/ad5b30297a43daeb5ce7311fa606ce4c1f16618f/raft/raftpb/raft.proto#L99)} 元数据), 最后,嵌套数据包含序列化为 JSON 格式的 [存储 v2 内容](#exemplar-json-serialized-store-v2-content-in-etcd-34-snap-files)。 特别是存在: * 任期 * 索引 * 成员数据: * /0/members/8e9e05c52164694d/attributes -> {\"name\":\"default\",\"clientURLs\":[\"http://localhost:2379\"]} * /0/members/8e9e05c52164694d/RaftAttributes -> "{\"peerURLs\":[\"http://localhost:2380\"]}" * 存储版本:/0/version -> 3.5.0 ### 工具 {#tools-2} #### protoc {#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](https://github.com/etcd-io/etcd/blob/ad5b30297a43daeb5ce7311fa606ce4c1f16618f/raft/raftpb/raft.proto#L31)。 ### etcd 3.4 *.snap 文件中示例 JSON 序列化存储 v2 内容: {#exemplar-json-serialized-store-v2-content-in-etcd-34-snap-files} ```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 } } ] } } } } ``` ## 变更记录 {#changes} 本节用于描述不同 etcd 版本之间引入的文件格式变更。 [^1]: bbolt 文件开头的元数据页会原地修改。 [^2]: 不一致,因为大多数 uint 的写入格式为大端字节序 [^3]: WAL 日志开头的初始快照(索引: 0)不与 *.snap 文件关联。此外,旧的 *.snap 文件(或 WAL 日志)可能已被清除。