kv数据库-leveldb (12) 数据块 (Block)

在上一章 版本集 (VersionSet / Version)中,我们了解了 LevelDB 是如何通过一套精巧的版本控制系统来管理数据库在不同时间点的文件快照的。我们知道,一个 Version 对象就像一份精确的"文件清单",它告诉我们当前哪些 排序字符串表 (SSTable) 文件是有效的。

现在,让我们把目光放得更近一些,深入到 SSTable 文件的内部。一个 SSTable 文件可能会有几 MB 甚至几十 MB 大。当我们只想读取其中一个很小的键值对时,难道要把整个巨大的文件都读入内存吗?那样效率也太低了。

这就像查一本厚重的字典。为了找一个单词,你不会把整本字典从头到尾读一遍。相反,你会根据索引找到包含那个单词的那一页 ,然后只把那一页的内容读入脑中。LevelDB 也采用了同样聪明的策略。SSTable 文件内部被划分成一个个更小的单元,而这个"页"的角色,就是我们本章的主角------数据块 (Block)。

什么是数据块 (Block)?

数据块 (Block) 是 SSTable 文件内部组织数据的基本单元,也是 LevelDB 从磁盘读取数据的最小单位 。当 LevelDB 需要读取某个键值对时,它首先通过 SSTable 的索引块(Index Block)定位到可能包含这个键的数据块,然后只将这一个数据块(通常只有几 KB 大小)从磁盘加载到内存中进行查找。

这种设计极大地减少了磁盘 I/O 的次数和数据量,是 LevelDB 实现高性能随机读取的关键。

一个数据块本身就像一个迷你的、自成一体的 SSTable。它内部包含了一系列有序的键值对。但为了让这几 KB 的空间得到最极致的利用,并能支持快速的内部查找,LevelDB 对数据块的格式进行了两大核心优化:前缀压缩重启点

数据块的内部结构

让我们打开一"页"字典,看看 LevelDB 是如何巧妙地组织内容的。

一个数据块的物理布局大致如下:

graph TD subgraph "数据块 (Block)" direction LR A["记录 1 (Entry)"] --> B["记录 2 (Entry)"] --> C[...] --> D["记录 N (Entry)"] --> E["重启点数组 (Restarts)"] --> F["重启点数量 (Num Restarts)"] end style F fill:#fec style E fill:#ccf

它主要由三部分组成:

  1. 一系列的记录 (Entries):存放着实际的键值对数据。
  2. 重启点数组 (Restarts) :一个 uint32 类型的数组,记录了块内某些特殊记录的偏移量。
  3. 重启点数量 (Num Restarts) :一个 uint32 整数,指明了重启点数组的大小。

优化一:前缀压缩 (Prefix Compression)

在一个数据块内,所有的键都是有序的。这意味着相邻的键通常有很长一段相同的前缀。例如:

  • user:name:alice
  • user:name:bob
  • user:name:cindy

如果完整地存储每一个键,user:name: 这部分就会被重复存储三次,造成了空间浪费。前缀压缩的核心思想是:对于一个键,我们只存储它与上一个键 相比,不同的那一部分

上面的例子经过前缀压缩后,存储的记录(Entry)会变成这样:

  • 记录 1 : 共享前缀长度=0, 非共享部分长度=15, 值长度=..., 非共享部分="user:name:alice", 值="..."
  • 记录 2 : 共享前缀长度=11, 非共享部分长度=3, 值长度=..., 非共享部分="bob", 值="..."
  • 记录 3 : 共享前缀长度=11, 非共享部分长度=5, 值长度=..., 非共享部分="cindy", 值="..."

通过这种方式,我们只用很小的空间就存储了大量键的信息,极大地提高了空间利用率。

优化二:重启点 (Restart Points)

前缀压缩虽然节省了空间,但也带来一个新问题:如果我们想查找块中间的某个键(比如 user:name:cindy),由于它的完整内容依赖于前一个键 (user:name:bob),而前一个键又依赖于更前一个键,我们似乎必须从块的开头开始,一条条地解码记录,才能最终拼凑出目标键。这使得查找变成了线性扫描,效率很低。

