kv数据库-leveldb (6) 迭代器 (Iterator)

9 阅读8分钟

在前面的章节中,我们学习了如何通过 数据库实例 (DB) 来 PutGetDelete 单条数据,以及如何使用 批量写 (WriteBatch)来原子性地写入多条数据。我们已经掌握了如何精确地存取数据,但这还不够。

想象一下,你想获取所有 user: 开头的键值对,或者想按顺序浏览数据库中的每一条记录。如果只用 Get 方法,我们就必须提前知道所有键的名字,这在很多场景下是不可能的。我们需要一个工具,让我们能够像翻书一样,一页一页地浏览数据库的内容。

这个工具就是本章的主角——Iterator(迭代器)。

什么是迭代器?

Iterator 是遍历数据库中键值对的标准接口。你可以把它想象成一个智能书签。这个书签非常强大,它不仅知道当前在哪一页(哪一个键值对),还知道如何翻到下一页、上一页、书的第一页、最后一页,甚至能直接跳到任何你指定的页码附近。

它最神奇的地方在于,它为你提供了一个统一且有序的视图。无论数据是存储在最新的 内存表 (MemTable) 中,还是分布在磁盘上不同层次的 排序字符串表 (SSTable)文件里,迭代器都会将它们无缝地拼接起来,按照键的顺序,一个不漏、一个不多地呈现给你。你完全不需要关心数据存储的复杂内部结构,只需使用这个书签愉快地浏览即可。

如何使用迭代器

使用迭代器的过程非常直观,就像操作播放器一样:定位、播放、下一曲。

1. 创建一个迭代器

首先,我们需要从 DB 实例那里获取一个迭代器。这通过 NewIterator 方法完成。

#include "leveldb/iterator.h"

// 假设 db 已经成功打开
leveldb::ReadOptions options;
leveldb::Iterator* it = db->NewIterator(options);

重要提示NewIterator 会创建一个新的迭代器对象。当你使用完毕后,必须使用 delete 将其释放,否则会造成内存泄漏。

2. 从头到尾遍历数据库

这是最常见的用法。我们可以用一个简单的 for 循环来遍历整个数据库的所有内容。

// 1. 定位到开头
for (it->SeekToFirst(); 
     // 2. 检查当前位置是否有效
     it->Valid(); 
     // 3. 移动到下一个
     it->Next()) {
  
  leveldb::Slice key = it->key();
  leveldb::Slice value = it->value();
  
  std::cout << key.ToString() << " : " << value.ToString() << std::endl;
}

// 别忘了检查迭代过程中是否发生错误
assert(it->status().ok());

// 使用完毕后释放迭代器
delete it;

解释:

  • it->SeekToFirst(): 将书签移动到第一页。
  • it->Valid(): 检查书签当前所在的位置是否有效(书是否翻完了)。
  • it->Next(): 将书签翻到下一页。
  • it->key()it->value(): 读取当前页的键和值。返回的是 数据切片 (Slice),没有内存拷贝,效率极高。

3. 进行范围扫描

迭代器最强大的功能之一是范围扫描。例如,假设我们存储了用户信息,键的格式是 user:用户名。现在我们想找出所有用户的信息。

leveldb::Iterator* it = db->NewIterator(leveldb::ReadOptions());

std::string start_key = "user:";
std::string end_key = "user;"; // "user;" 在字典序上紧跟 "user:"

// 1. 定位到第一个 "user:" 或之后的位置
for (it->Seek(start_key); 
     // 2. 检查当前位置是否有效,并且键是否仍在范围内
     it->Valid() && it->key().ToString() < end_key;
     // 3. 移动到下一个
     it->Next()) {

    std::cout << "找到用户: " << it->key().ToString() << std::endl;
}

delete it;

解释:

  • it->Seek(target): 将书签移动到第一个大于或等于 target 的键的位置。这是实现范围扫描的关键。
  • 循环条件 it->key().ToString() < end_key 确保了我们只处理 user: 前缀的键。

4. 反向遍历

想从后往前浏览?同样简单!

leveldb::Iterator* it = db->NewIterator(leveldb::ReadOptions());

for (it->SeekToLast(); it->Valid(); it->Prev()) {
  std::cout << it->key().ToString() << " : " << it->value().ToString() << std::endl;
}

delete it;

这里的 SeekToLast()Prev() 分别对应着定位到结尾和向前移动。

迭代器内部是如何工作的?

迭代器简洁的接口背后,隐藏着一套精妙的设计,用于处理 LevelDB 复杂的数据存储结构。

我们知道,LevelDB 的数据分布在多个地方:

  • 一个正在写入的 内存表 (MemTable)
  • 可能有一个刚写满、正准备落盘的不可变 MemTable
  • 磁盘上多层、多个排序字符串表 (SSTable) 文件

所有这些数据源内部都是按键排序的。迭代器的核心任务就是将这些各自有序的数据源合并成一个全局有序的视图。

合并迭代器 (MergingIterator)

