这是我参与「第四届青训营 」笔记创作活动的第19天
资料来源于[这]【大数据专场 学习资料五】第四届字节跳动青训营 - 掘金 (juejin.cn)
LSMT 存储引擎浅析
01.LSMT与存储引擎介绍
LSMT 与 B+Tree 的异同
先简单回顾下经典 B+Tree 写入流程,
Insertion in a B+ tree - GeeksforGeeks
有一 Order 为 5 的 B+Tree,目前存有 (10, 20, 30, 40),继续插入 15,节点大小到达分裂阈值 5,提取中位数 20 放入新的内部节点,比 20 大的 (30, 40) 移入新的叶节点。这个例子虽然简单,但是涉及了 B+Tree 最核心的两个变化,插入与分裂。
在 B+Tree 中,数据插入是原地更新的,装有 (10, 20, 30, 40) 的节点在插入和分裂后,原节点覆写成 (10, 15)。此外,B+Tree 在发生不平衡或者节点容量到达阈值后,必须立即进行分裂来平衡。
反观 LSMT,数据的插入是追加的(Append-only),当树不平衡或者垃圾过多时,有专门 Compact 线程进行 Compact,可以称之为延迟(Lazy)的。
思考一个问题,B+Tree 能不能把部分数据采用追加写,然后让后台线程去 Compact 维护树结构呢?或者 LSMT 能不能只有一层 L0,ImmemTable 给 Flush 线程之后,立马 Compact 呢?
答案是都可以。前者的做法叫做 Fractal tree(分型树)应用在了 TokuDB 中。后者的做法在 OceanBase 或者类似对延迟有严格要求的在线数据库中得到了应用,因为 LSMT 层数越少,读取越快。
所以从高层次的数据结构角度来看,B+Tree 和 LSMT 并没有本质的不同,可以统一到一个模型里,根据 Workload 的不同互相转换。这是 CIDR19 论文「Design Continuums and the Path Toward Self-Designing Key-Value Stores that Know and Learn」,www.cidrdb.org/cidr2019/pa…
Ref: Design Continuums and the Path Toward Self-Designing Key-Value Stores that Know and Learn
B+Tree 中内部节点指向其它节点的指针,被称之为 Fence Pointers。在 LSMT 也有,只不过是隐式表达的。B+Tree 直接通过 Fence Pointer 一层一层往下找,而 LSMT 是有一个中心的 Meta 信息记录所有 SST 文件的 Key 区间,通过区间大小关系,一层一层向下找。
再看 LSMT 的 SST,其实和 B+Tree 的 Node 也没有本质差别,逻辑上就是一个可查询的有序块,统一模型中称之为 Run。B+Tree 为了支持随机修改,结构会比较松散和简单,LSMT 则因为不需要支持随机修改,利用压缩技术,结构可以更紧凑。
更详细的统一模型描述,请同学们参见论文。尽管 LSMT 和 B+Tree 可以用一个模型描述,工程实践上我们还是用 LSMT 来表示一个 Append-only 和 Lazy Compact 的索引树,B+Tree 来表示一个 Inplace-Update 和 Instant Compact 的索引树。Append-only 和 Lazy Compact 这两个特性更符合现代计算机设备的特性。
存储引擎在数据库中做了什么?
在课前部分,我们以 ACID 特性为切入点,大概了解存储引擎在数据库系统中的定位,现在让我们来学习下除了保障 ACID 以外,存储引擎究竟还在数据库中做了什么。
屏蔽 IO 细节提供更好的抽象
IO 是一种具体实现很复杂,但是逻辑边界很清晰的任务,存储引擎需要屏蔽不同 IO 硬件设备(HDD,SSD,PMem etc),不同系统 API(pread / libaio / iouring)的差别,给出统一的抽象。
对于不同硬件设备,存储引擎要能选择最合适的数据分块尺寸,例如 SSD 中一般 Page 大小为 4KB,但在 PMem 中,读写单位就是 256Bytes 了。
对于不同的系统,最佳的读写 API 也不同。例如在 Linux 系统中提供了 libaio 和 iouring 这样的异步 IO 接口,可以避免多线程 + 同步接口带来的频繁线程 context swtich 开销,读写引擎要能在上层 API 不变或者很小的情况下充分利用这些特性。
存储引擎更好的利用这些环境的差异就能更好地服务上层。还有一种相反的做法是这些细节都由操作系统来屏蔽,比如使用 mmap 接口。由于操作系统并不完全感知数据库任务的特性,会造成以下问题:
1.事务不安全,因为操作系统并不知道具体的事务 commit 时机,有可能事务还没 commit,但数据已经落盘了
2.IO Stall,mmap 在发生 page fault 的时候,用户态程序是没办法插手的,也没办法预期什么时候完成,不可控
3.错误处理,mmap 在发生硬件错误的时候,并没有办法通知用户态程序,每次读取都要进行校验
4.性能,mmap 触发 page fault 的成本很高,无法完全发挥硬件性能
具体可以参见论文,Why Andy hates MMAP - 知乎 (zhihu.com)
提供统计信息与 Predicate Push Down 能力
数据库绝大部分持久化状态和数据都存放在存储引擎里,因此存储引擎相比于上层有着对数据更准确的信息。例如 RocksDB 提供了 ApproxSize 接口,可以让优化器在估算代价的时候,得到区间内大概有多少元素,生成更优的执行计划。
存储引擎是数据读取的源头,因此还可以将一些过滤条件下推引擎,避免无意义的 IO,例如在 Parquet 数据格式中,数据是分 column 存储的。如果只需要部分 column,就可以把这个信息传给存储引擎,不用去读不需要的 column。
02.LSMT存储引擎的优势
相对于 B+Tree 的优势
我们在前文已经阐述了 LSMT 与 B+Tree 的异同,在这里总结下 LSMT 的优势。
1.顺序写模型对于 SSD 设备更友好
2.SST 不可修改的特性使得其能使用更加紧凑的数据排列和加上压缩
3.后台延迟 Compact 能更好利用 CPU 多核处理能力,降低前台请求延迟
相对于 HashTable 的优势
LSMT 存储引擎是有序索引抽象,HashTable 是无序索引抽象。无序索引是有序索引的真子集。LSMT 相比于 HashTable 更加通用。HashTable 能处理点查请求,LSMT 也能,但 LSMT 能处理 TopK 请求,但 HashTable 就不行了。为了避免维护多套存储引擎,绝大多数数据库都直接采用一套有序的存储引擎而非针对点查和顺序读取分别维护两个引擎。
03.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 策略增加了写放大,降低了读和空间放大。
04.LSMT存储引擎调优案例与展望
TerarkDB aka LavaKV 是字节跳动内部基于 RocksDB 深度定制优化的自研 LSMT 存储引擎,其中完全自研的 KV 分离功能,上线后取得了巨大的收益。
KV 分离受启发于论文 WiscKey: Separating Keys from Values in SSD-conscious Storage,www.usenix.org/system/file… 较长的记录的 Value 单独存储,避免 Compact 过程中频繁挪动这些数据。做法虽然简单,但背后的原理却十分深刻。存储引擎其实存了两类数据,一类是索引,一类是用户输入的数据。对于索引来说,随着记录不断变更,需要维护索引的拓扑结构,因此要不断 Compact,但对于用户存储的数据来说,只要用户没删除,可以一直放着,放哪里不重要,能读就行,不需要经常跟着 Compact。只要 Value 足够长,更少 Compact 的收益就能覆盖 KV 分离后,额外维护映射关系的开销。