Pager模块作为作为一个事务管理器,需要保证事务提交的原子性,即写入数据要么完整写入,要么根本就没有写入,而不会出现只写到一半的情况。
但是磁盘的读写操作却不是原子的,有可能出现写到一半程序崩溃或断电而中断的情况。为此SQLite引入了日志回滚的机制保证事务的原子性。日志记录了事务在更新数据库文件之前的数据,如果此时发生异常中断,在下一次连接时会把日志里记录的原始数据还原回数据库文件。
关于SQLite中原子提交更详细的内容参考官方的文档:
http://www.sqlite.org/atomiccommit.html
下面这篇是别人的翻译
https://blog.csdn.net/javensun/article/details/8515690
1. 日志格式
日志文件的基本格式如下:
可以看到日志文件由多个Log segment组成,每个Log segment又由Segment header和Log record组成。Log record记录了数据库原始数据的页面,Segment header记录了每一个Log segment的表头信息,其格式如下:
其中Magic number为SQLite自己定义的8字节数字,分别为0xD9, 0xD5, 0x05, 0xF9, 0x20, 0xAl, 0x63, 0xD7,用来标记日志文件是否有效。
number of records(nRec)用来记录每个Log segment内的页数,初始化为0。如果nRec为-1,表示日志文件只有单一的Log segment,记录数由文件长度计算得到。
Random number是用来计算每个记录页面的校验和。
Sector size为磁盘扇区大小,一般是512字节。
Page size为页面大小,一般是4096字节。
总的来说Segment header的长度是512字节,为一个扇区的大小,其中只有28字节是有效的。
Log record的格式如下:
开头4字节是页面序号,中间是4096字节的数据页,最后4字节是校验和
2. 数据结构
变量:
Ÿ pPager->nRec:
每个Log segment的记录数
Ÿ pPager->journalHdr:
指向Segment header的首地址,必须是512的整数倍
Ÿ pPager->journalOff:
指向当前记录的首地址,每备份一页数据,该值增大一个页面的长度(4096)
Ÿ pPager->pageSize:
页面长度,一般为4096
配置:
Ÿ SQLITE_IOCAP_SAFE_APPEND
如果sqlite3OsDeviceCharacteristics()读到设备信息包含该值,说明操作系统
只有在数据已经写入磁盘的时候才变更文件的长度。
Ÿ SQLITE_IOCAP_SEQUENTIAL
如果sqlite3OsDeviceCharacteristics()读到设备信息包含该值,说明操作系统写数据到硬盘严格按照提交的先后顺序。
Ÿ PAGER_JOURNALMODE_DELETE
如果pPager->journalMode配置成该模式表示结束事务后删除日志
Ÿ PAGER_JOURNALMODE_TRUNCATE
如果pPager->journalMode配置成该模式表示结束事务后不删除日志,只是把日志文件的长度设为0
Ÿ PAGER_JOURNALMODE_PERSIST
如果pPager->journalMode配置成该模式,不删除文件也不修改长度,只是把日志头清0
3. 备份数据
3.1 写日志
日志文件作为数据库原始数据的备份,数据库写事务出现异常终止时,通过日志文件可以把数据库恢复到事务开始时的状态。
在数据备份时要先获得Reserverd锁,通过pager_open_journal()函数打开一个日志文件,如果没有创建一个新的,由writeJournalHdr()向日志头部写入数据,如果设备有SQLITE_IOCAP_SAFE_APPEND特性,nRec字段设为-1,否则该字段初始化为0。
接下来通过pagerAddPageToRollbackJournal()函数向日志添加页面,每添加一页数据修改文件偏移地址。
pPager->journalOff += 8 + pPager->pageSize;
3.2 日志刷盘
当把数据页写入到日志后还需要写记录数(nRec)和刷盘(flush),刷盘是为了确保在操作系统缓存里的数据写入到硬盘。这里总共需要2次刷盘,一次是对数据页刷盘,一次是对日志头刷盘。刷盘之前一定要先加独占锁,刷盘完成之后就可以把更新的数据写入到数据库文件了。
如果操作系统写文件有安全追加特性(SQLITE_IOCAP_SAFE_APPEND),那么无需写记录数,也无需再对日志头刷盘。
如果操作系统写文件有SQLITE_IOCAP_SEQUENTIAL特性,那么无需刷盘,因为写数据到硬盘是严格按照程序的写数据接口的调用顺序。
如果上一次事务的日志模式为PERSIST时,并不删除日志文件,日志内容还残留着,在刷盘之前要检查一下接下来的地址是否是日志头,如果正好是日志头将其无效化,从而在断电恢复时避免将这些残留的垃圾数据回滚。
i64 iNextHdrOffset;
u8 aMagic[8];
u8 zHeader[sizeof(aJournalMagic)+4];
memcpy(zHeader, aJournalMagic, sizeof(aJournalMagic));
put32bits(&zHeader[sizeof(aJournalMagic)], pPager->nRec);
iNextHdrOffset = journalHdrOffset(pPager);
rc = sqlite3OsRead(pPager->jfd, aMagic, 8, iNextHdrOffset);
if( rc==SQLITE_OK && 0==memcmp(aMagic, aJournalMagic, 8) ){
static const u8 zerobyte = 0;
rc = sqlite3OsWrite(pPager->jfd, &zerobyte, 1, iNextHdrOffset);
}
if( rc!=SQLITE_OK && rc!=SQLITE_IOERR_SHORT_READ ){
return rc;
}
在刷盘之后要调用writeJournalHdr()写下一个日志块头部,并更新日志头指针:
pPager->journalHdr = pPager->journalOff = journalHdrOffset(pPager);
如果文件系统是安全追加特性则省去上一个步骤,此时一个日志文件只有一个日志头。
4. 数据恢复
在事务异常中断后,在下一次开始读事务时会对日志回滚,从而把数据库文件恢复为上次事务开始前的状态。
在读事务开始之前要先判断日志是否为热日志,如果是热日志,说明数据库需要回滚。Pager模块根据能不能获取Reserved锁来判断是否为热日志,如果可以获取Reserved锁,上一次写数据时系统崩溃,数据库可能遭到破坏,需要回滚日志。
回滚时首先通过readJournalHdr()函数读取日志头,由于日志在持久模式下,并不删除日志,所以在回滚前还要对日志头的Magic number的值,Sector size和Page size的范围做判断,从而确定日志文件的有效性。
回滚时还需要知道记录数nRec的值,一般情况下从日志头获取,如果是安全追加模式,则根据文件的长度计算得到。
代码简要描述如下:
while( 1 ){
……
rc = readJournalHdr(pPager, isHot, szJ, &nRec, &mxPg);
获取nRec的值
……
for(u=0; u<nRec; u++){
……
//回滚一个记录,并更改pPager->journalOff的值
rc = pager_playback_one_page(pPager,&pPager->journalOff,0,1,0);
……
}
}
5. 相关问题
问题1:为什么写日志时需要2次flush?
答:在这里SQLite假定大部分操作系统是往文件中追加数据时是先增加文件的长度再写入数据,那么如果在写入长度后还没写数据前系统崩溃,此时日志头已经写入文件,并且是有效的,但是还没有写入的数据区域变成了垃圾数据区域,下一次日志回滚时会把这部分区域也回写到数据库文件造成数据库损坏。
当把记录和日志头分2次flush时,flush日志头时已经确保记录数据已经全部刷新到硬盘里,如果flush记录中途失败,此时还没写入日志头,回滚时判断日志头非法,所以此时不会回滚。
见官方原子提交文档6.2节
问题2:为什么一个日志文件内会有多个Log segment?
答:如果要修改的文件很大,那么占用的SQLite页缓存也很大,超过限制后SQLite会先把当前缓存里的数据写入到数据库,当再接下来写数据时,就会往日志文件后面再添加一个Log segment。
见官方原子提交文档6.3节
问题3:为什么在APPEND模式就不用2次flush日志了?
答:这个模式保证操作系统写入数据一定先于数据变长,也就没有问题1说到的问题。而且此时不需要日志头中记录nRec的值,只需根据文件长度算出即可。假如写到一半系统崩溃,文件长度没有增加,那么想当于没有写。
见官方原子提交文档7.5节
问题4:TRUNCATE和PERSIST模式相对DELETE模式有什么优势?
答:在许多系统中删除文件时一个昂贵的操作,这2种模式都不必删除文件,能带来性能的提升。PERSIST模式的缺点是事务提交很久后,日志文件还会留在磁盘上,占用磁盘空间,导致磁盘目录杂乱,而TRUNCATE会把文件长度置为0,不占用磁盘空间。在事务中写日志时,TRUNCATE模式会比PERSIST慢一点,TRUNCATE模式是在文件后追加内容,而PERSIST模式只是重写文件,而重写已存在的内容比在文件尾追加新内容要快。
见官方原子提交文档7.6节
问题5:考虑PERSIST模式和APPEND模式同时存在,此时下一次事务开始时只是重写上一次日志文件,如果程序崩溃,可能出现实际文件长度要大于有效数据的长度,而APPEND模式是根据文件长度算记录数,所以回滚时会出现把有效数据后面的内容也还原到数据库(也就是上次修改之前的数据),而有效数据是本次修改之前的数据,这样不会有问题吗?
答:暂时没想明白,程序里也没有发现有什么特殊处理