数据存储结构B+树 vs LSM Tree

关于存储的几个概念

对于一个存储系统来说,底层的存储结构基本上就决定了这个存储系统的功能,或者说性能偏向。比如

  • 使用B+树的存储系统,那就是查询友好的;

  • 使用倒排索引结构的,那就主要是用来做全文搜索的;

  • 使用key-value结构的,在分布式系统中,nosql基本上都是采用了key-value来存储的,这类系统往往是偏向于等值查询,比如缓存等场景。但是在newsql发展中,计算和存储分离后,底层的存储好多都是采用key-value的存储,然后再次基础之上,叠加一个计算组件,来满足二维表的结构化数据的查询和维护。

  • ...

对于一个存储系统来说,肯定不会是单一的数据结构就能够满足要求的,上面列出出来的,只是说一个特定偏向领域的存储系统主要的这种数据结构,比如mysql的数据是以B+树来组织的,但并不表示mysql的实现里没有用到hash表、跳表等等呢个数据结构。

不过这里需要单独说一下的是,LSM-Tree,也是本文重点会去讨论的,这个虽然叫xxx-Tree,我觉得它和二叉树、红黑树等等这种数据结构还是有本质的差别,LSM-Tree不是一个树结构,而是一种实现数据存储的工程方式,通过一些工程手段来解决读写放大的问题,但是其背后的数据结构可以上任何一种现有的数据结构。

另外,其实还有一个概念非常容易混淆,就是存储方式(数据的组织方式),这个在讨论OLTP、OLAP的时候,一定会讨论到。和这些概念揉到一起,比较容易混淆。

行存 vs 列存

为了解释清楚,我们以逻辑结构是二维表来举例,就可以一目了然了,比如有如下一张二维表数据(逻辑结构):

列式存储:数据在存储介质中是按照列来组织的。

通俗点来说就是将一列的数据进行连续存储,即id这这一列的数据都是连续存放的、姓名这一列的数据也是连续存放的(即geoge、snoopy、粽子这三个值在磁盘上是连续存放在一起的,当然也可能按列的范围进行拆分,所以不一定是一列所有的数据都是连续存放的,得看具体的实现)。对于OLAP场景,其实往往是要将一列或者几列的数据全部取出来,然后就这一列/几列数据进行分析,所以列式存储对于OLAP分析场景,就比较有又是。

行式存储:数据在存储介质中是按照行来组织的。

也就是说子啊存储介质中,会将id=1的这一行(1、geoge、180)数据进行连续存储、将id=2的这一行进行连续存储,依次类推。在OLTP场景中,一行数据往往存储了一个实体的一类数据,比如上述例子就是一行代表了一个人员的进本信息。在OLTP场景中,都是面向一个或者一个范围内的实体进行操作,所以往往会将一个实体的很多数据都查出来,那这种按行组织的方式对这种场景就很友好,不用随机IO/随机内存操作到处去找数据,然后才能将一行数据给组装完。

所以说行存还是列存,指的是数据在存储介质中的是按照逻辑行、还是逻辑列来连续存储的。所以行存和列存根给用户是否以表格的形式展示,没有任何关系(mysql是行存、hbase是列存,但会发现都可以给用户以二维表的方式来展示数据)

这里说明出了二维表的逻辑结构和行存/列存的关系后,其实还有一个概念和表比较容易混淆,顺便这简单说一下,那就是:结构化和非结构化。

结构化 vs 非结构化

要说清楚这个问题,只需要回到一个问题就清楚了,这也是以前一直困扰我的问题:我们说mysql是结构化的存储,它的结构化体现在哪儿?ES存储的是非结构化的数据,但是使用上看一样的有:document、field这些,感觉跟二维表没有任何区别,那么为啥说它存储的是非结构化的信息。

总结到一句话就是:是否结构化,跟给用户展示的逻辑结构、以及数据模型没有任何关系(不管他是不是有行、字段这些概念),就看一个:是否有一个模式来约束数据。

比如mysql,我们首先就是先创建一张表,其实创建表就是创建了一个约束数据的模式(schema),后续往mysql里写入数据,就只能按照这个模式的约束来写入,不满足约束,就不允许写入。而这个模式,我个人理解就是在定义数据的结构,那么根据这个模式落下去的数据,自然是满足这个模式定义的结构约束的。

