在上一章 合并 (Compaction) 中,我们学习了 LevelDB 是如何通过后台合并任务,来清理冗余数据、减少文件数量,从而保持数据库的高效和整洁。Compaction 过程会创建新的 排序字符串表 (SSTable) 文件,并废弃掉旧的文件。
这带来了一个关键问题:在任何一个时间点,数据库里都可能存在着大量的文件。当一个读请求进来时,LevelDB 如何精确地知道应该去查询哪些 SSTable
文件,又应该忽略哪些已经被废弃的文件呢?它需要一个权威的"文件清单"来指引它。
这个"文件清单"以及管理它的整个系统,就是我们本章要探讨的核心------VersionSet
和 Version
。
什么是 Version 和 VersionSet?
我们可以把整个数据库想象成一个大型图书馆。SSTable
文件就是书架上一本本的书。
-
Version
(版本) :可以看作是图书馆在某个特定时刻的完整目录清单 。这份清单精确地记录了每一层书架(Level)上都有哪些书(SSTable
文件)。它是一个不可变的快照 。一旦生成,这份目录清单就不会再被修改。任何对图书馆藏书的变动(比如 Compaction 完成后),都会产生一份全新的目录清单。 -
VersionSet
(版本集) :则是图书馆的总目录办公室 ,或者说,是管理所有目录清单的总图书管理员。它的职责是:- 保管着当前最新、最有效 的那份目录清单(我们称之为
current
版本)。所有的读写操作都依赖这份最新清单。 - 管理所有历史版本的目录清单。因为可能还有读者(迭代器 (Iterator))正拿着一份旧清单在查阅,所以旧清单不能马上销毁。
- 负责"发布"新的目录清单。当藏书发生变化(Compaction 完成)时,它会根据变更信息,在当前清单的基础上生成一份新清单,并原子性地切换过去。
- 保管着当前最新、最有效 的那份目录清单(我们称之为
简而言之,Version
描述了数据库在某一刻的静态状态 ,而 VersionSet
则管理着这些状态的动态演变。
状态的演变:VersionEdit
如果每次 Compaction 之后,我们都重新制作一份包含所有上千个文件信息的全新目录,那效率也太低了。一个更聪明的方法是只记录变化。
VersionEdit
(版本编辑) :这就是描述"变化"的对象。它就像一张小小的变更便签,上面只写着:"在 Level 1 书架上,移除 了 001.sst 这本书,同时新增 了 005.sst 和 006.sst 这两本书"。VersionEdit
本身不包含数据库的全部状态,它非常轻量,只记录了从一个Version
到下一个Version
的增量变化。
这个机制和版本控制系统 Git 非常相似:
Version
就像一个 Git commit,代表了项目在某个时间点的完整快照。VersionEdit
就像一个 Git diff 或 patch,描述了两次 commit 之间的具体变化。VersionSet
就像是整个 .git 仓库 ,管理着所有的 commit 历史,并知道HEAD
指向哪一个。
VersionSet 是如何工作的?
VersionSet
是 LevelDB 内部的管理者,我们无法直接操作它。但理解它的工作流程,是理解 LevelDB 一致性和恢复机制的关键。
应用一次变更
当一次 合并 (Compaction) 完成后,它不会直接修改文件系统,而是会生成一个 VersionEdit
对象,然后把它交给 VersionSet
。VersionSet
会执行一个被称为 LogAndApply
的关键操作。
应用 versionEdit
创建新版本 VersionSet ->> MANIFEST: 3. 将 versionEdit 内容
作为一条日志写入文件 Note right of MANIFEST: 保证状态变更不丢失 MANIFEST -->> VersionSet: 写入成功 VersionSet ->> VersionSet: 4. 原子地将 "current" 指针
从 CurrentVersion 切换到 NewVersion Note right of VersionSet: 数据库状态已更新 VersionSet -->> Compaction: 返回成功
- 生成
VersionEdit
:Compaction 任务完成后,会创建一个VersionEdit
,记录下哪些文件被删除了,哪些新文件被添加了。 - 创建新
Version
:VersionSet
接收到VersionEdit
后,它会以当前的Version
为基础,应用这个VersionEdit
描述的变更,在内存中创建一个全新的Version
对象。 - 写入 MANIFEST 文件 :在切换到新版本之前,
VersionSet
必须将这个VersionEdit
持久化 到一个特殊的日志文件中,这个文件通常叫做MANIFEST-xxxxxx
。这和 预写日志 (Log / WAL) 的原理一样,确保了即使在状态切换时发生崩溃,重启后也能通过重放 MANIFEST 日志来恢复到正确的状态。 - 原子切换 :只有当
VersionEdit
成功写入 MANIFEST 文件后,VersionSet
才会把内部的current_
指针,从旧的Version
对象切换到新的Version
对象。这个切换是内存中的一个指针赋值操作,是原子性的。从这一刻起,所有新的数据库操作都将看到更新后的数据库状态。
提供读取快照
当一个读请求 Get
或一个新的 迭代器 (Iterator) 被创建时,它们会从 VersionSet
获取当前 的 Version
并持有一个对它的引用。只要这个引用存在,VersionSet
就不会销毁这个 Version
对象以及它所代表的所有 SSTable
文件,即使这个 Version
后来被更新的版本所取代。
这确保了长时间运行的扫描操作能在一个一致的、不会改变的数据快照上进行,不受并发写入和 Compaction 的影响。
深入代码实现
让我们通过源码来具体看看这三个核心概念。
Version
类 (db/version_set.h
)
Version
类的核心就是它包含的文件列表。它自身并没有复杂的逻辑,更像一个只读的数据容器。
cpp
// 来自 db/version_set.h (简化后)
class Version {
private:
friend class Compaction;
friend class VersionSet;
// 每个层级的文件列表
// files_[0] 是 Level-0 的文件元数据列表
// files_[1] 是 Level-1 的文件元数据列表, 等等
std::vector<FileMetaData*> files_[config::kNumLevels];
// ... 其他用于 Compaction 决策的元数据 ...
VersionSet* vset_; // 指向所属的 VersionSet
Version* next_; // 在 VersionSet 的双向链表中的下一个版本
Version* prev_; // 上一个版本
int refs_; // 引用计数
};
可以看到,Version
的核心成员 files_
是一个数组,每个元素是一个 FileMetaData
指针的 vector
,代表了对应层级的所有 SSTable
文件。refs_
字段是引用计数,用于管理 Version
对象的生命周期。
VersionEdit
类 (db/version_edit.h
)
VersionEdit
则清晰地展示了它作为"变更描述"的角色。
cpp
// 来自 db/version_edit.h (简化后)
class VersionEdit {
private:
friend class VersionSet;
typedef std::set<std::pair<int, uint64_t>> DeletedFileSet;
// ... 其他元数据变更,如 comparator, log_number ...
// <level, file_number>
DeletedFileSet deleted_files_;
// <level, FileMetaData>
std::vector<std::pair<int, FileMetaData>> new_files_;
};
它的主要成员 deleted_files_
和 new_files_
直接对应了我们之前"变更便签"的比喻,一个记录要删除的文件,一个记录要新增的文件。
VersionSet
类 (db/version_set.h
)
VersionSet
是整个系统的中枢。它维护着一个 Version
对象的双向循环链表,以及指向当前版本的指针。
cpp
// 来自 db/version_set.h (简化后)
class VersionSet {
public:
// 将 *edit 应用到当前版本,形成一个新版本
// 并将变更内容写入 MANIFEST 日志
Status LogAndApply(VersionEdit* edit, port::Mutex* mu);
// 返回当前版本
Version* current() const { return current_; }
// ... 其他管理方法 ...
private:
// ...
// MANIFEST 文件的写入器
log::Writer* descriptor_log_;
// 版本链表的头节点 (哨兵)
Version dummy_versions_;
// 指向当前活跃的版本
Version* current_;
};
current_
: 指向当前最新的Version
。dummy_versions_
: 这是一个哨兵节点,它和它的next_
、prev_
指针构成了一个包含所有活跃Version
对象的双向循环链表。current_
总是等于dummy_versions_.prev_
。LogAndApply()
: 这是外部世界(主要是 Compaction 线程)改变数据库元数据的唯一入口,它保证了状态变更的原子性和持久性。
数据库恢复
当 LevelDB 启动时,它会找到 CURRENT
文件,通过它定位到最新的 MANIFEST
文件。然后,它会读取 MANIFEST
文件中的所有 VersionEdit
记录,并在内存中从头到尾重放 这些变更,一步步地重建出数据库关闭前的最后一个 Version
状态。这个过程完成后,数据库就恢复到了一个一致的状态,可以开始对外提供服务了。
总结
在本章中,我们深入了解了 LevelDB 的状态管理核心------VersionSet
。
Version
是数据库在某个时间点的文件列表快照 ,它定义了哪些SSTable
文件是有效的。VersionEdit
是一个描述版本之间增量变化的轻量级对象,记录了文件的增删。VersionSet
是所有Version
的管理器 ,它负责维护当前版本,并通过应用VersionEdit
来原子性地生成新版本。- 整个状态变更的过程被持久化地记录在 MANIFEST 文件中,这保证了数据库在崩溃后能够准确地恢复其元数据。
这个精巧的版本控制系统,是 LevelDB 能够实现读操作的一致性快照(MVCC 的一种形式)、安全的后台 Compaction 以及崩溃恢复能力的基础。
我们现在已经从高层到底层,探索了 LevelDB 的大部分核心组件:从内存中的 内存表 (MemTable),到磁盘上的 排序字符串表 (SSTable),再到管理它们的 Compaction 和 VersionSet
。接下来,让我们把目光聚焦到构成 SSTable
的更小单元上。SSTable
文件内部的数据并不是杂乱无章地堆放的,而是被组织成一个个的"块"。