如何设计一个 B+Tree

1,130 阅读4分钟

B+树的形状

二叉搜索树的形状应该都知道长什么样,如下:

在操作系统中,文件的最小单位,如下,我们创建一个txt文本,内容只有一个 1

查看一下它的大小,占用了4kb的大小,这也就是常说的page,这样设计的好处是一次读取4kb的数据,可以有效减少磁盘IO的次数。

在数据库中,也是基于page进行存储的,二叉所有树中的节点的类型即使page

page中的主要内容是什么呢?这个对于不同的数据库设计可能不同,本文以MySql为参考目标,在MySql中,节点分为两类,inner node 和 leaf node

  • inner node 主要存储索引值
  • leaf node 存储实际的数据行,可以称为 Tuple

一个4k page 可以存储很多的key,但是为了便于展示,我们假设一个page里最多存储3个key,

那么数据 [1,2,3,4,5,6,7,8] 的存储方式可以这样表示:

树的的重平衡

涉及到两个关键点,页分裂和页合并

  • 页分裂:当要插入的node已经满了,需要将一个node拆分成两个page,并将数据平均分配,这样可以保持数据的平衡分配,维持查询复杂度在log(n)
  • 页合并:当删除数据后,page的数据量过少,当两个兄弟节点的数据总和可以存储到一个page中时,就进行合并, 这样可以减少内存的占用。

页分裂

依然沿用上面一个node存储3个数据的规定,假设此时树的结构如下图:

这时我们要插入一个数据6,数据将会存储到最右边的leaf中,但是leaf已经有了3个值,无法存下6,就会发生页分裂

最后我们看到的结构就如下,

  • inner node中新增了一个索引5
  • 新增了leaf node,[3,4,5,6] 数据平均分配在了两个leaf node中

页合并

对于不同的数据库实现页合并的规则可能有一些不同,以MySQL为例,MySQL 提供了一个数据页合并临界值(MERGE_THRESHOLD),在 InnoDB 表里,默认 MERGE_THRESHOLD 值为 50,取值范围从 1 到 50,默认值即是最大值。也就是当页面记录数占比小于 50% 时,MySQL 会把这页和相邻的页面进行合并,保证数据页的紧凑,避免太多浪费。

现在假设一个page可以存储6个值,

首先,页合并可能发生在inner node和leaf node中,这两者的合并规则本质上是一样的,将一个chind的数据拷贝到兄弟节点中,然后删除。

针对这两个点,尤其是页merge,这时可能引发向上递归的,性能消耗非常大,所以在设计索引的时候,要尽量减少这两个步骤的次数,有一些点:

  • 使用逻辑删除,可以有效减少merge的次数
  • 索引如果不是递增的,像UUID这类随机值,导致页稀疏的同时也增加了merge的次数

树的前缀匹配的原理

这主要时针对联合索引,tree node 的中索引的存储方式是按照联合索引中字段的顺序构建的。

有序的规则时先按照联合索引的第一个字段全局排序,然后按照第二个字段局部排序,后续字段同理

那么,平时所谓的最左匹配规则,其实本质上就是查询时,没有按照索引排序的构建方式,这就导致树的索引有序性失效了

  • 当我们使用(A,*) 这样的方式查询时,我们可以在树中找到对应的路径。
  • 但是当我们使用(*,B)这类的方式,我们无法在树中找到对应的路径,需要遍历所有树。

从另一个角度看,树的本质和二分查找一样,而二分查找的使用条件之一就是数据有序。

如何估算节点中的数据量

一个inner node中的数据主要有,两部分组成:

  • 索引值:例如long类型,一个就占8字节
  • 指针:32位机器=4字节,64位机器=8字节

指针数量 = 索引值数量+1

num = 4kb / (索引长度+指针长度) ≈ 2KB / 索引长度

mysql中一个node是由4个page组成的,也就是16kb

空间优化措施

这里只是提一下,因为细节我也还没去了解

前缀压缩

索引的前缀都包含rob,那么node保存一个公共的前缀遍历,存储的时候每一个值就可以省略掉这个前缀了,这在数据量大的时候,可以有效的增加一个node中保存的数据量,也就意味着可以使用更少的内存存储更多的数据。

后缀截断

因为查询的规则时从前到后匹配时,当两个key的差异比较大,可以省略索引掉后面用不到部分,进行一个截断,也可以有效的减少内存消耗。

参考