LSM-Tree
LSM Tree
0 前言
最近在调研公司内的存储组件时,了解到其底层依赖于 rocksdb 这种嵌入式 kv 存储引擎进行文件存储管理,正好借此机会展开对 rocksdb 这类适用于密集型写操作的数据库的底层原理进行学习探讨.
本文主要聚焦在 rocksdb 的存储架构——log structured merge tree 内容的探讨,这方面内容由于笔者也是初学,如有不足之处,欢迎批评指正.
1 背景
1.1 读多写少、写多读少
存储组件的使用场景根据读写频次的不同,可以分为读多写少以及写多读少的类型.
以适用于不同类型的使用场景为目标,存储组件在设计时会根据侧重点的不同采用不同的思路和策略.
面向 读多写少 场景的一例典型是 mysql 中的 innodb 存储引擎,底层基于 B+ Tree 这一数据结构进行存储文件的组织和管理.
而我们今天要聊到的话题是关于 写多读少 的使用场景,探讨到的主角是 kv 型存储组件 rocksdb 及其底层采用到的 lsm tree(log structured merge tree)这一数据结构.
1.2 原地写、追加写
在基于磁盘的 kv 型存储组件中,针对于写操作的实现方案包括原地写和追加写两种,其区别主要体现在更新数据的操作流程当中:
- 原地写:
倘若要针对一组 kv 数据执行更新操作,首先要找到 kv 老数据的所在位置,再在其基础之上执行进行更新. 这个过程涉及到磁盘的随机 IO,因此写性能较差.
与之相对,在执行读操作时,可以根据 k 寻找到 kv 数据所在位置并直接拿到查询结果,因此读操作效率相对较高. 且具有着不错的空间利用率.
- 追加写:
追加写类型的写操作中,无须区分本次写操作是插入还是更新,而是选择将 kv 对以追加的形式直接插入到文件的末尾位置. 因此不涉及磁盘的随机 IO,只需要执行顺序 IO 操作,在写流程中的执行性能相较于原地写而言有较大的提升.
然而大家应该也注意到了,追加写策略在提升写操作效率的同时,所付出的代价是导致同一组 kv 对可能产生多份冗余数据,而除了最新记录外,此前的数据记录实际上都是无用的,因此会存在空间浪费的情况.
也正是因为这一原因,追加写策略下的读流程性能是比较差的. 每次根据 k 查询数据时,都需要沿文件末尾向前反向遍历追溯,直到找到第一笔满足条件的 kv 对数据为止. 在这种模式下,查询操作变成了线性时间复杂度,是无法接受的.

1.3 rocksdb
简单聊了原地写与追加写的策略,铺垫了一些基础设定,下面引入正题.
rocksdb 是 Facebook 使用纯 C++ 研发的嵌入式 kv 存储引擎,依赖的核心数据结构是 lsm tree 存储引擎,底层正是基于追加写策略实现,适用于写多读少的使用场景.
rocksdb 值得探讨学习的内容很多,我个人也处于初学阶段,本文先聚焦在数据结构 lsm tree 的部分,后续有机会再和大家展开探讨 rocksdb 的其他相关内容.
rocksdb 开源地址:https://github.com/facebook/rocksdb
rocksdb 中文网:http://rocksdb.org.cn/
2 lsm tree
本章我们从零出发,和大家一起推演出 lsm tree 的设计思路.
首先明确目标,我们需要解决的是 写多读少 的使用场景,在此之上提出的核心诉求包括:
- 由于写操作是最核心的使用场景,因此需要以提高写效率作为首要目标
- 读效率可以适当牺牲,但也需要保证在有限的容忍度范围以内
在此基础之上,我们很自然地想到使用追加写策略是较优的选择. 于是我们需要考虑的问题就是,利用追加写所带来的优势的同时,如何尽可能去规避或者弱化其存在于读流程以及空间利用率方面的劣势.
2.1 追加写存在的问题
首先再梳理一下追加写(顺序写)策略所存在的问题:
- 数据冗余(空间浪费)
顺序写无视 k 此前的存在状况,简单粗暴地追加的方式完成数据的写入或者更新操作,因此不可避免地存在一组 kv 对对应多份冗余记录的情况. 并且,除了最后一笔记录之外,此前多笔老数据都属于无用的冗余数据. 考虑一个最极端的场景,在对顺序写策略不执行任何优化改进的前提下,只需要一笔 kv 对数据,采用无限次写操作即可打满磁盘空间.
- 读性能低
顺序写模式下,执行一次查询操作需要反向追溯,直到找到第一笔满足条件的 kv 数据记录才能返回. 因此,其最坏的时间复杂度是线性的 O(N),显然无法满足使用诉求.
明确了存在的问题后,接下来我们就以这两项核心问题为主线展开推演和优化过程,尝试一步步构建出 lsm tree 的完整架构.
2.2 数据合并
首先考虑如何解决数据冗余的问题.
既然同一组 kv 对可能存在多份冗余数据,那我们自然能想到采用压缩合并的方式来解决这一问题.
于是,在读写主流程之外,我们可以 异步启动一个负责压缩合并的线程,持续对重复的 kv 对数据进行合并,只保留最新记录,消除此前无用的老数据.

