PostgreSQL B+树索引—基本结构
基本概念
PostgreSQL中使用的B+树索引结构如上图所示,其中有几个关键点需要说明。
-
B link Tree
PostgreSQL B+树索引的思想来源于lehman和yao的论文《Efficient Locking for Concurrent Operations on B-Trees》,该论文介绍了B+树的一个变种,B link Tree,也叫B*树。B*树和B+树的主要区别在于B*树的内部节点(非叶子节点)也用双向链表连接。B*树的主要优势在于具备更高的并发性。
-
high key
在叶子节点和内部节点中,有一个特殊的元素,称为high key(在上图中用绿色来标记),high key表示一个节点的upper bound,即节点所能存放key的最大值,一个节点中的high key必须等于其右兄弟的最小值,这一点需要尤为注意(PostgreSQL在分裂时就是将右节点的最小key作为左节点的high key)。每层的最右节点(rightmost)不存在high key,因为最右节点的最大key应该是无穷大(在上图中用蓝色来标记)。high key位于节点中的第一个元素,只是用于表示节点的upper bound。所以对于非最右节点来说,节点内的第一个data key应该是high key之后的那个key(即第二个key),对于最右节点来说,第一个data key才是节点内的第一个key。PostgreSQL中对high key和firstkey有如下定义:
#define P_HIKEY ((OffsetNumber) 1) #define P_FIRSTKEY ((OffsetNumber) 2) #define P_FIRSTDATAKEY(opaque) (P_RIGHTMOST(opaque) ? P_HIKEY : P_FIRSTKEY)
注意
这里有一个值得注意的问题:为什么high key是节点内的第一个key作为而非最后一个key?这是为了防止插入时频繁移动high key。high key是节点中最大的一个key,由于节点内部需要保持key的有序性,所以如果high key放在节点最后,那么不论插入什么值,都需要后移high key。所以为了防止这样移动,将high key放在节点开始位置。由于high key只在节点分裂时产生,所以不必担心high key发生变化导致key的大小顺序发生变化。
-
叶子节点与内部节点
PostgreSQL的B+树索引中,叶子节点中的元素存放的是key+tid,tid为tuple id及元组的位置信息,和Oracle的rowid是同一个概念。内部节点中的元素存放的是下级节点的最小值(min key)+下级节点的节点编号。注意这个下级节点其实是子树的根节点。与最右节点的high key类似,最左节点的min key也是无意义的,因为这个key应该表示无穷小。
B*树并发性概述
B*树最大的优势在于并发性。对于B+树的并发控制,传统的方式叫蟹行协议(carbbing step),蟹行协议遍历B+树的流程如下:
- 给当前节点加共享锁(初始时当前节点为根节点)。
- 需要访问下级节点时,首先给下级节点加共享锁,然后释放当前节点的锁。
- 重复step1、step2,直到叶子节点。
如果是插入操作,那么需要对待插入的节点加互斥锁。如果插入导致这个节点分裂,那么需要对父节点做插入,所以需要对父节点加互斥锁。所以,如果一个查询进程正试图锁定下级节点,同时另一个插入进程试图锁定父节点,就会发生死锁,如下图所示:
B*树的遍历流程如下:
- 给当前节点加共享锁(初始时当前节点为根节点)。
- 需要访问下级节点时,首先释放当前节点的锁,然后给下级节点加共享锁。
- 重复step1、step2,直到叶子节点。
这个流程和蟹行协议唯一的区别就在于step2,由于先释放当前节点的锁,在给下级节点加共享锁,所以在遍历时,最多只有1个节点持有锁,所以当然就不会有死锁发生。但这个流程又有另外的问题:
假设现在有一个查询操作需要获取44,在查询时B+树的结构如图3所示,这是一个分裂的中间状态,即block1已经发生了分裂,但还没有来得及将节点的min key和blockno写入父节点。查询进程先对block3加共享锁,然后从block3得知,44应该在block1中,于是释放block3上的共享锁,尝试锁定block1。此时插入进程对block3加互斥锁,然后完成分裂。查询进程获取block1的共享锁后,由于block1已经发生了分裂44被迁移到了block4中。在block1中,不难发现,block1的high key为38小于44,所以我们会沿着block1与block4之间的链访问block4。注意,对于block4的访问,也是先释放block1的共享锁,再对block4加共享锁。当访问block4时,block4也可能发生分裂,导致44再次迁移到右节点,所以我们需要向右遍历叶子节点,直到找到一个high key > 44的节点。在PostgreSQL中,有一个叫_bt_moveright
的函数就是为了实现上述功能的。
B*树redo
B+的redo最难的一个问题是要保证在分裂过程中,即便发生崩溃,也不能导致索引的损坏(即出现正确性问题)。我们先来回顾下索引分裂的几个步骤:
- 获取分裂点。
- 创建一个新的节点。
- 将原始节点中一半的数据迁移到新节点中。
- 修改原始节点的high key。
- 将新节点加入链表。
- 将新节点的min key和节点编号写入父节点。
从“B*树并发性概述”中,我们不难得出一个结论,在B*树中即便对图3这样的树进行查询,也不会影响查询的正确性,只是会影响查询性能(因为不能直接通过block3定位到block4)。这意味着什么?这就以为上述步骤的第6步,不影响正确性。所以在上面6步中,前5步需要作为一个原子操作,与前5步的相关的操作都需要记录到redo log中,而第6步不需要。这句话非常重要,只保证前5步的原子性要比保证所有6步的原子性简单得多,且并发性也要高的多。这个我们在后面的文档中再来详细讨论。