基于LSM-Tree 的分布式组件化 KV 存储系统 | DB·洞见回顾

随着云服务基础架构以及微服务技术的日益成熟,很多大型系统能够分解为根据应用 workload 需求的多个子系统,再通过网络交互组装在一起协同工作。

Nova-LSM,一个将基于LSM-Tree的分布式KV 存储系统分解为使用RDMA进行通信的组件的工作。这些组件将存储与处理分开,使处理组件能够共享存储带宽和空间。处理组件将文件块 (SSTable) 分散到任意数量的存储组件中,并通过一定机制平衡它们之间的负载,在运行时动态构建范围以并行化压缩并提高性能。Nova-LSM 具有很好的可伸缩性,在一些场景下性能优于其单机版本(LevelDB 和 RocksDB)几个数量级。

本期DB·洞见将由腾讯云数据库专家工程师唐彦,从前沿学术的角度深度解读Nova-LSM,重点介绍Nova-LSM的特性、重要设计及带来的启发。以下为分享实录:

直播回放完整版链接:https://www.bilibili.com/video/BV1tS4y1b7tR/

直播回放完整版+文字版搭配,效果更佳

视频内含课件获取方式哦~

一、LSM-Tree基本概念

1.1 LSM-Tree存储系统

LSM-Tree全称为“Log Structured Merge-Tree”,它是一种基于磁盘存储的数据结构。

在以前,数据库的索引基本上采用B+树方式来作为索引结构,在此情况下,随机写性能常被别人诟病。LSM-Tree则主打将离散的随机写请求转换成批量的顺序写操作。而无论是在机械硬盘还是在固态硬盘,顺序的读写性能永远好于随机读写。因此LSM-Tree作为一种高效的KV存储结构,被许多业界的成熟系统所应用,比如腾讯云数据库TDSQL新敏态引擎也是基于LSM-Tree进行开发。


file

LSM-Tree结构有较多优点:写性能强大、空间利用率高、较高的可调参特性、并发控制简单、有完备的修复方案等。


file

1.2 LSM-Tree的历史

在数据库更新方面,LSM-Tree与B+树的区别可以理解为:一个是in-place update,一个是out-place update。

基于B+树的更新,我们称之为in-place update。如下图所示,k1本来的值是v1,随后我们想在k1写下新值v4,这时需要找到k1,再把v4写进去。因此,在B+树的索引结构里对数据进行更新或者插入,会引起实时的I/O。在B+树的场景下,写性能会受到一定影响,但由于B+树可以支持较好的检索性能,因此读性能会较好。

相比之下,在LSM-Tree结构里,如果这时对k1进行v4的更新,我们不会马上把k1改成v4,而是将它转化成顺序写,把它写到内存里,追加在(k3,v3)后面,因为顺序写的性能远比随机写高,但这种方式则会牺牲读性能及导致空间放大。


file

下图展示的是1996年LSM-Tree最原始的结构。C0代表的是在内存里的状态,每当有数据写入,它就会逐渐往下merge。当第i层满时,它会把底下的叶子精简,从Ci到Ci+1去往下merge。层数越大则表明数据写入越早。每一层最初的版本的头部是B+树,C0是在内存的节点,接受最新的数据更新,C1层到Ck层都存在于磁盘。


file

1.3 LSM-Tree基本结构

目前主流的LSM-Tree基本架构如图所示。我们会在内存中保留memtable结构,用于接受最新的数据更新操作,memtable结构里的数据查找则通过跳表skiplist或者B+树实现。当memtable达到一定大小时,我们会进行flush操作,停止写入,再把memtable刷到磁盘上,变成静态文件即SSTable。

SSTable L0层与memtable保持一致,从L0层到L1层则会进行归并排序。排序意味着L1层到Lk层都处于有顺序的状态,因此在每一层往下沉时,内部的数据会在物理上保持有序。每个数据再往下沉时,会进一步根据不同的key range来转化成一个个互相不重叠的SSTable。


file

1.4 LSM-Tree在RocksDB中的实现

