这是我参与「第四届青训营 」笔记创作活动的第14天
LSMT与存储引擎介绍
早期的数据库一般采用B-Tree家族作为索引,2000年后诞生的数据库大多采用LSMT(Log-Structured Merge-Tree)索引。
LSMT是通过Append-only Write + 择机Compact来维护结构的索引树。
存储引擎
以单机数据库MySQL为例,大致分为计算层和存储层,计算层主要负责SQL解析/查询优化/计划执行,ACID特性在MySQL中全部强依赖于存储引擎。
除了保障ACID以外,存储引擎还要负责:
- 屏蔽IO细节提供更好的抽象
- 提供统计信息与Predicate Push Down能力
存储引擎不掌控IO细节,让操作系统接管,例如使用mmap,会有如下问题:
- 落盘时机不确定造成的事务不安全
- IO Stall
- 错误处理繁琐
- 无法完全发挥硬件性能
LSMT存储引擎的优势与实现
LSMT相比B+Tree的优势
在计算机存储乃至整个工程界都在利用Indirection处理资源的不对称性,存储引擎面对的资源不对称性在不同时期是不同的:
- HDD时代,顺序操作远快于随机操作
- SSD时代,顺序写操作远快于随机写操作
这二者的共性是顺序写是一个对设备很友好的操作,LSMT符合这一点,而B+Tree依赖原地更新,导致随机写。
LSMT存储引擎实现:以RocksDB为例
RocksDB是一款十分流行的开源LSMT存储引擎,最早来自Facebook(Meta),应用于 MyRocks,TiDB等数据库。在字节内部也有Abase,ByteKV,ByteNDB,Bytable等用户,因此以RocksDB为例子介绍LSMT存储引擎的经典实现。
Write
RocksDB写入流程主要有两个优化,批量WAL写入(继承自LevelDB)与并发MemTable更新。
RocksDB在真正执行修改之前会先将变更写入WAL,WAL写成功则写入成功。
多个写入者会选出一个Leader,由这个Leader来一次性写入WAL,避免小IO。
不要求WAL强制落盘(Sync)时,批量提交亦有好处,Leader可以同时唤醒其余Writer,降低了系统线程调度开销。
没有批量提交的话,只能链式唤醒,会加大前台延迟。
写完WAL还要写MemTable。RocksDB在继承LevelDB的基础上又添加了并发MemTable写入的优化。
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 SuperVersion Cache。
没有Thread Local缓存时,读取操作要频繁Acquire和Release SuperVersion,CPU缓存不友好。
有Thread Local缓存,读取只需要检查一下SuperVersion并标记Thread Local缓存正在使用即可,CPU缓存友好。
Get & BloomFilter
- RocksDB的读取在大框架上和B+Tree类似,就是层层向下。
- 相对于B+Tree,LSMT点查需要访问的数据块更多。为了加速点查,一般LSMT引擎都会在SST中嵌入BloomFilter。
BloomFilter可以100%断定一个元素不在集合内,大概率断定一个元素在集合内。
Compact - Level
Compact在LSMT中是将Key区间有重叠或无效数据较多的SST进行合并,以此来加速读取或者回收空间。Compact策略可以分为两大类,Level和Tier。
Level策略直接来自于LevelDB,也是RocksDB的默认策略。每一个层不允许有SST的Key区间重合。
Compact - Tier
Tier策略允许LSMT每层有多个区间重合的SST。
LSMT模型理论分析
HBase
RocksDB是单机存储引擎,那么现在都说云原生,HBase比RocksDB就更「云」一些,SST直接存储于HDFS上。二者在理论存储模型上都是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
- S:区间查询的记录数量
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。
O(Write_Level)=L * T * 1/B = T * L/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。
O(Write_Tier) = L * 1 * 1/B = L/B -
Point Lookup
对于每条Key,有L层。每层最多有T个重叠区间的SST,对于整个SST来说
T*L个可能命中的SST,乘上BloomFilter的失效率,e^(-M/N),可得结果。O(PointLookup_Tier) = L * T * e^(-M/N) = T * L * e^(-M/N)注意,这里不乘1/B系数的原因是写入可以批量提交拉低成本,但是读取的时候必须对齐到最小读取单元尺寸。
总结,Tier策略降低了写放大,增加了读放大和空间放大,Level策略增加了写放大,降低了读和空间放大。