B+树、B-Link树、LSM树,解读一下常用的存储结构

B+树、B-Link树、LSM树,解读一下常用的存储结构

0. 引子

我们知道,数据库有三大模块:存储、事务、SQL。其中存储这一模块,负责数据在磁盘和内存上的存储、检索和管理,并向上层提供细粒度的数据操作接口。

因为存储和其它模块的耦合较少,我们可以把它具象为一个专用的数据库组件,称为存储引擎。目前很多数据库都支持可插拔的存储引擎,比如MySQL支持InnoDB、MyISAM、Memory和基于RocksDB的MyRocks等存储引擎,这给存储引擎的迭代发展提供了空间。

B+树、B-Link树、LSM树,解读一下常用的存储结构_第1张图片

而存储这块最重要的就是存储的结构,内存、缓存、读写流程的任何设计,都是建立在存储结构的基础上的,由此存储结构和存储引擎的特性和性能方面关系十分密切。

但相比于浩如烟海的存储引擎,可用的存储结构却没有几个。B树作为一个非常适合于磁盘的存储结构,自关系型数据库的兴起后,一直占据着存储领域的主流。但近些年随着分布式技术的兴起和固态硬盘以及多核CPU的发展,另一种存储结构LSM树越来越火热,成为了学术界和工业界的关注焦点。

本文,就来跟大家讨论一下这两类主流存储结构的共性和特性,试着去找出存储结构发展的趋势,并介绍一下它们各自为了迎合这种趋势又发展出了哪些变种与优化。

接下来的内容,会分为以下几节来做介绍(相比于视频,这里对章节顺序和部分内容作出了调整,个人认为,这样在逻辑上更易理解):

  1. 存储引擎与存储结构(明确讨论对象的概念)
  2. 存储结构的共性特点(B树和LSM树具备了哪些特性,使得它们能从一众数据结构中脱颖而出成为存储结构,这些特性又指明了它们还有哪些不足?)
  3. 存储结构的分类和发展历程(说完了共性,再说差异,B树和LSM树的本质差异在哪?回顾发展历史,为什么B树在当年独占鳌头,而LSM近些年会异军突起?)
  4. B树及其变种(具体说一下B树的一些变种及其体现出的发展趋势)
  5. LSM树及其优化(具体说一下LSM树的主流结构及一些优化方法)
  6. 总结(剧透一下:B树和LSM树分别是in-place update和out-of-place update模式的集大成之作,而分布式、固态硬盘、多核CPU三种技术的发展使存储结构从in-place update向out-of-place update方向发展,LSM树也因此得到了越来越多的使用,而以Bw树为代表的新型B树融合了两种模式的特性,可能是未来的一个发展方向)
  7. Reference & 相关阅读

1. 存储引擎与存储结构

对于一个存储引擎而言,它需要存储的主要是数据文件和索引文件。

而我们要把抽象的一条条数据转换为具体的文件来存储,具体探究一下这个转换的细节,也就是数据文件的组织形式。

目前数据文件的组织形式主要有三种:索引组织表、堆组织表和哈希组织表

B+树、B-Link树、LSM树,解读一下常用的存储结构_第2张图片

索引组织表:数据文件和索引文件合并存储在一起。

B+树、B-Link树、LSM树,解读一下常用的存储结构_第3张图片

堆组织表:数据文件是一个“无序堆”数据结构,其中的数据记录一般是不需要有特定的顺序的,所以这里的“堆”实际上,其实也可以通俗理解成“一堆数据”。虽然堆组织表的数据文件是无序的,但我们可以通过索引文件里的索引结构,来快速得到数据在堆中的位置,从而进行快速的访问。

哈希组织表:把记录分散存储在一个个桶中,每条记录主键的哈希值来确定记录属于哪个桶。记录哈希值的部分就是索引文件,记录哈希结果的部分就是数据文件。因为目前主流的存储引擎基本都没有使用哈希组织表,所以我们后面的讨论一般不会涉及哈希组织表。

B+树、B-Link树、LSM树,解读一下常用的存储结构_第4张图片

堆组织表因为数据的插入是无序的,而索引组织表需要按索引的顺序插入数据,所以我们一般认为堆组织表的写入性能强于索引组织表,大部分情况下读取性能弱于索引组织表。

但数据文件的组织形式对性能的影响还是较小的。InnoDB存储引擎用的是索引组织表,而Oracle和PostgreSQL则是堆组织表,但我们很少从这个角度去区分它们的性能,因为它们都使用B树索引。

而索引作为我们读写数据的主要入口,索引文件的组织形式其实和存储引擎的性能关系更加密切,比如我们把索引文件的组织形式从B树换为LSM树,那么性能差异就会大得多了。

所以我们要关注的重点实际是索引文件的组织形式,我们后面的内容就把索引文件的组织形式专门称为存储引擎的存储结构。

B+树、B-Link树、LSM树,解读一下常用的存储结构_第5张图片

这里大家可能感觉有点绕,其实没那么复杂,就是和大家说明一下,数据文件和索引文件都有其组织形式,也就是数据结构。但数据文件的存储结构要么依托于索引文件(索引组织表),要么对性能的影响没有那么大(堆组织表),所以我们关注的重点、后文讨论的对象都是索引文件的组织形式,也就是“存储结构”。

2. 存储结构的共性特点

明确了存储结构的概念,我们问出这样一个问题:为什么B树和LSM树能用作存储结构呢?

实际上,我们在内存中是有多种数据结构可供选择的,从基础的数组和链表,到哈希表,再到二叉搜索树、平衡二叉树、红黑树、跳跃表等。但这些结构都不适合用作磁盘上的存储结构,为什么呢?
B+树、B-Link树、LSM树,解读一下常用的存储结构_第6张图片

我们考虑一下存储结构需要具有的特性,我先把我的答案列出来,主要有两点:

存储结构要适合磁盘存储。我们讨论的存储结构是在磁盘这种介质上的,磁盘IO的特性大家都知道,顺序IO性能是要远远优于随机IO的。所以存储结构的IO次数越少越好,并且应该尽量一次读取一块连续的区域。从这个角度上看,存储结构的单元越大越好。

存储结构要允许并发操作。我们希望存储结构是支持高并发的,从读取角度,增加并发引起的问题较少;但写入角度下并发的问题就大了。举个例子,大家如果用过共享文档就知道,一个共享的文档虽然可以同时被多个人打开,但同时只能被一个人修改,多个人同时修改的话只有一个能生效。共享文档就可以看做一个大文件,它的写入并发最高仅为1。存储结构需要支持增删改的高并发,从这个角度上看,存储结构的单元应该越小越好。

B+树、B-Link树、LSM树,解读一下常用的存储结构_第7张图片

这里提前声明一点,我们这里说的存储结构的并发操作,并不是我们平时了解的数据库事务并发,这里锁的对象是内存中的数据结构,是一个物理上的概念,而不是逻辑上用户的数据。

大家可能对这里还有一点迷惑,先别急,保留一下印象就好,等会我会具体解释的。

根据这两个特性,我们再审视一下上面提到的那些内存数据结构。
B+树、B-Link树、LSM树,解读一下常用的存储结构_第8张图片

有①没②,也就是只适合磁盘存储,但不适合并发操作的数据结构有:大文件、数组。它们都是连续的一大块存储区域,但如果要修改,影响面很大,基本要锁住整个数据结构。

有②没①,也就是适合并发操作,但不适合磁盘存储的数据结构有:链表、各种二叉树、跳跃表、红黑树、哈希表这些。它们的修改、写入操作影响小,但数据结构的粒度也非常小,一次一般只操作几个字节,不适合磁盘IO。

那么什么数据结构同时具备这两个特性呢?自然就是我们的B树和LSM树啦。

B+树、B-Link树、LSM树,解读一下常用的存储结构_第9张图片

B树的结构我就不多介绍了,大家可以看一下这张图回想一下B树的结构。B树是以页为单位组织的,InnoDB存储引擎中页的大小为16K,一般可以指出几百上千个指针,因而具有高扇出、低高度的特点,从而保证了B树是连续IO,并且IO次数极少,符合特性①。

到了特性②,聪明的读者可能会想,我知道,B树要修改的单位也是页,所以并发控制的锁上在页上,B树的并发程度也很高,满足②。

但并没有那么简单,虽然B树修改的单位确实是页,但B树存在SMO操作,导致B树的并发能力并不理想。

SMO操作是什么呢?它全称Structure Modification Operation,也就是结构修改操作。大家回想一下B树的结构,在我们向一个页中增加数据超过其大小,或者删除数据使其空间少于二分之一,是不是就会造成页的分裂或合并,影响关联节点或其父节点。并且,这种影响可能还会向上传递,甚至有可能造成根节点的分裂或合并。

所以如果我们想要保证出现SMO操作时读写的安全的话,就需要对有可能受SMO操作影响的节点加锁。
如果你不太清楚上面的逻辑的话,可以去了解一下B树或B+树分裂与合并的相关知识,网上的内容很多,我这里就不细说了。

好的,现在就可以给大家解释存储引擎的并发操作和事务并发操作的不同了。假设都使用锁机制来控制它们的并发的话,我们给这两种用途的锁起了不同的名字:Lock和Latch。

Locks Latchs
隔离级 用户事务 线程
保护对象 库中数据 内存中的数据结构
持续时间 整个事务周期 临界区代码前后
类型 共享、互斥等 读、写等
死锁机制 监测并解决 避免死锁出现
持续时间

Lock是用来维持数据库事务的ACID特性的,它是事务级隔离的,锁住的对象是用户的数据,是一个逻辑概念,我们熟知的Lock主要有共享锁和排他锁,一般称作S锁和X锁。

而Latch则是用来保护我们读取过程中加载到内存中数据结构的,它是线程级隔离的,主要是防止两个线程同时修改一块内存结构,或者一个线程读取另一个线程正在修改的内存结构。

大家可能还是有点难以理解,我举个例子吧,假设我们修改一行数据,那么我要把这行数据所在的页加载到内存中吧,然后我准备修改它,可是我在修改前要防止别的线程也来修改这个页,所以我要对它加Latch。加好Latch后,我可以修改这行数据了,为了防止我在事务提交之前别的事务读到这行我修改后未提交的数据,所以我要对它加一个Lock。

你可能会问,这Latch锁一个页,Lock才锁一行数据,那Lock实际没必要啊。其实不然,在我修改完成后,我对这个页就不会做任何操作了,所以Latch就可以释放了;而我的事务还没结束啊,所以Lock还要等到事务提交才能释放。在修改完成到事务提交这段时间里,Lock就能发挥作用了。

所以我们在对数据库做操作时,实际是有Latch和Lock共同生效的

总之,虽然B树有一定的并发能力,但SMO的存在使B树的性能并不高,勉强满足特性②,但还有很大的优化空间。具体是怎么优化的呢,我们等到第4节再来做具体介绍。

讨论完了B树,下面我们再聊一聊LSM树在这两个特性上表现如何。
对于LSM树,有些读者可能还不太了解,没关系,我们这里先简单讲一下概念,第5节会给大家深入解释LSM树的底层架构与原理的。
LSM树整体是一个分为多层,越向下层数据越多的层次树形结构。
B+树、B-Link树、LSM树,解读一下常用的存储结构_第10张图片

所有写入的操作都会直接写入内存,写啊写,内存写满了,这时我们就直接把内存中刚刚写入的这块数据插入磁盘。

这样一直向磁盘追加写,磁盘也会有装不下的时候,怎么办呢?我们就把这层和下一层合并,排序、去重,整理后的数据就都放在下层,这样数据的冗余就去除掉了,磁盘就能装得下了,我们把这个过程称作Compaction。