下图展示的是基于LSM-Tree数据结构进行二次开发的RocksDB。当遇到写请求时,RocksDB会先写一个log,即Write-Ahead Log (WAL)日志。当memtable还没有刷到磁盘上时,如果机器发生故障,Write-Ahead Log (WAL)日志则可以用于故障恢复。这是非常重要的功能。对TDSQL等金融应用场景数据库而言,可靠性永远排在第一位,写日志必须成功,才能把最新的数据插入到内存(memtable)里。


file

memtable还有阈值控制机制。在实际的生产环境中,一般将阈值设置为1G,到1G后则会冻结成immutable memtable。当活跃的memtable被冻结成 immutable memtable后,原来的memtable则可以清空内存,重新接受数据的写入。immutable memtable则不会再接受写入,会准备被flush到磁盘上。

随着越来越多的immutable memtable被flush到L0层,L0层的文件个数会逐渐达到一个阈值,这时会触发系统的compaction。由于L0层的SST文件之间呈现的是无序的状态,它们包含的key范围有可能重叠,这时需要把L0层的文件从磁盘上重新读取并进行归类排序,进而往下生成L1层的文件。从L1层到Ln层(生产环境中一般配置成7层),所有的文件的 key range 已经互不重叠,且按照 key 的顺序进行排放。

当我们要读取一个比较旧的数据,如果该数据不在内存也不在L0层时,我们会从L1层到Ln层去读取该SST文件。因为SST文件数量较多,在实际中我们会采用bloom filter来加快读取。

1.5 LSM-Tree查询

基于LSM-Tree的查询可分为点查与范围查询两大类,对应的执行方式如下:

●点查:从上往下进行查询,先查memtable,再到L0层、L1层。因为上层的数据永远比下层版本新,所以在第一次发生匹配后就会停止查询。

●范围查询:每一层都会找到一个匹配数据项的范围,再将该范围进行多路归并,归并过程中同一key只会保留最新版本。


file

1.6 LSM-Tree之空间/读/写放大

LSM-Tree性能的衡量主要考虑三个因素:空间放大、读放大和写放大。

一是空间放大。LSM-Tree的所有写操作都是顺序追加写,对数据的更新并不会立即反映到数据既有的值里,而是通过分配新的空间来存储新的值,即out-place update。因此冗余的数据或数据的多版本,仍会在LSM-Tree系统里存在一定时间。这种实际的占用空间大于数据本身的现象我们称之为空间放大。因为空间有限,为了减少空间放大,LSM-Tree会从L1往L2、L3、L4不断做compaction,以此来清理过期的数据以及不同数据的旧版本,从而将空间释放出来。

二是读放大。假设数据本身的大小为1k,由于存储结构的设计,它所读到的值会触发多次IO操作,一次IO意味着一条读请求,这时它所读取到的则是在后端所需要做大的磁盘读的实际量,已经远大于目标数据本身的大小,从而影响到了读性能。这种现象我们称之为读放大。为了减轻读放大,LSM-Tree采用布隆过滤器来避免读取不包括查询键值的SST文件。

三是写放大。在每层进行compaction时,我们会对多个SST文件进行反复读取再进行归并排序,在删掉数据的旧版本后,再写入新的SST文件。从效果上看,每条key在存储系统里可能会被多次写入,相当于一条key在每层都会写入一次,由此带来的IO性能损失即写放大。


file

LSM-Tree最初的理念是用空间放大和读放大来换取写放大的降低,从而实现较好的写性能,但也需要做好三者的平衡。以下是两种假设的极端情况。

第一种极端情况是:如果完全不做compaction,LSM-Tree基本等同于log文件,当memtable不断刷下来时,由于不做compaction,只做L0层的文件,这时如果要读一条key,读性能会非常差。因为如果在memtable里找不到该条key,就要去扫描所有的SST文件,但与此同时写放大现象也将不存在。

第二种极端情况是:如果compaction操作做到极致,实现所有数据全局有序,此时读性能最优。因为只需要读一个文件且该文件处于有序状态,在读取时可以很快找到对应的key。但要达到这种效果,需要做非常多的compaction操作,要不断地把需要删的SST文件读取合并再来写入,这会导致非常严重的写放大。


file

二、Nova-LSM的简介与特性

2.1 Nova-LSM架构设计一览

Nova-LSM是基于LSM-Tree结构的架构体系,其主要组件包括三部分:

