LSMT 存储引擎浅析 | 青训营笔记
这是我参与「第四届青训营 」笔记创作活动的的第14天,本篇笔记主要是关于第十四次大数据课程《LSMT 存储引擎浅析》的课堂笔记
LSMT与存储引擎介绍
数据格式层: 通过 Append-only Write + 择机 Compact 来维护索引树的结构。
首先数据会写入WAL,来确保原子性,接着数据会写入Memtable,达到一定的阈值后会进行冻结,生成Immutable,所有的修改只会作用于Memtable,所以ImmemTable 可以被转交给 Flush 线程进行写盘操作而不用担心并发问题,进一步生成SST(socket Stringtable),其实就是存储在硬盘上的索引。新生成的 SST 会存放于 L0(Layer 0),除了 L0 以外根据配置可以一直有 Ln。SST 每 Compact 一次,就会将 Compact 产物放入下一层。Compact 可以大致理解为 Merge Sort,就是将多个 SST 去掉无效和重复的条目并合并生成新的 SST 的过程。Compact 策略主要分为 Level 和 Tier 两种,会在课中进行更详细的描述。
数据库
传统数据库大致可以分为:
- 计算层:负责 SQL 解析/ 查询优化 / 计划执行
- 存储层(存储引擎层)
ACID:
- Atomicity 原子性依赖于存储引擎 WAL(Redo Log)
- Consistency (Correctness) 一致性需要数据库整体来保证
- Isolation 隔离性依赖于存储引擎提供 Snapshot(有时候会直接说 MVCC)能力。如果上层没有单独的事务引擎的话,也会由存储引擎提供事务能力。一般的是实现是 2PL(2 Phase Lock) + MVCC。2PL 可以简单理解为对所有需要修改的资源上锁。
- Durability 持久性依赖于存储引擎确保在 Transaction Commit 后通过操作系统 fsync 之类的接口确保落盘了
存储引擎
除了保障ACID以外,存储引擎还要负责:
- 屏蔽IO细节提供更好的抽象
- 提供统计信息与Predicate Push Down能力 存储引擎不掌控IO细节,让操作系统接管,例如使用mmap,会有如下问题:
- 落盘时机不确定造成的事务不安全:因为操作系统并不知道具体的事务 commit 时机,有可能事务还没 commit,但数据已经落盘了
- IO Stall:mmap 在发生 page fault 的时候,用户态程序是没办法插手的,也没办法预期什么时候完成,不可控
- 错误处理繁琐:mmap 在发生硬件错误的时候,并没有办法通知用户态程序,每次读取都要进行校验
- 无法完全发挥硬件性能:mmap 触发 page fault 的成本很高,无法完全发挥硬件性能
LSMT存健引隆的优势与实现
LSMT与B+Tree的异同
- 在B+Tree中,数据插入是原地更新的
- B+ Tree在发生不平衡或者节点容量到达阈值后,必须立即进行分裂来平衡
- LSMT:数据的插入是追加的(Append-only),当树不平衡或者垃圾过多时,有专门 Compact 线程进行 Compact,可以称之为延迟(Lazy)的。
为什么要用LSMT模型:
无论对于 HDD(机械) 还是 SSD(固态),顺序写都是一个很好的特质,LSMT 符合这一点,B+Tree 则依赖原地更新,会导致随机写。
LSMT存储引擎的实现-Write
- 多个写入者会选出一个Leader,由这个Leader来一次性写入WAL,避免小IO。
- 不要求WAL强制落盘(Sync)时,批量提交亦有好处,Leader可以同时唤醒其余Writer,降低了系统线程调度开销。
- WAL一次性写入完成后,唤醒所有Writer并行写入MemTable
- 由最后一个完成MemTable写入的Writer执行收尾工作
Snapshot & SuperVision
- RocksDB的数据由3部分组成,MemTable / ImmemTable / SST。持有这三部分数据并且提供快照功能的组件叫做SuperVersion。
- MemTable和SST的释放依赖于引用计数。对于读取来说,只要拿着SuperVersion,从MemTable一级一级向下,就能查到记录。拿着SuperVersion不释放,等于是拿到了快照。
- 如果所有读者都给SuperVersion的计数加1,读完后再减1,那么这个原子引用计数器就会成为热点。CPU在多核之间同步缓存是有开销的,核越多开销越大。
- 为了让读操作更好的scale,RocksDB做了一个优化是Thread Local SuperVersionCache
- 没有Thread Local缓存时,读取操作要频繁Acquire和ReleaseSuperVersion
- CPU缓存不友好
- 有Thread Local缓存,读取只需要检查一下SuperVersion并标记ThreadLocal缓存正在使用即可
- CPU缓存友好
Get & BloomFilter:
- RocksDB的读取在大框架上和B+ Tree类似,就是层层向下。
- 相对于B+Tree , LSMT点查需要访问的数据块更多。为了加速点查,一般LSMT引擎都会在SST中嵌入BloomFilter.
Compact
Compact 在 LSMT 中是将 Key 区间有重叠或无效数据较多的 SST 进行合并,以此来加速读取或者回收空间。Compact 策略可以分为两大类。
- Level
每一层不允许有 SST 的 Key 区间重合。当用户写入的 SST 加入 L0 的时候会和 L0 里区间重叠的 SST 进行合并。当 L0 的总大小到达一定阈值时,又会从 L0 挑出 SST,推到 L1,和 L1 里 Key 区间重叠的 SST 进行合并。Ln 同理。
- Tier
Tier 策略允许 LSMT 每层有多个区间重合的 SST,当本层区间重合的 SST 到达上限或者本层大小到达阈值时,一次性选择多个 SST 合并推向下层。Tier 策略理论上 Compact 效率更高,因为参与 Compact 的 SST 大小预期都差不多大,更接近于完美的 Merge Sort。
Cloud-Native LSMT Storage Engine
- 大数据分析领域使用最广的列存格式之一
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:区间查询的记录数量
- Level:
- Write:
- 每条记录抵达最底层需要经过L次Compact,每次Compact Ln的一个小SST和Ln+1的一个大SST。
- 设小SST的大小为1,那么大SST的大小则为T,合并开销是1+T,换言之将1单位的Ln的SST推到Ln+1要耗费1+T的I0单次 Compact 写放大为工。
- 每条记录的写入成本为1/B次最小单位IO。
- O(Write_ Level) = LT * 1/B = TL/B
- Point Lookup
- 对于每条Key,最多有L个重叠的区间。
- 每个区间都有BloomFilter ,失效率为e^(-M/N)只有当 BloomFilter 失效时才会访问下一层。
- O(PointLookup_ Level) = L * e^(-M/N)
- 注意,这里不乘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 策略增加了写放大,降低了读和空间放大。
总结
- 单机数据库的ACID特性依赖于存储引擎
- LSMT存储引擎的顺序写特性更适合现代计算机体系结构
- LSMT和B+Tree可以用同一模型描述并互相转化
- Level Compaction策略,降低了读放大和空间放大,增加了写放大
- Tier Compaction策略,降低了写放大,增大了读放大和空间放大
- 分布式KV存储,如HBase,背后的理论模型与单机存储引擎RocksDB一样都是LSMT