比如,如果我定义了一张表有id、姓名、身高、胸毛,那么这个表里存放的任何数据,都必须有这几个字段,哪怕是george、snoopy和粽子都没有尾巴,但由于schema定义了这个字段,我们就不得不给这个字段一个值(哪怕是null值,也得给它一个值),否则就不满足schema结构约束。

再看ES(hbase也是一样的),虽然Lucene在给用户的api中,一样是将数据建模成了:Document、Field的概念,但是一个document里到底可以有哪些字段、字段的类型是什么,这个是没有任何约束的,可以随便在Document里增加、减少。但是对于非结构化,就不一样了,没有就不需要就好了,注意哦,不是没有值,是连这个字段都没有,这是两个概念。也就是说,documentId=1/2/3是没有尾巴这个字段的,是连字段都没有,不是这个字段取值为null。

所以,当你使用ES的时候,你会发现,ES里都没有null这个特殊值,它给的查询接口里面,只有exits,根本就么有判断一个字段是否是null,但mysql里对null的判断是有特殊语法的:is null/is not null。

到这,几个概念性的东西都解释过了,接下来就进入正题:阐述一下B+树和LSM-Tree

B+树

B+树本质上是一个平衡的m-n树,其中m是指一个节点里能够存放的数据个数,n是指指向叶子节点的指正个数。其中+的意思就是同层节点是从小到大排序的,且有指向右兄弟的指正(换句话说,同层节点是一个从小到大排序的链表),下图就是一个B+树(2-3树)。

在B+树中,对范围查找非常友好,比如要等值查询,id=9的数据。

  • 首先就从根节点判断,9是小于40的(如果m更大,那么在节点内部就是一个二分查找,目标值在哪个区间里),所以去最左指正的指向的子树中继续查找。

  • 到了子树上,发现9是小于10的(m较大的时候就是个二分查找),然后继续到最左指正指向的节点

  • 发现是叶子节点,且值是0,所以就向右遍历,一直遍历到id=9,则找到对应的数据。

如果是范围查找9<=id<10,那么遍历到id=9的时候,继续往后遍历,发现15是大于10的,所以结果集中也就只有id=9的数据;

如果9<=id<20,同样的继续向右遍历,遍历到id=15、id=40,发现已经比右边界大了,所以收集到结果是id=9和15的数据。

如果是id<20,同样的使用树定位到倒数第二层的(10,15),那么就会利用mptr找到id=15的叶子节点,开始向右遍历。

总结起来,B+树的查找分为三部分:

  1. 节点内的二分查找

  1. 非叶子节点树的遍历查找

  1. 叶子节点链表的遍历查找

所以B+树的查询其实是hi比较高效的,二分查找和树查找的效率都是O(logN),而最后的叶子节点遍历虽然是个O(N)的操作,但实际上,只要遍历到不满足条件的节点,就会终止。所以对于一些OLTP的业务,其实更多的是等值或者少量数据的查询,所以B+树的查询效率是非常高的。但也需要注意,对于大批量的数据扫描,那其实就是遍历整个叶子节点链表了,这个效率就会比较低,比如Id>1,那基本就是全表扫描。所以对于用B+树作为存储引擎的数据结构的时候,对于大量数据的扫描其实也不是很友好,直接会全表遍历的。

再来看写入,写入的时候的一个最大的成本就是要维护B+树的结构:

  • 节点内数据是有序的

  • 子树里维护的数据,要满足非叶子节点维护数据的范围。

  • 同层节点,要是排序的。

因为要维护这些结构,所以就会存在额外的数据写入,即写放大问题。比如要插入一个35的节点,

除了会插入35这个叶子节点外,还有额外的一个中间节点指针的修改,以及新增了一个中间节点,为了数据的均衡,其实还可能修改原有叶子节点里的数据的范围,那就会进一步放大写入操作。