第一部分是写日志的组件,将WAL写成功后再往LSM-Tree的memtable中查询新的数据。

第二部分是本身处理LSM-Tree写入的线程,其缩写为LTC(LSM-Tree Component),代表着将该线程单独组件化。

第三部分则是底层的存储,负责把接收到的上层LTC组件下发下来,并提供标准的文件接口。


file

Nova-LSM是一个存算分离的架构。上面处理LSM-Tree的是计算节点,它们要写磁盘时,需要用flush操作将memtable写到磁盘,compaction时要先从存储节点读取上来,接着进行归并排序并再次写回,再写下去时则由底下的分布式存储节点来进行。

它的架构借用了当下较好的数据库产品理念。在计算节点和存储里,存储节点会按照彼此的功能去划分独立的线程池,每个线程池的线程数可以配置。这相当于在计算节点里将线程的功能分为四种:第一种线程与client相关,负责收发客户请求;第二种线程负责RDMA、网络IO的相关操作;第三种线程与compaction相关,会不断扫描当前的SST是否符合compaction及推动compaction的进行;第四种线程与Drange相关,负责不断整理当前Drange的重排、重组织。

该工作的主要亮点之一,是在于把原本LSM-Tree这样的单机系统明确地划分出计算层、存储层,通过一定方式解决了在计算层本来会发生的停写、缓写现象。

2.2 Nova-LSM所解决的核心问题

Nova-LSM所解决的核心问题主要有两个。

第一个核心问题是:基于LSM-Tree结构的存储系统,例如LevelDB、RocksDB等,都会不可避免地遇到缓写或者停写的问题。比如内存里的memtable,在配置时最多可以写8个,因为写入多,需要全部flush到磁盘上。与此同时,当前L0层的SST文件非常多,L0层即将开始做compaction。但compaction会涉及到磁盘IO,在还没做完时,就会阻塞内存中的memtable对L0层SST进行flush的过程。当flush无法进行时,就会发生缓写,随着阈值的推进,在实在写不进时甚至会停写,这种现象体现在客户端就是请求掉零。

为了解决LSM-Tree结构存储系统中的缓写、停写问题,该文章提出了两个解决办法:

第一种方法是设计了存算分离的架构体系,具体如上图所示。该架构的重要作用之一,是把处理写入和处理磁盘IO的两大主力模块拆分,计算存储分离,哪个部分慢就为哪个部分增加节点以此来提高该部分的能力,这是比较亮眼的突破。

第二种方法是引入了动态分区,即Drange机制。该机制的目的是为了让业务的写入压力,在LTC即计算层的memtable上进行区间划分,每个range都有自己的memtable,通过区间划分,从而实现多个range之间进行并行compaction。以L0层为例,我们可以把L0层变成没有互相重叠的状态,这时我们就可以对L0层进行并行的compaction,可以加快L0层的文件的消化,从而减轻对memtable flush到磁盘上的过程的影响。


file

第二个核心问题是:在这种方式下需要划分很多不同的Drange,每个range都会增加一定的memtable数量,memtable数量的增加会影响scan和get的性能。假设有一个主请求,在原来所有数据都写在一个memtable里的情况下,在读取时,索引只需要面向这个memtable,再根据跳表进行get,如果get到则可以马上返回。现在划分成不同的Drange,memtable数量增加,因此需要查找的memtable以及L0层的SST也会变多。解决办法是:实现了一个索引,可以查询到一个key在memtable或L0 SST中的最新值(若存在)。

2.3 Nova-LSM论文主要成果

这篇文章将原本独立的单节点存储系统做成了一个存算分离、可以任意扩展的分布式架构。这种存算分离的架构体系,在扩展时对总吞吐量、总的响应和请求的能力有显著提升。下图是对该效果的具体展示。

左下角采用了最原始的配置,只有1个存储节点和1个计算节点,计算节点只配置了32M的内存,这也意味着memtable相对较少,在这种情况下它的总吞吐量只有9k,相对较低。然后我们从纵轴来看,把计算能力向上扩展,通过垂直扩容把内存从32M变成4G,这时总吞吐量已经从9k提高到50k。


file