为了解决这个问题,LevelDB 引入了重启点 (Restart Points)

一个重启点就是一个不使用前缀压缩 、完整存储了整个键的记录。数据块内每隔一定数量(由 options.block_restart_interval 控制,默认是 16)的记录,就会设置一个重启点。

这就像字典每一页的第一个词条 总是完整地印出来一样。重启点数组则记录了块内所有重启点的偏移位置

现在,当我们要在一个数据块内查找一个键时,过程就变成了:

  1. 二分查找重启点:首先在重启点数组中进行二分查找,快速定位到最后一个键小于等于目标键的重启点。这步操作非常快,因为它是在一个很小的、定长的数组中进行的。
  2. 线性扫描:从该重启点开始,向后逐个解码记录,进行线性扫描,直到找到目标键或发现目标键不存在。

由于重启点之间的记录数量不多(默认不超过 16 个),所以第二步的线性扫描成本非常低。通过这种"二分查找 + 小范围线性扫描"的方式,LevelDB 在保持高压缩率的同时,也实现了数据块内部的快速查找。

数据块是如何被构建和读取的?

数据块的生命周期由 BlockBuilderBlock 两个类管理。

构建 (BlockBuilder)

当 LevelDB 从 内存表 (MemTable) 生成 排序字符串表 (SSTable) 时,它会使用 BlockBuilder 来创建每一个数据块。

BlockBuilder 会接收一个个有序的键值对。在 Add 方法中,它会计算当前键与上一个键的共享前缀,然后将压缩后的记录追加到内部的 buffer_ 中。同时,它会检查是否达到了重启点间隔,如果达到了,就会将当前记录的偏移量添加的 restarts_ 数组中。

读取 (BlockBlock::Iter)

当需要从 SSTable 读取数据时,LevelDB 会将对应的数据块内容读入内存,并用这些内容创建一个 Block 对象。Block 对象本身不包含太多逻辑,它的主要作用是提供一个 迭代器 (Iterator) 来遍历块内的数据。

这个特殊的迭代器 Block::Iter 封装了数据块内部的查找逻辑。它的 Seek 方法完美地诠释了我们上面描述的查找过程:先二分查找重启点,再进行线性扫描。

深入代码实现

让我们看看相关的源码,加深对数据块结构的理解。

BlockBuilder::Add (table/block_builder.cc)

这个方法是构建数据块的核心,它实现了前缀压缩和重启点的逻辑。

cpp 复制代码
// 来自 table/block_builder.cc (简化逻辑)
void BlockBuilder::Add(const Slice& key, const Slice& value) {
  Slice last_key_piece(last_key_);
  // ...
  size_t shared = 0;
  // 如果还没到重启点间隔
  if (counter_ < options_->block_restart_interval) {
    // 计算与上一个 key 的共享前缀长度
    const size_t min_length = std::min(last_key_piece.size(), key.size());
    while ((shared < min_length) && (last_key_piece[shared] == key[shared])) {
      shared++;
    }
  } else {
    // 达到重启点间隔,将当前偏移量记为重启点
    restarts_.push_back(buffer_.size());
    counter_ = 0;
  }
  const size_t non_shared = key.size() - shared;

  // 将 <shared><non_shared><value_size> 写入 buffer
  PutVarint32(&buffer_, shared);
  PutVarint32(&buffer_, non_shared);
  PutVarint32(&buffer_, value.size());

  // 写入 key 的非共享部分和 value
  buffer_.append(key.data() + shared, non_shared);
  buffer_.append(value.data(), value.size());
  
  // 更新 last_key_
  last_key_.resize(shared);
  last_key_.append(key.data() + shared, non_shared);
  counter_++;
}

这段代码清晰地展示了:

  • 每隔 block_restart_interval 个条目,就会记录一个新的重启点。
  • 对于非重启点的条目,会计算与 last_key_ 的共享前缀长度 shared
  • 最终写入 buffer_ 的是编码后的元数据,以及键的非共享部分和完整的值。

BlockBuilder::Finish (table/block_builder.cc)