但是一旦这样做,新的问题也就随之产生了. 这是因为针对同一文件的写入操作和压缩操作是需要互斥的,否则可能产生一系列严重的并发问题.
然而,我们一旦采取了互斥操作,那么在文件压缩合并期间,写入操作就会被阻塞,这样会严重影响到使用性能.
2.3 文件分块
在 2.2 小节的基础之上,我们进一步采取的对策是对文件进行拆分.
倘若我们把一个大文件拆分为一系列的小文件(table),每次写操作只会追加到最新的 new table 中,而异步的压缩合并操作则只面向于老文件 old table,这样两个流程之间就能够实现解耦,写操作不再有陷入阻塞的风险,同时压缩操作和写操作之间也能存在清晰明确的界限.

2.4 数据有序存储
数据冗余的优化探讨我们暂时先告一段落,接下来我们聊聊顺序写存在的另一个问题:很差的读性能.
目前现状是,每查询一个 k, 需要在全量数据范围内执行逆序遍历操作,直到找到满足条件的第一笔 kv 对记录位置.
要想降低读流程的时间复杂度,就需要在 kv 对的存储组织结构上作作文章.
这里我们可以采取的策略是,在组织每个 table 内的数据时,事先根据 k 进行数据排序,那么在后续的查询环节中,我们就无需承受线性遍历的代价,而是能通过二分的方式在对数级的时间复杂度下获得我们想要的结果.

但是大家需要注意,一旦 kv 数据在 table 中的存放顺序有了限制,这一点其实就和我们探讨这一问题的前提条件自相矛盾,已经打破了追加写策略的基础设定(追加写无需排序).
2.5 内存+磁盘
为了解决 2.4 小节中的问题(磁盘里面的 table 是有序的),我们在磁盘文件 disktable 的基础之上,额外引入在内存中维护的 memtable 结构.
首先明确几个事项:
- memtable 在内存中缓存
- memtable 本身基于 k 进行有序存储
- memtable 中的写操作统一采用就地写
- memtable 是用户写操作的唯一入口
- memtable 数据量达到阈值后溢写到磁盘,成为 disktable
重要的事情,我们再次强调一遍,内存中的 memtable 采用就地写操作,而非追加写. 因为数据是维护在内存中的,因此哪怕写操作需要承受随机 IO 的代价,也处在可以接受的范围以内.
此外,当 memtable 中的数据量达到阈值后,再一次性 flush 到磁盘中成为 disktable. 这个过程中实际上是以 table 为粒度,在磁盘中执行了我们所谓的“追加写”操作.
这样做还带来的一点好处是,由于所有的 disk table 都是由内部有序的 memtable 生成的,因此能做到 disktable 文件内部天然就是局部有序的.

然而,引入缓存之后,由此也引发出了三个新的问题:
- 内存是易失性存储,倘若 memtable 在溢写磁盘前就宕机,那么导致的数据丢失问题如何解决?
- 在 memtable 溢写磁盘的过程中,外部的写操作需要阻塞,所带来的性能问题如何解决?
- memtable 作为有序存储结构,内部采用什么样的数据结构进行实现?
这几个问题,我们在后续几个小节中来逐一解决.
2.6 预写日志
首先面对第一个问题,如何避免因宕机导致 memtable 数据丢失.
解决这个问题的手段就是 WAL(write-ahead log)预写日志技术:在将数据写入 memtable,先通过追加写的方式,将操作记录到处于磁盘的 WAL 当中,这样哪怕宕机导致内存数据丢失,也能通过重放 WAL 的方式,重新恢复 memtable 的数据.
此外, WAL 和 memtable 可以建立对应关系,每当一个 memtable 被溢写到磁盘中成为 disktable,其发生数据丢失问题的风险也就随之消除,因此对应的 WAL 也就可以删除了. 并且,由于 WAL 中也是追加写的操作,属于磁盘顺序 IO,因此性能不会成为瓶颈.