但从图中也可以看到,这时性能曲线中间有空隙的地方越来越多,这些就是前面所提到的请求掉零。计算能力的加强意味着可以进行更多的写入,内存变大意味着memtable的数量变多,L0层的SST文件生成速度也会加快。当L0层的生成文件速度加快后,就会对存储层compaction的能力造成压力,因为它在默认配置下只有1个节点。这时虽然它的峰值已经提高到了5k,但请求掉零的情况也更多了,即发生了停写。因为L0 SST已经来不及compaction,这时只能等待,相当于计算节点在等存储节点。

为了解决这个问题,我们需要对存储节点进行扩容,比如将1个存储节点扩到10个。这时可以明显看到总吞吐量从5万提高到了约250万。虽然某些地方请求也会骤降,稳定性还有待提高,但从整体上看,几乎已经没有请求掉零现象出现了。

这也体现了传统单机单节点LSM-Tree存储系统与Nova-LSM之间的区别。在传统单机单节点LSM-Tree存储系统中,如果计算能力非常好但是磁盘能力不够,这时很难在单节点上进行扩展。但在Nova-LSM中,如果发现哪部分能力不够就可以进行扩展,计算能力不够就扩计算节点,存储能力不够则扩存储节点。这也遵循了当前分布式数据库里比较常见的存算分离、计算层和存储层可以独立扩容的理念。

三、Nova-LSM若干重要设计

3.1 LTC和StoCs之间的写数据流程

第一个比较重要的设计是LTC和StoCs之间的写数据流程。该流程展示的是:当在客户端发起写请求时,计算节点和存储节点是以怎样的方式将数据写进去的过程。

首先是计算节点的客户端发起一个新的写请求操作。存储节点在接收到该请求后,基于RDMA交互,它会在buffer区域分配一个内存区域,并且为这块内存和偏移量(当前哪块内存可以写)分配一个id,告知StoC。客户端接到响应后就会开始写数据,完成后会通知存储节点。存储节点接收到信号后,将数据持久化并且再告知客户端。

上述流程是写一个数据文件即SSTable。写完后,我们要以同样的流程将元数据文件更新。因为底层是分布式架构,需要知道哪些文件写在哪里以及每个SST的范围、版本号。


file

3.2 动态区间划分

第二个比较重要的设计是动态区间划分。假设业务的请求范围为0-1万,当前有10个计算节点,将这10个计算节点的区间划分为10等份,比如第一个key的空间范围为0-1000。在负责0-1000的计算节点里,它会再进行划分,这一层划分业务无感知。这就叫动态区间划分,简称Drange。其作用主要有以下几点:

首先,每个range都是一棵LSM-Tree,按照数据区间,不同的Drange都有自己的memtables。比如0-1000区间又可以划分为10个Drange,10个Drange之间的memtable相互独立。这样做的好处是这些Drange之间的key互不重叠,例如0-100、100-200、200-300。

其次,在Dranges下还有一层Tranges。如果发现Drange里的部分range比如890-895存在热点现象,而旁边的range并非热点,则可以用Tranges进行细粒度的复杂重均衡,实现动态均衡负载。

最后,在此基础上,因为Drange的key范围互不相交,当memtable变成immutable,不可再写后,它们需要独立地flush到磁盘上。这时,在L0层的SSTable来自不同的Drange,它们之间的key完全不相交,我们就可以进行并行的compaction。


file

3.3 Compactions

文章还将没有Drange划分和有Drange划分两种情况进行了对比。

在没有Drange划分的情况下,L0的compaction无法很好并行。在这种情况下,如果遇到最坏的情况,L0层的某一个SST有可能覆盖了整个key空间,假设key范围为0-600,L0层的SST文件的范围是0-1000,当发生compaction时,它必须要跟其他4个SST做归并,这时不但要把L0层的其他SST全部读取比较一遍,还要把L1层所有的SST都读一遍再做归并排序。这时写放大会较为严重,意味着L0层到L1层的compaction会变慢,flush也会变慢,甚至flush不了时,前端就会出现缓写、停写现象。

有Drange划分后,相当于compaction可以分开区间,如下方的示意图所示。在0-100区间,L0到L1可以独立去compaction,100-200区间也可以独立去compaction,可以较好地实现并行compaction。而在原生的RocksDB里,只有从L1开始compaction,才能进行并行compaction操作。


