B+树索引—分裂持久性
预备知识
概述
在了解了B+树的分裂流程之后,我们来讨论B+树的持久性,也就是ACID的D。与B+树持久性相关有几个非常重要的问题:
- 在B+树分裂期间,如果发生系统故障,如何确保系统重启后,B+树依然可用。
- B+树完成分离,并且相关事务已经提交。如果系统在B+树落盘之前发生故障,在系统重启后如何对分裂进行恢复。
应对这两个问题的方案就是WAL。所以我们下面来看看在B+树发生分裂时,需要将哪些信息写入XLOG,以确保系统重启后能正确的恢复B+树。
回顾:B+树的分裂流程
首先,我们来回顾下《B+树索引—分裂》中讲过的B+树的分裂流程。B+树分裂可以分为三个步骤:
- 定位分裂点
- 分裂
- 向父节点插入
这三个步骤中,定位步骤不涉及写数据的操作,所以无需XLOG。对于分裂步骤和父节点插入步骤都涉及写数据,那么当然就应该产生相关的XLOG。现在,我们来思考一个非常重要的问题:如果省略父节点插入这个步骤,分裂完成后,B+树会出现正确性问题么?这个问题,我个人认为是B+树索引中最核心的一个问题。B+树的并发控制以及备份恢复都是围绕这个问题展开的。这个问题的答案是:省略父节点插入这个步骤,B+树不会出现正确性问题。为什么?
我们以图1为例,图1中,block4是由block1分裂产生的。整个分裂过程完整的执行了节点分裂的三个步骤。那么现在,我们假设block1发生分裂时,没有执步骤3,那么分裂完成后,我们得到的B+树就应该如图2所示:
此时,如果需要查询2,那么首先会定位到block1,然后发现block1的high key < 2,于是执行move right操作,移动到block4,最终找到2。所以,不难看出,在这样的流程下图2所示的B+树索引没有任何正确性问题。只是存在性能问题,由于非叶子节点无法直接定位到block4,需要先访问blok1,从而多读取一个块。
这意味着什么?这意味着步骤2和步骤3是两个独立的步骤,只用分别保证步骤2和步骤3的原子性和持久性即可。在恢复时,如果只恢复了步骤2,而没有恢复步骤3,对B+树不会造成正确性问题。所以,我们需要重点关注分裂步骤(也就是前面的步骤2)产生的XLOG。
分裂之XLOG
我们用一个用例来阐述分裂的备份恢复流程:
block1的状态如图3所示,现在我们想block1中插入1,block1将发生分裂,分裂点位于3。经过分裂步骤之后,block1的状态应该如图4所示。
现在的场景是,insert操作成功执行并且提交,数据库在B+树落盘之前发生了崩溃,重启之后,block1的状态依然如图3所示,现在我们需要依据XLOG从图3的状态恢复到图4的状态。
分裂阶段在XLOG相关的代码在_bt_split
的line:1268~1350。依据XLOG对分裂进行恢复的函数是btree_xlog_split
。为了看起来更清晰我们把写XLOG和恢复的代码对应起来看。对于将图3恢复到图4,我们需要做三件事:
- 恢复左节点
- 恢复右节点
- 恢复节点间的关系
现在,我们分别来看看如何完成这三件事。
恢复左节点
左节点block1,是发生分裂的节点。也就是原始节点。在重启之后block1的状态有两种可能。
-
与图3的block1相同
数据库在图4 block1落盘之前就崩溃了。
-
与图4的block1相同
数据库在图4 block1落盘之后才崩溃了。
显然如果重启后block1的状态为图4,那么我们就不需要恢复(page lsn一定等于log lsn)。我们需要考虑的是如何将block1由图3恢复到图4的状态?为此我们需要知道两个信息:
- 分裂点。
- insert的数据,即new item。
有了这两个信息之后,我可以采取如下流程恢复block1:
- 分裂一个临时块tempblock。
- 将block1中分裂点的数据作为tempblock的high key(分裂点的数据就是右节点的第一条数据)。
- 将block1中分裂点之前的数据,以及new item的数据写入tempblock。
- 将tempblock中的内容拷贝到block1中。
下面,我们来看看相关代码。
构建XLOG:
//位置:nbtinsert.c line 1274
//功能:将分裂点(firstright)写入XLOG
xlrec.level = ropaque->btpo.level;
xlrec.firstright = firstright;
xlrec.newitemoff = newitemoff;
XLogRegisterData((char *) &xlrec, SizeOfBtreeSplit);
//位置:nbtinsert.c line 1298
//功能:将new item写入XLOG
/*
* Log the new item, if it was inserted on the left page. (If it was
* put on the right page, we don't need to explicitly WAL log it
* because it's included with all the other items on the right page.)
* Show the new item as belonging to the left page buffer, so that it
* is not stored if XLogInsert decides it needs a full-page image of
* the left page. We store the offset anyway, though, to support
* archive compression of these records.
*/
if (newitemonleft)
XLogRegisterBufData(0, (char *) newitem, MAXALIGN(newitemsz));
恢复:
//位置:nbtxlog.c line 299
//功能:恢复左节点
//向tempblock写入high key(newlpage就是tempblock)
leftoff = P_HIKEY;
if (PageAddItem(newlpage, left_hikey, left_hikeysz,
P_HIKEY, false, false) == InvalidOffsetNumber)
elog(PANIC, "failed to add high key to left page after split");
leftoff = OffsetNumberNext(leftoff);
//将block1中分裂点之前的item以及new item写入tempblock
for (off = P_FIRSTDATAKEY(lopaque); off < xlrec->firstright; off++)
{
ItemId itemid;
Size itemsz;
Item item;
/* add the new item if it was inserted on left page
* 写new item
*/
if (onleft && off == xlrec->newitemoff)
{
if (PageAddItem(newlpage, newitem, newitemsz, leftoff,
false, false) == InvalidOffsetNumber)
elog(ERROR, "failed to add new item to left page after split");
leftoff = OffsetNumberNext(leftoff);
}
itemid = PageGetItemId(lpage, off);
itemsz = ItemIdGetLength(itemid);
item = PageGetItem(lpage, itemid);
if (PageAddItem(newlpage, item, itemsz, leftoff,
false, false) == InvalidOffsetNumber)
elog(ERROR, "failed to add old item to left page after split");
leftoff = OffsetNumberNext(leftoff);
}
/* cope with possibility that newitem goes at the end
* 说明new item为左节点的左后一条item
*/
if (onleft && off == xlrec->newitemoff)
{
if (PageAddItem(newlpage, newitem, newitemsz, leftoff,
false, false) == InvalidOffsetNumber)
elog(ERROR, "failed to add new item to left page after split");
leftoff = OffsetNumberNext(leftoff);
}
//将tempblock中的内容拷贝到block1中
PageRestoreTempPage(newlpage, lpage);
不难看出,为了恢复左节点,我只需要将非常少量的信息写入XLOG。原因在于原始节点本身具备恢复锁需要的全部数据。
恢复右节点
右节点是分裂时新创建的一个节点。所以在分裂时我们需要将右节点的信息(比如:节点编号),写入XLOG。以便在恢复时创建右节点。在重启后,右节点的状态依然有两种可能:
- 没有右节点
- 与图4的block2相同
显然如果是第二种情况,我们也不用恢复右节点了。我们需要思考的是如何从无到有将block2创建出来。现在思考一个问题:我们是否可以按照恢复左节点的方式来恢复右节点。即直接将图3中block1中分裂点及分裂点之后的数据写入block2。这样XLOG中只要记录了分离点即可。然而这样做是行不通的,因为重启后的block1有两种状态,如果重启后block1为图3的状态,就可以采用这种方式。但如果重启后的block1为图4状态,那么压根就不存在分裂点右边的数据。所以要恢复右节点需要如下信息:
- 原始右节点的节点信息
- 原始右节点的数据
其实就相当于将右节点做备份区块。然而,为了减小XLOG的体量,PostgreSQL对于这部分XLOG做了一些精细处理。我们来看看如何精细。
索引结构
要理解右节点备份恢复的精细之处,首先需要了解索引节点的内部结构,假设我们按照如下顺序向block1中写入1~5:insert 1、insert 4、insert 3、insert 2、insert 5
。那么block1的内部结构如下图5所示(假设block1为最左节点,没有high key):
其中:
-
块头
即块的头部记录块的基本信息
-
item区
item是一个定长的数据,最重要的作用就是用于指向indextuple,item区的item是按照indextuple的大小升序排列。
-
空闲区
空闲空间
-
indextuple区
存放实际的indextuple,indextuple是按照插入顺序逆序存放。因为indextuple是从后向前写入,item是从前向后写入。
-
special区
存放当前节点的左、右兄弟。
其实索引节点的结构与《基础模块—表和元组组织方式》中讲到到的数据块的结构几乎一致,只有两个区别:
- 索引节点有special区,数据块没有(因为数据块没有左右兄弟一说)。
- 索引节点的item区是有序的,数据块的Item区是无序的。
由于item区是有序的,所以B+树的二分法是在item区进行的,插入位置、分裂点也都是指item区的位置。
分裂流程
我们再来看看分裂流程。假设我们向图5中block1插入元素2。节点发生分裂时,首先确定分裂点,假设分裂点如图6所示:
在分裂期间,PostgreSQL遵循如下步骤:
- 分配两个节点,一个作为分裂后的右节点,一个作为临时节点用于存放分裂后的左节点。
- 向临时节点插入high key(临时节点的high key就是分裂点的那个元素,也是右节点的第一个元素)。
- 遍历原始节点block1,将分裂点之前的元素依次迁移到临时节点,由于当前插入的new item为2,显然也应该写入临时节点。
- 将分裂点即分裂点之后的元素依次迁移到右节点(右节点为最右节点不需要high key)。
- 将临时节点和右节点串链。
- 将临时节点的内容memcpy到左节点。
分裂完成之后,分裂后的两个节点情况应该如图7所示:
右节点的备份与恢复
仔细观察图7,经过分裂之后,左右两个节点中的indextuple被重新整理过了。现在item区的item和indextuple区的indextuple成逆序关系(item:3、1、2、2,indextuple:2、2、1、3)。这个结论非重要,前面说了那么多都是为了得到这个结论。这意味着什么?这意味着只要有indextuple我们就可以得到item区的顺序,从而重构出item区。如此,我们在写XLOG的时候,不必备份整个右节点,只需要备份右节点的indextuple区。代码如下:
构建XLOG:
//位置:nbtinsert.c line 1328
//功能:有节点的indextuple区写入XLOG
/*
* Log the contents of the right page in the format understood by
* _bt_restore_page(). We set lastrdata->buffer to InvalidBuffer,
* because we're going to recreate the whole page anyway, so it should
* never be stored by XLogInsert.
*
* Direct access to page is not good but faster - we should implement
* some new func in page API. Note we only store the tuples
* themselves, knowing that they were inserted in item-number order
* and so the item pointers can be reconstructed. See comments for
* _bt_restore_page().
*/
XLogRegisterBufData(1,
(char *) rightpage + ((PageHeader) rightpage)->pd_upper,
((PageHeader) rightpage)->pd_special - ((PageHeader) rightpage)->pd_upper);
恢复:
//位置:nbtxlog.c line 34
//功能:恢复右节点
static void
_bt_restore_page(Page page, char *from, int len)
{
IndexTupleData itupdata;
Size itemsz;
char *end = from + len;
Item items[MaxIndexTuplesPerPage];
uint16 itemsizes[MaxIndexTuplesPerPage];
int i;
int nitems;
/*
* To get the items back in the original order, we add them to the page in
* reverse. To figure out where one tuple ends and another begins, we
* have to scan them in forward order first.
*
* indextuple区的的indextuple与item区的item区是逆序,所以这里拷贝到数组里面方便后面逆序遍历
* 这里使用数组会比使用栈更快
*/
i = 0;
while (from < end)
{
/* Need to copy tuple header due to alignment considerations */
memcpy(&itupdata, from, sizeof(IndexTupleData));
itemsz = IndexTupleDSize(itupdata);
itemsz = MAXALIGN(itemsz);
items[i] = (Item) from;
itemsizes[i] = itemsz;
i++;
from += itemsz;
}
nitems = i;
/* 逆序遍历indextuple数组,做插入操作 */
for (i = nitems - 1; i >= 0; i--)
{
if (PageAddItem(page, items[i], itemsizes[i], nitems - i,
false, false) == InvalidOffsetNumber)
elog(PANIC, "_bt_restore_page: cannot add item to page");
from += itemsz;
}
}
恢复节点间关系
恢复节点间关系就比较简单了,这里直接上代码:
构建XLOG:
//位置:nbtinsert.c line 1281
//功能:将原始节点、原始节点的右节点、分裂后的右节点,这三个节点的节点信息写入XLOG
XLogRegisterBuffer(0, buf, REGBUF_STANDARD);
XLogRegisterBuffer(1, rbuf, REGBUF_WILL_INIT);
/* Log the right sibling, because we've changed its prev-pointer. */
if (!P_RIGHTMOST(ropaque))
XLogRegisterBuffer(2, sbuf, REGBUF_STANDARD);
恢复:
//位置:nbtxlog.c line 211
//功能:从XLOG中获取原始节点、原始节点的右节点、分裂后的右节点的节点信息。
XLogRecGetBlockTag(record, 0, NULL, NULL, &leftsib);
XLogRecGetBlockTag(record, 1, NULL, NULL, &rightsib);
if (!XLogRecGetBlockTag(record, 2, NULL, NULL, &rnext))
rnext = P_NONE;
遗留问题
对于非叶子节点分裂的特殊处理流程目前还不太清楚是怎么回事。比如下面两段代码的意义:
//位置:nbtinsert.c line 1302
/* Log left page */
if (!isleaf)
{
/*
* We must also log the left page's high key, because the right
* page's leftmost key is suppressed on non-leaf levels. Show it
* as belonging to the left page buffer, so that it is not stored
* if XLogInsert decides it needs a full-page image of the left
* page.
*/
itemid = PageGetItemId(origpage, P_HIKEY);
item = (IndexTuple) PageGetItem(origpage, itemid);
XLogRegisterBufData(0, (char *) item, MAXALIGN(IndexTupleSize(item)));
}