0%

WAL Mode

在版本为release 3.7.0的版本上,SQLite介绍了一种新的日志机制,叫WAL日志。这个日志模式与传统回滚日志模式二选一。可以通过pragma journal_mode=WAL指令来开启这个模式。当数据库在WAL模式下(在Pager文章内提到,日志文件的头偏移量处于18位置的值是2),SQLite就会完全使用WAL日志了。在wal日志模式下,日志文件的名字和数据库的名字一样,后面紧跟’-wal’后缀,并且会与原始数据库文件存在于同一个目录下。

在当前日志模式下,日志活动和传统日志的活动非常相似。写事务会在修改页面之前,将页面镜像放在日志记录中存储在日志内。(但是页面镜像是被更新或者新增后的新版本,俗称重做镜像。)当事务提交的时候,一个提交记录就会添加在wal日志文件的尾部。事务可能不会在数据库文件上请求独占锁,并且可能不会写入文件。这就避免了写事务阻塞了并发的读事务。读事务也不会阻塞写事务。新的写事务就持续将日志添加到wal日志文件中。

wal日志文件的前32个字节表述了这个文件的格式。下表展示了wal日志文件头部的格式。它们都是4字节的无符号大端整型。这两个魔数确定了二选一的校验和算法用于计算每个日志帧的校验和。

偏移 大小 描述
0 4 魔数:0x377f0682(小端整型) 或 0x377f0683(大端整型)
4 4 文件格式版本
8 4 数据库页面大小
12 4 检查点的序号值
16 4 盐-1: 随机整数,每个检查点都递增
20 4 盐-2: 诶个检查点都不同的随机整数
24 4 校验和-1: 文件头24字节校验和的第一部分
28 4 校验和-2: 文件头24字节校验和的第二部分

在wal日志文件头后面,跟随着若干个日志帧。每一个日志帧由一个24字节的日志帧头开头,后面跟随着被日志记录的页面的内容。新的日志帧永远都会是被添加到日志文件的尾部。下面的表格展示了wal帧头部的格式。它们也都是4字节的无符号大端整型。

偏移 大小 描述
0 4 当前被日志记录的页面的页面号
4 4 对于提交记录来说,这个字段是提交后数据库文件的页面数。
对其他记录来说,都为0
8 4 盐-1 同WAL头
12 4 盐-2 同WAL头
16 4 校验和-1:当前帧的前8个字节和页面镜像内容的累计校验和的前第一部分
20 4 校验和-2:当前帧的前8个字节和页面镜像内容的累计校验和的前第二部分

wal日志的结构对读数据库会有一点影响。当pager开启一个新的读事务时,它会为这个事务记录在WAL日志中的最后的有效提交记录(即一个日志帧)的索引号。事务将使用此标记的帧作为所有后续读取操作的哨点。假设,pager请求在事务中读取页面p。它首先会在WAL日志文件中查找,看看在标记的页面帧之上或者之前是否存在这个页面p。在这种情况之下,标记帧之上的或者在标记帧之前的最后一个有效帧将会被读取使用。否则,pager就会从数据库文件中读取这个页面。并发的写事务确实可以将新的日志记录追加到日志文件的尾部,但是只要读事务忽略在标记帧之后追加的内容,那么它在某个时间点,就能看到数据库的一个具有一致性的快照。这个技术可以允许存在并发的读事务,看到数据库的不同版本。因为页面p的日志帧可以出现在wal日志文件的任何地方,所以pager需要从开头开始扫描查找这个页面p的日志帧,一直到那个哨兵帧。当事务开始的时候,日志文件已经很大了,那么这个扫描就会非常耗时。SQLite维护了一个单独的数据结构,名为wal-index来加快检索。它就是基于哈希索引来将一个页面号p映射到一个合适的日志帧内。

wal-index通过共享内存或者内存映射文件来实现。被映射的文件和数据库文件在同一个目录下,并且在文件名后追加了’-shm’。(由于使用了共享内存和内存映射文件,因此当数据库文件在一个网络文件系统上,并且用户是位于不同的机器上时,wal日志模式不可使用。因为分布式客户端无法共享内存。)当与数据库的最后一个连接关闭时,SQLite会截断或使wal-index文件无效。这意味着wal-index文件是一个临时文件。崩溃恢复后,将从wal日志文件中重建wal-index文件。

