深入理解LSM-Tree
lsm-tree的背景、定义和适用场景本文不再详述。本文意在论述LSM的存储引擎、工业取舍以及发展现状。
基础概念
- 读放大(
read amplification
):一次读取操作需要访问磁盘的次数,主要由compaction策略引入。可以引入cache和bloom filter优化。 - 写放大(
write amplification
):一次写操作带来的数据落盘的次数。在LSM-Tree中可由某个key数据在磁盘上存储的数量这个指标体现,主要是由compaction策略引入的。 - 空间放大(
space amplification
):实际空间占用/理论数据量
,LSM-Tree的架构导致空间放大必然存在 - 临时空间:compaction任务需要临时的空间来完成多个sstable的合并。
run
:借用wiki中的解释:-
The on-disk data is organized into sorted runs of data. Each run contains data sorted by the index key. A run can be represented on disk as a single file, or alternatively as a collection of files with non-overlapping key ranges. To perform a query on a particular key to get its associated value, one must search in the Level 0 tree and each run.
- 可见,每一个run都是有序的数据块。
-
compaction策略
LSM-Tree存储引擎的工作流程如下:
- 当写入时,将其天际到内存的平衡树数据结构中(如红黑树、AVL树或SkipList),这个内存树有时候被称为MemTable(leveldb)。
- 当内存表大于某个阈值(通常以MB为单位),将其作为SSTable文件写入磁盘,由于树已经维护了按键排序的key-value对,写磁盘的效率可以比较高效。新的SSTable文件称为数据库的最新部分,当SSTable写磁盘的同时,写入可以继续添加到一个新的内存表中
- 不同的实现使用了不同的压缩合并策略,用以优化write-intensive或read-intensive场景。
- LevelDB和RocksDB使用leveled compaction,HBase采用tiered compaction,Cassandra则同时支持这两种压缩。
- 为了处理读请求,首先尝试在内存表中查找key,然后按照LSM-Tree的层级依次向下查找,知道找到或未找到目标。
- 后台进程周期性的执行段合并与压缩过程,合并多个SSTable,丢弃已经stale的值。
从上面的存储流程可以看出,LSM-Tree的性能主要取决于Compaction策略,它主要分为两类,tiered compaction和leveled compaction
- tiered流派从Bigtable->HBase->Cassandra一脉相承,后面又有MyRocks、InfluxDB、Kudu等等
- Leveled流派工程实现主要有LevelDB->RocksDB
Size-tired compaction strategy(STCS)/Tiered
tiered适合write-intensive workload,有较低的写放大,缺点是读放大和空间放大较高。
tiered compaction的策略是:memtable达到阈值之后刷入下一层(落盘)作为一个run,每层的run达到一定数量之后,将当前层所以的run进行compaction并刷入下一层,循环往复。这种策略给人直观的感受是,越下层的run越大,当然实际的策越要比描述的复杂,因为run与run之间重叠的部分是不确定的,有可能完全重叠,也有可能完全不重叠。
- 由于所有的上层level的数据可以直接追加写入下层,所以写放大比较低
- 由于每层都有多个run,且每个run中的数据可以重叠,所以点读操作需要由上到下遍历每一个run,读放大较严重。更不要提range查询肯定要合并每一层的每一个run的查询结果,读放大更严重。
- 由于每层有多个run,每个run都可能含有重叠数据,所以空间放大较为严重。空间放大可以说是
STCS
最严重的问题了。Scylla的文章通过实验论证了这个问题 - 每层的多个run需要先合并,才能写入下层,因此有可能导致临时空间较大。耗时也较长
- 这里讨论一个特例
time-series data
。key是时间,几乎是恒定递增的,因此越是下层,run越是不重叠,写入和查询的策略都可以做对应的优化。
leveled compaction
leveled compaction
和上面的STCS相比,降低了空间放大和读放大,比较适合read-intensive workload,当然这个也只是相对而言,如果真的是读很多,不如用B+ Tree。
leveled compaction
每层只维护一个run,按照key排序可以分为多个partition(SSTable file)。每层容量达到一定限制后,选择某个sstable与上层merge。
- 由于每层只有一个run,所以降低了读放大和空间放大。但是点查询仍然有可能会遍历所有的run、范围查询必然会遍历所以的run。
- 由于
leveled compaction
上层与下层merge的过程有可能涉及到上下层所有的数据,可能造成上层run全量重写,导致写入放大,但是由于每个run可以分为多个partition(sstable),因此可以节约部分临时空间。
Leveled-N
不同于leveled,Leveled-N允许每层多个Sorted run。Dostoevsky paper
引入了一种Fluid LSM
:允许最高层只有一个run,而其它层可以有多个run。
Hybrid
这里讨论scylladb实现的Hybrid模式。主要体现在这篇文章,其希望推出一种模式,可以吸纳tiered和leveled两者的优点。scylladb引入Hybrid主要为了优化tiered糟糕的空间放大。我推测主要有以下几点优化:
- 总体布局和tiered类似,只不过每层的run的数量可以根据负载动态调节,下层的run可以直接merge到上层的run里面。
- 运行时可以感知到当前磁盘剩余空间和run的key分布,如果剩余空间不多或这个每个run重叠太多(说明是overwrite场景),则compaction略逐步由tiered向leveled转移。
- 总的来说我觉得这个优化是将rocksDB繁杂的优化参数调整为代码中的一些策略,以优化特定的workload
Time-Window
这里主要讲scylladb/Cassandra针对时序数据引入的Time-Window compaction,时序数据有以下特点:
- key是单调递增的(timeline),
- 写入速率基本恒定,
- 查询方式以range-query为主,如查询某年、月、日的数据
- 可以通过设置TTL的方式删除超过一定时间的数据。
这种数据状况下,通过将不同的时间分配在不同的Time-Window中,将SSTable分配成了一个个time bucket,实现形式上采用tiered compaction,不同的time bucket不会合并(优化读操作)。由于overlap并不多,所以compaction压力不大,空间放大也不大。由于数据分布较好,查询也较方便。
比较
scylladb的文章总结了以下表格:
workload | Size-Tiered | Leveled | Hybrid | Time-Window |
---|---|---|---|---|
Write-only | 2x peak space | 2x writes | Best | - |
Overwrite(指同一个key多次update) | Huge peak space | write amplification | high peak space bug not like size-tiered | - |
Read-mostly, few updates | read amplification | Best | read amplification | - |
Read-mostly, but a lot of updates | read and space amplification | write amplification may overwhelm | read amplification | - |
Time series | write,read,and space amplification | write and space amplification | write and read amplification | best |
工业实现
leveldb
顾名思义,levelDB使用leveled compaction
,其CRUD流程如下:
- 写流程:
- 找到合适的memtable(skiplist)并写入数据,如果memtable写完并且immutableTable还未完成持久化,或者L0文件太多,则等待。如果memtable满了,则换一个memtable,并触发compaction。由此可见,
l0
是tiered
,对于每个run,key的范围有重叠。其它层是leveled
- 写日志,下刷日志
- 写memtable
- 找到合适的memtable(skiplist)并写入数据,如果memtable写完并且immutableTable还未完成持久化,或者L0文件太多,则等待。如果memtable满了,则换一个memtable,并触发compaction。由此可见,
- 读流程:
- 首先查找memtable,再查找immutable memtable
- 如果没有,查找下面所有持久化的level
Version::Get
,逐层向下搜索。搜索方法的策略是并不是遍历每一个sstable,而是先看需要查找的key是否落在sstable的范围内,如果落在,对sstable搜索。 - 对于读取mem中的数据(skiplist),正向遍历时间复杂度是
O(1)
,反向遍历时间复杂度是O(log(N))
- 对于读取sstable中的数据,正向遍历有指针指向下一个数据位置,反向遍历每次
Prev()
都要重新定位
- 删除流程:
- 后台线程也会按需做compaction。通过
MaybeScheduleCompaction()
函数判断。
levelDB代码清晰,代码量少,阅读方便,不过缺少大规模工程应用场景。
RocksDB
LSM-Tre工业级实现,和levelDB相比,参数更多,优化更多,使用更灵活,Features Not in LevelDB这篇文章介绍了RocksDB相对于levelDB所做的优化,我选择一些优点列举如下:
- 实现了tiered(Universal Compaction),tiered+leveled(l0 tiered,高层leveled),和FIFO compaction(一条数据写入后,超过 TTL 指定的时间戳,就过期淘汰,对用户不可见。)
- 前缀遍历,针对相同前缀(prefix)的场景做了优化
Options.prefix_extractor
- checksum数据校验
- 支持多线程compaction
- 支持全量和备份
- 支持
read-only
模式以优化读速度 - memtable优化:
- 支持多种数据结构:skiplist为默认,vector模式以优化Bacth-insert,hashtable模式以优化读操作。
- 支持多memtable并发插入(Memtable Pipelining)
- memtable持久化的时候会做合并,GC掉某些旧数据
Column Families
,可以设置某个kv归并为一个column family列族,如{cf1, key1, value1}
,列族之间可以相互隔离(不共享memtable和sstable)
Write Stalls
RocksDB会在某种情况下限制前台写请求的速度,称为Write Stalls。产生的原因不外乎因为某种原因,compaction的速率赶不上前台write的速率了。这种现象如果持续下去,会产生以下结果:
- 空间放大增大,写入的数据来不及压缩,最终耗尽磁盘空间
- 读放大增大,越是来不及compact,读放大就越大
这种情况下需要将前台写请求降速,直到compaction执行到某种可以继续高效处理前台读写请求的程度。
一般情况下,Write Stall产生的原因有以下几种可能:
- memtable,前台写入太快,memtable过多,而且都没有flush进l0
- l0 SSTable过多,导致读放大、merge也慢
- pending compaction bytes过多。磁盘 I/O 能力在业务高峰跟不上写入
scyllaDB/cassandra
scyllaDB/cassandra默认采用tiered,可以配置为levled、Hybrid或Time-Window。只有Hybrid是ScyllaDB独有。实现细节如上所述
hbase
HBase是一个分布式,版本化,面向列的开源数据库(面向列族Column Family
,在)、分布式持久化KV数据库。HDFS(仅支持追加写)为Hbase提供可靠的底层数据存储服务,MapReduce为Hbase提供高性能的计算能力,Zookeeper为Hbase提供稳定服务和Failover机制,因此我们说Hbase是一个通过大量廉价的机器解决海量数据的高速存储和读取的分布式数据库解决方案。Hbase适合存储PB级别的海量数据,虽然当个请求是时延不低,但是可以通过并行请求增大吞吐,并且单个IO的时延下降并不多。
架构和bigtable比较类似,通过key-range,将数据水平切分到多台服务器上,数据持久化在HDFS中。这点和现代的应用如tidb略有不同,其是将数据切分之后通过一致性协议存储在多个RSM上,每个RSM的数据引擎为LSM-Tree
hbase在实现中,是把整个内存在一定阈值后,flush到disk中,形成一个file,这个file的存储也就是一个小的B+树,因为hbase一般是部署在hdfs上,hdfs不支持对文件的update操作,所以hbase这么整体内存flush,而不是和磁盘中的小树merge update。内存flush到磁盘上的小树,定期也会合并成一个大树。整体上hbase就是用了lsm tree的思路。
因为小树先写到内存中,为了防止内存数据丢失,写内存的同时需要暂时持久化到磁盘,对应了HBase的HLog(WAL)和MemStore
MemStore上的树达到一定大小之后,需要flush到HRegion磁盘中(一般是Hadoop DataNode),这样MemStore就变成了DataNode上的磁盘文件StoreFile,定期HRegionServer对DataNode的数据做merge操作,彻底删除无效空间,多棵小树在这个时机合并成大树,来增强读性能。
TiKV/Titan
TiKV使用RocksDB作为存储引擎,并且实现了Titan作为RocksDB 的高性能单机 key-value 存储引擎插件。以支持大value(Blob),其核心特性在于支持将 value 从 LSM-tree 中分离出来单独存储,以降低写放大。
其主要设计灵感来源于 USENIX FAST 2016 上发表的一篇论文 WiscKey。WiscKey 提出了一种高度基于 SSD 优化的设计,利用 SSD 高效的随机读写性能,通过将 value 分离出 LSM-tree 的方法来达到降低写放大的目的。
Titan 适合在以下场景中使用:
- 前台写入量较大,RocksDB 大量触发 compaction 消耗大量 I/O 带宽或者 CPU 资源,造成 TiKV 前台读写性能较差。
- 前台写入量较大,由于 I/O 带宽瓶颈或 CPU 瓶颈的限制,RocksDB compaction 进度落后较多频繁造成 write stall。
- 前台写入量较大,RocksDB 大量触发 compaction 造成 I/O 写入量较大,影响 SSD 盘的寿命。
学术研究
Dostoevsky
这篇论文详细地分析了各种操作在tired和leveled不同策略下的i/o复杂度,使用了level num、相邻level size比、entry数量、buffer size等一系列相关变量推导出不同操作具体的I/O代价公式和空间放大,对于point read还考虑了bloom filter带来的影响,range query分了short和long两种来分析。并且提出了优化策略lazying leveling,是tiering和leveling的结合体,最大一层使用leveling其它层使用tiering,lazying适合包含update、point lookup和long range lookup的混合负载,tiering适合大多为update的负载,leveling适合大多为lookup的负载。通过控制merge频率在tiering、leveling和lazying leveling几种不同策略下转换以适应不同的workload,称作fluid LSM((类似rocksdb的leveled-n)),并提出Dostoevsky,在执行期间动态计算最优配置以到达最大吞吐。
从作者的分析中可以看出compaction这个机制的复杂程度,要使用一套通用机制在不同场景下消耗最少的资源带来最好的性能基本是不可能的,实际工业界实现中需要考虑更多的因素(cache、delete、任务拆分粒度、复用技术等),因此大部分系统使用多种策略多个参数用以适配不同场景。
参考链接
- Cassandra Compaction
- LevelDB 之 Compaction
- 深入探讨LSM Compaction机制
- LSM Tree的Leveling 和 Tiering Compaction
- Scylla’s Compaction Strategies Series: Space Amplification in tiered CompactionSTCS图出处
- Size Tiered Compaction Strategy In Apache Cassandra
- rocksdb Leveled Compaction 图文并茂
- RocksDB Overview
- RocksDB Performance Benchmarks
- Direct IO什么场景里可以使用directIO
- RocksDB调优指南翻译自官方wiki
- RocksDB中文网
- RocksDB Tuning Guide
- Compaction rocksdb讲tirered和leveled
- Name that compaction algorithmTiered/Leveled比较,这哥们专门做lsmtree的。
- How to Ruin Your Performance by Choosing the Wrong Compaction Strategyscylladb的这篇文章写得很好
- Scylla’s Compaction Strategies Series: Write Amplification in Leveled Compaction
- Scylla’s Compaction Strategies Series: Space Amplification in Size-Tiered Compaction
- Titan 的设计与实现
- SIGMOD’18|Dostoevsky