另外在mysql的实现中,哪怕是叶子节点,也不是一个节点只是存储一条数据,而是以page为单位,一个page里存放了多条数据(按索引排序的多条数据,主键索引就是按主键排序、辅助索引就是按照辅助索引排序的),那么对于mysql来讲,其实还多了页内数据的维护。而且mysql的page的大小是固定的,所以在写入数据的时候,为了保证页内数据的有序性,以及同层page之间的有序性,那在写入一条数据的时候,就有可能出现页分裂的问题。比如按page组织的叶子节点如下,要插入数据8

其实三个page都需要重新写入的,想下如果数据量很大的时候,本来只是想插入一个数据,那所有的page都需要修改一下,那这个效率是会有多慢。所以在mysql的使用中,也是建议使用递增的主键,以避免主键索引树发生页分裂的问题。而mysql内存中的page和磁盘中的page是对应的,那么也就是说,这里所有的page都需要刷一次磁盘。

衡量写放大问题有个写放大系数(Write Amplification Factor,WAF)就是来衡量写放大的一个指标,它指实际写入磁盘的数据量和应用程序要求写入数据量之比。比如上述的例子WAF=100kb/1kb=100。

对于写放大的的优化方式,常见的方式就是:预留填充,基本思路就是在page中预留一些空间,这样不会因为少量的数据写入造成树结构的大幅变动。但是到底预留多大就是个问题:

  • 过大则无法解决写放大问题;

  • 过小会造成页表数量膨胀,增大对磁盘的扫描范围,降低查询性能

B+树的另外一个问题就是实际存储是不连续的,也就是说在逻辑结构上,同层节点是链表结构,通过指正链接到一起,但实际上,各个page存储到磁盘,不是连续空间存放的,所以这种不连续的存储就会导致查询的时候寻址成本的增加。

在一个存储系统中,关于读、写、空间三个方面的优化中,有一个RUM猜想:对任何数据结构来说,在Read Overhead(读)、Update Overhead(写)和Memoryor Storage Overhead(存储)中,同时优化两项时,需要以另一项劣化作为代价。

这跟分布式系统的CAP理论一样,在一个存储系统中,就需要权限读、写、占用空间之间的一个权衡。

LSM-Tree

B+树作为存储引擎的结构时候,由于存在写放大、和存储的不连续的问题,所以一些新兴的存储型的组件,开始采用LSM-Tree(Log Structured-MergeTree)来作为底层的存储结构,比如很多分布式数据库,如TiDb、OceanBase等,以及ES的新版本的数据存储也开始采用LSM-Tree了。

首先需要明确一点,LSM-Tree是google提出来的,虽然它的名字里有个tree,但它不是一个数据结构里的那个树。其核心在于前面的LSM:Log Structured-Merge

LSM-Tree的核心思想是将数据的写入分成了两部分:内存部分和磁盘部分。写入过程:

  1. 首先写入内存的memTable,以及wal日志,写入就直接返回了。所以正常情况下,写入操作会非常的快。只是涉及一个内存操作和一个磁盘的顺序写操作。

  1. 当memTable达到一定的阈值的时候。就会将memTable flush到磁盘中。

  1. 新创建一个memTable,使用新的memTable来服务新的写入请求。

  1. 原来的memTable就变成了imutable memTable。然后将imutable memTable中的数据进行整理:按照key进行排序。

  1. 将排序后的imutable memTable直接刷到磁盘,在磁盘里对应的就是SStable。所以,在磁盘中,会不断新增SStable。

  1. 随着系统的运行SStable越来越多,这个时候就会按照一定的规则对SStable进行compact整理合并。

通过整个写入过程会发现,会发现其实LSM-Tree不是没有写放大,而是将写放大的过程给转移了,转移到compact过程了。

所以,LSM-Tree的写放大指数,就看compact过程的合并策略了。

Size-Tiered Compact Strategy合并策略

Size-Tiered Compact Strategy,简称Tiered,其基本能思想就是每当某个尺寸的SSTable数量达到既定个数时,将所有SSTable合并成一个大的SSTable。这种策略的优点是比较直观,实现简单,但是缺点也很突出

Tiered合并策略的读放大问题

