在上一章 内存表 (MemTable) 中,我们学习了 LevelDB 如何使用内存中的跳表(Skip List)来高速缓冲新的写入数据。MemTable 速度飞快,但它有两个天生的限制:容量有限且断电后数据会丢失(尽管有 预写日志 (Log / WAL) 保护)。
那么,当 MemTable 这块“写字板”写满之后,上面的数据该何去何从呢?为了永久保存这些数据,并能继续高效地访问它们,LevelDB 需要将它们从内存转移到磁盘。这个过程就像是把草稿纸上的内容,整理、排版后,印刷成一本正式的书。
这本印刷精美、内容有序、一旦出版就不能再修改的“书”,就是本章的主角——SSTable (Sorted String Table),即排序字符串表。
什么是 SSTable?
SSTable 是 LevelDB 在磁盘上存储数据的主要文件格式。它是 LevelDB 的基石。我们可以把它想象成一本已经印刷好的、并按字母顺序排序的字典。它具有以下几个核心特性:
- 不可变 (Immutable):一个
SSTable文件一旦被完整写入到磁盘上,就永远不会再被修改。就像一本已经出版的字典,你不能在上面涂改或增删词条。任何更新都将通过生成新的SSTable文件来完成。 - 有序 (Sorted):文件内部的所有键值对,都是严格按照键(key)的顺序排列的。这使得查找(
Get)和范围扫描(使用 迭代器 (Iterator))变得极其高效。 - 面向磁盘 (Disk-Oriented):它的文件结构经过精心设计,旨在最小化磁盘 I/O 次数,从而在机械硬盘和固态硬盘上都能获得良好的读取性能。
数据库中的绝大部分数据,最终都以一系列 SSTable 文件的形式,安静地躺在磁盘上。
一本 SSTable 的内部结构
为了理解 SSTable 是如何做到高效读取的,让我们来解剖一下它的文件结构。继续用“字典”来做比喻,一本 SSTable 文件通常由以下几个部分组成:
graph TD
subgraph "SSTable 文件 (例如 000005.sst)"
A["数据块 1<br/>(Data Block)"]
B["数据块 2<br/>(Data Block)"]
C[...]
D["过滤器块<br/>(Filter Block)"]
E["元数据索引块<br/>(Metaindex Block)"]
F["索引块<br/>(Index Block)"]
G["文件尾注<br/>(Footer)"]
end
F -- "指向" --> A
F -- "指向" --o B
E -- "指向" --> D
G -- "指向" --> E
G -- "指向" --> F
style A fill:#cde
style B fill:#cde
style C fill:#cde
style D fill:#f9f
style F fill:#ccf
style G fill:#fec
-
数据块 (Data Blocks) - 字典的正文 这是文件的主要部分,存放着成组的、有序的键值对。为了避免一次性读取整个巨大的文件,数据被切分成一个个的“块”(通常每个块几 KB)。
- 类比:字典中一页一页的正文内容,每一页包含若干个按顺序排列的词条。
-
索引块 (Index Block) - 字典的目录 这个块不存储完整的数据,而是存储每个数据块的“索引”。索引条目大致是
(数据块的最后一个 key, 数据块在文件中的位置)。当你查找一个 key 时,LevelDB 可以通过二分查找快速扫描这个索引块,从而定位到可能包含该 key 的那个数据块,而无需扫描所有数据。- 类比:字典书页顶部的“A...-Ada...”这样的引导词,或者书末的“词条索引表”。它能告诉你某个词条大概在哪一页。
-
过滤器块 (Filter Block) - 快速排除器 这是一个可选的部分。它使用一种叫 过滤器策略 (FilterPolicy)(通常是布隆过滤器)的概率性数据结构,可以非常快速地判断一个 key 是否可能不存在于这个
SSTable中。- 类比:一个“备忘录”。在你查字典前,先看一眼备忘录。如果备忘录说“这个词肯定没有”,你就完全不用去翻那本厚重的字典了,从而节省了大量的磁盘读取时间。它偶尔会误报(说“可能有”,但实际没有),但绝不会漏报(不会说“没有”而实际有)。
-
文件尾注 (Footer) - 版权页/寻路图 这是
SSTable文件的“寻路图”,它位于文件的末尾,并且长度固定。它包含了指向索引块和元数据索引块的指针(即它们在文件中的偏移量和大小)。LevelDB 读取一个SSTable文件时,会先从文件末尾读取这个固定长度的Footer,就像我们拿到一本书先翻到最后看版权页和索引信息一样。
SSTable 的生命周期
SSTable 是 LevelDB 内部自动管理的,我们无法直接创建或读取它。但我们可以了解它的生命周期。
1. 创建 (TableBuilder)
当一个不可变的 内存表 (MemTable) 需要被持久化到磁盘时,LevelDB 在后台会启动一个构建流程:
- 创建一个新的
.sst文件和一个TableBuilder对象。 TableBuilder会遍历MemTable中所有有序的键值对。TableBuilder将这些键值对一个个地Add进来,并负责将它们组织成数据块、生成过滤器内容,并构建索引。- 当所有数据都添加完毕后,调用
TableBuilder::Finish()。这个方法会将过滤器块、索引块和尾注等元信息写入文件,最终完成一个SSTable文件的构建。
这个过程就像排版印刷一本新字典。
2. 读取 (Table)
当用户的 Get 请求在内存中(MemTable)找不到数据时,LevelDB 就会去查询磁盘上的一系列 SSTable 文件。对于每个 SSTable,查询过程如下:
sequenceDiagram
participant DB as DB 实例
participant Table as Table 对象 (代表一个 SSTable 文件)
participant Filter as 过滤器块
participant Index as 索引块
participant Data as 数据块
participant Disk as 磁盘
DB->>Table: Get("my_key")
Table->>Filter: 1. KeyMayMatch("my_key")?
alt 过滤器说 "肯定不存在"
Filter-->>Table: 返回 false
Table-->>DB: 未找到
else 过滤器说 "可能存在"
Filter-->>Table: 返回 true
Table->>Index: 2. 在索引中查找 "my_key"
Index-->>Table: 定位到可能的数据块 X
Table->>Disk: 3. 从磁盘读取数据块 X
Disk-->>Data: 加载数据块 X
Data->>Data: 4. 在数据块 X 内部查找 "my_key"
alt 找到
Data-->>Table: 返回 "my_value"
Table-->>DB: 找到,返回 "my_value"
else 未找到
Data-->>Table: 未找到
Table-->>DB: 未找到
end
end
这个查找路径被设计得尽可能减少昂贵的磁盘读取操作。第一步的过滤器检查和第二步的索引块查找通常都在内存中完成,只有在必要时才会触发第三步的磁盘 I/O。
深入代码实现
让我们看看 SSTable 构建和读取背后的一些关键代码。
构建 SSTable (table/table_builder.cc)
TableBuilder::Add 方法是构建过程的核心。它接收键值对,并将它们添加到当前的数据块中。如果数据块满了,它就会调用 Flush 将这个数据块写入文件。
// 来自 table/table_builder.cc (简化逻辑)
void TableBuilder::Add(const Slice& key, const Slice& value) {
Rep* r = rep_;
// ... 省略状态检查和断言 ...
// 将键值对添加到当前的数据块
r->data_block.Add(key, value);
const size_t estimated_block_size = r->data_block.CurrentSizeEstimate();
// 如果当前数据块的大小超过了预设值
if (estimated_block_size >= r->options.block_size) {
// 就将这个数据块刷到磁盘
Flush();
}
}
Flush 方法负责将填满的数据块写入文件,并为这个块在索引块中创建一个待处理的条目。
// 来自 table/table_builder.cc (简化逻辑)
void TableBuilder::Flush() {
Rep* r = rep_;
// ... 省略状态检查 ...
if (r->data_block.empty()) return;
// 将当前数据块写入文件,并获取其位置句柄 (handle)
WriteBlock(&r->data_block, &r->pending_handle);
if (ok()) {
// 标记有一个索引条目需要被添加到索引块中
r->pending_index_entry = true;
r->status = r->file->Flush(); // 确保写入磁盘
}
// ...
}
当所有数据都 Add 完毕后,TableBuilder::Finish 会被调用,它负责写入所有元数据并完成文件。
// 来自 table/table_builder.cc (简化逻辑)
Status TableBuilder::Finish() {
Rep* r = rep_;
Flush(); // 先刷入最后一个数据块
// ...
// 1. 写入过滤器块 (Filter Block)
if (ok() && r->filter_block != nullptr) {
WriteRawBlock(r->filter_block->Finish(), ...);
}
// 2. 写入元数据索引块 (Metaindex Block)
// ...
// 3. 写入索引块 (Index Block)
if (ok()) {
WriteBlock(&r->index_block, &index_block_handle);
}
// 4. 写入文件尾注 (Footer)
if (ok()) {
Footer footer;
footer.set_index_handle(index_block_handle);
// ...
footer.EncodeTo(&footer_encoding);
r->status = r->file->Append(footer_encoding);
}
return r->status;
}
这个 Finish 函数的执行顺序,完美地对应了我们在 mermaid 图中看到的 SSTable 文件从上到下的结构。
读取 SSTable (table/table.cc)
Table::Open 是读取 SSTable 的入口。它的第一步就是去文件末尾读取 Footer。
// 来自 table/table.cc (简化逻辑)
Status Table::Open(const Options& options, RandomAccessFile* file,
uint64_t size, Table** table) {
// ...
// 从文件末尾读取 Footer::kEncodedLength 长度的字节
char footer_space[Footer::kEncodedLength];
Slice footer_input;
Status s = file->Read(size - Footer::kEncodedLength, Footer::kEncodedLength,
&footer_input, footer_space);
if (!s.ok()) return s;
// 解析 Footer 内容
Footer footer;
s = footer.DecodeFrom(&footer_input);
if (!s.ok()) return s;
// 根据 Footer 提供的位置信息,读取索引块
BlockContents index_block_contents;
s = ReadBlock(file, opt, footer.index_handle(), &index_block_contents);
if (s.ok()) {
// 成功读取 Footer 和索引块,准备提供服务
Block* index_block = new Block(index_block_contents);
Rep* rep = new Table::Rep;
rep->index_block = index_block;
// ...
*table = new Table(rep);
(*table)->ReadMeta(footer); // 读取过滤器等元数据
}
return s;
}
这段代码清晰地展示了 SSTable 的自描述能力。只要拿到文件,就能通过末尾的 Footer 找到索引,进而有能力访问文件内的所有数据。
总结
在本章中,我们深入探索了 LevelDB 在磁盘上的核心数据结构——SSTable。
SSTable是一个不可变的、有序的键值对集合,是数据在磁盘上的主要存储形式。- 它的结构被精心设计以优化读取性能,主要由数据块、索引块、可选的过滤器块和文件尾注组成。
- 这种“字典式”的结构,使得 LevelDB 可以通过几次高效的查找(通常在内存中)和一次磁盘读取,就能快速定位到所需数据。
SSTable由TableBuilder创建,并由Table对象负责读取和解析。
现在我们知道了,数据从 MemTable 落盘后,会变成一个个独立的、有序的 SSTable 文件。但随着时间的推移,数据库中会积累越来越多的 SSTable 文件。一个键的最新值可能在 MemTable 里,旧值可能在 SSTable 文件A里,更旧的值在 SSTable 文件B里,甚至还有一些删除标记散落在各处。这会降低读取效率并浪费磁盘空间。