为了实现这个目标,LevelDB 使用了一种叫做 MergingIterator 的特殊迭代器。它的工作方式如下:

  1. 创建子迭代器:LevelDB 首先为每一个数据源(每个 MemTable,每个 SSTable 文件)创建一个独立的迭代器。
  2. 创建合并迭代器:然后,LevelDB 创建一个 MergingIterator 来管理所有这些子迭代器。
  3. 寻找最小值:当你调用 Next() 时,MergingIterator 会询问它管理的所有子迭代器:“你们当前指向的键是什么?”然后,它会比较所有这些键,找出其中最小的一个。这个最小的键就是全局有序视图中的下一个键。
  4. 前进一小步:找到了最小键之后,MergingIterator 会让持有这个最小键的那个子迭代器前进一步 (Next()),而其他子迭代器保持不动。

这个过程不断重复,就好像从多个有序的牌堆顶端,不断地找出最小的那张牌,从而形成一个全局有序的序列。

graph TD
    subgraph "你使用的 DB 迭代器"
        A[统一视图]
    end

    subgraph "LevelDB 内部"
        B(MergingIterator)
    end

    subgraph "各个数据源的子迭代器"
        C[MemTable 迭代器]
        D[SSTable 1 迭代器]
        E[SSTable 2 迭代器]
        F[...]
    end
    
    A --> B
    B -- "寻找全局最小键" --> C
    B -- "寻找全局最小键" --> D
    B -- "寻找全局最小键" --> E
    B -- "寻找全局最小键" --> F

    style A fill:#cde,stroke:#333
    style B fill:#f9f,stroke:#333

处理重复键和删除

MergingIterator 找到的可能只是内部的键。LevelDB 在 MergingIterator 之上还有一个包装层(DBIter),负责处理最终的逻辑:

  • 处理重复键:如果在不同层级(比如 MemTableSSTable)找到了相同的用户键,DBIter 会根据内部的版本号(Sequence Number)选择最新的那一个,并屏蔽掉旧的。
  • 处理删除:如果 DBIter 找到了一个删除标记,它会直接跳过这个键以及所有比它更旧的版本,对用户来说就像这个键不存在一样。

深入代码实现

迭代器的接口定义在 include/leveldb/iterator.h 中。它是一个抽象基类,定义了我们用过的所有核心方法。

// 来自 include/leveldb/iterator.h
class LEVELDB_EXPORT Iterator {
 public:
  // ...
  virtual bool Valid() const = 0;
  virtual void SeekToFirst() = 0;
  virtual void SeekToLast() = 0;
  virtual void Seek(const Slice& target) = 0;
  virtual void Next() = 0;
  virtual void Prev() = 0;
  virtual Slice key() const = 0;
  virtual Slice value() const = 0;
  // ...
};

这里的 = 0 意味着这些是纯虚函数,具体的实现由不同的子类完成,比如用于 SSTable 的迭代器或 MergingIterator

MergingIterator 的核心逻辑在 table/merger.cc 中。让我们看看它寻找最小键的简化逻辑:

// 来自 table/merger.cc (简化逻辑)
void MergingIterator::FindSmallest() {
  IteratorWrapper* smallest = nullptr;
  // 遍历所有子迭代器
  for (int i = 0; i < n_; i++) {
    IteratorWrapper* child = &children_[i];
    if (child->Valid()) {
      if (smallest == nullptr) {
        smallest = child;
      } else if (comparator_->Compare(child->key(), smallest->key()) < 0) {
        // 如果当前子迭代器的键更小,就更新 smallest
        smallest = child;
      }
    }
  }
  // current_ 指向持有最小键的那个子迭代器
  current_ = smallest;
}

这段代码清晰地展示了 MergingIterator 是如何在线性扫描所有子迭代器后,找到当前全局最小键的。

一个重要的特性:快照(Snapshot)

当你通过 db->NewIterator() 创建一个迭代器时,它实际上捕获了数据库在该时间点的一个一致性视图,我们称之为“快照”。

这意味着,在迭代器创建之后对数据库进行的任何写入、删除或更新操作,都不会被这个迭代器看到。这保证了你在遍历数据的过程中,不会因为其他线程的并发修改而看到不一致或混乱的结果。这就像你给整座图书馆拍了张照片,然后你在这张照片上浏览书籍,无论现实中的图书馆如何变化,你的照片内容是固定的。

总结

在本章中,我们学习了 LevelDB 中用于数据遍历的强大工具——Iterator

  • Iterator 提供了一个统一且有序的视图来浏览数据库中的所有键值对。
  • 它屏蔽了数据存储在 内存表 (MemTable)还是多层 排序字符串表 (SSTable) 中的复杂性。
  • 我们掌握了迭代器的基本用法,包括全量遍历范围扫描反向遍历
  • 我们理解了其内部核心是 MergingIterator,它通过合并多个子迭代器来提供全局有序的视图。
  • 迭代器工作在一个快照上,保证了遍历过程的一致性。

到目前为止,我们已经覆盖了 LevelDB 最核心的几个用户接口:DBWriteBatchIterator。我们已经知道如何使用 LevelDB 了。从下一章开始,我们将真正深入 LevelDB 的内部,探索那些支撑起这些强大功能的底层组件。

我们将从保证数据持久性的第一道防线开始。当你调用 PutWrite 时,数据在被写入内存表的同时,还去了哪里以确保即使发生断电也不会丢失?