RocksDB源码学习:深入BlockBasedTable(一)
BlockBasedTable是RocksDB中SST文件的默认格式,本文是源码学习BlockBasedTable的第一篇,主要围绕数据块(Data Block)层面,进行介绍。
一、 BlockBasedTable 整体认知
在深入研究 RocksDB 的核心存储单元之前,我们首先需要建立一个关于 BlockBasedTable (SST 文件格式) 的宏观视图。它是 RocksDB 默认的、用于在磁盘上持久化存储键值对的格式。
1.1 SST 文件磁盘布局概览
一个 BlockBasedTable 格式的 SST 文件在逻辑上由多个部分组成,一个典型的 SST 文件结构如下,特别是注意其读取入口位于文件的末尾:
<文件起始>
[数据块 1]
[数据块 2]
...
[数据块 N]
// --- 以下元数据块的顺序并非固定 ---
[Meta Block: Filter Block]
[Meta Block: Properties Block]
[Meta Block: Compression Dictionary Block]
[Meta Block: Range Deletion Block]
... (其他元数据块) ...
[Index Block] (数据块的总索引)
[Metaindex Block] (元数据块的索引)
[Footer] (固定大小的文件尾部,包含指向 Index 和 Metaindex 的指针)
<文件结束>- Data Blocks: 存储实际的、已排序的键值对数据。
- Index Block: 数据块的索引,使得可以快速定位到包含特定 key 的数据块,而无需扫描整个文件。
- Meta Blocks: 存储元数据,如布隆过滤器 (
Filter)、表属性 (Properties) 等。 - Metaindex Block: “元数据的索引”,用于定位所有的 Meta Block。
- Footer: 文件的入口点,固定大小,存储了指向 Index Block 和 Metaindex Block 的指针。RocksDB 读取一个 SST 文件时,总是先从这里开始。
1.2 数据块 (Data Block)
在整个 SST 文件结构中,数据块 (Data Block) 是最基本的存储单元。所有用户的键值对数据最终都被组织在一个个数据块中。因此,理解数据块的内部格式、构建方式和读取逻辑,是掌握 BlockBasedTable关键第一步。
二、 数据块的内部格式
数据块的设计目标是在保证快速查找的同时,尽可能地节省存储空间。为此,它采用了两种关键技术:前缀压缩 (Prefix Compression) 和 **重启点 (Restart Points)**。
2.1 键值对条目 (Entry) 的存储格式
块内的每一个键值对(Entry)都遵循以下格式进行编码,以实现前缀压缩:
// 源码: table/block_based/block_builder.cc 注释
shared_bytes: varint32 // 与上一个 key 共享的前缀长度
unshared_bytes: varint32 // 当前 key 不共享的后缀长度
value_length: varint32 // value 的长度
key_delta: char[unshared_bytes] // key 的非共享后缀部分
value: char[value_length] // 完整的 value 数据2.2 核心设计一:前缀压缩 (Prefix Compression)
由于数据块内的 key 是有序的,相邻的 key 常常拥有共同的前缀。前缀压缩技术正是利用了这一点,对于一个 key,我们不存储它的完整内容,而是存储它与上一个 key 的共享前缀长度 (shared_bytes),以及它自己独有的后缀部分 (key_delta)。这样,在数据密集且前缀重复度高的场景下(如 URL、文件路径等),可以节省大量存储空间。
2.3 核心设计二:重启点 (Restart Points)
虽然前缀压缩节省了空间,但它也带来了一个问题:要解码任意一个 key,必须先解码它之前的所有 key,这是一个 O(N) 的操作。为了解决这个问题,引入了“重启点”机制。
一个重启点是一个不使用前缀压缩、完整存储 key 的条目。数据块中每隔 block_restart_interval 个条目(默认是16)就会设置一个重启点。这些重启点的存在,使得我们可以在块内进行二分查找,从而将查找复杂度降低到 O(logR + K)(R是重启点数量,K是重启点间隔),极大地提高了查找效率。
2.4 块尾 (Trailer) 的结构
在一个数据块所有条目存储完毕后,其尾部会附加一个 Trailer,包含两部分信息:
- 重启点偏移量数组: 一个由
uint32_t组成的数组,按顺序列出了块内所有重启点相对于块起始位置的偏移量。 - 重启点总数: 一个
uint32_t整数,表示重启点的数量。
这个 Trailer 结构为块内的高效查找提供了必要的元数据。
三、 数据块的构建:BlockBuilder 的工作原理
BlockBuilder 类负责将有序的键值对序列打包成一个遵循上述格式的数据块。
3.1 BlockBuilder 的设计目标与初始化
BlockBuilder 的目标就是高效地完成数据块的构建。其构造函数接收几个关键参数:
// 源码: table/block_based/block_builder.h
explicit BlockBuilder(int block_restart_interval,
bool use_delta_encoding = true,
...);-
block_restart_interval: 定义了重启点的间隔。 -
use_delta_encoding: 控制是否启用前缀压缩。
3.2 Add 方法详解:如何添加一个键值对
Add 方法是 BlockBuilder 的核心。每当一个键值对被添加时,它会执行以下逻辑(主要在 AddWithLastKeyImpl 中实现):
// 源码: table/block_based/block_builder.cc
inline void BlockBuilder::AddWithLastKeyImpl(...) {
size_t shared = 0;
// 1. 检查是否需要创建重启点
if (counter_ >= block_restart_interval_) {
restarts_.push_back(static_cast<uint32_t>(buffer_.size()));
counter_ = 0;
} else if (use_delta_encoding_) {
// 2. 计算共享前缀
shared = key_to_persist.difference_offset(last_key_persisted);
}
const size_t non_shared = key_to_persist.size() - shared;
// 3. 写入元数据
PutVarint32Varint32Varint32(&buffer_, static_cast<uint32_t>(shared),
static_cast<uint32_t>(non_shared),
static_cast<uint32_t>(value.size()));
// 4. 写入数据
buffer_.append(key_to_persist.data() + shared, non_shared);
buffer_.append(value.data(), value.size());
// 5. 更新状态
counter_++;
// last_key_ 会在 Add() 方法中被更新
}3.2.1 前缀压缩与重启点的协同工作
Add 方法的核心逻辑完美地展示了前缀压缩和重启点是如何协同工作的。
- 重启点创建: 当内部计数器
counter_达到block_restart_interval时,当前条目就成为一个新的重启点。此时,共享前缀长度shared为 0,完整的 key 会被写入,并且其在缓冲区中的起始偏移量会被记录在restarts_数组中。 - 前缀压缩: 当条目不是重启点时,代码通过
key_to_persist.difference_offset(last_key_persisted)计算出与上一个 key 的共享前缀长度shared。然后,只将非共享的后缀部分 (key_delta) 写入缓冲区,从而实现压缩。
3.2.2 一个具体的构建示例
为了更清晰地理解这个过程,我们假设 block_restart_interval = 3,并依次添加以下5个键(为简化,忽略值):
-
key: "apple" -
key: "apply" -
key: "apricot" -
key: "banana" -
key: "bandana"
BlockBuilder 内部会发生以下过程:
Entry 1: 添加 “apple”
-
counter为 0,满足重启点条件,这是一个重启点。 -
restarts数组记录当前偏移量0。 -
shared = 0,non_shared = 5。 - 写入 buffer:
<varint(0)><varint(5)>... "apple" ... -
last_key更新为"apple",counter变为 1。
Entry 2: 添加 “apply”
-
counter为 1,不是重启点。 - 与
"apple"比较,共享前缀为"appl"。 -
shared = 4,non_shared = 1(后缀为 “y”)。 - 写入 buffer:
<varint(4)><varint(1)>... "y" ... -
last_key更新为"apply",counter变为 2。
Entry 3: 添加 “apricot”
-
counter为 2,不是重启点。 - 与
"apply"比较,共享前缀为"ap"。 -
shared = 2,non_shared = 5(后缀为 “ricot”)。 - 写入 buffer:
<varint(2)><varint(5)>... "ricot" ... -
last_key更新为"apricot",counter变为 3。
Entry 4: 添加 “banana”
-
counter为 3,满足重启点条件,这是一个新的重启点。 -
restarts数组记录下当前条目的起始偏移量。 -
shared = 0,non_shared = 6。 - 写入 buffer:
<varint(0)><varint(6)>... "banana" ... -
last_key更新为"banana",counter重置为 1。
Entry 5: 添加 “bandana”
-
counter为 1,不是重启点。 - 与
"banana"比较,共享前缀为"ban"。 -
shared = 3,non_shared = 4(后缀为 “dana”)。 - 写入 buffer:
<varint(3)><varint(4)>... "dana" ... -
last_key更新为"bandana",counter变为 2。
这个例子清晰地展示了 BlockBuilder 如何在节省空间(通过前缀压缩)和保证查找效率(通过重启点)之间取得平衡。
3.3 Finish 方法:完成一个数据块的构建
当一个块的数据全部添加完毕,Finish 方法被调用以完成最终的打包工作。
// 源码: table/block_based/block_builder.cc
Slice BlockBuilder::Finish() {
// 将重启点数组写入 buffer
for (size_t i = 0; i < restarts_.size(); i++) {
PutFixed32(&buffer_, restarts_[i]);
}
// 将重启点数量和其他信息打包成 footer 并写入
uint32_t num_restarts = static_cast<uint32_t>(restarts_.size());
uint32_t block_footer = PackIndexTypeAndNumRestarts(index_type, num_restarts);
PutFixed32(&buffer_, block_footer);
finished_ = true;
return Slice(buffer_);
}这个方法将之前记录的所有重启点偏移量和总数作为 Trailer 追加到数据缓冲区的末尾,形成一个完整、独立、可解析的数据块。
四、 数据块的解析与迭代:Block 与 DataBlockIter
Block 和 DataBlockIter 类执行的是与 BlockBuilder 相反的操作:解析数据块并提供遍历能力。
4.1 Block 类的角色:块的容器
Block 类是一个轻量级的容器。它的构造函数接收一个数据块的原始二进制 Slice,但它只解析块尾的 Trailer,提取出重启点数组的位置和数量。它并不会立即解码所有的键值对,这是一种延迟加载(Lazy Loading)的思想,避免了不必要的计算开销。
// 源码: table/block_based/block.cc
Block::Block(BlockContents&& contents, ...) {
auto& size = contents_.data.size_;
if (size < sizeof(uint32_t)) {
size = 0; // Error marker
} else {
// 从块的末尾读取重启点的数量
num_restarts_ = NumRestarts();
// ... 根据块的索引类型计算重启点数组的起始偏移量
restart_offset_ = static_cast<uint32_t>(size) -
(1 + num_restarts_) * sizeof(uint32_t);
// ... 其他初始化
}
}Block 类的主要职责是作为工厂,通过 NewDataIterator() 方法创建一个 DataBlockIter 实例,并将解析好的重启点等信息传递给它。
4.2 DataBlockIter:数据块的遍历者
DataBlockIter 是实现块内数据访问的核心。它是一个有状态的迭代器,在内部维护了遍历所需的所有上下文信息。
4.2.1 核心状态变量
DataBlockIter (及其基类 BlockIter) 内部维护着以下关键状态:
-
data_: 指向整个数据块内存的指针。 -
restarts_: 指向重启点数组在data_中的偏移量。 -
num_restarts_: 重启点总数。 -
current_: 当前正在处理的条目在data_中的偏移量。 -
restart_index_: 当前条目所属的重启点区间的索引。 -
raw_key_: 一个可复用的内部缓冲区(IterKey类型),用于存放当前完整解码后的 key。 -
value_: 一个Slice,直接指向data_中当前条目的 value 部分。
4.2.2 顺序遍历 (Next) 与增量解压
当调用迭代器的 Next() 方法时,其核心是 ParseNextKey() 函数。该函数负责从当前位置解码下一个条目,并重建出完整的 key。
// 源码: table/block_based/block.cc (BlockIter::ParseNextKey)
// ...
// 解码元数据: shared, non_shared, value_length
uint32_t shared, non_shared, value_length;
p = DecodeEntryFunc()(p, limit, &shared, &non_shared, &value_length);
if (shared == 0) {
// 重启点,key 是完整的。raw_key_ 直接指向这块内存。
UpdateRawKeyAndMaybePadMinTimestamp(Slice(p, non_shared));
} else {
// 增量编码的 key。
// raw_key_ 需要保留前 shared 个字节,并追加新的 non_shared 部分。
raw_key_.TrimAppend(shared, p, non_shared);
}
// value_ 指向 value 的内存区域
value_ = Slice(p + non_shared, value_length);
// ...这个过程精确地反映了 BlockBuilder 的编码逻辑:通过 shared 和 non_shared 的信息,迭代器可以高效地从上一个 key 的状态(保存在 raw_key_ 中)和当前条目的增量数据中重建出完整的 key。
4.2.3 随机查找 (Seek) 与重启点的应用
Seek(target_key) 操作充分展现了重启点的威力。它分为两步:
二分查找重启点: 首先,在块尾部的重启点偏移量数组上进行二分查找 (
BinarySeek),快速找到不大于target_key的最后一个重启点。这一定位过程非常快,复杂度为O(logR)。// 源码: table/block_based/block.cc, DataBlockIter::SeekImpl bool ok = BinarySeek<DecodeKey>(seek_key, &index, &skip_linear_scan);小范围线性扫描: 定位到重启点后,迭代器跳转到该重启点的内存位置,然后开始逐个解码条目并比较,直到找到第一个大于或等于
target_key的条目。由于重启点间隔不长(默认为16),这个线性扫描的范围很小,开销也很低。// 源码: table/block_based/block.cc, DataBlockIter::SeekImpl FindKeyAfterBinarySeek(seek_key, index, skip_linear_scan);
这个“二分查找 + 小范围线性扫描”的策略,使得在数据块内部的随机查找性能远高于纯粹的线性扫描。
4.3 关键实现细节:raw_key_ 的状态管理
在迭代过程中,迭代器内部的 raw_key_ 成员变量(一个可复用的缓冲区)如何被维护,是一个体现性能优化的关键细节。
4.3.1 指针引用 vs. 内存拷贝
raw_key_ 的更新方式有两种,取决于当前解码的条目类型:
| 情况 | 对应方法 | 内存操作 | 原因 |
|---|---|---|---|
| 重启点的 Key | SetKey(..., copy=false) | 指针引用 | 完整的 key 在原始数据块中是连续的,直接指向它可以避免不必要的内存拷贝,效率更高。 |
| 增量编码的 Key | TrimAppend(...) | 构建/拷贝 | 完整的 key 在原始数据块中不是连续存放的,必须在迭代器自己的缓冲区中通过“截断+追加”的方式重新构建出来。 |
这种双模操作的设计,体现了在可能的情况下(重启点)尽量避免内存拷贝,在必要的情况下(增量编码)高效复用缓冲区的优化思想。
五、 总结与展望
通过本次学习,我们深入了解了 RocksDB 中最基本的数据单元——数据块(Data Block)的设计与实现。其核心在于前缀压缩和重启点机制。
- 构建 (
BlockBuilder): 将有序的键值对通过增量编码和重启点策略,打包成紧凑的二进制块。 - 解析 (
Block&DataBlockIter): 通过解析块尾的重启点数组,实现了块内高效的“二分查找+线性扫描”式查找,并通过状态化的迭代器缓冲区raw_key_高效地完成前缀解压。
理解了单个数据块的运作原理后,我们下次学习将进入更高的层次,去探索 BlockBasedTable 是如何将这些数据块、索引块、过滤器块等组织成一个完整的 SST 文件,并提供高效的读写服务的。