file

如果协调者发现当前存储层的节点资源非常充足,compaction操作可以由存储层主动发起,不需要计算层去发现当前有哪些可以做compaction,这是这篇文章中提到的另一个想法。

至于考虑下沉的原因,因为文章并未深入展开,个人猜测主要是考虑到在这种架构体系里,存储层比较容易扩展,而计算层较难扩展。因为计算层相当于分库分表,如果扩展则会涉及到一定的路由重分布,需要告诉前端请求路由的变化。但存储层则非常容易扩展,如果能将这些非常耗时的操作放到存储层,可以极大地减少在计算节点跟存储节点之间数据的开销。存储层做完后,可以直接把更新后的元数据告诉计算层。

3.4 索引查找以及Scan操作

因为划分了很多不同的动态区间,memtable的数量也会增加,意味着查询操作的耗时也会增加。所以要如何在原来的基础上维护好读性能?这篇文章提出了以下解决思路:

每个LTC维护了一个lookup index。如果这些数据存在于memtable和L0层的SST上,通过lookup index我们就可以快速查找到想要的数据。当某一个L0层SST被compaction到L1层时,索引上就会移除掉对应的key。

LTC同时还维护了一个范围索引即range index。因为知道每个Drange的范围,所以当一个scan请求所涉及到的key,都可以在memtable和L0层SST中找到时,该范围索引就能快速响应scan操作。


file

3.5 SSTable的分布

最后一个比较重要的设计涉及到存储层。当某个SST文件要写到存储节点时,分布式系统首先要保证负载均衡,要保证数据避免单点故障不可恢复的场景。

该文章提出根据一定策略,将数据文件即SST打散写入到多个存储节点里。考虑到存储成本,每个SSTable采用纠删码(Erasure Coding)的方式进行编码然后分布式存放。默认情况下对每个 SSTable 采用 “3+1”的 EC 配置,将一个SSTable切分为3个数据块,根据一定算法,在这3个数据块里去计算出一个校验块,变成了“3+1”的形式。这种方式比传统的多副本可以节省更多空间。假设一个SSTable是3M,这种“3+1”的方式最终所占空间为4M,并且能容忍一个节点的丢失,与占用6M空间的双副本方案拥有同样的故障容忍等级。而元数据文件因为体积比较小,所以直接采用多副本存储的方式,比如1个元数据文件可以写3个副本。


file

四、Nova-LSM性能效果展示

在本篇论文中,Nova- LSM具有较好的性能数据表现。以自身调参测试为例,数据表明,Nova- LSM可以通过调整不同的参数达到较好的扩展效果。文中分别使用Uniform均匀分布和Zipf分布来打散数据,存在热点(比如80%的访问概率都集中在20%的数据上)的情况下,实验结果数据表明,在读写比例、数据访问概率不一样的各种场景下,Nova- LSM都能取得较好的性能结果。

下图所示为Nova-LSM在自身调参下几组不同参数的比较:


file

下图展示了Nova-LSM自身扩展性的效果:


file

下图所示为Nova-LSM吞吐量扩展性测试:


file

以上测试是Nova- LSM自身不同参数下的对比。除此之外,该文章还将Nova- LSM与LevelDB以及RocksDB进行性能对比。


file

在小数据场景下,Nova- LSM的性能表现比RocksDB要更优异,特别在Zipf分布且热点数据存在、读写各占一半的情况下,测试出来的性能数据要比RocksDB高4倍。但随着数据量的扩大,在某些workload下 Nova-LSM 的优势逐渐变得不明显。比如在上图中的(d)情况,一个10节点、2T的数据库,RocksDB将其分为10份,在这种写较多的场景下,Nova- LSM与原生的RocksDB差距不明显。

另外,上图中的蓝色数据项RocksDB-tuned,它是RocksDB进行调优后产生的数据项,红色数据项则没有经过RocksDB调优,而红色项却取得了比蓝色项更好的性能数据。

