kv数据库-leveldb (10) 合并任务 (Compaction)

79 阅读9分钟

Chapter 9: 合并 (Compaction)

在上一章 排序字符串表 (SSTable) 中,我们学习了数据是如何从内存中的 MemTable 转移到磁盘,并以一种不可变、有序的文件格式(SSTable)永久保存下来的。

这引出了一个新问题。随着数据不断写入,数据库会产生越来越多的 SSTable 文件,尤其是在 Level-0 层级。同时,我们更新或删除数据时,旧的数据和删除标记并不会立即消失,而是留在了旧的 SSTable 文件中。如果不加以管理,磁盘上会堆满大量零散、冗余的文件,这会导致:

  • 读取性能下降:一次 Get 操作可能需要检查多个文件才能找到最新的数据。
  • 磁盘空间浪费:已被删除或被覆盖的旧数据依然占用着宝贵的磁盘空间。

为了解决这个问题,LevelDB 引入了一个至关重要的后台任务,它就像一位勤劳的图书馆管理员,定期整理书架。这个过程,就是本章的主角——合并(Compaction)。

什么是合并 (Compaction)?

Compaction 是 LSM 树(LevelDB 的底层结构)的核心后台任务。它会定期地将较低层级(比如 Level-N)的一些 SSTable 文件,与它们在更高层级(Level-N+1)中键范围有重叠的文件进行合并。

这个“合并”的过程主要做三件事:

  1. 消除冗余:对于同一个键(key),只保留其最新版本的值。所有旧版本的值都会被丢弃。
  2. 清理已删除数据:如果一个键的最新版本是删除标记,那么这个删除标记和这个键的所有旧版本数据都将被彻底清除。
  3. 减少文件数量:将多个小的、零散的输入文件,合并成少数几个大的、更有序的输出文件,并写入到更高层级。

这就像图书馆管理员定期整理书架:

  • 把同一本书的多个旧版本(旧数据)处理掉,只保留最新版。
  • 把借阅卡上标记为“已丢弃”的书(删除标记)和书本身一起从书架上拿走。
  • 把几本内容相关的薄册子(小的 SSTable),合并成一本厚的大部头(大的 SSTable),让书架更整洁,找书也更快。

这个过程对用户是完全透明的,它在后台默默进行,持续地优化数据库的结构,以保持高效的读性能和空间利用率。

Compaction 是如何被触发的?

Compaction 并不是随时都在发生,它由特定的条件触发。LevelDB 主要根据两个指标来决定是否需要启动一次 Compaction:

  1. 基于大小 (Size Compaction):这是最主要的触发方式。LevelDB 为除 Level-0 外的每一层都设定了一个目标大小。当某一层(Level-N)所有 SSTable 文件的总大小超过了这个阈值时,就会触发一次从 Level-N 到 Level-N+1 的 Compaction。

    • 类比:管理员发现“科幻小说”区的书架(Level-N)太满了,超过了规定容量,于是他就要把一些书整理到楼上的“大型书库”(Level-N+1)去。
  2. 基于文件数量 (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: 成功
  1. 选择文件 (Pick):后台线程首先会调用 VersionSet::PickCompaction() 来决定需要进行哪种合并,并选定输入文件。通常会从 Level-N 中选择一个文件,然后找出 Level-N+1 中所有与它键范围重叠的文件。
  2. 创建合并迭代器:LevelDB 会为所有这些输入文件创建一个 合并迭代器 (MergingIterator)。这个迭代器能按键的顺序,依次访问所有文件中的所有键值对,就好像它们本来就在一个巨大的有序文件中一样。
  3. 创建输出文件:为合并结果创建一个或多个新的 SSTable 文件(通过 TableBuilder),这些新文件将位于 Level-N+1。
  4. 遍历与筛选:后台线程开始遍历合并迭代器。对于每一个键,它只处理其最新版本的记录。所有旧版本的数据都会被忽略。如果最新版本是一个删除标记,那么这个键的所有相关数据(包括删除标记本身)都会被丢弃。
  5. 写入新文件:有效的键值对被写入到新的 SSTable 文件中。
  6. 原子化更新:当所有输入数据都处理完毕,并且新的 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.ccdb/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 的结果又是如何安全地应用到数据库状态中的呢?这就需要一个“版本控制系统”。