本节摘要:LevelDB 的数据文件不可变,但系统视图(哪些文件、在哪个层级、管哪些键)随时在变。本节讲透四件套——Version(某个时间点的视图)、VersionSet(版本族谱管理)、Manifest(视图变更的持久化日志)、CURRENT(指向最新 Manifest 的指针)——如何配合实现多版本并发控制与故障恢复。这是全书最烧脑但也最能体现 LevelDB 设计功力的一节。
阅读完本节,你应当能够:
先问三个问题,如果你答不上来,说明系统还差一块拼图:
这三个问题的答案,都系于一套多版本并发控制机制,而它的物理载体就是本节的主角:Version、VersionSet、Manifest、CURRENT。
数据文件是不可变的砖瓦,但"这些砖瓦如何组成一栋楼"的蓝图是不断变化的。蓝图变了,正在施工的工人(读取线程)看到的应该是他们进场时的旧蓝图,而不是被撕掉的半张纸。
每个 SSTable 文件的元数据至少包含:
把这些文件元信息按层级组织起来,在某个时间点形成一个完整自洽的物理视图,就是一个 Version。可以理解为数据库在某一瞬间的"全景快照"——注意,它只持有文件元信息引用,不复制文件数据本身,所以创建新版本的成本极低。
单个 Version 是静态的,版本的演进是动态的。VersionSet 管理版本生命周期,内部维护一个 Version 双向链表记录演进历史,并持有 current_ 指针永远指向最新版本。
current_ 版本引用(引用计数 +1),在读取期间视图固定current_,旧版本等引用归零后回收这是典型的"Copy-on-Write"思路:后台随便改,前台读的都是自己锁定那一刻的视图。
内存里的 VersionSet 是易失的,崩溃就没了。Manifest 文件把这些变更持久化——它是一个特殊的日志文件,顺序记录对数据库状态的所有增量修改:
数据库启动时,从空 VersionSet 开始,像放录像一样回放 Manifest,重建最新 Version。因为 SSTable 不可变,只要文件还在磁盘上,重建的视图就是准确的。
当 Manifest 增长过大或重新打开数据库时,LevelDB 会执行一次"快照"——把当前完整状态序列化写进新的 Manifest,之后开始新一段增量记录。
磁盘上可能有多个 Manifest 文件(MANIFEST-000001、MANIFEST-000002……),重启时读哪个?答案是 CURRENT——一个通常只有几行的轻量级文件,内容就是最新 Manifest 的文件名。
CURRENT 是"引导加载"思想的经典应用。每次创建新 Manifest 并写完,系统用原子操作(通常是重命名)更新 CURRENT。它是指向正确 Manifest 的最后一道、也是最关键的一道防线。
Manifest 写入前崩溃:新文件已成孤儿文件,重启后 Manifest 无记录,孤儿文件在后续清理中被识别删除,状态回滚到 Compaction 前。
Manifest 写入后、指针切换前崩溃:变更已持久化在 Manifest,重启后回放即恢复,current_ 指向 Compaction 后的状态,状态完整恢复。
| 组件 | 形态 | 职责 |
|---|---|---|
| Version | 内存对象 | 某时刻的文件集合视图 |
| VersionSet | 内存对象 | 管理版本链表与 current_ 指针 |
| Manifest | 磁盘日志 | 持久化版本变更 |
| CURRENT | 磁盘小文件 | 指向最新 Manifest |
这套设计最妙的地方在于:所有状态变更都遵循"先写日志,再切指针"的顺序。这意味着无论崩溃发生在哪个瞬间,数据库都能恢复到某个一致的、合法的状态——要么是 Compaction 前,要么是 Compaction 后,绝不会停在中间态。
⚠️ 常见坑:Manifest 是日志型文件,会随运行不断增长。如果长期不重新打开数据库,Manifest 可能变得很大,导致重启恢复变慢。理解它的快照机制后,你就知道"定期重启或触发快照"是合理的运维手段。
💡 关键直觉:把 Version 想成"拍照"。系统每次改布局就拍一张新照片(Version),读者的任务从"实时看现场"变成"拿着一张旧照片参观"——现场再怎么施工,游客看到的永远是照片里的样子。Manifest 就是那本照片档案,CURRENT 是告诉管理员"最新相册放哪"的标签。
Version 类内部的核心数据结构是一个向量数组 std::vector<FileMetaData*>,每个元素代表该层级所有文件的元数据列表。查找时,Level-0 由于键范围重叠需要遍历所有文件,而 Level-1 及以上可用二分查找。另外,VersionSet 的版本切换在持久化层面的关键操作叫 LogAndApply——把 VersionEdit 追加写入 Manifest 后再原子更新 current_ 指针,这个顺序在崩溃恢复时保证不丢不重。
一个直觉的方案是:每次文件变化都直接更新一份"最新状态文件"。但这样有几个问题:覆盖写有损坏风险、并发写要加锁、崩溃时新旧内容可能混杂。LevelDB 选择用 Manifest(追加式日志)记录变更,每次只是往文件末尾追加一条 VersionEdit。
这个选择的深层理由呼应了全书反复出现的哲学:追加比覆盖更可靠、更简单。日志一旦写入就不可变,读取时从"空版本"开始重放所有记录即可重建最新状态——不需要处理"写到一半"的中间态。它把"状态管理"变成了"事件序列",复杂度从"状态机的一致性"降到了"日志的追加顺序"。
VersionSet 维护的双向链表不只是"历史档案",它直接参与文件的生命周期管理。每个被读者引用的 Version 都有引用计数,只有引用归零的版本才会从链表中移除,其独占的文件才被允许物理删除。
这个机制回答了第 3.3 节遗留的问题:Compaction 删文件,凭什么确定没有读者在用?答案就是版本引用计数——旧文件先留在"旧版本的元数据"里,等所有基于旧版本的读者结束,引用归零,文件才真正删除。元数据系统不只是"记录状态",它还是"资源安全回收"的仲裁者。理解这一点,你就理解了为什么读操作可以无锁并发:因为有版本系统在背后担保它引用的文件不会凭空消失。
Manifest 是追加日志,长时间运行必然膨胀。每次 Version 变更追加一条记录,积累到一定程度,重启时重放会很慢。LevelDB 的解法是定期"压缩 Manifest":把当前完整状态序列化写入一个新的 Manifest 文件,然后更新 CURRENT 指向它,此后从新文件继续追加。
这个机制可以类比"日志压缩":历史细节被合并成一张快照,之后只记录增量。工程上的启示是:所有"追加型"日志系统最终都会遇到"日志无限增长"的问题,解法也都殊途同归——定期快照 + 增量继续。理解了 Manifest 的压缩,你就理解了几乎所有日志系统的共性挑战。
读操作可能在任何瞬间发生,如果版本切换不是原子的,读者可能拿到"一半新、一半旧"的视图——比如看到新增了 L1 文件、但没看到 L0 文件被删除,导致查找路径错乱。LevelDB 用"先写 Manifest 再切指针"保证这一点:指针切换是一瞬间完成的,要么读者看到旧版本(切换前),要么看到新版本(切换后),绝无中间态。
这与第 5.1 节 WriteBatch 的原子性逻辑一脉相承:"先持久化日志,再原子更新内存指针"是 LevelDB 保证一致性的统一配方。数据层如此,元数据层也如此。识别出这个模式,你会发现整个系统的正确性论证其实很统一。
问:Manifest 和 WAL 都是日志,有什么不同? 作用对象不同。WAL 记录的是"用户写入操作",用于恢复内存中的 MemTable;Manifest 记录的是"文件集合变更",用于恢复磁盘文件的层级视图。一个是数据层日志,一个是元数据层日志。
问:CURRENT 文件丢失会怎样? 等于失去了"引导入口",系统无法定位最新的 Manifest,数据库打开会失败。因此 CURRENT 的更新必须原子(重命名),这也是它被设计成"小文件 + 原子替换"的原因。
问:多个 Version 并存,内存会不会很大? 不会。Version 只存文件元数据引用(文件号、键范围、大小),不复制文件内容,每个版本通常几百字节到几 KB。只要读者不长期持有旧版本,版本链长度就保持在很小的规模。
用一句话串起四件套:Version 是"某时刻的文件清单",VersionSet 是"清单的族谱管理员",Manifest 是"族谱的档案簿"(记每次增删),CURRENT 是"档案簿的目录卡"。 读者查清单(Version),写者改清单(新 Version),档案簿记变化(Manifest),目录卡指向最新档案(CURRENT)。任何一个环节缺失,系统都无法在"持续变化的文件集"和"需要稳定视图的读者"之间维持秩序。
这套元数据系统,是 LevelDB 被称为"教科书级实现"的重要原因之一。它把"多版本并发控制"这个听起来很高深的概念,落实成了"日志 + 指针 + 引用计数"三个朴素机制的组合。搞懂了它,你不但理解了 LevelDB,也理解了 MVCC 的本质——用"时间"而不是"锁"来协调并发。
静态架构讲完了。下一章我们让组件跑起来——看写入、读取、压缩三条动态路径如何运转。