在上一章 预写日志 (Log / WAL) 中,我们了解了 LevelDB 如何通过"记流水账"的方式,确保任何写入操作在返回成功时都已持久化,即使发生断电也不会丢失。这解决了数据的安全问题。
现在,我们面临一个新的问题:日志是顺序追加的,查找效率很低。为了让用户能够快速地读到刚刚写入的数据,我们需要一个位于内存中的、支持快速查询的数据结构。这本"写字板"或"速记本",就是本章的主角------MemTable
(内存表)。
什么是 MemTable
?
MemTable
是一个位于内存中的数据结构,用于缓冲新的写入操作。你可以把它想象成一块高效的写字板 。当你进行 Put
或 Delete
操作时,在数据被记入 WAL 流水账之后,它会立刻被写到这块写字板上。
这块写字板有几个关键特性:
- 速度极快:因为它完全在内存中,写入操作几乎没有延迟。
- 内容有序:所有写在上面的键值对,都会自动按照键(key)的顺序排列好。
- 查询方便:因为内容有序,所以查找某个特定的键也同样迅速。
DB::Get
操作会优先查询这块写字板。由于大部分应用都倾向于读取最近写入的数据(数据局部性原理),MemTable
的存在极大地提升了数据库的读取性能。
当这块写字板写满时(大小由 选项 (Options) 中的 write_buffer_size
控制),LevelDB 会把它"冻结",变成一块只读的"历史记录板"(我们称之为不可变 MemTable),然后换上一块新的、干净的写字板继续工作。同时,会有一个后台线程默默地将这块"历史记录板"上的内容,整理成一个永久的文件存档,存入磁盘。这个文件存档就是我们下一章要讲的 排序字符串表 (SSTable)。
MemTable
的核心:跳表 (Skip List)
为了在内存中既能快速写入又能保持数据有序,MemTable
需要一个高效的底层数据结构。LevelDB 在这里选择了一个非常巧妙的数据结构------跳表 (Skip List)。
什么是跳表?让我们用一个比喻来理解。
想象一下,你要在一长串排好队的珠子里找到第 50 颗。最笨的方法是从第 1 颗开始,一颗一颗地往后数。这就是普通的链表。
而跳表则是在这串珠子之上,加了好几层"快速通道"。
- 第一层(最底层):连接了所有的珠子,就像普通链表。
- 第二层:每隔几颗珠子,就拉一根"快进线",连接它们。
- 第三层:在第二层的基础上,再隔更远的距离拉一根"超级快进线"。
现在,你要找第 8 颗珠子。你可以:
- 先走"超级快进线",从 1 直接跳到 9。发现跳过了,退回到 1。
- 降到"快进线",从 1 跳到 4,再跳到 7,再跳到 9。又跳过了,退回到 7。
- 降到基础链表,从 7 往后走一步,就找到了 8。
通过这种方式,我们跳过了大量的中间节点,查找速度得到了指数级的提升。跳表的插入和查找性能与平衡二叉树相当,但实现起来更简单,也更适合并发场景。
MemTable
的工作流程
MemTable
是 LevelDB 内部自动管理的,我们无法直接操作它。但理解它的工作流程对理解 LevelDB 的性能至关重要。
写入操作 (Put
)
当你调用 db->Put("key", "value")
时,MemTable
参与的流程如下:
读取操作 (Get
)
当你调用 db->Get("key")
时,MemTable
是第一道被查询的关卡。
这个查询顺序(活跃 MemTable -> 不可变 MemTable -> 磁盘文件)确保了最新的数据总能被最快地找到。
深入代码实现
让我们深入代码,看看 MemTable
和它的核心 SkipList
是如何实现的。
MemTable
类 (db/memtable.h
)
MemTable
类的定义非常简洁,它封装了底层的跳表。
cpp
// 来自 db/memtable.h (简化后)
class MemTable {
public:
explicit MemTable(const InternalKeyComparator& comparator);
// 向 MemTable 中添加一条记录
void Add(SequenceNumber seq, ValueType type, const Slice& key,
const Slice& value);
// 从 MemTable 中获取一条记录
bool Get(const LookupKey& key, std::string* value, Status* s);
// ... 其他方法 ...
private:
struct KeyComparator { /* ... */ };
typedef SkipList<const char*, KeyComparator> Table;
KeyComparator comparator_;
Arena arena_; // 内存分配器
Table table_; // 底层的跳表
};
table_
: 这就是底层的跳表实例。它存储的键是const char*
,指向下面arena_
中分配的内存。arena_
: 这是一个自定义的内存分配器。为了避免频繁地调用new
和delete
带来的性能开销和内存碎片,MemTable
会先向系统申请一大块内存(Arena),然后跳表的所有节点都在这块内存上进行分配。当MemTable
被销毁时,整块 Arena 内存会被一次性释放,效率非常高。
MemTable::Add
方法 (db/memtable.cc
)
Add
方法负责将键、值、序列号和操作类型打包成一个连续的内存块,然后插入到跳表中。
cpp
// 来自 db/memtable.cc (简化后)
void MemTable::Add(SequenceNumber s, ValueType type, const Slice& key,
const Slice& value) {
// 计算内部键和值编码后的总长度
const size_t encoded_len = ...;
// 从 Arena 中分配所需内存
char* buf = arena_.Allocate(encoded_len);
// 将 internal_key 和 value 编码进 buf ...
// ...
// 将指向这段内存的指针插入到跳表中
table_.Insert(buf);
}
这个方法的核心在于,它将所有信息(键、值、元数据)序列化到由 Arena
管理的一块内存中,然后只把指向这块内存的指针作为键插入到跳表中。这样做既高效又节省内存。
SkipList
类 (db/skiplist.h
)
跳表的实现是 LevelDB 的一个亮点。我们来看一下它的节点定义和插入逻辑。
Node
结构
每个节点都包含一个键,以及一个指向不同层级下一个节点的指针数组。
cpp
// 来自 db/skiplist.h (简化后)
template <typename Key, class Comparator>
struct SkipList<Key, Comparator>::Node {
explicit Node(const Key& k) : key(k) {}
Key const key; // 节点存储的键
// 指向下一个节点的指针数组
// next_[0] 是最底层的指针
std::atomic<Node*> next_[1];
};
next_
数组的大小是在创建节点时动态决定的,取决于这个节点拥有的"快进线"层数。
Insert
方法
插入新节点时,会随机生成一个高度(层数),这决定了新节点会出现在哪些"快进线"层上。
cpp
// 来自 db/skiplist.h (简化后)
template <typename Key, class Comparator>
void SkipList<Key, Comparator>::Insert(const Key& key) {
// 1. 找到每一层应该插入的位置
Node* prev[kMaxHeight];
FindGreaterOrEqual(key, prev);
// 2. 随机生成一个新节点的高度
int height = RandomHeight();
// 3. 创建新节点,并将它连接到每一层的链表中
Node* x = NewNode(key, height);
for (int i = 0; i < height; i++) {
x->SetNext(i, prev[i]->Next(i));
prev[i]->SetNext(i, x);
}
}
这个 RandomHeight()
函数是跳表的精髓所在。它通过一个简单的概率算法,就能构造出一个高效的、多层次的索引结构,就像我们之前比喻的地铁快线系统一样。
总结
在本章中,我们深入了解了 LevelDB 在内存中的核心组件------MemTable
。
MemTable
是一个有序的、位于内存中的写缓冲区,它使得写入操作极快,并能加速对新数据的读取。- 它的底层实现是跳表 (Skip List),一个性能堪比平衡树但实现更简单的数据结构。
- 为了提高内存分配效率,
MemTable
使用了 Arena 来进行内存管理。 - 当
MemTable
写满后,它会变为不可变 (immutable) 状态,等待后台线程将其内容转存到磁盘。
我们已经清楚了数据在内存中的旅程:从写入 WAL 保证安全,到存入 MemTable
提供快速访问。但是,内存终究是有限的。当 MemTable
的数据需要被永久保存时,它们会去向何方?