就这样一层层向下Compaction,越下层的数据就越多,最终就形成了LSM树的整体结构。

读取的时候需要注意,因为我们一个key对应的value可能会存在于多个层次上,这时以哪个为准呢?肯定是以新的为准啦,而越新的数据所在的层就越往上。那么我们就从上至下一层层搜索就好,第一条找到的就是我们要读的结果了。

由此看来,对于LSM树,情况跟B树肯定是完全不同的。因为其正常插入数据到内存时,完全不会改变历史数据的结构,不需要通过Latch来实现并发控制,所以并发能力很强, B树在特性②上的瓶颈在LSM树中不存在。

而LSM树每层数据都比较多,在缓冲池没有命中时,我们读取LSM树时要读取的次数有可能非常多,所以LSM树在特性①上表现并不好。

那么怎么优化LSM树的特性①呢?对了,主要要靠Compaction操作整理LSM树的结构,减少读IO的次数。
我们再审视一下特性②,LSM树真的没有SMO过程吗?当然是有的,Compaction操作不就是标准的SMO操作吗?在Compaction操作期间,不仅会占用大量资源,还会造成缓冲丢失、写停顿(write stall)等问题,减少并发能力。

所以,对LSM树的优化的关键点,就落在Compaction操作上。对于Compaction,学术界和工业界都作出了很多探索来优化其性能、降低其影响,我们等到第5节再具体介绍。

3. 存储结构的分类和发展历程

虽然上一节是在讨论B树和LSM树的共性特点,但我们也从存储结构的两个特性中看出它们是有很大的差异的。本节我们从一个更高的角度来去分析一下这两者的本质差异。

这里引用2020年“LSM-based Storage Techniques: A Survey”这篇文章的论述,来说明一下这两者的区别。
B+树、B-Link树、LSM树,解读一下常用的存储结构_第11张图片

In-place update可以翻译为就地更新结构,B树、B+树都是就地更新结构。它们都是直接覆盖旧记录来存储更新内容的。我们看图(a)的部分,为了更新key为k1的value,选择直接涂改掉(k1, v1),再在原位置写入(k1, v4)。这种就地更新的结构,因为只会存储每个记录的最新版本,所以往往读性能更优,但写入的代价会变大,因为更新会导致随机IO。

Out-of-place update翻译成异位更新结构,LSM树就是标准的异位更新结构。异位更新结构会将更新的内容存储到新的位置,而不是覆盖旧的条目。看右侧图(b)的部分,更新k1的value不会修改掉(k1, v1)这个键值对,而是会在新的位置写一个新的条目(k1, v4)。这种设计因为是顺序写,所以写入性能显然更高,但读性能显然就被牺牲掉了,因为我们可能要扫描多个位置,才能读到我们想要的结果。此外,这种数据结构还需要一个数据整合过程,主要是为了防止数据的无限膨胀,这个过程也就是我们刚刚讲的的Compaction过程。

好的,在概念性分类的基础上,我们就可以来探讨由B树向LSM树发展的趋势了:有三个技术促成存储结构从in-place update向out-of-place update的演进,它们是分布式技术、固态硬盘技术以及多核CPU技术,下面我们分别来看一下。
B+树、B-Link树、LSM树,解读一下常用的存储结构_第12张图片

①分布式技术

2003年 “The Google File System”论文发布,在这篇论文中,Google详细解读了他们的分布式文件系统GFS是怎样设计与实现的。GFS堪称分布式系统步入商用的奠基之作,也是Google著名的“三驾马车”中的第一架。

在GFS的一致性模型中,有个点和单机文件系统很不一样,就是GFS对追加的一致性保证是要强于改写的。
因为GFS把一整个大文件分成了一个个小块来存储,这也是分布式文件系统的通用做法。这却导致在并发进行改写操作时,不同数据块的执行顺序可能产生不一致,造成“consistent but undefined”,也就是虽然并发的所有改写都成功,但查询的结果却和任何一次改写的预期结果都不一致。而追加因为只影响最后一个数据块,就不会有这个问题。

虽然改写的不安全比较违反我们的直觉,但这种方案在性能方面很有保证,何况还有追加的方案作为替代。所以此后的分布式文件系统基本也都沿用了这个特性,推荐追加写文件,这就比较契合out-of-place update的特性。

到了2006年 “Bigtable: A Distributed Storage System for Structured Data”论文发布,Google Bigtable系统发布。

Bigtable相当于一个在GFS基础上搭建的不支持事务的分布式存储引擎,Bigtable就根据GFS追加的优势,采用了类似于LSM树的存储结构,LSM树的存储结构在分布式体系下证实了其可行性。并且很显然,in-place update的模式,因为有大量的改写操作,是不适合搭建在GFS架构的分布式文件系统上的。
所以,分布式技术的发展给了LSM树得到使用的契机。

②固态硬盘技术

2014年左右,固态硬盘SSD开始进入大规模商用阶段。相比于传统的机械硬盘HDD,SSD除了性能大幅度提升外,还有两个显著的特性,第一个是没有寻道时间,相比于机械硬盘,随机读性能有很大的提升;第二,SSD是基于闪存进行存储的,但闪存不能覆盖写,如果要修改一个块的内容的话,只能把整个闪存块擦除后才能写入。

并且闪存的使用寿命就是和擦除次数直接关联的,如果能减少擦除次数,就相当于延长了SSD的使用寿命。

B+树、B-Link树、LSM树,解读一下常用的存储结构_第13张图片

而这两个特性,也是契合了out-of-place update结构。SSD随机读取性能的提升,在很大程度上弥补了LSM树的读取性能弱这一短板;而LSM树追加写的写入模式,也有助于节省SSD擦除的损耗,并提升SSD的使用寿命。

③多核CPU技术

