You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

124 lines
14 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 06 | 数据库检索如何使用B+树对海量磁盘数据建立索引?
你好,我是陈东。
在基础篇中,我们学习了许多和检索相关的数据结构和技术。但是在大规模的数据环境下,这些技术的应用往往会遇到一些问题,比如说,无法将数据全部加载进内存。再比如说,无法支持索引的高效实时更新。而且,对于复杂的系统和业务场景,我们往往需要对基础的检索技术进行组合和升级。这就需要我们对实际的业务问题和解决方案十分了解。
所以,从这一讲开始,我会和你一起探讨实际工作中的系统和业务问题,分享给你一些工业界中常见的解决方案,帮助你积累对应的行业经验,让你能够解决工作中的检索难题。
在工业界中我们经常会遇到的一个问题许多系统要处理的数据量非常庞大数据无法全部存储在内存中需要借助磁盘完成存储和检索。我们熟悉的关系型数据库比如MySQL和Oracle就是这样的典型系统。
数据库中支持多种索引方式比如哈希索引、全文索引和B+树索引其中B+树索引是使用最频繁的类型。因此今天我们就一起来聊一聊磁盘上的数据检索有什么特点以及为什么B+树能对磁盘上的大规模数据进行高效索引。
## 磁盘和内存中数据的读写效率有什么不同?
首先,我们来探讨一下,存储在内存中和磁盘中的数据,在检索效率方面有什么不同。
内存是半导体元件。对于内存而言,只要给出了内存地址,我们就可以直接访问该地址取出数据。这个过程具有高效的随机访问特性,因此内存也叫**随机访问存储器**Random Access Memory即RAM。内存的访问速度很快但是价格相对较昂贵因此一般的计算机内存空间都相对较小。
而磁盘是机械器件。磁盘访问数据时需要等磁盘盘片旋转到磁头下才能读取相应的数据。尽管磁盘的旋转速度很快但是和内存的随机访问相比性能差距非常大。到底有多大呢一般来说如果是随机读写会有10万到100万倍左右的差距。但如果是顺序访问大批量数据的话磁盘的性能和内存就是一个数量级的。为什么会这样呢这和磁盘的读写原理有关。那具体是怎么回事呢
磁盘的最小读写单位是扇区较早期的磁盘一个扇区是512字节。随着磁盘技术的发展目前常见的磁盘扇区是4K个字节。操作系统一次会读写多个扇区所以操作系统的最小读写单位是**块**Block也叫作**簇**Cluster。当我们要从磁盘中读取一个数据时操作系统会一次性将整个块都读出来。因此对于大批量的顺序读写来说磁盘的效率会比随机读写高许多。
现在你已经了解磁盘的特点了,那我们就可以来看一下,如果使用之前学过的检索技术来检索磁盘中的数据,检索效率会是怎样的呢?
假设有一个有序数组存储在硬盘中,如果它足够大,那么它会存储在多个块中。当我们要对这个数组使用二分查找时,需要先找到中间元素所在的块,将这个块从磁盘中读到内存里,然后在内存中进行二分查找。如果下一步要读的元素在其他块中,则需要再将相应块从磁盘中读入内存。直到查询结束,这个过程可能会多次访问磁盘。我们可以看到,这样的检索性能非常低。
由于磁盘相对于内存而言访问速度实在太慢,因此,对于磁盘上数据的高效检索,我们有一个极其重要的原则:**对磁盘的访问次数要尽可能的少**
那问题来了,我们应该如何减少磁盘的访问次数呢?将索引和数据分离就是一种常见的设计思路。
## 如何将索引和数据分离?
我们以查询用户信息为例。我们知道一个系统中的用户信息非常多除了有唯一标识的ID以外还有名字、邮箱、手机、兴趣爱好以及文章列表等各种信息。一个保存了所有用户信息的数组往往非常大无法全部放在内存中因此我们会将它存储在磁盘中。
![](https://static001.geekbang.org/resource/image/3a/56/3ad283ed20ba36a8f5f8350ee4bd7d56.jpg)
常规数组存储
当我们以用户的ID进行检索时这个检索过程其实并不需要读取存储用户的具体信息。因此我们可以生成一个只用于检索的有序索引数组。数组中的每个元素存两个值一个是用户ID另一个是这个用户信息在磁盘上的位置那么这个数组的空间就会很小也就可以放入内存中了。这种用有序数组做索引的方法叫作**线性索引**Linear Index
![](https://static001.geekbang.org/resource/image/0e/1e/0e7ca7c9a2c9c373353ae5ec824f4f1e.jpg)
索引与数据分离:线性索引
在数据频繁变化的场景中,有序数组并不是一个最好的选择,二叉检索树或者哈希表往往更有普适性。但是,哈希表由于缺乏范围检索的能力,在一些场合也不适用。因此,二叉检索树这种树形结构是许多常见检索系统的实施方案。那么,上图中的线性索引结构,就变成下图这个样子。
![](https://static001.geekbang.org/resource/image/02/b4/0203a7cc903e3acf38e47a59ad3aa6b4.jpeg)
索引与数据分离:树形索引
尽管二叉检索树可以解决数据动态修改的问题,但在索引数据很大的情况下,依然会有数据无法完全加载到内存中。这种情况我们应该怎么办呢?
一个很自然的思路就是将索引数据也存在磁盘中。那如果是树形索引我们应该将哪些节点存入磁盘又要如何从磁盘中读出这些数据进行检索呢你可以先想一想然后我们一起来看看业界常用的解决方案B+树是怎么做的。
## 如何理解B+树的数据结构?
B+树是检索技术中非常重要的一个部分。这是为什么呢?因为**B+树给出了将树形索引的所有节点都存在磁盘上的高效检索方案**,使得索引技术摆脱了内存空间的限制,得到了广泛的应用。
前面我们讲了,操作系统对磁盘数据的访问是以块为单位的。因此,如果我们想将树型索引的一个节点从磁盘中读出,即使该节点的数据量很小(比如说只有几个字节),但磁盘依然会将整个块的数据全部读出来,而不是只读这一小部分数据,这会让有效读取效率很低。**B+树的一个关键设计就是让一个节点的大小等于一个块的大小。节点内存储的数据不是一个元素而是一个可以装m个元素的有序数组**。这样一来,我们就可以将磁盘一次读取的数据全部利用起来,使得读取效率最大化。
B+树还有另一个设计,就是将所有的节点分为内部节点和叶子节点。尽管内部节点和叶子节点的数据结构是一样的,但存储的内容是不同的。
内部节点仅存储key和维持树形结构的指针并不存储key对应的数据无论是具体数据还是文件位置信息。这样内部节点就能存储更多的索引数据我们也就可以使用最少的内部节点将所有数据组织起来了。而叶子节点仅存储key和对应数据不存储维持树形结构的指针。通过这样的设计B+树就能做到节点的空间利用率最大化。
![](https://static001.geekbang.org/resource/image/a9/eb/a994e93f2fdd38291998cba8149270eb.jpg)
B+树的内部节点和叶子节点
此外B+树还将同一层的所有节点串成了有序的双向链表这样一来B+树就同时具备了良好的范围查询能力和灵活调整的能力了。
因此B+树是一棵完全平衡的m阶多叉树。所谓的m阶指的是每个节点**最多**有m个子节点并且每个节点里都存了一个紧凑的可包含m个元素的数组。![](https://static001.geekbang.org/resource/image/72/65/72499a6180cfb1ee7e3c33e6ca433b65.jpg)
B+树的整体结构
## B+树是如何检索的?
这样的结构使得B+树可以作为一个完整的文件全部存储在磁盘中。当从根节点开始查询时,通过一次磁盘访问,我们就能将文件中的根节点这个数据块读出,然后在根节点的有序数组中进行二分查找。
具体的查找过程是这样的我们先确认要寻找的查询值位于数组中哪两个相邻元素中间然后我们将第一个元素对应的指针读出获得下一个block的位置。读出下一个block的节点数据后我们再对它进行同样处理。这样B+树会逐层访问内部节点,直到读出叶子节点。对于叶子节点中的数组,直接使用二分查找算法,我们就可以判断查找的元素是否存在。如果存在,我们就可以得到该查询值对应的存储数据。如果这个数据是详细信息的位置指针,那我们还需要再访问磁盘一次,将详细信息读出。
我们前面说了B+树是一棵完全平衡的m阶多叉树。所以B+树的一个节点就能存储一个包含m个元素的数组这样的话一个只有2到4层的B+树就能索引数量级非常大的数据了因此B+树的层数往往很矮。比如说一个4K的节点的内部可以存储400个元素那么一个4层的B+树最多能存储400^4也就是256亿个元素。
不过因为B+树只有4层这就意味着我们最多只需要读取4次磁盘就能到达叶子节点。并且我们还可以通过将上面几层的内部节点全部读入内存的方式来降低磁盘读取的次数。
比如说对于一个4层的B+树每个节点大小为4K那么第一层根节点就是4K第二层最多有400个节点一共就是1.6M第三层最多有400^2也就是160000个节点一共就是640M。对于现在常见的计算机来说前三层的内部节点其实都可以存储在内存中只有第四层的叶子节点才需要存储在磁盘中。这样一来我们就只需要读取一次磁盘即可。这也是为什么B+树要将内部节点和叶子节点区分开的原因。通过这种只让内部节点存储索引数据的设计,我们就能更容易地把内部节点全部加载到内存中了。
## B+树是如何动态调整的?
现在你已经知道B+树的结构和原理了。那B+树在“新增节点”和“删除节点”这样的动态变化场景中,又是怎么操作的呢?接下来,让我们一起来看一下。
首先我们来看插入数据。由于具体的数据都是存储在叶子节点上的因此数据的插入也是从叶子节点开始的。以一个节点有3个元素的B+树为例如果我们要插入一个ID=6的节点首先要查询到对应的叶子节点。如果叶子节点的数组未满那么直接将该元素插入数组即可。具体过程如下图所示
![](https://static001.geekbang.org/resource/image/9e/d4/9ed9028cab65e6530966faae00d0d3d4.jpg)
插入ID 6
如果我们插入的是ID=10的节点呢按之前的逻辑我们应该插入到ID 9后面但是ID 9所在的这个节点已经存满了3个节点无法继续存入了。因此我们需要将该叶子节点**分裂**。分裂的逻辑就是生成一个新节点,并将数据在两个节点中平分。
![](https://static001.geekbang.org/resource/image/67/f2/674115e7c61637e56791e001ea840af2.jpg)
插入ID 10叶子节点分裂
叶子节点分裂完成以后,上一层的内部节点也需要修改。但如果上一层的父节点也是满的,那么上一层的父节点也需要分裂。
![](https://static001.geekbang.org/resource/image/03/57/03af63ed8cd065743bd8b2bd812e5057.jpg)
插入ID 10内部节点修改
内部节点调整好了,下一步我们就要调整根节点了。由于根节点未满,因此我们不需要分裂,直接修改即可。
![](https://static001.geekbang.org/resource/image/53/0f/53fd14349369951706f53abd1eff560f.jpg)
插入ID 10根节点修改
删除数据也类似,如果节点数组较满,直接删除;如果删除后数组有一半以上的空间为空,那为了提高节点的空间利用率,该节点需要将左右两边兄弟节点的元素转移过来。可以成功转移的条件是,元素转移后该节点及其兄弟节点的空间必须都能维持在半满以上。如果无法满足这个条件,就说明兄弟节点其实也足够空闲,那我们直接将该节点的元素并入兄弟节点,然后删除该节点即可。
## 重点回顾
好了今天的内容就先讲到这里。你会发现即使是复杂的B+树我们将它拆解开来其实也是由简单的数组、链表和树组成的而且B+树的检索过程其实也是二分查找。因此如果B+树完全加载在内存中的话它的检索效率其实并不会比有序数组或者二叉检索树更高也还是二分查找的log(n)的效率。并且,它还比数组和二叉检索树更加复杂,还会带来额外的开销。
但是B+树最大的优点在于,它提供了将索引数据存在磁盘中,以及高效检索的方案。这让检索技术摆脱了内存的限制,得到了更广泛地使用。
另外这一节还有一个很重要的设计思想需要你掌握那就是将索引和数据分离。通过这样的方式我们能将索引的数组大小保持在一个较小的范围内让它能加载在内存中。在许多大规模系统中都是使用这个设计思想来精简索引的。而且B+树的内部节点和叶子节点的区分,其实也是索引和数据分离的一次实践。
## 课堂讨论
最后,咱们来看一道讨论题。
B+树有一个很大的优势就是适合做范围查询。如果我们要检索值在x到y之间的所有元素你会怎么操作呢
欢迎在留言区畅所欲言,说出你的思考过程和最终答案。如果有收获,也欢迎把这篇文章分享给你的朋友。