在上一章 版本集 (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 是如何巧妙地组织内容的。
一个数据块的物理布局大致如下:
它主要由三部分组成:
- 一系列的记录 (Entries):存放着实际的键值对数据。
- 重启点数组 (Restarts) :一个
uint32
类型的数组,记录了块内某些特殊记录的偏移量。 - 重启点数量 (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)的记录,就会设置一个重启点。
这就像字典每一页的第一个词条 总是完整地印出来一样。重启点数组则记录了块内所有重启点的偏移位置。
现在,当我们要在一个数据块内查找一个键时,过程就变成了:
- 二分查找重启点:首先在重启点数组中进行二分查找,快速定位到最后一个键小于等于目标键的重启点。这步操作非常快,因为它是在一个很小的、定长的数组中进行的。
- 线性扫描:从该重启点开始,向后逐个解码记录,进行线性扫描,直到找到目标键或发现目标键不存在。
由于重启点之间的记录数量不多(默认不超过 16 个),所以第二步的线性扫描成本非常低。通过这种"二分查找 + 小范围线性扫描"的方式,LevelDB 在保持高压缩率的同时,也实现了数据块内部的快速查找。
数据块是如何被构建和读取的?
数据块的生命周期由 BlockBuilder
和 Block
两个类管理。
构建 (BlockBuilder
)
当 LevelDB 从 内存表 (MemTable) 生成 排序字符串表 (SSTable) 时,它会使用 BlockBuilder
来创建每一个数据块。
BlockBuilder
会接收一个个有序的键值对。在 Add
方法中,它会计算当前键与上一个键的共享前缀,然后将压缩后的记录追加到内部的 buffer_
中。同时,它会检查是否达到了重启点间隔,如果达到了,就会将当前记录的偏移量添加的 restarts_
数组中。
读取 (Block
和 Block::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 终究是缓慢的。如果某个数据块被频繁地访问,每次都从磁盘读取显然不是最优解。有没有办法将这些"热点"数据块缓存在内存中,以避免重复的磁盘读取呢?