2001年 “Cache-Conscious Concurrency Control of Main-Memory Indexes on Shared-Memory Multiprocessor Systems”论文发布,探讨了基于锁(Latch)的机制在多核CPU场景下的额外代价。
这里偏硬件,我也只是半懂,所以就只给大家做一个简单的解释。

大家可以简单理解成CPU的每个核都有独立的一块存储区域,而读取和写入的过程就需要把页加载到这块存储区中。这样,并发读写的时候,一些节点是不是就可能存在多个核的空间中呢?而加Latch和解Latch的操作又必须同步给多个核,这就会造成很多损耗。最显著的例子,对B+树而言,读写时根节点是不是必经之路?那么频繁的对根节点加解Latch,就会极大影响多核场景下的并发。并且现在NUMA模式已经成为主流,相当于每个核都会有一块小的独立的内存区域,更加剧了这种同步Latch的代价。

B+树、B-Link树、LSM树,解读一下常用的存储结构_第14张图片

回到我们的两类存储结构上来,In-place update structure要把磁盘中的结构加载到内存中,再修改,并写回磁盘,所以免不了要用Latch的机制来做并发控制;而Out-of-place structure虽然还是有别的因素干扰其并发能力,但因为所有的写入都是追加,就不用采用基于latch的机制做并发控制。

所以,多核处理器的发展也为out-of-place update结构提供了优势。

了解完上面的三个技术发展以影响后,我们来做个小总结,回顾一下两类存储结构的发展历程:

B树诞生的较早,1970年,Rudolf Bayer教授在“Organization and Maintenance of Large Ordered Indices”一文中提出了B树,从它基础上演化产生了B+树。此后,B树诞生了很多的变种,我们会在后面深入解读B树的变种。
B树成为主流后,因为其In-place update结构带来的先天写性能较差,学术界和工业界一直有对Out-of-place update结构的探索,直到LSM树的出现,成为了Out-of-place update结构特性的集大成者。
1996年 “The Log-Structured Merge-Tree (LSM-Tree)”论文发布,LSM树的概念正式诞生。
2003年 “The Google File System” Google分布式文件系统GFS发布,其对追加的一致性保证是要强于改写的,此后的分布式文件系统基本也都沿用了这个特性,推荐追加写文件,这就比较契合out-of-place update的特性。
2006年 “Bigtable: A Distributed Storage System for Structured Data” Google Bigtable发布,Bigtable基于GFS,采用了类似于LSM-Tree的存储结构,LSM树的存储结构在分布式场景下证实了其可行性。
同样是2006年,Intel发布了大家熟悉的酷睿(core)和至强(Xeon)系列多核处理器,标志着商用CPU全面迈入多核时代(此前已经有多核的处理器,但并不主流)。多核处理器的机制在很大程度上影响了存储机构的并发性能,无Latch(out-of-place update)的并发机制表现要远优于有Latch(in-place update)的并发机制。
2014年左右,固态硬盘SSD开始进入大规模商用阶段,LSM-Tree在SSD上有两个显著的优势。第一个是SSD没有寻道时间,相比于机械硬盘,读性能有很大的提升,很大程度上弥补了LSM树的读取性能弱这一短板;第二,SSD是基于闪存进行存储的,但闪存不能覆盖写,闪存块需要擦除才能写入。而LSM树追加写的写入模式,天然契合了SSD这种特性,从而很大程度上节省了擦除的损耗,并提升了SSD的使用寿命。

总而言之,在进入二十一世纪后,分布式、固态硬盘以及多核CPU这三种关键的软硬件技术的发展,竟然都是有利于out-of-place update模式的,而以B树为代表的in-place update模式从这些技术里得到的好处是远远不如LSM树的,这真是“时来天地皆同力,运去英雄不自由”啊。

但不管怎么说,B树的架构还是积累了很多的优势的,并且B树也是可以吸收out-of-place update的一些特性,来发挥这些软硬件优势。下一节,我就要来介绍一些B树的新的变种,让我们来看一下它们是怎么融合二者的优势的。

4. B树及其变种

先把我们要解释的B树变种都列出来,B树的变种主要有B+树、B*树、B-Link树、COW B树、惰性B树、Bw树等。

B+树、B-Link树、LSM树,解读一下常用的存储结构_第15张图片

其实还有一些其他变种,比如我研究了半天实在是搞不懂的FD树。但主流的B树变种也就这些,下面我们就具体分析这些变种的优势和发展趋势。

原始的B树结构是图中这样,其中最关键的点是每个数据只存储一份,这样,如果要遍历一个B树,就要进行对树这种数据结构的中序遍历,即先遍历左子树,再遍历根节点,再遍历右子树。
这种中序遍历的方式显然会产生大量的随机IO,性能自然不佳。

B+树、B-Link树、LSM树,解读一下常用的存储结构_第16张图片

因此,基本没有直接使用原始B树来实现存储结构的。B树早期有两个主要的变种,一个是最著名的B+树,另一个名气小一点,叫B*树。

(1)B+树

相比于B树,B+树的数据按键值大小顺序存放在同一层的叶子节点中,各叶子节点按指针连接,组成一个双向链表。
换句话说,B树的非叶子节点中的数据是全部数据的一部分,而B+树则仅作为查找路径的判断依据。一个key值,可能在B+树中存在两份。
这种结构解决了B树中序遍历扫描的痛点,在一定程度上也能降低层数,所以B+树算是B树系列中应用最广泛的一个变种了。
B+树、B-Link树、LSM树,解读一下常用的存储结构_第17张图片

(2)B*树