SSTable文件是按照时间间隔产生的,所以在不同的SSTable文件中,就有可能存在相同的key的数据。所以每次读取的时候,都需要遍历所有的SSTable,然后合并key的值后,才能确定某个key的最终取值是什么。

SSTable内部是按照Key排序的,所以查找方法可以是二分查找,所以查找单个SSTable的时间复杂度是O(logN),其中N是SSTable中key的个数。有M个SSTable,整体时间复杂度就是O(MlogN)。当然执行Compact后,读的复杂度会有所下降,因为SSTable的文件减少了。

当然也可以发现,如果系统中只有一个SSTable的时候,其实是没有读放大的。

Tiered合并策略的写放大问题

Compact后会减少SStable的个数,从而降低读放大。但是Compact过程会带来更多的写放大和空间放大。

通过LSM-Tree的写入过程会发现,LSM-Tree不是没有写放大问题,而是将将写放大转移了,只是说在实际写入操作的时候没有写放大,从而可以承载大量的并发写入。

但如果持续写入,产生大量的SStable,就会执行Comapct过程,而Compact过程就会有比较大的I/O开销、内存、cpu的开销。而Tiered合并策略是在Compact的过程,会合并当前系统中的所有SSTable,所以相比于B+树写入时候的写放大成本,总体上Tiered合并策略在Compact时的成本可能会更大。而且由于Compact会占用I/O、cpu等资源,其实反过来就会可能影响写入操作以及读操作。

Tiered合并策略的空间放大问题

从空间放大的角度看,Tiered策略需要分别存储合并前的多个SSTable和合并后的一个SSTable,所以需要两倍于数据大小的空间。所以空间放大指数(SAF)就是2,而B+树的SAF是1.33.

一通分析过后,LSM-Tree只是在短时间内写入速度快以外,还存在更多的读放大、写放大、空间放大的问题,而且Compact时会耗费大量的I/O的资源,反过来还会影响读写操作,看起来比B+树没有什么优势。

其实这些问题只是Tiered合并策略的问题,LSM-Tree的核心优势在于另外一个合并策略:Leveled Compact Strategy合并策略,这个合并策略将有效的解决这些问题

Leveled Compact Strategy合并策略

Tiered策略之所以有严重的写放大和空间放大问题,根问问题是因为每次Compact需要全量SSTable参与,开销自然就很大。

那么如果每次Compact的时候都只是部分SSTable参与,那么Compact过程的写放大和空间放大的问题就能够缓解。

Leveled合并策略的主要思想就是在Compact的时候,只是合并部分SSTable。它将SSTable进行了分层。

  • 将数据分成一系列Key互不重叠且固定大小的SSTable文件,并分层(Level)管理

  • 同时,系统记录每个SSTable文件存储的Key的范围。

LSM-Tree的核心思想是将数据的写入分成了两部分:内存部分和磁盘部分。写入过程:

  1. 首先写入内存的memTable,以及wal日志,写入就直接返回了。所以正常情况下,写入操作会非常的快。只是涉及一个内存操作和一个磁盘的顺序写操作。

  1. 当memTable达到一定的阈值的时候。就会将memTable flush到磁盘中。

  1. 新创建一个memTable,使用新的memTable来服务新的写入请求。

  1. 原来的memTable就变成了imutable memTable。然后将imutable memTable中的数据进行整理:按照key进行排序。

  1. 将排序后的imutable memTable直接刷到磁盘,在磁盘里对应的就是SStable。所以,在磁盘中,会不断新增SStable。这种直接由imutable memTable刷盘得到的SStable称为L0 SStable。L0 SStable也是按照时间生成的,只是按照key进行了排序,不会去做重复key的整理,所以L0 SStable中也是有重复的交叉key的。这么做的目的就是为了权衡flush效率,

  1. 随着系统的运行L0层的SStable越来越,系统中就会存在很多L0 SStable对应的很多小文件。这个时候就会按照一定的规则多(一般系统都会支持一层SStable数量和大小的策略)对L0层的SStable进行compact合并:将所有L0 SStable中的key进行排序、去重合并后,形成一个更大的L1 SStable,然后删除L0的SStable。因为L1 SStable生成的时候会有key的重复整理,L1中的各个SStable中的key都不是重复的。

