sqlite3.36版本 btree实现(三)- journal文件备份机制

2021-12-22
9分钟阅读时长

《sqlite3.36版本 btree实现》系列文章:

概述

在上一节中(sqlite3.36版本 btree实现(二)- 并发控制框架),已经讲解了sqlite中的并发控制机制,里面会涉及到一个“备份页面”的模块:

  • 备份所有在一个事务中会修改到的页面。
  • 出错时回滚页面内容。

里面也提到,有两种备份文件的机制:journal文件,以及WAL文件。今天首先讲解journal文件的实现,它的效率会更低一些,也正是因为这个原因后续推出了更优的WAL机制。

相关命令

sqlite中,可以使用PRAGMA journal_mode来修改备份文件机制,包括以下几种:

  • delete:默认模式。在该模式下,在事务结束时,备份文件将被删除。
  • truncate:日志文件被截断为零字节长度。
  • persist:日志文件被留在原地,但头部被重写,表明日志不再有效。
  • memory:日志记录保留在内存中,而不是磁盘上。
  • off:不保留任何备份记录。
  • wal:采用wal形式的备份文件。

其中,前面三种delete、truncate、persist都是使用journal文件来实现的备份,区别在于事务结束之后的对备份文件的处理罢了。

本节首先讲解journal文件,下一节讲解wal备份文件。

journal文件格式

journal文件的文件名规则是:与同目录的数据库文件同名,但是多了字符串“-journal”为后缀。比如数据库文件是“test.db”,那么对应的journal文件名为“test.db-journal”。

文件头

偏移量 大小 描述
0 8 文件头的magic number: 0xd9, 0xd5, 0x05, 0xf9, 0x20, 0xa1, 0x63, 0xd7
8 4 journal文件中的页面数量,如果为-1表示一直到journal文件尾
12 4 每次计算校验值时算出来的随机数
16 4 在开始备份前数据库文件的页面数量
20 4 磁盘扇区大小
24 4 journal文件中的页面大小

这里大部分的字段都自解释了,不必多做解释,唯一需要注意的是随机数,因为这是用来后续校验备份页面的字段,这将在后面结合流程来说明。

页面内容

紧跟着文件头之后,journal文件还有一系列页面数据组成的内容,其中每部分的结构如下:

偏移量 大小 描述
0 4 页面编号
4 N 备份的页面内容,N以页面大小为准,其中每页面大小在文件头中定义
N+4 4 页面的校验值

由上面分析可见,整个journal文件是这样来组织的:

  • 28字节的文件头。
  • 页面数据组成的数组,其中数组每个元素的大小为:4+页面大小(N)+4。

journal文件结构

流程

判断页面是否已经备份

启动一个写事务的时候,可能会修改多个页面,但是这其中可能有些修改,修改的是同一个页面的内容,因此这种情况下只需要对这个页面备份一次即可。

如何知道页面是否已经被备份过?页面管理器通过一个位图数据结构来保存这个信息:

Bitvec *pInJournal;         /* One bit for each page in the database file */

计算页面校验值

计算一个页面校验码的流程在函数pager_cksum中实现,其核心逻辑是:

  • 以随机算出的校验值为初始值,这个初始值就是存在journal文件头中偏移量为[12,16]的数据。
  • 从后往前遍历页面数据,每隔200字节取一个u32类型的值,累加起来。

有了这样的关联,进行数据恢复时就能马上通过文件头存储的随机数,计算出来页面的数据是否准确。

static u32 pager_cksum(Pager *pPager, const u8 *aData){
  u32 cksum = pPager->cksumInit;         /* Checksum value to return */
  int i = pPager->pageSize-200;          /* Loop counter */
  // 每隔200字节算一个值累加起来
  while( i>0 ){
    cksum += aData[i];
    i -= 200;
  }
  return cksum;
}

备份页面

有了前面计算校验值、以位图来判断页面是否已经备份过的了解,现在开始将备份页面的流程。

每一次需要修改一个页面之前,都会调用函数pager_write,这样就能在修改之前首先备份这个页面的内容。

要区分两种不同的页面:

  • 如果页面编号比当前数据库文件的页面数量小,说明是已有页面,需要走备份页面的流程。
  • 否则,说明是新增页面,新增的页面不需要备份,只需要修改该页面的标志位是需要落盘(PGHDR_NEED_SYNC),并且放入脏页面链表即可。

第二种情况是新增页面,没有备份的需求,这里就不做解释。

这里具体解释第一种情况,即备份已有页面的流程,其主要逻辑如下:

  • 首先根据前面的pInJournal位图数据,传入页面编号,判断这个页面是否备份过,如果已经备份过,不做任何操作。
  • 否则说明需要备份页面,将进入函数pagerAddPageToRollbackJournal中将该页面内容备份写入journal文件:
    • 调用前面提到的pager_cksum函数,计算页面的校验值。
    • 按照上面解释的journal文件格式,依次写入页面编号、页面内容、第一步计算出来的校验值。
    • 由于备份了页面,所以要把这个新增的备份页面编号写入pInJournal位图数据。

备份页面的例子

