170 lines
15 KiB
Markdown
170 lines
15 KiB
Markdown
|
# 26|B+ Tree:PostgreSQL 的索引是如何建立的?
|
|||
|
|
|||
|
你好,我是微扰君。
|
|||
|
|
|||
|
过去几讲我们学习了一些经典的分布式算法,主要涉及多个节点之间的协作方式,在现在的业务场景下,它们更多被封装在各种中间件或者类库中,直接供我们使用,不过背后的很多思想还是很值得好好学习体悟的。
|
|||
|
|
|||
|
从今天开始,我们将更加贴近日常业务开发,剖析常用中间件里用到的、单机上的一些算法,帮助自己更好地分析和优化系统性能。比如在使用数据库的时候,如果我们不清楚底层索引的原理,写出来的查询语句可能性能会很差,甚至不见得能正确建立索引;再比如有时候我们希望不引入额外的网关组件,直接在业务代码里实现一个简单的限流模块,如何设计更合适……
|
|||
|
|
|||
|
类似场景还有很多,话不多说,今天我们一起来了解这些中间件的秘密。
|
|||
|
|
|||
|
## 数据库
|
|||
|
|
|||
|
我们先从数据库聊起。数据库,应该是我们广大程序员日常开发中必不可少的组件了,作为数据持久化的基石,在互联网应用中,大部分的业务数据都以结构化的方式存储在数据库中,而数据库为我们提供了良好的数据查询、管理、持久化、事务的能力。
|
|||
|
|
|||
|
在数据库场景下,我们存储的都是大量的数据,显然没有办法一次性把所有的数据全部加载到内存中,用内存高效的数据结构或者算法进行搜索。那数据库是如何快速查询数据的呢?比如:
|
|||
|
|
|||
|
```scala
|
|||
|
select * from student where id = 5130309492
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
如果我们逐一遍历数据库的每个记录逐个对比,也就是常说的“全表扫描”,查找速度肯定很慢。如何提高查找指定ID记录的速度呢?
|
|||
|
|
|||
|
通常为了提高查找速度,我们都会设计一种特殊的数据结构,在内存中如此,在磁盘中也不例外。我们需要设计一种**适合磁盘场景的数据结构,对业务数据进行某种有序性的维护,在磁盘读写次数不多的情况下,结合内存,就能快速找到需要查询的记录在磁盘中所在的位置。这就是我们常说的“索引”**。
|
|||
|
|
|||
|
那索引一般是用什么样的数据结构实现的呢?
|
|||
|
|
|||
|
其实在之前[学习Kafka](https://time.geekbang.org/column/article/473255)的时候,我们学过线性稀疏的索引,适用于 append only 的存储模式,利用有序性带来的二分搜索,帮助我们加速查找指定offset的日志内容,你可以再回顾一下理解索引背后用空间换时间的思想。
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/37/68/37906ed9a9b8c062a7b2edd5a45e7568.jpg?wh=1920x1385)
|
|||
|
|
|||
|
但是kafka的索引,在数据库的场景下并不好用,因为数据库中,我们随时可能需要删除或者修改某个字段的值,**如果要保持索引的线性有序性的要求,就要不断调整索引文件的前后顺序,在磁盘上,这个代价是非常高的**。
|
|||
|
|
|||
|
所以,为了能适配需要随机修改插入的数据库场景,我们的索引结构不能是线性的了。
|
|||
|
|
|||
|
### 树状索引
|
|||
|
|
|||
|
如果你熟悉第一章的内容,估计很快就会想到[红黑树这样的结构](https://time.geekbang.org/column/article/471434)。
|
|||
|
|
|||
|
在内存中,红黑树任意字段的查询可以做到logN的复杂度,而且相比于二分搜索所需具备的有序性,在红黑树上做元素的调整和增删效率要高得多。我们之前也提到了,对于百万量级的存储场景,红黑树也只需要20层这个数量级的高度就可以容纳全部元素。查询效率当然是很有保证的。
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/e3/84/e366b0ed2775a57ca6d73261fa3cd284.jpg?wh=1920x1178)
|
|||
|
|
|||
|
那可不可以在数据库的索引中采用红黑树呢?
|
|||
|
|
|||
|
因为在数据库中,不同于内存的场景,磁盘读写比内存慢的多,所以相比于查询的计算成本,IO成本可能要显著的多。
|
|||
|
|
|||
|
数据库里存储的数据比较多,如果我们采用二叉树来存储的话,层数必然不会很少,且层和层之间的数据在物理上基本上是不连续的,即使前几层的元素可以被预加载至内存中,我们仍然可能需要在树上进行10余次的跳转查询,也就对应着10余次的磁盘IO,这是不可接受的。
|
|||
|
|
|||
|
有没有什么办法利用磁盘读写的特性,既可以保持树状结构的灵活性,又同时降低查询的IO次数呢?**这就是B+树的用武之地了,核心就是通过引入更多的分叉,在节点同样数量级的范围内,显著地降低树状索引的层数**。
|
|||
|
|
|||
|
## B-树B+树
|
|||
|
|
|||
|
B+树是传统关系型数据库索引的标配,在MySQL、PostgreSQL等主流DBMS中,B+树都是索引的底层实现。
|
|||
|
|
|||
|
B+树是由B-树演化而来,这里的B一般被解读为balance,也就是平衡树,和之前介绍的2-3树差不多,B-树、B+树每个节点也包含多个键和多条链。
|
|||
|
|
|||
|
我们先看B-树,这个数据结构就是为大量数据的存储和快速访问而设计的。B-树的每个节点都包含若干个键和若干个指针域,指针域就用于指向存储的数据本身。m阶B-树的主要约束还包括:
|
|||
|
|
|||
|
1. 所有叶子结点处于同一高度;
|
|||
|
2. 除了根结点和叶子结点之外,每个节点最少包含 m/2 个键;
|
|||
|
3. 每个节点最多包含m-1个键和m条链,如果某个节点有k-1个键,则对应k条链;
|
|||
|
4. 每个节点内部的键有序排列,每个链指向的节点中的键,都在链左右节点的确定范围之间;
|
|||
|
5. 根节点在不为叶子节点的时候至少有两个子节点。
|
|||
|
|
|||
|
下图就是一个典型3阶B-树的例子,可以看出,2-3树是一种特殊的B-树。
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/09/c3/09c877a350e60b5b74d83525404ac7c3.jpg?wh=1920x961)
|
|||
|
|
|||
|
在数据库场景下,毫无疑问,我们会把建立索引的字段作为key,每个key后面也会跟上对应记录的指针。
|
|||
|
|
|||
|
为什么磁盘上的B-树会比对应的二叉平衡树快很多呢?主要就是利用了磁盘访问的局部性原理。
|
|||
|
|
|||
|
之前讲LRU的时候也提到了相关概念,计算机在读取磁盘的时候,往往是以页为单位读取的,读取某一页中的部分内容和读取该中的全部内容,所花费的代价其实是一样的。**如果我们把B-树的每个节点中存储的大小设成一个页的大小,利用磁盘预读的能力,就可以做到仅通过一次IO就将整个节点的全部内容加载到内存中**。
|
|||
|
|
|||
|
一个页的大小通常是4K~16K,能包含的键数可以高达几千条。以InnoDB通常采用的16K大小的页为例,如果我们的索引字段和指针域大小为8B,B-树上的每个节点能包含的键数高达2048个,这就意味着用4层的高度,就可以存储接近10亿级别的记录,在索引字段大小更大的时候,我们通常也只需要5层以内,就可以构造大部分表的索引。
|
|||
|
|
|||
|
这就是多叉的B树的主要优点了,利用磁盘的预读能力和树状结构,我们通过3~5次磁盘IO就可以在10亿级的数据表中进行快速检索了。
|
|||
|
|
|||
|
检索过程的伪代码如下:
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/87/e1/87a72ec29225e2b8ac47b62d9b2498e1.jpg?wh=1920x1145)
|
|||
|
|
|||
|
基本上和2-3树或者普通二叉树的检索思路是一致的:从根节点出发进行遍历,如果在某个节点中查到了目标键,直接返回;如果查到的目标值在两个键之间,就进入两键之间的链所指向的节点进行下一层的查找。
|
|||
|
|
|||
|
**因为每个节点中的键是有序存储的,当我们加载到内存中后,通常也会直接采用二分搜索,整个搜索过程仍然是logN这样非常低的复杂度**。所以主要耗时还是花费在IO中。
|
|||
|
|
|||
|
### B+树
|
|||
|
|
|||
|
通常数据库中采用的索引结构都是B+树,B+树和B-树的区别主要在于树节点的组织形式,包括两点:
|
|||
|
|
|||
|
1. B+树的所有的叶子节点之间会通过双向指针串联在一起,构成一个双向链表;
|
|||
|
2. B+树的中间节点不会存储数据指针,而只有叶子节点才会存储,中间节点只用于存储到叶子节点的路由信息。
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/3a/c5/3a31972c700bed70736e7f6aa45e34c5.jpg?wh=1920x1145)
|
|||
|
|
|||
|
这个图就是一个典型的3阶B+树了。可以看到,红色的非叶子节点和绿色的叶子结点的键会有一定的重合,这就是因为非叶子结点不再存储实际的数据信息,所以叶子结点实际上需要存储整张表的信息,但因为树状结构的特性,两者的层数预期仍然都是logN。
|
|||
|
|
|||
|
同时,因为非叶子节点中不再需要存储数据本身相关的信息,每个节点能存储的键的数量也会有所增加,所以B-树和B+树的层数期望是差不多的,在大部分业务场景下,3~5次的IO查询就可以让我们查询到任意目标值。
|
|||
|
|
|||
|
那为什么要引入这样额外的指针和约束呢?主要原因在于**在数据库中我们经常需要范围查询**,比如这样的查询语句,查询所有年龄小于20的学生:
|
|||
|
|
|||
|
```sql
|
|||
|
select * from students where age < 20
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
因为B+树的特性,叶子节点之间也一定是有序排列的,我们只需要找到比20小的第一个元素,借助双向链表,我们从链表头遍历到这个元素,就能快速获得所有比20小的元素。这样高效的范围查询能力是B-树所没有的。
|
|||
|
|
|||
|
当然了,如果能让叶子结点的指向数据,能在磁盘上连续存储,当然可以获得更好的查询能力,不过这件事情非常困难,似乎没有什么太好的办法。
|
|||
|
|
|||
|
好有了B+树这样的结构,我们已经可以做到快速查询了,但是数据库中的元素是会被时刻修改的,如何在增删改操作中维持B+树的性质呢?我们一起来看一下。
|
|||
|
|
|||
|
## 插入操作
|
|||
|
|
|||
|
为了方便演示和讨论,我们用比较简单的3阶B+树来讲解,和2-3树的过程非常相似我们重点看如何通过节点的合并和分裂操作,应对树结构的变化。
|
|||
|
|
|||
|
假设我们的树一开始只有 1、2 两个键,此时键的数量没有超过单节点能容纳的范围,我们用一个叶子结点就可以表示。
|
|||
|
|
|||
|
**接下来要加入一个3节点,和2-3树一样,我们就需要对原来的叶子节点做分裂操作,因为3阶B+树,每个节点最多能承载的元素就是2个**。现在多出来一个键,我们就需要把节点中键的中位数取出来,提高到上一层,然后分成左右两个部分,每个部分都包括 m/2 个节点。
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/98/c7/98fbfc7ae2f2df35e94859fb0d6620c7.jpg?wh=1920x1145)
|
|||
|
|
|||
|
这里,对于3阶B+树而言,左子节点就分到一个键,右子节点则分到两个键。叶子节点间,我们同样需要用双链表的方式进行串联。
|
|||
|
|
|||
|
我们尝试继续添加键,现在添加键4到树里,首先要做的就是进行前面提到的B树的查询,我们会查询到最右侧的叶子结点,从而试图将键4放入(2,3)构成的节点中,但同样由于每个节点最多存放2个键,所以我们需要把3提高到上一层,把原来的节点拆成2和(3,4)两个节点。
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/da/2c/daedc8ea501e4203a3abc1af2b0dac2c.jpg?wh=1920x1145)
|
|||
|
|
|||
|
后面的添加就是类似的过程,如果继续添加键5,同样会先放入(3,4)节点中,把4提高到根节点,而根节点也已经“满载”了,所以会把中间值3提到更上一层,此时我们就得到了一个三层的树。
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/a8/45/a86d01145bbae4cdd2a03274ff46b045.jpg?wh=1920x1145)
|
|||
|
|
|||
|
整个分裂节点的过程自底向上递归进行,可以注意到所有叶子节点的高度其实是始终不变的。
|
|||
|
|
|||
|
## 删除操作
|
|||
|
|
|||
|
删除操作看起来要稍微复杂一点,不能简单理解成插入过程的逆过程。我们用刚才构造出的树来演示几种不同的删除操作。
|
|||
|
|
|||
|
首先我们尝试删除键2。因为存储2的节点本身只存储了一个键,如果删去,2节点所存储的键数量不满足约束条件了,这个时候我们有两种选择:
|
|||
|
|
|||
|
* 一种是如果本身左侧的兄弟节点存储有多余的键,比如存储了2个键,我们就可以很简单地直接借用一个键;
|
|||
|
* 另一种则需要让父节点下移,并合并子节点。
|
|||
|
|
|||
|
在这个例子中,左侧存放key1的节点也只有一个键,所以不满足可以借用的情况,我们只能考虑让父节点3下沉,和4节点合并。
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/66/34/66a40c093bd161c2601c3e09e310b834.jpg?wh=1920x1145)
|
|||
|
|
|||
|
这样我们就可以重新得到一颗平衡的B+树了。每次让父节点下沉,也可能重新破坏父节点的约束条件,我们同样要递归地找左侧兄弟节点借用键或者考虑让更上层的父亲节点下沉,直到整颗树满足约束为止。
|
|||
|
|
|||
|
第二种我们继续在这棵树上删除键5,这个情况比较简单,可以直接删除键5,并不会破坏原来的约束。
|
|||
|
|
|||
|
![图片](https://static001.geekbang.org/resource/image/39/5d/39a738ed62521ed1922866eb3bd4e05d.jpg?wh=1920x1145)
|
|||
|
|
|||
|
在数据库中数据有插入或者删除的时候,我们就可以及时地调整索引内部的结构了。当然可以想见这其中会有很多并发的问题,比较复杂,通常需要加锁解决,也是研究的热点之一,这次就不展开讨论了。
|
|||
|
|
|||
|
## 总结
|
|||
|
|
|||
|
今天我们一起学习了非常经典的索引实现方式,利用空间换时间的思路,通过为数据表建立额外的有序索引结构,做到大大加速查询的效果。
|
|||
|
|
|||
|
由于数据库需要经常对数据进行增删改,我们的索引数据结构要能高效地变动,而且数据库本身海量的数据也意味着,索引结构不会只存在内存中,需要在二级存储中存储。相比于传统的二叉搜索树,通过B+树,我们可以让整个树状结构变得更加矮胖,而磁盘的预读特性每次都可以加载一整个节点中全部的键,到内存进行二分查找,这样我们只需要通过3~5次的磁盘IO就可以查询加了索引的字段,非常高效。
|
|||
|
|
|||
|
B+树,相比于B-树的主要特点就是,只在叶子结点存储数据,而且叶子节点间用首尾相连的指针串联成双向链表,可以获得良好的范围查询的效果,背后的本质就是索引和数据的分离,这同样是非常值得好好体会的一种思想。
|
|||
|
|
|||
|
### 思考题
|
|||
|
|
|||
|
我们说平衡树的约束之一是每个节点的键不能少于 m/2 也不能多于 m,这里 m/2 是怎么来的呢? 不知道你有没有深入思考过,如果能回答清楚这个问题,我想你对B+树索引的分裂策略就理解地很深刻了。
|
|||
|
|
|||
|
欢迎你在评论区留下你的思考,如果觉得有帮助的话,也欢迎你把这篇文章转给你身边的好朋友,一起学习。下节课见。
|
|||
|
|