另外就是在后续的L0 SStable compact过程中,还会结合现有的L1 SStable的情况,进行数据的均衡处理,让数据尽量均匀分散到各个L1 SStable中。

  1. 当L1 SStable越来越多的时候,还会继续将L1 SStable进行整理合并成L2 SStable。因为L1中各个SStable中的key是没有重复的,所以compact的时候是没必要所有的L1 SStable都参与,只是合并整理部分就好了,这样主要是可以减少单次compact的I/O等的开销。

Leveled策略的读放大

其实相比于Tired compact策略,Leveled策略并没有减少多少读放大。SStable依然是按照key有序的,那么读取的时候,如果有M个SSTable,每个SSTable文件中有N个key,那么复杂度依然是O(MLgo(N))

但是在实际实现的时候,会有一些优化措施:Leveled会有一个类似于协调节点的角色,来记录每个SStable的meta信息,比如该SStable中保存的key的范围,读取的时候通过这份meta数据,就可以精准的去对应的SStable找到对应的key的数据。而实际实现这个meta数据,可能就是给每个SStable维护一个BloomFilter过滤器,来快速获取某个key是否在该SSTable中存在。

但是需要注意的是,L0 SStable,因为有key的交叉,在读取数据的时候是分两步:

  1. 大于L0(L1及以上)的SStable中找到对应的key,因为这些SStable中都key都是没有重复的,那么只要找到一个就可以返回了。

  1. 遍历L0 SStable,找出对应key的值。因为L0 SStable按时间生成,是有key重复的情况,所以必须遍历所有L0 SStable,只是说可以利用给L0 SStable维护的BloomFilter加速遍历,不需要真的需要都去打开对应的文件。

  1. 合并从L0 SStable找到的key的值(可能是0个或者多个),以及从L1及以上SStable找到的key的值(可能是0个或者1个),合并后的就是最终的值。

所以整理的读取复杂度O(XlogN + (L-1)logN)=O((X+L-1)logN),其中X是L0中的SStable数量、L是层数、N是一个SSTable中的key的个数

但是需要注意的是,其实内存中还有一份数据,即memtable,这部分在读取的时候,还可以充当读取缓存,对于一些热数据,基本上都是在内存中,都不一定有IO发生。

Leveled策略写放大

Leveled策略对写放大是有明显改善的,除了L0以外,每次更新仅涉及少量SSTable。但是L0的Compact比较频繁,所以仍然是读写操作的瓶颈

Leveled策略空间放大

数据在同层的SSTable不重叠,这就保证了同层不会存在重复记录。而由于每层存储的数据量是按照比例递增的,所以大部分数据会存储在底层。因此,大部分数据是没有重复记录的,所以数据的空间放大也得到了有效控制

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
LSMB+树是两种常见的索引结构,它们在不同的场景下有着不同的性能表现。 LSM(Log-Structured Merge Tree)是一种基于日志结构的数据结构,主要用于写入密集的场景。它将数据分为多个层级,每个层级都是一个有序的日志文件,最底层是最新的数据。当写入数据时,LSM会将数据追加到最底层的日志文件中,而不是直接更新原始数据。当读取数据时,LSM会先从最顶层的层级开始查找,如果找不到则继续向下查找。由于写入操作只需要追加到日志文件中,而不需要更新原始数据,所以LSM在写入性能上表现较好。但是,在读取操作上可能需要多次磁盘访问,因此在读取性能上相对较差。 B+树是一种平衡的多路搜索,主要用于读取密集的场景。它将数据存储在叶子节点上,并使用指针连接各个叶子节点,形成一个有序的链表。当写入数据时,B+树会按照特定的规则进行插入操作,保持的平衡性。当读取数据时,B+树可以通过二分查找或者顺序遍历的方式快速定位到目标数据。由于B+树数据存储在叶子节点上,读取操作只需要一次磁盘访问,所以在读取性能上表现较好。但是,在写入操作上可能需要进行节点的分裂和合并,因此在写入性能上相对较差。 综上所述,LSM在写入性能上表现较好,适用于写入密集的场景;而B+树在读取性能上表现较好,适用于读取密集的场景。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值