Chapter 9: 合并 (Compaction)
在上一章 排序字符串表 (SSTable) 中,我们学习了数据是如何从内存中的 MemTable 转移到磁盘,并以一种不可变、有序的文件格式(SSTable)永久保存下来的。
这引出了一个新问题。随着数据不断写入,数据库会产生越来越多的 SSTable 文件,尤其是在 Level-0 层级。同时,我们更新或删除数据时,旧的数据和删除标记并不会立即消失,而是留在了旧的 SSTable 文件中。如果不加以管理,磁盘上会堆满大量零散、冗余的文件,这会导致:
- 读取性能下降:一次
Get操作可能需要检查多个文件才能找到最新的数据。 - 磁盘空间浪费:已被删除或被覆盖的旧数据依然占用着宝贵的磁盘空间。
为了解决这个问题,LevelDB 引入了一个至关重要的后台任务,它就像一位勤劳的图书馆管理员,定期整理书架。这个过程,就是本章的主角——合并(Compaction)。
什么是合并 (Compaction)?
Compaction 是 LSM 树(LevelDB 的底层结构)的核心后台任务。它会定期地将较低层级(比如 Level-N)的一些 SSTable 文件,与它们在更高层级(Level-N+1)中键范围有重叠的文件进行合并。
这个“合并”的过程主要做三件事:
- 消除冗余:对于同一个键(key),只保留其最新版本的值。所有旧版本的值都会被丢弃。
- 清理已删除数据:如果一个键的最新版本是删除标记,那么这个删除标记和这个键的所有旧版本数据都将被彻底清除。
- 减少文件数量:将多个小的、零散的输入文件,合并成少数几个大的、更有序的输出文件,并写入到更高层级。
这就像图书馆管理员定期整理书架:
- 把同一本书的多个旧版本(旧数据)处理掉,只保留最新版。
- 把借阅卡上标记为“已丢弃”的书(删除标记)和书本身一起从书架上拿走。
- 把几本内容相关的薄册子(小的
SSTable),合并成一本厚的大部头(大的SSTable),让书架更整洁,找书也更快。
这个过程对用户是完全透明的,它在后台默默进行,持续地优化数据库的结构,以保持高效的读性能和空间利用率。
Compaction 是如何被触发的?
Compaction 并不是随时都在发生,它由特定的条件触发。LevelDB 主要根据两个指标来决定是否需要启动一次 Compaction:
-
基于大小 (Size Compaction):这是最主要的触发方式。LevelDB 为除 Level-0 外的每一层都设定了一个目标大小。当某一层(Level-N)所有
SSTable文件的总大小超过了这个阈值时,就会触发一次从 Level-N 到 Level-N+1 的 Compaction。- 类比:管理员发现“科幻小说”区的书架(Level-N)太满了,超过了规定容量,于是他就要把一些书整理到楼上的“大型书库”(Level-N+1)去。
-
基于文件数量 (Seek Compaction):Level-0 是一个特殊的层级,它的
SSTable文件之间可能存在键范围的重叠。当 Level-0 的文件数量达到一个阈值(例如 4 个)时,就会触发一次从 Level-0到 Level-1 的 Compaction。这是因为 Level-0 文件越多,读取时需要检查的文件就越多,会严重影响读取性能。- 类比:管理员发现前台的“新书速递”推车(Level-0)上堆的书太杂乱了,超过了 4 本,他必须马上把这些新书分类整理到正式的书架(Level-1)上,否则读者找书会很困难。
Compaction 的工作流程
虽然 Compaction 有不同的触发条件和类型(比如从 MemTable 到 Level-0 的 Minor Compaction,以及层级之间的 Major Compaction),但其核心流程是相似的。让我们以一次典型的从 Level-N 到 Level-N+1 的 Compaction 为例。
sequenceDiagram
participant BG as 后台线程
participant VS as VersionSet
participant Inputs as 输入文件 (L-N 和 L-N+1)
participant Output as 输出文件 (L-N+1)
participant Builder as TableBuilder
Note over BG: Compaction 被触发
BG->>VS: 1. PickCompaction() (挑选要合并的文件)
VS-->>BG: 返回 Compaction 任务 (包含输入文件列表)
BG->>Inputs: 2. 创建一个合并迭代器<br/>遍历所有输入文件
BG->>Builder: 3. 创建新的 SSTable 文件
loop 遍历合并后的有序键值
Inputs->>BG: 返回下一个最新的键值对
Note right of BG: (跳过旧版本和已删除的键)
BG->>Builder: 4. Add(key, value)
end
BG->>Output: 5. Finish() (完成新文件的写入)
BG->>VS: 6. LogAndApply() (记录变更)
Note right of VS: 原子地将输入文件<br/>替换为输出文件
VS-->>BG: 成功
- 选择文件 (Pick):后台线程首先会调用
VersionSet::PickCompaction()来决定需要进行哪种合并,并选定输入文件。通常会从 Level-N 中选择一个文件,然后找出 Level-N+1 中所有与它键范围重叠的文件。 - 创建合并迭代器:LevelDB 会为所有这些输入文件创建一个 合并迭代器 (MergingIterator)。这个迭代器能按键的顺序,依次访问所有文件中的所有键值对,就好像它们本来就在一个巨大的有序文件中一样。
- 创建输出文件:为合并结果创建一个或多个新的
SSTable文件(通过TableBuilder),这些新文件将位于 Level-N+1。 - 遍历与筛选:后台线程开始遍历合并迭代器。对于每一个键,它只处理其最新版本的记录。所有旧版本的数据都会被忽略。如果最新版本是一个删除标记,那么这个键的所有相关数据(包括删除标记本身)都会被丢弃。
- 写入新文件:有效的键值对被写入到新的
SSTable文件中。 - 原子化更新:当所有输入数据都处理完毕,并且新的
SSTable文件成功写入磁盘后,LevelDB 会记录一个“版本变更(VersionEdit)”。这个变更会原子性地告知数据库:用这些新的 Level-N+1 文件,替换掉那些旧的 Level-N 和 Level-N+1 的输入文件。最后,删除那些不再被任何版本引用的旧文件。
一个特殊的优化:Trivial Move
在一种特殊情况下,Compaction 可以被极大地简化。如果 Level-N 的一个文件与 Level-N+1 没有任何键范围重叠,并且与 Level-N+2 的重叠也很小,那么 LevelDB 就不需要做任何合并操作,而是直接把这个文件从 Level-N “移动”到 Level-N+1。这被称为“平凡移动”(Trivial Move),它的开销极小,因为它只涉及元数据的修改,而没有实际的数据读写。
深入代码实现
Compaction 的逻辑主要分布在 db/db_impl.cc 和 db/version_set.cc 中。
后台工作循环 (db/db_impl.cc)
DBImpl::BackgroundCompaction 是后台合并任务的入口点。它展示了 Compaction 的高层逻辑。
// 来自 db/db_impl.cc (简化逻辑)
void DBImpl::BackgroundCompaction() {
mutex_.AssertHeld();
// 优先处理 MemTable 的落盘 (Minor Compaction)
if (imm_ != nullptr) {
CompactMemTable();
return;
}
Compaction* c;
// ... 此处处理手动 Compaction 的逻辑 ...
// 选择一个常规的 Compaction 任务 (Major Compaction)
c = versions_->PickCompaction();
Status status;
if (c == nullptr) {
// 无事可做
} else if (c->IsTrivialMove()) {
// 执行平凡移动
// 1. 在元数据中移除 L-N 的文件
c->edit()->RemoveFile(c->level(), f->number);
// 2. 在元数据中添加 L-N+1 的文件
c->edit()->AddFile(c->level() + 1, f->number, ...);
// 3. 应用元数据变更
status = versions_->LogAndApply(c->edit(), &mutex_);
} else {
// 执行一次标准的合并工作
CompactionState* compact = new CompactionState(c);
status = DoCompactionWork(compact);
// ... 清理工作 ...
}
// ...
}
这段代码清晰地展示了后台线程的决策流程:优先将内存中的数据刷到磁盘,然后检查是否有常规的 Compaction 任务。如果任务可以被优化为“平凡移动”,就走捷径;否则,就执行完整的 DoCompactionWork。
选择合并任务 (db/version_set.cc)
VersionSet::PickCompaction 负责计算每一层的“健康度”分数,并决定哪个层级最需要进行 Compaction。
// 来自 db/version_set.cc (简化逻辑)
void VersionSet::Finalize(Version* v) {
int best_level = -1;
double best_score = -1;
for (int level = 0; level < config::kNumLevels - 1; level++) {
double score;
if (level == 0) {
// Level-0 的分数基于文件数量
score = v->files_[level].size() /
static_cast<double>(config::kL0_CompactionTrigger);
} else {
// 其他层的分数基于总字节大小
const uint64_t level_bytes = TotalFileSize(v->files_[level]);
score = static_cast<double>(level_bytes) / MaxBytesForLevel(options_, level);
}
if (score > best_score) {
best_level = level; // 找到分数最高的层
best_score = score;
}
}
v->compaction_level_ = best_level;
v->compaction_score_ = best_score;
}
在每次版本变更后,Finalize 方法都会重新计算每一层的 compaction_score_。当某个分数值大于等于 1 时,就意味着需要进行 Compaction 了。
合并的核心工作 (db/db_impl.cc)
DoCompactionWork 是执行 Compaction 的核心函数。它创建合并迭代器,遍历数据,并生成新的 SSTable 文件。
// 来自 db/db_impl.cc (简化逻辑)
Status DBImpl::DoCompactionWork(CompactionState* compact) {
// 创建一个合并输入文件内容的迭代器
Iterator* input = versions_->MakeInputIterator(compact->compaction);
// ... 释放锁,开始耗时的 I/O 操作 ...
mutex_.Unlock();
input->SeekToFirst();
// ...
while (input->Valid()) {
Slice key = input->key();
// ...
bool drop = false; // 是否要丢弃这个键
// 这里的逻辑会解析 internal_key,比较 sequence number
// 以确定是否是旧版本或者已被删除
if (/* 是一个过时的或已删除的键 */) {
drop = true;
}
if (!drop) {
// 如果需要,打开一个新的输出文件
if (compact->builder == nullptr) {
status = OpenCompactionOutputFile(compact);
}
// 将有效的键值对写入 TableBuilder
compact->builder->Add(key, input->value());
// ... 如果输出文件太大,就关闭它并创建新的 ...
}
input->Next();
}
// ... 完成最后一个输出文件 ...
mutex_.Lock();
// ...
// 安装 Compaction 的结果,原子地更新版本信息
status = InstallCompactionResults(compact);
return status;
}
这个循环是 Compaction 过程的“引擎”。它不断地从合并迭代器中取出下一个全局最小且最新的键,并决定是保留还是丢弃它。这个过程保证了输出的 SSTable 是干净、有序且无冗余的。
总结
在本章中,我们学习了 LevelDB 的内部维护机制——Compaction。
- Compaction 是一个后台任务,旨在优化数据库的读取性能和空间利用率。
- 它的核心工作是合并多个
SSTable文件,同时消除过时的数据和删除标记。 - Compaction 主要由两个条件触发:某一层级的总大小超过限制,或 Level-0 的文件数量过多。
- 整个过程通过创建一个新的数据库“版本”来原子性地提交,保证了数据的一致性和安全。
我们现在知道,LevelDB 通过 Compaction 来管理磁盘上的 SSTable 文件。但它是如何精确地知道哪些文件属于当前版本,哪些是待删除的旧文件呢?Compaction 的结果又是如何安全地应用到数据库状态中的呢?这就需要一个“版本控制系统”。