B树是B树的另一个变种,我们先说B树最关键的一点:其把节点的最低空间利用率从B树和B+树的1/2提高到2/3,并由此,改变了节点数据满时的处理逻辑。
这个逻辑是怎么样的呢?我们知道B树和B+树的空间利用率为1/2,这个1/2怎么来的呢?就是在它们一个叶子节点满而分裂时,默认状态下会分裂成两个各占一半数据的节点,这不就是1/2的空间利用率吗?
而B树的逻辑不同,在一个节点满了,却又有新数据要插入进来,他会把其部分数据搬迁到下一个兄弟节点,直到两个节点空间都满了,就在中间生成一个节点,三个节点平分原来两个节点的数据,诶,不就是各自都是2/3的空间利用率吗?
(这里要声明一点,在B
树的概念上一直存在争议,我这里选取《The Ubiquitous B-Tree》这篇论文对B树的解释。大家也不要纠结于B树的具体实现,我们这里只是为了引出和兄弟节点关联起来的这种思想。)
B+树、B-Link树、LSM树,解读一下常用的存储结构_第18张图片
B+树、B-Link树、LSM树,解读一下常用的存储结构_第19张图片

B树的这种设计虽然可以提升空间利用率,对减少层数,提升读性能有一定帮助,但这种模式还是增加了写入的复杂度,向右兄弟节点搬迁数据的过程也要视作一种SMO操作,对写入和并发能力有极大的损耗,所以B树并没有被大量使用。

那B树的这种模式就没有用了吗?当然不是,否则我们就没必要介绍它了。B树虽然不太行,但基于B*树设计的另一个变种——B-Link树,却发扬光大了这种模式,赋予了其突破B树并发控制能力瓶颈的魔力。
但在介绍B-Link树前,我们要先把上节含糊带过的B树并发机制搞清楚。我们以MySQL InnoDB存储引擎为例,说一下基于B+树的存储引擎如何通过Latch进行并发控制。

在MySQL 5.6及之前的版本,只有写和读两种Latch,我们称之为X Latch和S Latch。
B+树、B-Link树、LSM树,解读一下常用的存储结构_第20张图片

对于读的过程,要先上整个索引的Index S Latch,再从上至下找到要读取的叶子节点的Page。然后上叶子节点的Page S Latch,这时就可以释放Index S Latch了。然后我们进行查询并返回结果,最后释放叶子节点的Page S Latch,完成整个读操作。
B+树、B-Link树、LSM树,解读一下常用的存储结构_第21张图片

至于写的过程,就有些复杂了。首先进行乐观的写入,即假设我们的写入不会引起索引结构变更,也就是不触发SMO操作。要先上整个索引的Index S Latch,再从上至下找到要修改的叶子节点的Page,这个过程跟读的时候是一样的。
接下来判断叶子节点是否安全,也就是写入是否触发分裂或合并。
如果叶子节点Page安全,就上Page X Latch,释放Index S Latch,然后再修改就可以了,这样就完成了乐观写入的过程。
如果叶子节点Page不安全,那么就要重新进行悲观写入。释放一开始上的Index S Latch,重新上Index X Latch,阻塞对这棵B+树的所有操作,然后重新搜索,找到要发生结构变化的节点,上Page X Latch,再修改树结构。这时就可以释放Index X Latch,然后修改叶子节点,完成悲观写入过程。

大家应该都能看出来,这样的缺点非常明显,在触发SMO操作过程的情况下,因为会持有Index X Latch,所有操作都无法进行,包括读操作。

于是,在MySQL 5.7及之后的8.0版本,针对SMO操作会阻塞读的问题,引入了SX Latch。SX Latch介于S Latch和X Latch之间。和X Latch、SX Latch冲突,但和S Latch不冲突。
下面我们来看一下这时的并发控制方案。
B+树、B-Link树、LSM树,解读一下常用的存储结构_第22张图片

相比于MySQL 5.6之前,这时读步骤主要加上了对查找路径上节点的锁。
我们想一下为什么会有这种改变,因为引入了SX Latch之后,发生SMO的时候读操作也可以进行。那么为了保证我们读取的时候查找路径上的非叶子节点不会被改变,我们就要对路径上的节点也加上S Latch。
这里还有必要再说一点,Index级别和Page级别是两种不同对象的Latch。所以哪怕有Index上的X Latch或SX Latch也不会阻塞Page上的S Latch。大家不要觉的索引上有X Latch,就不能在Page级别再上Latch了哦。

B+树、B-Link树、LSM树,解读一下常用的存储结构_第23张图片

写的过程,一样是先进行乐观的写入。因为假设只会修改叶子节点,所以乐观写入的查找过程跟读的过程一样,加整个索引的Index S Latch和路径上节点的Page S Latch即可。
接下来判断叶子节点是否安全。
如果叶子节点Page安全,那么就上Page X Latch,释放索引和路径上的S Latch,然后再修改就可以了。
但如果叶子节点Page不安全,那么就要重新进行悲观写入。释放一开始上的所有S Latch,这时我们上的就不是Index X Latch而是SX Latch了,然后重新搜索,找到要发生结构变化的节点,上Page X Latch,再修改树结构。这时就可以释放Index SX Latch和路径上的Page X Latch,然后完成对叶子节点的修改,返回结果,释放叶子节点的Page X Latch,最终完成悲观写入过程。

这就是MySQL对B+树并发控制机制的发展。当然,对B+树并发控制机制的研究和方案远远不止这里介绍的MySQL的两种方法,但我们这里只是为了说明一下B+树并发控制的痛点,更多的内容这里就不多做介绍了。

了解了这些,我们就知道了,B+树的问题在于自上而下的搜索过程决定了加锁过程也是要自上而下的。哪怕只对一个小小叶子节点做读写操作,也都要先对根节点上Latch。一旦触发SMO操作,那么好了,整个树都不能动了。哪怕我这个SMO操作仅仅增加了一个页,改动量相比于整个树只是九牛一毛。

归根结底,是B+树自上而下的搜索模式造成的这一问题。那么B-Link树是怎么针对这点进行优化的呢?

(3)B-Link树

B-Link树相比B+树主要有三点区别:

①非叶子节点也都有指向右兄弟节点的指针
②分裂模式上,采用和B*树类似的做法
③每个节点都增加一个High key值,记录当前节点的最大key