经过较多场景的验证,像Nova-LSM这种基于LSM-Tree结构的存储系统,实际上并不存在某一组参数能够让它在所有不同性质的workload下都取得较好性能。如上图(d)组,即中间100%写、均匀分布的测试组,RocksDB经过调优后比没经过调优、用原始参数的对照组的吞吐量更低。因为Nova-LSM本身需要有非常多的调优参数,因此很难存在一套参数在所有的场景里都为最优。

五、Nova-LSM带来的启发和讨论

一般情况下,基于LSM-Tree结构去进行优化的工作都面临以下问题——读放大、写放大及空间放大。

如果完全不做compaction,LSM-Tree将会退化为Log文件,此时读性能最差,需要扫描所有SSTable文件,但不存在写放大。如果通过compaction操作将所有SSTable文件维持为一个sorted run,即始终保持所有kv数据的全局有序,则退化为sorted array,此时读性能最优,查询时只需读取一个SSTable中的一个数据块,但频繁的compaction操作会导致严重的写放大。所以我们不能走极端,需要在两者之间提出新的改进方法,在读放大、写放大及空间放大之间做好平衡。

Nova-LSM就是在这三个因素之间做取舍,它的设计原理之一是将原本单机单节点的系统用分布式组件化的方式,将原本一份代码里面的不同模块拆分出来,从而令每一个模块具有可扩展性,打破原先单机资源的限制。此外,该文章还创新性地提出,将不定期的compaction对磁盘IO造成的短期冲击剥离出去。


file

与此同时,该篇文章在实验验证及工程实践上仍有许多地方需要完善和优化。

第一,实验使用的每个KV的默认大小是1KB。根据原理判断,Drange这种设计在该场景下比较占优势。在具体实现中,当一个memtable包含的unique key 小于一定阈值时,不同的Drange之间会将memtable进行合并,其目的都是为了减少磁盘的写入。因此使用1KB这种不算小的测试数据,对它而言是占据优势的。而其他原生的RocksDB则需要不断地写磁盘,由于每一条key的体积都不小,1000条可达到1兆,100万条就能达到1G,这时Drange机制所带来的减少磁盘写入的优势就会被放大了。

第二,Drange和Tranges机制的设立,可以保证一个计算节点在不同的memtable写入之间存在动态均衡。从工业界落地的角度出发,这会涉及到较多的数据一致性保证。但文章并没有进一步论述数据的移动是否会造成没写或者双写。

第三,工程实践上仍存在不少流程细节有待深入推敲,比如在LTC和StoC的写入流程交互中,文中提到先更新数据文件block再更新元数据文件block的流程,但如果在这两次写入中间发生了故障,如何处理?StoC使用erasure code方式保证数据可靠性时,如何保证n+1个数据块和校验块写入同时成功?故障时如何回滚?单个数据块发生丢失时如何发现以及重新生成?这些问题都值得我们进行推敲。


file

第四,N个LTC会负责N个区域数据的写入。比较传统的基于中间件的分布式数据库,会存在一个中间件,中间件知道其下的存储节点以及负责写入的节点分别负责哪一部分数据,此时路由变更的灵活性会存在一定限制。

第五,所有的性能测试中基本只描述性能的最大值。最大值、最大吞吐量这些指标很重要,它代表着一个系统的能力上限。但在真实的业务场景中,除了能力的最大值,另一个非常重要的考察指标是稳定性。而Nova-LSM基于分布式架构,它所有的读写数据一直在进行网络交互。compaction本身因为磁盘的IO,给总体性能带来了不稳定性,现在又加入了网络之间的开销,网络抖动就更加频繁,性能的抖动也是我们必须考虑的因素。


file

Nova- LSM并非只有理论,它在LevelDB的源码基础上新增了2万多行代码,实现了一套核心的设计,上图所示为其源码地址,感兴趣的同学可以尝试进行二次开发。

关于讲师

唐彦,腾讯云数据库专家工程师、浙江大学博士。研究领域主要关注分布式存储、大规模数据密集型系统相关的关键技术,曾以第一作者身份在领域Top类期刊和会议上发表多篇论文。博士毕业后来到腾讯从事基础研究与技术工程化工作,目前主要负责分布式数据库 TDSQL 的元数据管理与集群管控调度相关工作。

你可能感兴趣的:(基于LSM-Tree 的分布式组件化 KV 存储系统 | DB·洞见回顾)