我们以一个例子来说明备份页面的流程,假设写事务执行时,情况如下:

  • 当时数据库的页面数量为2,即有2个页面,其中页面的内容如下:

    • 页面一:保存了x=0y=1的数据。
    • 页面二:保存了z=2的数据。
  • 写事务执行时,依次做了如下的修改:

    • 修改页面1的一处内容:x=1
    • 修改页面2的一处内容:z=3
    • 修改页面1的一处内容:y=2,注意这里跟第一次修改属于同一个页面的不同位置。
    • 新增页面3:p=4

那么,对照上面的流程,这四次页面修改在调用函数pager_write时,情况是这样的:

  • 修改页面1的一处内容x=1:由于在备份页面位图中查不到页面编号为1的页面,且页面1小于当前数据库文件的页面数量2,因此属于修改当前已有页面,于是将这个页面备份到journal文件,即将页面一的未修改之前的内容x=0,y=1写入journal备份文件中,完事了之后将这个页面编号1加入位图,表示已经备份了这个页面的未修改之前的内容。
  • 修改页面2的一处内容:类似的,也是备份了页面2的内容z=2,同时将页面2加入位图,表示已经备份了这个页面的未修改之前的内容。
  • 修改页面1的一处内容y=2:这一次虽然也是要修改已有页面,但是由于在位图中找到这个页面编号,说明在这一次事务中已经备份过这个页面了,于是不再需要备份操作,直接返回。
  • 新增页面3p=4:发现该页面的编号3,大于当前数据库页面数量2,属于新增页面,于是不进行备份,只是加入到脏页面链表中同时标记需要落盘。

即:在这一次写事务执行的过程中,虽然需要修改4处内容,实际修改备份文件两次,新增数据库页面页面一次。

这个例子前后数据库文件以及备份文件内容的对比见下图:

journal备份页面例子

何时落盘

前面备份待修改页面的流程中,备份的页面内容只是写到了备份文件里,实际还并没有执行sync操作强制落盘,只要没有落盘就还是存在备份数据损坏的情况。

在上一节的(sqlite3.36版本 btree实现(二)- 并发控制框架),备份文件内容落盘是放在第七步做的,此时对用户空间的页面内容的修改已经完成了,不清楚这一流程的可以回头再看看上一节的内容。

具体到journal文件的机制,这一步是放在函数pager_end_transaction进行的,pager_end_transaction函数就是上面介绍的:在事务修改完毕用户空间的页面之后,被调用。

错误恢复

继续以上面的例子来解释一下使用journal备份文件机制下的错误恢复的流程。

从上面的流程里,我们可以看到:

  • journal备份文件备份的是未修改之前的页面内容,如果一个页面在一次修改中会被多次修改,也只会备份一次(如上面例子中的页面1)。
  • 写事务完成之后,首先会将journal备份文件中的内容首先sync到磁盘,才开始将页面缓存中的内容落到数据库文件中。

再次来回顾一下之前sqlite3.36版本 btree实现(二)- 并发控制框架中的内容:

  • 数据库文件:任何写操作的修改最终都将落到数据库文件中。
  • 页面缓存:暂存每次写操作过程中修改的内容。
  • journal备份文件:备份页面被修改之前的内容。

上面的例子,加上页面缓存之后如下图所示:

journal恢复流程例子

对应这个流程,这一次写操作只可能在以下这几个阶段中发生错误宕机,其对应的恢复机制如下:

  • 写操作开始之前:这个没有太多可以说的,由于还没有开始真正的写操作,数据库文件中的内容还是完整的,且journal备份文件中没有内容,于是可以直接以数据库文件内容来启动即可。
  • 写操作流程中:即写了一部分数据,还没有完成整个写事务的时候发生错误。这个场景中,之前写入的数据都在页面缓存里,备份修改页面的内容在备份文件中,而数据库文件还未发生任何改动。所以在错误重新启动的时候,页面缓存中已经没有任何内容了,然后会去校验一下备份文件,由于只写了一部分数据而已,所以备份文件是不完整即损坏的,此时备份文件的内容不能算数。于是和上面的场景一样,以数据库文件来启动即可,即这次不完整的写操作,之前写入的部分内容会被全部丢弃了。
  • 写操作完成之后:这个阶段是写操作完成,修改的页面在修改之前的内容已经全部写入备份文件,但是页面缓存中的内容还没有全部落盘到数据库文件时,发生了错误崩溃。这种情况下重启,那么数据库文件可能是错乱的,因为只有部分内容落盘了,如这里的页面1,初始内容是x=0,y=1,完整的修改应该是x=1,y=2,如果只修改了一部分则是x=1,y=1。这种情况下重启时,检查到备份文件中的内容是完整的,这就会以备份文件中的内容,来覆盖数据库文件中的内容,即将数据库文件恢复到这次写事务开始之前的情况。

从这个恢复流程可以看到:使用页面备份机制,在完成写操作、但是还未完全将页面缓存的内容落盘到数据库文件之前,任何出错都会导致这个写事务的修改(不管是部分修改还是全部修改)被丢掉。

总结

本节讲解了journal文件的实现机制,从最早的sqlite btree实现时,备份页面的机制就一直使用journal机制,从这里的分析可以看到,这种机制很“朴素”,性能也并不好,所以后续在3.7版本的sqlite中引入了更优的WAL实现机制。

本节也并没有把所有journal文件实现机制都详细描述,只是把最核心的文件结构以及备份流程做了讲解,因为并不想在这个性能不高的机制上着墨更多,有兴趣的读者可以自行阅读相关代码。

参考资料