这样,B-Link树的整体结构如图所示,其中加下划线的key就是high key:
B+树、B-Link树、LSM树,解读一下常用的存储结构_第24张图片

我们刚刚讲的,B+树令人诟病的一点就是它在读写过程中需要对整个树、或一层层向下加Latch,从而造成SMO操作会阻塞其他操作。而B-Link树通过对分裂和查找过程的调整,避免了这一点。

这里的图就是B-Link树节点分裂的过程,可以看到,就是继承自B*树的思路。先把老节点的数据拷贝到新节点,然后建立同一层节点间的连接关系,最后再建立从父节点指向新节点的连接关系,这个顺序很重要。那么这种分裂过程是怎样规避整个树的锁的呢?
B+树、B-Link树、LSM树,解读一下常用的存储结构_第25张图片

这时指向右兄弟节点的指针和high key就可以发挥作用了。如图,当节点y分裂成y和y+两个节点后,在B+树中就要提前锁住它们的父节点x;而B-Link树可以先不锁x,这时查找15,顺着x找到节点y,在y中未能找到15,但判断15大于其中记录的high key,于是顺着指针找到其右兄弟节点y+,仍能找到正确的结果。
正因如此,B-Link树中的SMO操作可以自底向上加锁,不必像B+树那样自顶向下加锁,这不就避免了B+树中的并发控制瓶颈了吗?
B+树、B-Link树、LSM树,解读一下常用的存储结构_第26张图片

好了,这就是B-Link树的基本思路,当然,真正要实现一个可用的B-Link树要考虑的因素还很多,比如对删除操作就需要单独进行设计,原始论文中对一些操作原子化的假定也不符合现状。但总体上来说,B-Link树仍是一种非常优秀的存储结构,在很大程度上突破了B+树的性能瓶颈,在我们熟知的数据库中,PostgreSQL的B树索引类型,就是基于B-Link树来实现的。这可能也是在很多场景下PG的性能要优于MySQL的原因之一吧。

接下来我们再来介绍比较新一点的B树变种,这些新变种在很大程度上采用了LSM树类似的思想。

(4)COW B树(写时复制B树)

首先介绍COW B树,也称写时复制B树。
COW B树采用copy-on-write技术来保证并发操作时的数据完整性,从而避免使用Latch。
当页要被修改时,就先复制这个页,然后在复制出来的页上进行修改。
你可能有一个问题:复制产生的新页要替换旧页,那么父节点指向这个页的指针就要变化了,这就要修改父节点了,怎么办呢?
这个问题的答案也很直白,也把父节点复制并修改一份就好了,接下来再复制并修改父节点的父节点,直至根节点。
所以COW B树的修改逻辑如图所示,对叶子节点的修改,会产生一个全新的根节点。当然,你也可以认为产生了一个全新的树。
B+树、B-Link树、LSM树,解读一下常用的存储结构_第27张图片

写时复制B树在很大程度上具有out-of-place update的特性。因为已经写入的页是不变的,所以COW B树可以像LSM树那样,完全不依赖Latch实现并发控制。
当然,为了达成这一点,写时复制B树付出的代价也十分之大,为了修改一点小小的数据,就要重新复制多个页,带来巨大的写放大。
因为写时复制B树的种种问题,所以其一直也没成为主流。但写时复制B树的思路却很重要,后续的诸多变种都对其进行了借鉴或改良,其中就包括我们接下来要介绍的惰性B树。

(5)惰性B树和LA树

惰性B树相比写时复制B树更进一步,为每个页都设置了一个更新缓冲区。这样,更新的内容就放在更新缓冲区中,读取时将原始页中的内容和更新缓冲区进行合并,来返回正确的数据。如图所示。
B+树、B-Link树、LSM树,解读一下常用的存储结构_第28张图片

大家会发现,惰性B树是不是更像LSM树了?甚至可以说,惰性B树的每个页,就像一个小型的LSM树。更新缓冲区就像memtable,当更新缓冲区达到一定程度,就压缩到页中,就像一个小型的compaction过程。
惰性B树同样避免了Latch机制,但没有写时复制B树那么夸张的写放大代价,整体来讲,是非常优秀的一种存储结构。MongoDB的默认存储引擎WriedTiger就使用的这种存储结构。

惰性B树还有一个分支,叫做惰性自适应树,Lazy-Adaptive Tree,称作LA树。整体思路和惰性B树一致,只是把更新缓冲区的对象变为子树,用来进一步减少写放大,如图所示。
B+树、B-Link树、LSM树,解读一下常用的存储结构_第29张图片

(6)Bw树

前面我们介绍了硬件方面SSD和多核CPU的发展,大家可能有一种感觉,就是硬件技术的发展都是更有利于LSM树的。
但其实反过来说,也是硬件技术的发展,推动了LSM树的应用,才让我们更多的接触到LSM树。
而B树方面自然也会对新硬件做出适应性的改造,比如刚刚介绍的COW B树和惰性B树,都在相当程度具有和LSM树类似的特性了。下面我们要介绍的Bw树,是近些年诞生的,在这方向更进一步的一种B树变种,甚至在很大程度上比LSM树都更近了一步。

Bw树整体分为三层,从上而下分别是Bw树索引层、缓冲层和存储层。
B+树、B-Link树、LSM树,解读一下常用的存储结构_第30张图片

对于最上层的Bw树索引层,我们可以把它理解成一个逻辑上的类B树结构,对外提供操纵这个索引结构的API,但它是完全无Latch的。
最底层的Flush Layer使用了Log Structure Store来与固态硬盘交互,管理物理数据结构。关于Log Structure Store,大家可以理解成利用日志追加形式来实现的文件系统,它更符合SSD的特性,这里的原理和LSM树类似。
好了,到了最关键的缓冲层,它把索引层和存储层连接起来,通过一个映射表,记录逻辑页号到物理指针的映射,来实现了无锁追加的特性。
B+树、B-Link树、LSM树,解读一下常用的存储结构_第31张图片

