目录
一、引言
二、LSM-Treee 核心功能概述
三、核心功能实现
1.MemTable 实现
2.SSTable
3.Write
4.Iterators
5.Read/Scan
6.压缩
四、总结
一.引 言 LSM-Tree(Log-Structured Merge Tree)是一种高效的键值存储数据结构,广泛应用于NoSQL数据库和大数据处理系统中。其核心思想是通过分层、有序地利用磁盘顺序写入的性能优势,优化写入操作,同时牺牲部分读取性能以换取更高的写入吞吐量。
图片
图片
在互联网的各个基础设施中,不论是数据库还是缓存亦或是大数据框架,LSM-Tree这个数据结构都是很常见的身影。
我每天都在使用这个存储引擎,但是对它的了解还流于表面,所以我想要自己实现一次LSM-Tree加深理解。
本次实现我们采用了Zig语言,简要的实现LSM-Tree的核心功能(读写、数据压缩、持久化,不包含MVCC的内容)。
Zig是一种新兴的系统编程语言,其设计目标是提供现代特性的同时保持低复杂性。
本项目极大的受到了Mini-Lsm这个项目的启发,强烈推荐大家学习这个项目!
二.LSM-Treee 核心功能概述 在开始自己编写之前,我先简单介绍一下LSM-Tree(Log-Structured Merge Tree)的架构以及读写流程。
LSM-Tree它结合了日志和索引的特点,优化了写入和读取性能。每次写入都是采用append-only的方式,所以写入性能很高。
而作为代价,追加写入会造成存储放大,LSM-Tree时采用了多层SSTable的方式将数据堆叠在硬盘上。所以需要一个合并压缩的过程来回收过多的空间。
图片
写流程
读流程
预写日志(WAL) :写操作首先写入预写日志(WAL),用于记录未提交的数据,确保数据的持久性和一致性。 MemTable:随后将数据写入内存中的MemTable,MemTable是一个平衡树(如skiplist),支持快速插入和删除操作。 触发Compaction:当MemTable达到一定阈值时,会触发后台线程将MemTable中的数据刷入磁盘,生成SSTable文件。 SSTable:生成的SSTable文件是不可变的,存储在磁盘上,用于后续读取操作。 合并操作(Merge) :当多个SSTable文件达到一定数量时,会触发合并操作,将它们合并为一个更大的SSTable文件,以减少文件数量。 MemTable优先:读取操作首先从MemTable中查找数据,因为MemTable是按升序排列的,查找效率较高。 Block Cache:如果MemTable中未找到数据,则从Block Cache中查找。Block Cache存储了预先加载到内存中的SSTable块,以提高读取性能。 SSTable查找:如果Block Cache中也未找到数据,则从磁盘上的SSTable文件中查找。Lsm-tree会从最低层(L0)开始查找,逐层向上查找,直到找到目标数据。 多版本并发控制(MVCC) :Lsm-tree支持多版本并发控制,允许同时访问不同版本的数据,从而提高并发性能。
三、核心功能实现
MemTable 实现
首先,我们先实现 LSM 存储引擎的内存结构—Memtable。我们选择跳表实现作为 Memtable 的数据结构,因为它支持无锁的并发读写。我们不会深入介绍跳表的工作原理(Redis的同学应该不陌生这个东西),简单来说,它是一个易于实现的有序键值映射。
图片
Skiplist的实现非常简单,这里我利用Zig编译时的能力实现了一个泛型版本的跳表src/skiplist.zig,有兴趣的小伙伴可以直接去仓库中参观代码。
基于SkipList的能力,我们即可包装出Memtable的基本功能。
我们这个LSM支持WAL功能的,即写入内存表之前要先写入磁盘日志,方便在意外宕机重启后可以恢复数据。
WAL的能力我就不想自己再实现了,于是从网上扒了一个C的实现(Zig集成C语言非常便捷,可以参考与 C 交互)。
注意到这里我们没有实现删除的功能,这里我仿照了RocksDB中的墓碑机制,用空值代表删除,所以删除被put(key, "")取代。
SSTable
接下来,我们就着手开始实现LSM中另外一个重要元素 --- SSTable。
SSTable(Sorted String Table)是一种不可变的(Immutable)磁盘文件,内部按Key有序排列,存储键值对数据。每个SSTable文件生成后不再修改,更新和删除操作通过追加新记录或标记删除,最终通过合并(Compaction)清理冗余数据。
每当LSM-Tree中的MemTable体积超出阈值,就会将内存中的数据写入SsTable。
图片
每个SSTable由多个Block组成,每个Block是一组KV的package。
Block的编码格式如下:
图片
为了构建一个Block,我们实现了一个BlockBuilder的模块,这部分代码见src/block.zig:
可能有同学注意到,我们写key的时候没有直接将key值写入,而且只写了key与当前block的第一个key不重叠的suffix部分。由于block中的key都是有序的,所以一个block中的key有很大概率是前缀类似的,所以这里是一个空间优化的小技巧,例如:
Key: foo, foo1, foo2, foo3 ....
我们写入block时,只需要写:
foo|1|2|3|....
很多有序表的实现中都会用到这个小技巧。
有了block的实现,我们可以进一步来定义SSTable的格式。一个SSTable由多个Block、block元数据以及布隆过滤器构成。
图片
布隆过滤器是一种概率性数据结构,用于维护一组键。您可以向布隆过滤器中添加键,并且可以知道在添加到布隆过滤器中的键集中可能存在或必须不存在的键。
在SSTable中添加布隆过滤器可以有效提升查询key的效率。
元数据包含了block的第一个与最后一个key以及block在sst中的offset信息,记录元数据主要为了在后续的检索中可以快速定位某个key落在哪个block中。
同样的套路,为了构建SSTable,我们先实现一个SSTableBuilder,部分代码见src/ss_table.zig
复制 pub const SsTableBuilder = struct {
allocator: std.mem.Allocator,
builder: BlockBuilder, // 刚才实现的block构建装置
first_key: ?[]const u8,
last_key: ?[]const u8,
meta: std.ArrayList(BlockMeta),
block_size: usize,
data: std.ArrayList(u8),
bloom: BloomFilterPtr, // 布隆过滤器
pub fn add(self: *Self, key: []const u8, value: []const u8) !void {
try self.setFirstKey(key);
try self.bloom.get().insert(key); // 写入布隆过滤器
if (try self.builder.add(key, value)) {
try self.setLastKey(key);
return;
}
// block is full
try self.finishBlock();
std.debug.assert(try self.builder.add(key, value));
try self.resetFirstKey(key);
try self.setLastKey(key);
}
// 写入一个block的数据
fn finishBlock(self: *Self) !void {
if (self.builder.isEmpty()) {
return;
}
var bo = self.builder;
// reset block
defer bo.reset();
self.builder = BlockBuilder.init(self.allocator, self.block_size);
var blk = try bo.build();
defer blk.deinit();
const encoded_block = try blk.encode(self.allocator); // block序列化
defer self.allocator.free(encoded_block);
// 记录block的元数据
try self.meta.append(.{
.allocator = self.allocator,
.offset = self.data.items.len,
.first_key = try self.allocator.dupe(u8, self.first_key.?),
.last_key = try self.allocator.dupe(u8, self.last_key.?),
});
const cksm = hash.Crc32.hash(encoded_block); // 写入4b的校验值
try self.data.appendSlice(encoded_block);
try self.data.writer().writeInt(u32, cksm, .big);
}
// 构建为一个SSTable
pub fn build(
self: *Self,
id: usize,
block_cache: ?BlockCachePtr, // 读取block数据的缓存,减少block的反序列化成本
path: []const u8,
) !SsTable {
var arena = std.heap.ArenaAllocator.init(self.allocator);
defer arena.deinit();
const allocator = arena.allocator();
try self.finishBlock();
const w = self.data.writer();
// 写入元数据及其offset
const meta_offset = self.data.items.len;
const meta_b = try BlockMeta.batchEncode(self.meta.items, allocator);
_ = try w.write(meta_b);
try w.writeInt(u32, @intCast(meta_offset), .big);
// 写入布隆过滤器及其offset
const bloom_offset = self.data.items.len;
const encoded_bloom = try self.bloom.get().encode(allocator);
_ = try w.write(encoded_bloom);
try w.writeInt(u32, @intCast(bloom_offset), .big);
const file = try FileObject.init(path, self.data.items);
errdefer file.deinit();
const fk = self.meta.items[0].first_key;
const lk = self.meta.getLast().last_key;
return .{
.allocator = self.allocator,
.file = file,
.block_metas = try self.meta.toOwnedSlice(),
.meta_offset = meta_offset,
.block_cache = block_cache,
.bloom = self.bloom.clone(),
.id = id,
.first_key = try self.allocator.dupe(u8, fk),
.last_key = try self.allocator.dupe(u8, lk),
.max_ts = 0,
};
}
}
1. 2. 3. 4. 5. 6. 7. 8. 9. 10. 11. 12. 13. 14. 15. 16. 17. 18. 19. 20. 21. 22. 23. 24. 25. 26. 27. 28. 29. 30. 31. 32. 33. 34. 35. 36. 37. 38. 39. 40. 41. 42. 43. 44. 45. 46. 47. 48. 49. 50. 51. 52. 53. 54. 55. 56. 57. 58. 59. 60. 61. 62. 63. 64. 65. 66. 67. 68. 69. 70. 71. 72. 73. 74. 75. 76. 77. 78. 79. 80. 81. 82. 83. 84. 85. 86. 87. 88. 89. 90. 91. 92. 93. 94. 95. 96. 97. 98. 99. 100. 101. 102. 103. 104. 105.
Write
有了SSTable和MemTable,我们就有了LSM-Tree需要的两个最重要的材料,后续的读写不过是对这两类材料的组合拼装。
在实现写操作之前,我们先假想一下LSM-Tree的数据结构:
首先我们需要一个数据结构存储当前MemTable、冷MemTables和多层的SST,如下图所示。 图片
其次我们需要一个锁用于同步上述数据结构的读写行为。 我们还需要一个SSTable的自增id。 最后还需要一些必要的配置,例如存储路径、线程管理器等。
最终,我们实现的LSM数据结构如下:
先不考虑逐层压缩的逻辑,只考虑一层SSTable的简单情况,写逻辑可以简化为如下流程:
图片
当MemTable体积超出阈值,压入冷MemTable数组,重置当前MemTable 当冷MemTable数组大小超出配置阈值,触发SSTable落盘,弹出最冷的MemTable,写入磁盘SSTable,并记录在L0的SSTable数组中。这一过程是在一个线程中定时触发 当然,这里只实现了一半的写逻辑,数据停留在L0的SST中,后续的多层SST还没有使用。
剩下一半的写逻辑会在数据压缩的章节中介绍。
Iterators
写入的过程比较好理解,但是读就略微复杂了,以上面我们实现的写结果为例子,最终我们的数据沉淀在一个3层的数据结构中,要如何高效的从其中检索数据呢?
图片
如同写过程一般,读过程也是对各个基础单元(MemTable、SSTable、Block)读过程的组合,为了方便组合逻辑,我们要先统一各个模块的读行为。
在LSM-Tree中,所有的读行为都定义为了如下的Interface(Zig中没trait或者Interface,所以这里实例代码我用Rust描述):
我们首先对MemTable、SSTable、Block这些模块实现读接口,代码可见:src/MemTable.zig,src/block.zig,src/ss_table.zig,这里单独简单介绍下SSTable的读接口实现思路,其他的模块实现思路类似,感兴趣的直接阅读源码即可。
复制 pub const SsTableIterator = struct {
allocator: std.mem.Allocator,
table: SsTablePtr,
blk: BlockPtr,
blk_iterator: BlockIteratorPtr,
blk_idx: usize,
const Self = @This();
pub fn initAndSeekToFirst(allocator: std.mem.Allocator, table: SsTablePtr) !Self {
const s = try seekToFirstInner(allocator, table);
return .{
.allocator = allocator,
.table = table,
.blk_iterator = s.blk_iter,
.blk = s.blk,
.blk_idx = 0,
};
}
pub fn initAndSeekToKey(allocator: std.mem.Allocator, table: SsTablePtr, k: []const u8) !Self {
const b = try seekToKeyInner(allocator, table, k);
return .{
.allocator = allocator,
.table = table,
.blk_iterator = b.blk_iter,
.blk_idx = b.blk_idx,
.blk = b.blk,
};
}
fn seekToFirstInner(allocator: std.mem.Allocator, table: SsTablePtr) !struct {
blk: BlockPtr,
blk_iter: BlockIteratorPtr,
} {
var blk = try table.get().readBlockCached(0, allocator); // 读取第一个block
errdefer blk.release();
var blk_iter = try BlockIterator.createAndSeekToFirst(allocator, blk.clone());
errdefer blk_iter.deinit();
return .{
.blk = blk,
.blk_iter = try BlockIteratorPtr.create(allocator, blk_iter), // 从SSTable的读接口转换为Block的读接口
};
}
fn seekToKeyInner(allocator: std.mem.Allocator, table: SsTablePtr, k: []const u8) !struct {
blk_idx: usize,
blk: BlockPtr,
blk_iter: BlockIteratorPtr,
} {
const table_ptr = table.get();
var blk_idx = try table_ptr.findBlockIndex(k);
var blk = try table_ptr.readBlockCached(blk_idx, allocator);
errdefer blk.deinit();
var blk_iter = try BlockIterator.createAndSeekToKey(allocator, blk.clone(), k);
errdefer blk_iter.deinit();
var blk_iter_ptr = try BlockIteratorPtr.create(allocator, blk_iter);
errdefer blk_iter_ptr.release();
// 如果当前block读完了,跳到下一个block,并生成block的读接口
if (blk_iter.isEmpty()) {
blk_idx += 1;
if (blk_idx < table_ptr.numBlocks()) {
{
blk.deinit();
blk_iter.deinit();
}
var blk2 = try table_ptr.readBlockCached(blk_idx, allocator);
errdefer blk2.deinit();
var blk_iter2 = try BlockIterator.createAndSeekToFirst(allocator, blk2.clone());
errdefer blk_iter2.deinit();
return .{
.blk_idx = blk_idx,
.blk_iter = try BlockIteratorPtr.create(allocator, blk_iter2),
.blk = blk2,
};
}
}
return .{
.blk_idx = blk_idx,
.blk_iter = blk_iter_ptr,
.blk = blk,
};
}
pub fn key(self: Self) []const u8 {
return self.blk_iterator.get().key();
}
pub fn value(self: Self) []const u8 {
return self.blk_iterator.get().value();
}
pub fn isEmpty(self: Self) bool {
return self.blk_iterator.get().isEmpty();
}
pub fn next(self: *Self) !void {
try self.blk_iterator.get().next();
// 若当前的Block读完了,就跳到下一个block,并生成Block读接口。
if (self.blk_iterator.get().isEmpty()) {
self.blk_idx += 1;
if (self.blk_idx < self.table.get().numBlocks()) {
self.reset();
const blk = try self.table.get().readBlockCached(self.blk_idx, self.allocator);
const blk_iter = try BlockIterator.createAndSeekToFirst(self.allocator, blk.clone());
self.blk = blk;
self.blk_iterator = try BlockIteratorPtr.create(self.allocator, blk_iter);
}
}
}
};
1. 2. 3. 4. 5. 6. 7. 8. 9. 10. 11. 12. 13. 14. 15. 16. 17. 18. 19. 20. 21. 22. 23. 24. 25. 26. 27. 28. 29. 30. 31. 32. 33. 34. 35. 36. 37. 38. 39. 40. 41. 42. 43. 44. 45. 46. 47. 48. 49. 50. 51. 52. 53. 54. 55. 56. 57. 58. 59. 60. 61. 62. 63. 64. 65. 66. 67. 68. 69. 70. 71. 72. 73. 74. 75. 76. 77. 78. 79. 80. 81. 82. 83. 84. 85. 86. 87. 88. 89. 90. 91. 92. 93. 94. 95. 96. 97. 98. 99. 100. 101. 102. 103. 104. 105. 106. 107. 108. 109. 110. 111. 112. 113. 114. 115. 116. 117. 118. 119. 120. 121. 122. 123. 124. 125. 126. 127. 128.
有了几个基本元素的读接口之后,我们便遇到第一个问题:我们如何对多个MemTable做读检索?
图片
这个时候,我们需要一个新的数据结构来实现多个读实例的合并检索---- MergeIterator
MergeIterator在内部维护一个二叉堆。堆中数据的优先级如下:
当各个迭代器key不同时,具有最小key的迭代器最优。当多个迭代器有相同的当前key时,最新的迭代器一个最优。
假设我们有如下MemTable(iter1最新,iter3最旧):
iter1: b->del, c->4, d->5 iter2: a->1, b->2, c->3 iter3: e->4
经过合并后迭代器结果应该为:
a最小,iter2优先迭代 iter2迭代一次后,iter1与iter2 key相同,iter1优先迭代,b->2跳过 c最小,iter1优先迭代,iter2中c->3跳过 d最小,iter1优先迭代 只剩iter3,迭代iter3 最终结果:a->1, b->del, c->4, d->5, e->4
实现代码如下:
有了MergeIterator这个工具,我们具备了在多个MemTable和多个SSTable中迭代检索的能力,但是还有个问题,我们当前有两个MergeIterator,应该如何在两个迭代器中执行迭代任务?
图片
此时,我们再引入一个新的数据结构:TwoMergeIterator,这个是MergeIterator在元素只有两个的情况下的简化版。
TwoMergeIterator由两个迭代器构成,一个高优一个低优,每次迭代优先迭代高优,当key相同时,优先迭代高优。实现如下:
至此,我们读行为所需要的武器就完备了!
Read/Scan
让我们再来看看LSM的架构图:
我们将每个数据层中的数据标上优先级,由于LSM-Tree是append-only的,所以优先级越高的数据层中数据越新。
所以我们的读策略也很明显:按照上图中P0至P2依次检索,这部分代码实现见src/storage.zig。
压缩
在上一节的写过程中,我们实现了从内存表到Level0的SSTable堆叠。
随着写入的持续,Lv0的SSTable会越来越多,这个时候就需要我们将Lv0中的数据合并写入至Lv2,并依次类推重复这个过程,直到堆叠到最深的层数,这个逐层合并数据的过程就是数据压缩。
图片
LSM-Tree中数据压缩的过程大致如下:
图片
具体的实现代码可见src/compact.zig,src/storage.zig。
简单分层压缩与原始 LSM 论文中的压缩策略相似。它为 LSM 树维护多个层级。当一个层级太大时,它会将此层级的所有 SST 与下一层合并。压缩策略由 3 个参数控制:
size_ratio_percent:【文件低级数量/文件高级数量】,当实际计算的值低于此阈值时触发压缩。假设这里我们设置为60%,当L0中SST数量为2,L1中SST数量为1,此时ratio为1/2 = 50% < 60%,此时我们应该将L0压缩合并至L1。 level0_file_num_compaction_trigger: 第一层SSTable达到多少后触发压缩。因为这是最高层,没法与更高层比较,只能固定触发压缩。 max_levels: 顾名思义,最大的层数限制。
做好这些准备工作,我们可以逐步实现压缩逻辑:
有了上一小节中读过程的介绍,多层数据的压缩过程就很好理解了。
例如我们想将L1与L2的SSTable合并压缩至L2,我们只需要把L1和L2的数据放在一起创造一个迭代器,再持续从该迭代器中读出数据写入新的SSTable中,这个过程保证了新的SSTable中数据不重复且有序。
这部分逻辑并不复杂,即删除此次压缩任务中的原有两层数据,用新合并的SSTable替换至较低层数据。
这里有个需要注意的点,即压缩过程是在一个线程中单独执行的,压缩过程中LSM-Tree的原数据可能发生了改变,所以这里执行SSTable删除时要注意过滤掉新数据,不能覆盖了有效数据。
并发问题是软件中的Bug集散地!
四、总结 我们使用Zig语言实现了一个LSM-Tree的核心功能,包括MemTable、SSTable、写流程、各类Iterator与数据压缩能力。通过这个项目,我收获了很多心得体会。
了解了LSM-Tree的核心流程
以往对LSM这个数据结构的多层SST设计与写过程早有耳闻,但是读流程的实现不太理解。这个项目解答了我疑惑很久的读流程的实现,特别是MergeIterator的算法设计非常巧妙。
摸索了个zig语言的智能指针
Zig语言没有内存安全的保证,为了不想指针乱飞到处泄露,在Deepseek的帮助下实现了一个简单的智能指针,极大降低了内存管理的心智负担。
工程经验
尽可能多的做assertion的工作,可以提前暴露很多bug。 大型多模块的项目,一定要写单元测试,不然出了bug无法分块定位问题。 千万不要把IO过程放在锁的范围里,极大的影响性能!