这是我参与「第四届青训营 」笔记创作活动的第20天
LSMT是什么
LSMT,即Log-Structured Merge-Tree,这是一个经典的数据结构,在大数据系统中有着非常广泛的应用。很多耳熟能详的经典系统,底层就是基于LSMT实现的。
B+ Tree和B Tree的最大区别是将所有数据都放在了叶子节点,从而优化了批量插入和批量查询的效率,而优化的核心逻辑就是无论是什么存储介质,顺序存储的效率一定优于随机存储。
一言以蔽之,通过Append-only Write +择机Compact来维护结构的索引树。
LSMT 存储引擎的优势
- 相对于 B+Tree 的优势
我们在前文已经阐述了 LSMT 与 B+Tree 的异同,在这里总结下 LSMT 的优势。
- 顺序写模型对于 SSD 设备更友好
- SST 不可修改的特性使得其能使用更加紧凑的数据排列和加上压缩
- 后台延迟 Compact 能更好利用 CPU 多核处理能力,降低前台请求延迟
- 相对于 HashTable 的优势
LSMT 存储引擎是有序索引抽象,HashTable 是无序索引抽象。无序索引是有序索引的真子集。LSMT 相比于 HashTable 更加通用。HashTable 能处理点查请求,LSMT 也能,但 LSMT 能处理 TopK 请求,但 HashTable 就不行了。为了避免维护多套存储引擎,绝大多数数据库都直接采用一套有序的存储引擎而非针对点查和顺序读取分别维护两个引擎。
LSMT 存储引擎的实现,以 RocksDB 为例
RocksDB 是一款十分流行的开源 LSMT 存储引擎,最早来自 Facebook(Meta),应用于 MyRocks,TiDB,在字节内部也有 Abase,ByteKV,ByteNDB,Bytable 等用户。因此接下来将会以 RocksDB 为例子介绍 LSMT 存储引擎的经典实现。
Write
为了确保操作的原子性,RocksDB 在真正执行修改之前会先将变更写入 WAL(Write Ahead Log),WAL 写成功则写入成功。因为即使这时候程序 crash,在重启阶段可以通过回放 WAL 来恢复或者继续之前的变更。操作只有成功和失败两种状态。
RocksDB WAL 写入流程继承自 LevelDB。LevelDB 在 WAL 写入主要做的一个优化是多个写入者会选出一个 Leader,由这个 Leader 来一次性写入。这样的好处在于可以批量聚合请求,避免频繁提交小 IO。
但很多业务其实不会要求每次 WAL 写入必须落盘,而是写到 Kernel 的 Page Cache 就可以,Kernel 自身是会聚合小 IO 再下刷的。这时候,批量提交的好处就在于降低了操作系统调度线程的开销。
批量提交时,Leader 可以同时唤醒其余 Writer。
如果没有批量提交就只能链式唤醒了。
写完 WAL 实际还要写 MemTable,这步相比于写 WAL 到 Page Cache 更耗时而且是可以完全并行化的。RocksDB 在 LevelDB 的基础上主要又添加了并发 MemTable 写入的优化,由最后一个完成 MemTable 写入的 Writer 执行收尾工作。完整 RocksDB 写入流程如下:
为了方便更好表明哪些事件是同时发生的,相同时刻的事件的背景颜色是一样的。
RocksDB 为了保证线性一致性,必须有一个 Leader 分配时间戳,每条修改记录都会带着分配到的时间戳,也必须有一个 Leader 推进当前可见的时间戳。目前的写入流程已经相当优化了。
Snapshot & SuperVision
RocksDB 的数据由 3 部分组成,MemTable / ImmemTable / SST。直接持有这三部分数据并且提供快照功能的组件叫做 SuperVersion。
RocksDB 的 MemTable 和 SST 的释放与删除都依赖于引用计数,SuperVersion 不释放,对应的 MemTable 和 SST 就不会释放。对于读取操作来说,只要拿着这个 SuperVersion,从 MemTable 开始一级一级向下,就能查询到记录。那么拿着 SuperVersion 不释放,等于是拿到了快照。
如果所有读者开始操作前都给 SuperVersion 的计数加 1,读完后再减 1,那么这个原子引用计数器就会成为热点。CPU 在多核之间同步缓存是有开销的,核越多开销越大。一般工程上可以简单估计,核多了之后 CAS 同一个 cache line,性能不会超过 100W/s。为了让读操作更好的 scale,RocksDB 做了一个优化是 Thread Local SuperVersion Cache。每个读者都缓存一个 SuperVersion,读之前检查下 SuperVersion 是否过期,如果没有就直接用这个 SuperVersion,不需要再加减引用计数器。如果 SuperVersion 过期了,读者就必须刷新一遍 SuperVersion。为了避免某一个读者的 Thread Local 缓存持有一个 SuperVersion 太久导致资源无法回收,每当有新的 SuperVersion 生成时会标记所有读者缓存的 SuperVersion 失效。
没有 Thread Local 缓存时,读取操作要频繁 Acquire 和 Release SuperVersion
有 Thread Local 缓存时,读取只需要检查一下 SuperVersion 并标记缓存正在使用即可,可以看出多核之间的交互就仅剩检查 SuperVersion 缓存是否过期了。
Get & BloomFilter
由于 LSMT 是延迟 Compact 的且 SST 尺寸(MB 级别)比 B+Tree Node (KB 级别)大得多。所以相对而言,LSMT 点查需要访问的数据块更多。为了加速点查,一般 LSMT 引擎都会在 SST 中嵌入 BloomFilter,例如 RocksDB 默认的 BlockBasedTable。BloomFilter 可以 100% 断言一个元素不在集合内,但只能大概率判定一个元素在集合内。
RocksDB 的读取在大框架上和 B+ Tree 类似,就是层层向下。[1, 10] 表示这个索引块存储数据的区间在 1 - 10 之间。索引块可以是 MemTable / ImmemTable / SST,它们抽象上是一样的。查询 2,就是顺着标绿色的块往下。如果索引块是 SST,就先查询 BloomFilter,看数据是否有可能在这个 SST 中,有的话则进行进一步查询。
除了 BloomFilter 外,BlockBasedTable 还有额外两个值得提的实现。一个是两层索引:
浅黄部分是 DataBlock,绿色部分是 IndexBlock。DataBlock 记载实际数据,IndexBlock 索引 DataBlock。假如要查询 3,先从 IndexBlock 中找到 >= 3 的第一条记录是什么,发现是 4,对应的 value 是 data_block_0 的 offset,直接定位到 Data Block 0。然后可以在 Data Block 0 中进行搜索。
Compact
Compact 在 LSMT 中是将 Key 区间有重叠或无效数据较多的 SST 进行合并,以此来加速读取或者回收空间。Compact 策略可以分为两大类。
- Level
Ref: LSM-based Storage Techniques: A Survey
Level 策略直接来自于 LevelDB,也是 RocksDB 的默认策略。每一个层不允许有 SST 的 Key 区间重合。当用户写入的 SST 加入 L0 的时候会和 L0 里区间重叠的 SST 进行合并。当 L0 的总大小到达一定阈值时,又会从 L0 挑出 SST,推到 L1,和 L1 里 Key 区间重叠的 SST 进行合并。Ln 同理。
由于在 LSMT 中,每下一层都会比上一层大 T 倍(可配置),那么假设用户的输入是均匀分布的,每次上下层的合并都一定是一个小 SST 和一个大 SST 进行 Compact。这个从算法的角度来说是低效的,增加了写放大,具体理论分析会在之后阐述,这里可以想象一下 Merge Sort。Merge Sort 要效率最高,就要每次 Merge 的时候,左右两边的数组都是一样大。
实际上,RocksDB 和 LevelDB 都不是纯粹的 Level 策略,它们将 L0 作为例外,允许有 SST Key 区间重叠来降低写放大。
- Tier
Ref: LSM-based Storage Techniques: A Survey
Tier 策略允许 LSMT 每层有多个区间重合的 SST,当本层区间重合的 SST 到达上限或者本层大小到达阈值时,一次性选择多个 SST 合并推向下层。Tier 策略理论上 Compact 效率更高,因为参与 Compact 的 SST 大小预期都差不多大,更接近于完美的 Merge Sort。
Tier 策略的问题在于每层的区间内重合的 SST 越多,那么读取的时候需要查询的 SST 就越多。Tier 策略是用读放大的增加换取了写放大的减小。
Cloud-Native LSMT Storage Engine
RocksDB 是单机存储引擎,那么现在都说云原生,HBase 比 RocksDB 就更「云」一些,SST 直接存储于 HDFS 上,Meta 信息 RocksDB 自己管理维护于 Manifest 文件,HBase 放置于 ZK。二者在理论存储模型上都是 LSMT。
LSMT 模型理论分析
T: size ratio,每层 LSMT 比上一层大多少,L0 大小为 1,则 L1 大小为 T,L2 为 T^2,以此类推
L: level num,LSMT 层数
B: 每个最小的 IO 单位能装载多少条记录
M: 每个 BloomFilter 有多少 bits
N: 每个 BloomFilter 生成时用了多少条 Key
e−MNe^{- \frac{M}{N} } e−NM是 BloomFilter 的 false positive rate
S:区间查询的记录数量
Ref: LSM-based Storage Techniques: A Survey
Short Range Query / Long Range Query / Space Amplification,篇幅有限,建议直接阅读原论文。
- Level
Write:每条记录抵达最底层需要经过 L 次 Compact,每次 Compact Ln 的一个小 SST 和 Ln+1 的一个大 SST。设小 SST 的大小为 1,那么大 SST 的大小则为 T,合并开销是 1+T,换言之将 1 单位的 Ln 的 SST 推到 Ln+1 要耗费 1+T 的 IO,单次 Compact 写放大为 T。每条记录的写入成本为 1/B 次最小单位 IO。三者相乘即得结果。
Point Lookup:对于每条 Key,最多有 L 个重叠的区间,每个区间都有 BloomFilter,失效率为e−MNe^{- \frac{M}{N} } e−NM,只有当 BloomFilter 失效时才会访问下一层。因此二者相乘可得读取的开销。注意,这里不乘 1/B 的原因是写入可以批量提交,但是读取的时候必须对齐到最小读取单元尺寸。
- Tier
Write:每条记录抵达最底层前同样要经过 L 次 Compact,每次 Compact Ln 中 T 个相同尺寸的 SST 放到 Ln+1。设 SST 大小为 1,那么 T 个 SST Compact 的合并开销是 T,换言之将 T 单位的 Ln 的 SST 推到 Ln+1 要耗费 T 的 IO,单次 Compact 的写放大为 T / T = 1。每条记录的写入成本为 1/B 次最小单位 IO。三者相乘即得结果。
Point Lookup:对于每条 Key,有 L 层,每层最多有 T 个重叠区间的 SST,对于整个 SST 来说有 T *
L 个可能命中的 SST,乘上 BloomFilter 的失效率即可得结果。
总结,Tier 策略降低了写放大,增加了读放大和空间放大,Level 策略增加了写放大,降低了读和空间放大。