这么说有点抽象,我们还是看实例。和惰性B树有点像,Bw树对每个节点的修改也是不直接修改页,而是生成一个叫delta record的结构,记录这次修改的情况。如果再有修改,就再生成一个。这样很多个delta record和页一起,就组成了一个类似链表的数据结构。
B+树、B-Link树、LSM树,解读一下常用的存储结构_第32张图片

我们查找的逻辑也就明了了,从新delta record向旧delta record至数据页查找,找到符合条件的结果就返回——这里和LSM树的查找过程就很像。

但这里有两个问题需要解决:
①怎样确保并发情况下这步操作的安全性——也就是有两个操作同时要生成delta record怎么办?
②另一个问题是发生树结构修改——比如分裂或合并节点怎么实施?

对于第一个问题,Bw树通过刚刚说的映射表,记录从逻辑页号到delta链表头的指针。如果要新增一个delta record,那么就要修改映射表中的value。这样,Bw树就把变更delta record操作转变为了映射表的修改操作,再通过CAS操作实现映射表的修改,从而把整个过程原子化了,以实现不依赖Latch的并发控制。
关于CAS操作,大家可以简单理解成一个修改值的CPU指令,所以它是一个CPU层面的原子操作。

这样,我们调整delta链的操作就变得非常简单了。如果要修改一页中某行的值,那么就先生成一个delta record结构,为其赋值,并让它指向原delta链表头;然后在映射表中通过CAS操作把页号映射到这个delta record,这样,整个修改操作就完成了,如图(a)。

如果delta链表太长,要把他们固化到页中,操作也是大同小异。我们生成一个新页,把delta链表加原有页的所有结果合并写入新页,然后一样在映射表中通过CAS操作把页号映射到这个新页,完成修改操作如图(b)。
B+树、B-Link树、LSM树,解读一下常用的存储结构_第33张图片

这里大家可能有一个疑问,变化了新页,不用改变其父节点指向它的指针吗?注意,实际上Bw树中的指针可以是逻辑指针,即父节点记录子节点的逻辑页号,就足够指向子节点了。这样,只要不修改页号,父节点就不用改变。所以我们虽然替换了页,但因为有映射表的存在,逻辑页号并没有变,所以父节点也完全不用变。

这样我们就解决了第一个问题,接下来我们再看第二个问题,新增或删减节点时怎么变化。

这里Bw树的做法基本借鉴了B-Link树的思路,采用一个指向右兄弟节点的指针,来使分裂过程分为child split和parent update两步。每一步都是原子操作,这样就实现了原子化的SMO操作。

图中就是页P分裂为P和Q的过程。(a)步生成Q,但这时P仍是指向原下一个节点R的,Q不可达;(b)步对P增加一个特殊的delta record,它负责对P和Q两部分的数据进行路由;(c)步再按同样的方法调整父节点。当然,随着P和父节点的压缩操作,会把delta record链合并到页中,这样可能更符合大家对终态的想象。
B+树、B-Link树、LSM树,解读一下常用的存储结构_第34张图片

至于合并操作,和分裂的思路类似。先通过增加一个remove delta,来标记要删除的节点;再通过对其前置节点加一个merge delta标记修改;最后再修改父节点。我就不多解释了。
B+树、B-Link树、LSM树,解读一下常用的存储结构_第35张图片

好了,简单的给大家介绍了一下Bw树,大家有了之前介绍的基础,应该能感受到,Bw树更像B-Link树和LSM树的结合。从而兼具无Latch的特性,又在相当程度上拥有B树一族的读优势。目前Bw树在学术界有不少的探索和关注,在工业界还没听说过有成功的实践,期待它后续的发展吧。

5. LSM树及其优化

说完了B树的主要变种,下面我们再来看看LSM树的发展,讨论一下目前主流LSM树存储结构和优化。
LSM树的概念起源于1996年的论文“The Log Structure Merge Tree”,此后由Google Bigtable第一个商业化实现并于2006年发表论文,此后Google的两位专家基于Bigtable的经验实现了LevelDB,一个单机LSM树存储引擎,并开源,此后FaceBook基于LevelDB开发了RocksDB。RocksDB做了相当多的迭代演进,如多线程、column family、compaction策略等等,目前RocksDB已经成为LSM树领域的一个事实标准。
那我们就先介绍一下RocksDB的架构,看这张图:
B+树、B-Link树、LSM树,解读一下常用的存储结构_第36张图片

写入的数据首先要记录WAL,用来做实时落盘,以实现持久性。

然后数据有序的写入Active Memtable中,Active Memtable也是这里唯一可变的结构。在一个Active Memtable写满后,就把它转换为Immutable Memtable。

两类Memtable都在内存中,使用的数据结构基本上是跳跃表(也有vector,hash-skiplist等)。这里给出一张跳跃表的图,我就不具体解释了。
B+树、B-Link树、LSM树,解读一下常用的存储结构_第37张图片

在Immutable Memtable达到指定的数量后,就把Immutable Memtable落盘到磁盘中的L0层,我们把这步操作称为minor merge。通常,对这步落盘操作的memtable不做整理,直接刷入磁盘。这也就意味着L0层可能有重复的数据。

在L0层的数据满后,就会触发major merge,也就是关键的Compaction操作。把L0的数据和L1层的数据进行合并,全部整理为固定大小的、不可变的数据块,我们称之为SSTable,并放在L1层。

SSTable是LevelDB最初实现的一种数据格式,称为有序字符串表,Sorted String Table。一个SST通常由两部分组成:索引文件和数据文件。数据文件就是要存储的KV数据,索引文件可以是B树或哈希表。大家可以把SST理解成一个小型的聚簇索引结构,只是这个结构整体是不可变的。

