概述

在上一节中(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位图数据。

备份页面的例子

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

  • 当时数据库的页面数量为100,即有100个页面。
  • 写事务执行时,依次做了如下的修改:
    • 修改页面10的一处内容。
    • 修改页面20的一处内容。
    • 修改页面10的一处内容,注意这里跟第一次修改属于同一个页面的不同位置。
    • 新增页面101。

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

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

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

何时落盘

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

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

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

总结

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

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

参考资料