当一个数据块的所有记录都添加完毕后,Finish 方法会将重启点数组和它的长度追加到 buffer_ 的末尾,完成整个块的构建。

cpp 复制代码
// 来自 table/block_builder.cc
Slice BlockBuilder::Finish() {
  // 追加重启点数组
  for (size_t i = 0; i < restarts_.size(); i++) {
    PutFixed32(&buffer_, restarts_[i]);
  }
  // 追加重启点数量
  PutFixed32(&buffer_, restarts_.size());
  finished_ = true;
  return Slice(buffer_);
}

Block::Iter::Seek (table/block.cc)

这个方法是数据块内查找的核心。虽然代码比较长,但我们可以看到它清晰的两阶段查找逻辑。

cpp 复制代码
// 来自 table/block.cc (简化逻辑)
void Block::Iter::Seek(const Slice& target) {
  // 阶段 1: 在重启点数组中进行二分查找
  uint32_t left = 0;
  uint32_t right = num_restarts_ - 1;
  while (left < right) {
    uint32_t mid = (left + right + 1) / 2;
    uint32_t region_offset = GetRestartPoint(mid);
    // ... 解码重启点的 key ...
    Slice mid_key(/* ... */);
    if (Compare(mid_key, target) < 0) {
      // mid 的 key < target,所以 mid 之前的都不需要考虑
      left = mid;
    } else {
      // mid 的 key >= target,所以 mid 和之后的都不需要考虑
      right = mid - 1;
    }
  }

  // 阶段 2: 从找到的重启点开始进行线性扫描
  SeekToRestartPoint(left);
  while (true) {
    if (!ParseNextKey()) {
      return;
    }
    if (Compare(key_, target) >= 0) {
      // 找到了第一个 >= target 的 key
      return;
    }
  }
}

这个实现先用二分查找将搜索范围缩小到最多 16 个记录,然后再通过线性扫描 ParseNextKey 精确查找,高效地完成了块内定位。

总结

在本章中,我们深入 SSTable 的内部,探索了其最小的组成单元------数据块 (Block)。

  • 数据块是 LevelDB 磁盘读写的最小单位 ,它将大的 SSTable 文件划分为易于管理的小块。
  • 为了节省磁盘空间,数据块使用了前缀压缩技术来存储有序的键。
  • 为了在压缩的同时支持快速查找,数据块引入了重启点 机制,实现了"二分查找 + 小范围线性扫描"的高效查找策略。
  • BlockBuilder 负责构建数据块,而 Block::Iter 则负责在块内部进行高效的遍历和查找。

我们现在知道,当 LevelDB 需要数据时,它会从磁盘读取一个数据块到内存中。但是,磁盘 I/O 终究是缓慢的。如果某个数据块被频繁地访问,每次都从磁盘读取显然不是最优解。有没有办法将这些"热点"数据块缓存在内存中,以避免重复的磁盘读取呢?

相关推荐
重启的码农2 小时前
kv数据库-leveldb (13) 缓存 (Cache)
数据库
lypzcgf2 小时前
Coze源码分析-资源库-创建数据库-后端源码-应用/领域/数据访问层
数据库·go·后台·coze·coze源码分析·ai应用平台·agent平台
枫叶丹42 小时前
金仓数据库替代MongoDB:电子证照系统国产化改造实战
数据库·mongodb
麦兜*2 小时前
Redis 7.0 新特性深度解读:迈向生产级的新纪元
java·数据库·spring boot·redis·spring·spring cloud·缓存
可涵不会debug3 小时前
金仓数据库:破解电子证照国产化难题,开启政务效能新篇
数据库·政务
元闰子3 小时前
对 Agent-First 数据库的畅想
数据库·后端·aigc
java水泥工3 小时前
学科竞赛管理系统|基于SpringBoot和Vue的学科竞赛管理系统(源码+数据库+文档)
数据库·vue.js·spring boot
kobe_OKOK_4 小时前
django 数据库迁移
数据库·oracle·django
寻星探路4 小时前
数据库造神计划第二十一天---JDBC编程
数据库·oracle