这样,除了L0层之外磁盘中的每一层,都是由一个个SST组成的,它们互不重叠。SST的出现,结合后面会介绍的Bloom过滤器,在很大程度上提升了LSM树的读性能。并且L1和之后层次间的合并,可以仅合并部分重叠的SST,使Compaction过程更灵活,效率更高。

就这样,一条数据进入LSM树后,写入active memtable,然后进入immutable memtable,接下来被刷入L0层,然后随着Compaction操作一层层向下。这个过程中如果碰到了更下层的同key数据,那么就会把对方合并;如果在Compaction过程遇到了从更高层来的同key新的数据,那么就会被合并。
读取的过程就是从上至下层层扫描,直至找到数据。

在查找的过程中有个关键的优化,可以加速我们对数据的筛选,那就是布隆过滤器。

布隆过滤器用来筛选一层中是否包含我们要查找的数据。它可能返回假阳性结果,也就是返回一个key在这层,但实际查找下来是不在的;但不会返回假阴性结果,也就是如果布隆过滤器返回一个key不在这一层,那么这个key一定是不在的。

布隆过滤器的原理实际上也很简单,就是对每个key做哈希,做成一个比特映射数组,然后对要查找的key也进行哈希,然后和比特映射数组比对。这样是不是就可以达成上面的效果了。
B+树、B-Link树、LSM树,解读一下常用的存储结构_第38张图片

但只有一个哈希函数的话哈希值重合的概率就比较高,使误报率较高。这时我们可以设置多个哈希函数,这样进来一个key的话,只有所有比特映射数组都命中,才需要真正查询,可以极大程度上降低误报率。当然,哈希函数太多的话,布隆过滤器这里的代价就会过大,占用内存也会增多,所以这里需要好好协调,是一个重要的调参方向。
B+树、B-Link树、LSM树,解读一下常用的存储结构_第39张图片

上面讲的是目前主流的LSM树实现,现在我们来简单介绍一下另一些实现和探索。

LevelDB一系列LSM树实现都采用的是Leveling Merge Policy方法,如图中(a)。看图应该很好理解,leveling合并策略就是把相联两层的数据做合并,然后一起写入下面那层。

其实还有另一种合并策略,就是图中的(b),Tiering Merge Policy。Tiering合并策略的每层都有多个重叠的组件,合并时也并不是把相联两层合并,而是把一层的所有组件进行合并并放入下一层。

相比于Leveling合并策略,Tiering合并策略显而易见的对写入更友好,但读取的性能会进一步降低,因为每一层也有多个重叠的区域,查找时都是要查找的。在我们常用的数据库中,Cassandra使用的是Teiring合并策略。

然后我们再讨论一下LSM树的并发控制机制,总体来讲,LSM树因为其天然的out-of-place update特性,在并发控制方面的问题是要比B树少很多的。我们关注的重点主要就在两个会引起结构变更的操作:memtable落盘和compaction过程。

在早期只有一个memtable的情况下,memtable落盘会造成一段时间的不可写。目前区分active memtable和immutable memtable的设计就能在很大程度上避免memtable落盘造成的问题。

compaction一直是LSM树的瓶颈所在,Compaction过程中占用大量资源并调整数据位置,会引发缓冲池中数据的大量丢失,影响LSM树结构的读性能;严重情况下,占用资源过多,还可能造成写停顿,Write Stall。
关于Compaction的优化也一直是LSM树领域的关注重点。

①使用Tiering合并策略是提升综合写性能,减少写放大的一个重要手段。

②还有另外一些手段来优化Compaction。可以采用流水线技术,把读取、合并、写入三个操作以流水线的形式执行,以增强合并操作的资源利用率,减少耗时;

B+树、B-Link树、LSM树,解读一下常用的存储结构_第40张图片

③可以复用组件,在合并的过程中识别出不变的部分并保留;

④Compaction操作会造成Cache丢失,为了优化这一点,可以在Compaction结束后主动更新Cache,或采用机器学习的方式预测回填;

⑤还可以利用额外硬件执行Compaction,把Compaction操作offload到FPGA等额外的硬件上执行,使Compaction不占用本地的CPU和内存,减少Compaction对读写操作的影响。

6. 总结

最后我们来做个总结(其实引子里已经剧透过了):B树和LSM树分别是in-place update和out-of-place update模式的集大成之作,而分布式、固态硬盘、多核CPU三种技术的发展使存储结构从in-place update向out-of-place update方向发展,LSM树也因此得到了越来越多的使用,而以Bw树为代表的新型B树融合了两种模式的特性,可能是未来的一个发展方向。

好了,到此为止,已经把存储结构的种种给大家基本介绍完成了。受篇幅和水平所限,不少技术细节都没有很全面地展示给大家,如果有错误,也希望大家能够给予指正。

7. Reference & 相关阅读

[1]《数据库系统内幕》(讲近些年数据库和分布式技术发展的书,文中很多图片的来源)
[2] http://mysql.taobao.org/monthly/2018/09/01/
[3] http://mysql.taobao.org/monthly/2022/01/01/
[4] https://zhuanlan.zhihu.com/p/151397269(这三篇博客详细的讲了B树体系的并发控制机制)
[5] “The Ubiquitous B-Tree”(1979年,横向比较了B树、B*树、B+树)
[6] “Efficient Locking for Concurrent Operations on B-Trees”(1981年,提出了B-Link树)
[7] “The Log-Structured Merge-Tree” (1996年,正式提出LSM树的概念)
[8] “Bigtable: A Distributed Storage System for Structured Data”(2006年,Google的Bigtable,算是LSM树最早的应用)
[9] “LSM-based Storage Techniques: A Survey”(2019年,一篇LSM树的综述)
[10] 《PostgreSQL数据库内核分析》(对PG使用的B-Link树存储结构有介绍)
[11] 《MySQL技术内部——InnoDB存储引擎》
[12] 《MySQL是怎样运行的》(两本介绍MySQL技术细节的好书)

你可能感兴趣的:(分布式数据库,存储引擎,存储结构,数据结构)