不像回滚日志的架构,wal日志的架构需要检查点来控制日志文件的大小。应用无需手动执行检查点,但是如果需要的话,也可以关闭自动检查点,从而可以在空闲时间或者其他线程或者其他进程内执行检查点。当wal日志文件的达到阈值为1000个页面的时候,SQLite会自动执行检查点。(可以通过在编译期设置SQLITE_DEFAULT_WAL_AUTOCHECKPOINT宏来设置不同的阈值。)检查点操作按有序执行,并且独占数据库。每次检查点的调用执行期间,SQLite按照如下次序执行:

  • 首先,flush刷新写入wal日志文件。
  • 第二,将有效的页面内容转移到数据库文件中去。
  • 第三,flush刷新写入到数据库文件中(仅当整个wal日志都被拷贝进数据库文件了)。
  • 第四,wal文件头部的盐-1值递增,盐-2值重随机。(使wal日志文件中的所有日志无效化。)
  • 第五,更新wal-index

检查点操作可以与读事务并发执行。在这种情况下,检查点操作会在任何一个读事务的wal标记帧前停止操作。因为再执行下去,就有可能会影响到其中一个读事务正在读取的页面。检查点会记住它已经执行到的地方;下一次执行的时候,从那个地方再次继续。当整个wal日志都被检查点操作执行完毕的时候,日志就会被倒回以防止日志文件的无限制增长。因此一个长时间的读事务可能会导致检查点长时间无法执行,但是事务终归会终止,而检查点也终归会继续执行。

相比于两次flush日志文件来说(对于普通的journal日志来说,第一次需要flush页面镜像,第二次需要flushnRec这个值,因为如果说一个扇区的写入可能是原子的,那么两个扇区的写入必然不是原子的),WAL的写操作速度会变得非常快,因为它只有一次写和flush。如果应用愿意牺牲在系统掉电情况下数据库的耐久性,那么flush日志内容到磁盘也不是必须的。(可以通过设置PRAGMA synchronous=FULL来让每一个写事务都将日志flush到磁盘,设置PRAGMA synchronous=NORMAL来取消这个强制flush。)

应用可以通过sqlite3_wal_hook()方法在任何一个事务即将提交给WAL的时候,回调hook的方法。回调方法中可以根据自己的需要来决定何时调用sqlite3_wal_checkpoint()sqlite3_wal_checkpoint_v2() 来执行检查点。自动检查点就是通过sqlite3_wal_hook()做了一个简单实现。checkpoint一共有三种子类型,分别是PASSIVE,FULL,RESTART。通过sqlite3_wal_checkpoint()启动的检查点以及自动检查点都是属于PASSIVE类型,这也是默认的类型。这种子类型的检查点会因为数据库内并发存在读写事务而导致检查点的终止。FULL和RESTART类型均会尽最大能力去完成检查点的执行,这些子类型会通过调用sqlite3_wal_checkpoint_v2()函数来完成检查点。

**校验和算法:**校验和的算法会将输入值划分为偶数个4字节的字节流。换句话说,校验和的输入值必须是8字节的整数倍。假设输入为x(0)-x(N),每个x为4字节,N为奇数,算法如下:

1
2
3
4
5
6
s0 = s1 = 0
for i from 0 to n-1 step 2:
s0 += x(i) + s1;
s1 += x(i+1) + s0;
end for
//result in s0 and s1

输出s0和s1均为加权校验和,使用斐波纳契权重以相反的顺序进行。(最大斐波那契权重出现在求和序列的第一个元素上。)s1值覆盖序列的所有32位整数项,而s0省略最后一项。

此功能有一些优点和缺点。

优点是:

  1. 减少数据库文件上的flush刷新。
  2. 提高读写并发。
  3. 事务处理在大部分场景下更快。

缺点是:

  1. 操作系统需要支持内存文件映射。
  2. 数据库文件等需要在本地机器上。
  3. 数据库文件不得挂载在NFS上。
  4. 数据库事务尽管在单个数据库中是原子的,但是跨数据库事务可能不是原子的。
  5. 事务回滚会更慢。
  6. 需要两个文件来支持(-wal,-shm)
  7. 检查点的存在。