2.7 内存读写+只读分层
接下来面对第二个问题,在 memtable 溢写磁盘期间,到来的写操作如何处理?
这里的解决思路是“金蝉脱壳”. 每当 memtable 需要溢写时,就将其一分为二,将已有的旧数据归属到 readonly memtable 部分,成为一个只读的数据结构,专注于执行将其溢写到磁盘的流程;于此同时,建立一个全新空白的 active memtable,作为写操作的新入口,这样两个流程之间就实现了解耦,写操作不再需要阻塞.
由此可见,active memtable 持有的是最新的数据,readonly memtable 则次之,已经落到磁盘的 disktable 则再次.

2.8 内存数据结构
再聊第三个问题,内存中的 memtable 采用什么样的数据结构进行实现,其背后的核心要求是作为数据结构需要是一种有序表,能基于 k 进行数据的有序存储,从而保证所有的读操作和写操作都能在 O(logN) 的时间复杂度之内拿下.
在这些诉求之下,进入视野的候选项包括红黑树和跳表两种.
红黑树结构示意如下

跳表结构示意如下

在读写性能上,跳表和红黑树的性能表现不分伯仲. 但跳表相比于红黑树具有两大核心优势:
- 更简单的实现
- 更细的并发锁粒度
作为存储组件,memtable 中的有序数据不可避免地会被用户并发读写访问,因此需要加锁保证临界资源的安全性和一致性.
在锁粒度上,红黑树由于自身染色的机制,每次写操作时加锁负责且相对笨重;
而跳表则不同,插入/删除通常只影响目标节点附近的几个前驱指针以及最大高度信息。拥有着更细的锁粒度,在很多场景中是可以做到并发写的.
综上,跳表无疑是更好的选择. 同时,也是 rocksdb 中默认采用的 memtable 数据结构.
2.9 磁盘分层
2.2 小节我们聊到磁盘文件需要要分块细化粒度,由此诞生出了一系列的 disktable.
又如 2.5 小节所言,所有 disktable 是由内存中的 memtable 溢写得到的,这样 disktable 就天然具有两大优势:
- disktable 内部不存在重复 kv 对数据,因为 memtable 执行的是就地写操作
- disktable 内部的 kv 对数据是有序的,因为 memtable 数据本身就是有序的
然而,disktable 间还存在一个局限:由于每个 memtable 视野有限,只能做到自身范围内的 k 去重和排序. 因此,不同 disktable 之间可能存在重复冗余的 kv 对数据,且不同 disktable 之间的数据无法做到全局有序.
理清了现状,我们再在此基础之上,进一步引入磁盘 disktable 分层的概念.
- 首先,我们将磁盘整体分为 level0-levelk 共计 k+1层.
- 每个 level 层中的 disktable 数量保持一致
- level(i+1) 中 disktable 的容量大小固定为 level(i) 的 T 倍,T 为常量,通常取值为 10 左右
- 数据流向是由浅入深,层层递进,即由 level(i) -> level(i+1)
- memtable 溢写的数据落到 level0
- levelk 作为兜底
- 当某个 level 内数据总量达到达到阈值时,会发起 level(i) -> level(i+1) 的归并操作
- 数据从 level(i) 流向 level(i+1) 过程中,通过归并操作进行去重和排序,保证 level(i+1) 中 kv 数据无重复且全局有序
结合上述设定,我们可以得出以下结论:
- level0 是特殊的,其中 disktable 之间可能存在冗余的 kv 对数据且不保证全局有序,因为其数据来自 memtable
- level1~levelk 中单层之内没有冗余的 kv 对数据,且保证全局有序
- 不同 level 层之间可能存在冗余的 kv 数据
- 较热(最近写入)的数据位于浅层,较冷(更早写入)的数据位于深层
- levelk 作为最深的一层,整体沉淀的数量达到全局的百分之九十左右
下面举一例,说明一下从 level1 到 level2 的数据归并过程.
假设此时 level1 层的数据总量已经达到阈值,接下来需要发起 level(1) -> level(2) 的归并操作:
- 当 level1 层的数据总量达到阈值后,会触发一次 level1 → level2 的 compaction。首先从 level1 中按照一定的 compaction 策略选择一个待归并的 disktable。假设该文件中最小的 key 为
k_min = 3,最大的 key 为k_max = 30,因此其 key 范围为[3,30] - 由于 level2 中的 disktable 在同一层内按照 key 有序排列,并且各个文件之间的 key 范围互不重叠,因此可以快速找到与
[3,30]存在重叠的文件。假设找到两个 disktable,其 key 范围分别为[0,16]和[17,32] - 将 level1 中的
[3,30]与 level2 中的[0,16]、[17,32]一起进行归并。由于每个 disktable 内部的数据本身都是有序的,因此这个过程本质上是一个多路归并过程。归并过程中还会处理同一个 key 的多个版本、删除标记以及可以被清理的旧数据 - 三个 disktable 归并之后,得到的数据整体仍然按照 key 有序,其 key 范围为
[0,32]。这些数据不会简单地全部写入一个新的 disktable,而是会按照 level2 的目标 SSTable 文件大小,在归并输出的过程中拆分成多个新的 disktable - 假设最终生成两个新的 disktable,其 key 范围分别为
[0,15]和[16,32],那么这两个新的 disktable 会被加入 level2。由于二者的 key 范围互不重叠,因此仍然能够保证 level2 层整体有序 - 对应的旧数据,包括 level1 中的
[3,30],以及 level2 中参与此次归并的[0,16]和[17,32],都会被新的 disktable 替代。这些旧文件会从新的版本中移除,并在确认没有其他读操作继续引用它们之后被真正删除
整个过程可以概括为:
level1:
[3------------30]
│
│
▼
level2:
[0------16] [17------32] [33------50]
│ │
└─────┬──────┘
│
│ merge
▼
[0----------------32]
│
│ 按目标文件大小切分
▼
[0------15] [16------32]
最终:
level1:
[3,30] 被移除
level2:
[0------15] [16------32] [33------50]
值得一提的是,倘若因为这一合并操作,导致 level2 的数据容量又超出阈值,则会进一步引起 level 2 到 level 3 的数据合并操作,以此类推,层层递进.
2.10 sstable
事实上,在 lsm tree 的设定中,对于前文提到的每个磁盘文件块 disktable,设计了一类专门的数据结构 sstable(sorted string table).
2.9 小节聊到,每个 level 的 sstable 容量大约是上一层的 10 倍,因此一旦到了深层,sstable 的容量可能很大,对应展开的读操作会略显笨重.
sstable 在此基础上进行了优化
- 首先 sstable 内部会进一步将 table 拆分为多个 block 块,其在逻辑意义上从属于同一个 sstable;
- 其次,sstable 中会额外维护一个索引信息,其中记录了每个 block 的 k_min 和 k_max 以及每个块中各行的 k_max 和 k_min,便于辅助的查询操作
- 此外,lsm tree 还维护着一个全局索引信息,记录着不同 level 中,每个 sstable 对应的 k_min 和 k_max 的范围
- 最后,每个 sstable 还维护着一个布隆过滤器 bloomfilter,用于快速判断一个 k 是否存在于当前 sstable 中.
由于 bloom filter 具有假阳性的特点,因此判定不存在的 k 是必然不存在的,然而判定为存在的 k 也可能存在着一定的失误概率. 有关 bloom filter 的内容,后续我单读写篇文章再展开聊聊,本文先不展开.

