sqlite3.36版本 btree实现(三)- journal文件备份机制
《sqlite3.36版本 btree实现》系列文章:
- sqlite3.36版本 btree实现(零)- 起步及概述 - codedump的网络日志
- sqlite3.36版本 btree实现(一)- 管理页面缓存 - codedump的网络日志
- sqlite3.36版本 btree实现(二)- 并发控制框架 - codedump的网络日志
- sqlite3.36版本 btree实现(三)- journal文件备份机制 - codedump的网络日志
- sqlite3.36版本 btree实现(四)- WAL的实现 - codedump的网络日志
- sqlite3.36版本 btree实现(五)- Btree的实现 - codedump的网络日志
概述
在上一节中(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。
流程
判断页面是否已经备份
启动一个写事务的时候,可能会修改多个页面,但是这其中可能有些修改,修改的是同一个页面的内容,因此这种情况下只需要对这个页面备份一次即可。
如何知道页面是否已经被备份过?页面管理器通过一个位图数据结构来保存这个信息:
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=0
和y=1
的数据。 - 页面二:保存了
z=2
的数据。
- 页面一:保存了
-
写事务执行时,依次做了如下的修改:
- 修改页面1的一处内容:
x=1
。 - 修改页面2的一处内容:
z=3
。 - 修改页面1的一处内容:
y=2
,注意这里跟第一次修改属于同一个页面的不同位置。 - 新增页面3:
p=4
。
- 修改页面1的一处内容:
那么,对照上面的流程,这四次页面修改在调用函数pager_write
时,情况是这样的:
- 修改页面1的一处内容
x=1
:由于在备份页面位图中查不到页面编号为1的页面,且页面1小于当前数据库文件的页面数量2,因此属于修改当前已有页面,于是将这个页面备份到journal文件,即将页面一的未修改之前的内容x=0,y=1
写入journal备份文件中,完事了之后将这个页面编号1加入位图,表示已经备份了这个页面的未修改之前的内容。 - 修改页面2的一处内容:类似的,也是备份了页面2的内容
z=2
,同时将页面2加入位图,表示已经备份了这个页面的未修改之前的内容。 - 修改页面1的一处内容
y=2
:这一次虽然也是要修改已有页面,但是由于在位图中找到这个页面编号,说明在这一次事务中已经备份过这个页面了,于是不再需要备份操作,直接返回。 - 新增页面3
p=4
:发现该页面的编号3,大于当前数据库页面数量2,属于新增页面,于是不进行备份,只是加入到脏页面链表中同时标记需要落盘。
即:在这一次写事务执行的过程中,虽然需要修改4处内容,实际修改备份文件两次,新增数据库页面页面一次。
这个例子前后数据库文件以及备份文件内容的对比见下图:
何时落盘
前面备份待修改页面的流程中,备份的页面内容只是写到了备份文件里,实际还并没有执行sync
操作强制落盘,只要没有落盘就还是存在备份数据损坏的情况。
在上一节的(sqlite3.36版本 btree实现(二)- 并发控制框架),备份文件内容落盘是放在第七步做的,此时对用户空间的页面内容的修改已经完成了,不清楚这一流程的可以回头再看看上一节的内容。
具体到journal文件的机制,这一步是放在函数pager_end_transaction
进行的,pager_end_transaction
函数就是上面介绍的:在事务修改完毕用户空间的页面之后,被调用。
错误恢复
继续以上面的例子来解释一下使用journal备份文件机制下的错误恢复的流程。
从上面的流程里,我们可以看到:
- journal备份文件备份的是未修改之前的页面内容,如果一个页面在一次修改中会被多次修改,也只会备份一次(如上面例子中的页面1)。
- 写事务完成之后,首先会将journal备份文件中的内容首先sync到磁盘,才开始将页面缓存中的内容落到数据库文件中。
再次来回顾一下之前sqlite3.36版本 btree实现(二)- 并发控制框架中的内容:
- 数据库文件:任何写操作的修改最终都将落到数据库文件中。
- 页面缓存:暂存每次写操作过程中修改的内容。
- journal备份文件:备份页面被修改之前的内容。
上面的例子,加上页面缓存之后如下图所示:
对应这个流程,这一次写操作只可能在以下这几个阶段中发生错误宕机,其对应的恢复机制如下:
- 写操作开始之前:这个没有太多可以说的,由于还没有开始真正的写操作,数据库文件中的内容还是完整的,且journal备份文件中没有内容,于是可以直接以数据库文件内容来启动即可。
- 写操作流程中:即写了一部分数据,还没有完成整个写事务的时候发生错误。这个场景中,之前写入的数据都在页面缓存里,备份修改页面的内容在备份文件中,而数据库文件还未发生任何改动。所以在错误重新启动的时候,页面缓存中已经没有任何内容了,然后会去校验一下备份文件,由于只写了一部分数据而已,所以备份文件是不完整即损坏的,此时备份文件的内容不能算数。于是和上面的场景一样,以数据库文件来启动即可,即这次不完整的写操作,之前写入的部分内容会被全部丢弃了。
- 写操作完成之后:这个阶段是写操作完成,修改的页面在修改之前的内容已经全部写入备份文件,但是页面缓存中的内容还没有全部落盘到数据库文件时,发生了错误崩溃。这种情况下重启,那么数据库文件可能是错乱的,因为只有部分内容落盘了,如这里的页面1,初始内容是
x=0,y=1
,完整的修改应该是x=1,y=2
,如果只修改了一部分则是x=1,y=1
。这种情况下重启时,检查到备份文件中的内容是完整的,这就会以备份文件中的内容,来覆盖数据库文件中的内容,即将数据库文件恢复到这次写事务开始之前的情况。
从这个恢复流程可以看到:使用页面备份机制,在完成写操作、但是还未完全将页面缓存的内容落盘到数据库文件之前,任何出错都会导致这个写事务的修改(不管是部分修改还是全部修改)被丢掉。
总结
本节讲解了journal文件的实现机制,从最早的sqlite btree实现时,备份页面的机制就一直使用journal机制,从这里的分析可以看到,这种机制很“朴素”,性能也并不好,所以后续在3.7版本的sqlite中引入了更优的WAL实现机制。
本节也并没有把所有journal文件实现机制都详细描述,只是把最核心的文件结构以及备份流程做了讲解,因为并不想在这个性能不高的机制上着墨更多,有兴趣的读者可以自行阅读相关代码。