目录
深入了解B树及其变种
BTree
B+Tree
B*Tree
BTree并发机制
B-Link Tree
深入了解B树及其变种
先把我们要解释的B树变种都列出来,B树的变种主要有B+树、B*树、B-Link树、COW B树、惰性B树、Bw树等。
下面具体来分析这些变种的优势和发展趋势。
BTree
下图是原始 BTree 的结构,
可以注意到:在 BTree 中,每个数据只存储一份。
如果要进行全表扫描,则需要中序遍历整个 BTree,因此会产生大量的随机 IO,性能不佳。所以基本上没有直接使用 BTree 实现存储结构的。
BTree 早期有两个变种:
- B+Tree
- B*Tree
B+Tree
相比于 BTree,B+Tree 的数据按照键值大小顺序存放在同一层的叶子节点中(和上面 BTree 中在非叶子节点也存放数据不同),各个叶子节点按照指针连接,组成一个双向链表。
因此,对于 B+Tree 而言,其非叶子节点仅仅作为查找路径的判断依据,一个 key 值可能在 B+Tree 中存在两份(仅 Key 值)。
B+Tree 的结构解决了 BTree 中中序遍历扫描的痛点,在一定程度上也能降低层数。
B*Tree
B*Tree 是 BTree 的另一个变种,其最关键的一点是将节点的最低空间利用率从 BTree 和 B+Tree 的 1/2 提高到了 2/3,并由此改变了节点数据满时的处理逻辑。
我们知道,BTree 和 B+Tree 的空间利用率为 1/2,即:当它们的叶子节点满而分裂时,默认状态下会分裂为两个各占一半数据的节点;
而 B*Tree在一个节点满了却又有新的数据要插入进来时,它会将其部分数据搬迁到下一个兄弟节点,直到两个节点空间都满了,就在中间生成一个节点,三个节点平分原来两个节点中的数据。
B*Tree 的思想主要是:将当前节点和兄弟节点相关联。
B*Tree 的这种设计虽然可以提升空间利用率,对减少层数、提升读性能有一定的帮助,但这种模式增加了写入操作的复制度;
而且向右兄弟节点搬迁数据的过程也要视作为一种 SMO 操作,对写入和并发能力有极大的损耗!因此,B*Tree 并没有被大量使用。
BTree并发机制
这里以 MySQL InnoDB 存储引擎为例,讲述基于 B+Tree 的存储引擎是如何通过 Latch 进行并发控制的。
在 MySQL 5.6 之前的版本,只有写和读两种 Latch,被称为 X Latch 和 S Latch。
对于读的过程,
- 首先要在整个索引上添加 Index S Latch。
- 再从上至下找到要读的叶子节点的 Page,然后上叶子节点的 Page S Latch。
- 这时就可以释放 Index S Latch了。
- 然后进行查询并返回结果,最后释放叶子节点中的 Page S Latch,完成整个读操作。
对于写的过程,
- 首先进行乐观的写入,即:假设写入操作不会引起索引结构的变更(不触发 SMO 操作)。
- 要先上整个索引的 Index S Latch,再从上至下找到要修改的叶子节点的 Page,此过程和上面的读取步骤相同!
- 接下来判断叶子节点是否安全,即:写入操作是否会触发分裂或者合并;
- 如果叶子节点 Page 安全,就上 Page X Latch,并释放 Index S Latch,然后再修改数据即可。
- 完成乐观写入过程。
-
如果叶子节点 Page 不安全,那么就要重新进行悲观写入。
-
释放一开始上的 Index S Latch,重新上 Index X Latch,阻塞对整棵 B+Tree 的所有操作。
-
然后重新搜索,并找到要发生结构变化的节点,上 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 不冲突(可以理解为类似RWLock)。
下面来看一下引入 SX Latch 之后的并发控制方案。
对于读操作而言,
- 相比于 MySQL 5.6 之前,这时读步骤主要加上了对查找路径上节点的锁。这是因为在引入了 SX Latch 之后,发生 SMO 操作的时候,读操作也可以进行。
- 此时为了保证读取的时候查找路径上的非叶子节点不会被 SMO 操作改变,因此就需要对路径上的节点也加上 S Latch。
写的过程和上面类似,
- 一样是先进行乐观写,
- 由于此时假设只会修改叶子节点,因此,乐观写的查找过程和读操作一致:添加整个索引的 Index S Latch 和读取路径上节点的 Page S Latch 即可!
-
接下来判断叶子节点是否安全,如果叶子节点 Page 安全,则上 Page X Latch,同时释放索引和路径上的 S Latch,然后再修改即可。
-
但是如果叶子节点的 Page 不安全,这需要重新进行悲观写入。
-
释放一开始上的所有 S Latch,这时我们上 Index SX Latch,然后重新搜索,找到要发生结构变化的节点,上 Page X Latch,再修改树结构,此时就可以释放 Index SX Latch 和路径上的 Page X Latch。
-
随后即可完成对叶子节点的修改,返回结果,并释放叶子节点的 Page X Latch。
-
完成悲观写入过程。
-
我们可以知道,B+Tree 的问题在于:其自上而下的搜索过程决定了加锁过程也必须是自上而下的!哪怕只对一个小小的叶子节点做读写操作,也都必须首先对根节点上 Latch。并且一旦触发 SMO 操作,就需要对整个树进行加锁!
B-Link Tree
B-Link Tree 相比于 B+Tree 主要做了三点优化:
- 非叶子节点也都有指向右兄弟节点的指针。
- 分裂模式上,采用和 BTree 类似的做法:将当前层数据向兄弟节点中迁移。
- 每个节点都增加一个 High Key 值,记录当前节点的最大 Key。
B-Link Tree 结构如下图,其中加下划线的 Key 为 High Key。
在前面提到,B+Tree 中一个严重的问题就是,在读写过程中都需要对整棵树、或一层层向下的加 Latch,从而造成 SMO 操作会阻塞其他操作。
而 B-Link Tree 通过对分裂和查找过程的调整,避免了这一点!
下图就是 B-Link Tree 树节点分裂的过程:先将老节点的数据拷贝到新节点,然后建立同一层节点的连接关系,最后再建立从父节点指向新节点的连接关系(此顺序非常重要!)。
那么上面的分裂过程是如何避免整棵树上的锁的呢?可以通过指向右兄弟节点的指针和 High Key 实现!
如下图, 当节点 y 分裂为 y 和 y+ 两个节点后,在 B+Tree 中就必须要提前锁住他们的父节点 x。
而 B-Link Tree 可以先不锁 x,这时查找 15,顺着 x 找到节点 y,在节点 y 中未能找到 15,但判断 15 大于其中记录的 high key,于是顺着指针就可以找到其右兄弟节点 y+,仍能找到正确的结果。
因此,B-Link Tree 中的 SMO 操作可以自底向上加锁,而不必像 B+Tree 那样自顶向下加锁!从而避免了 B+Tree 中并发控制瓶颈。
上面就是 B-Link Tree 的基本思路。
但是在实现 B-Link Tree 时需要考虑的还有很多:
- 删除操作需要单独设计;
- 原论文中对于一些原子化的假定也不符合现状;
但是 B-Link Tree 仍是一种非常优秀的存储结构,很大程度上突破了 B+Tree 的性能瓶颈。