2.11 纵览 lsm tree
到此为止,有关于 lsm tree 的拼图碎片已经集齐了,下面我们回过头来做个拼接和总览.
lsm tree 全称 Log Structure Merge Tree,其核心设定如下:
- 存储介质主要依赖磁盘(sstable),但上层也会借助内存的辅助(memtable)
- 内存(memtable)就地写,磁盘(sstable)顺序写
- 写入口为可读可写的 active memtable
- 达到阈值后 active memtable 转为只读的 readonly memtable
- memtable 保证有序,默认基于跳表实现
- 由于 sstable 来自 memtable,每个 sstable 内部无冗余数据且有序
- 磁盘文件分层(level0~levelk),上层为近期写入的热数据,下层为较早写入的冷数据
- level(i+1) sstable 大小恒定为 level(i) 层 T 倍
- level0 sstable 之间存在冗余数据
- level1~levelk 单层内无冗余数据且全局有序
- 数据沿着 level 0 -> level k 的方向合并,自顶向下流动

3 lsm tree 读写流程
下面对访问 lsm tree 的读写流程做个梳理.
3.1 写流程

- 基于就地写模式,写入内存中的 active memtable
- active memtable 达到阈值后转为只读的 readonly memtable
- readonly memtable 会 flush 到磁盘,成为 level0 的 sstable
- level(i) 层数据容量达到后,会基于归并的方式合并到 level(i+1),以此类推
3.2 读流程

- 尝试读 active memtable
- 尝试读 readonly memtable
- 尝试读 level0,需要按照溢写顺序进行倒序,依次读 level0 中的每个 sstable(level0 sstable 间数据可能冗余)
- 根据全局的索引文件,依次读 level1~levelk,每个 level 最多只需要读一个 sstable
- 读一个 sstable 时,借助内部的 bloom filter 和索引,加速查询流程
综上所述,在 lsm tree 架构下,一次读操作可以在常数级别的 IO 次数下完成,同时每次 IO 操作中则需要承受对应 table 内数据量对数级别的查询时间复杂度.

浙公网安备 33010602011771号