SQLite Write-ahead Logging
1. 概述
默认模式下SQLite使用回滚日志来实现原子提交和回滚. 从3.7.0版本开始, 可以使用"预写式日志"(WAL)选项.
比起回滚日志, WAL有一些好处:
- 在大多数情况下WAL速度更快
- 提供并发性, 读和写不会互相阻塞
- IO操作变得更有序
- 更少的使用fsync(), 这样当fsync()不能正常使用时可避免一些错误
WAL也有一些缺点:
- 虚拟文件系统(VFS)必须支持共享内存技术, UNIX和Windows自带的VFS支持这种技术, 但定制系统的第三方VFS可能不支持.
- 使用该数据库的进程必须在一台主机中, WAL不支持网络文件系统
- ATTACH的事务对于单个数据库是原子性的, 但对于多个数据库来说不是原子性的
- 进入WAL模式后不能改变数据库页面大小, 即使是在一个空数据库、使用VACCUM或从备份中恢复数据. 必须从回滚日志模式中修改页面大小.
- 不能打开只读WAL数据库, 只有对该数据库相关的"-shm"WAL索引共享内存文件有写入权限才能执行打开操作. 否则只有"-shm"文件不存在才能打开该数据库.
- 如果频繁使用读操作很少使用写操作, WAL可能比回滚日志方法慢一点点(大概百分之一二)
- 每个数据库都会有对应的"-WAL"和"-shm"共享内存文件, 这使得SQLite不能像一个应用文件格式一样使用.
- 需要额外进行checkpointing操作, 模式是自动执行的. 有时应用开发者需要注意这些.
- WAL能更好的处理小事务, 对大的事务处理的并不好. 对于大于100M的事务, 传统的回滚日志模式处理的更快. 对于大于G的事务操作, WAL模式会报I/O错误. 所以建议使用回滚日志模式来处理上几十M的事务.
2. WAL如何工作
传统回滚日志通过将还未改变的数据库部分内容写入一个独立的回滚日志文件中, 并将修改的信息直接谢日数据库文件中. 在回滚中, 回滚日中的原始内容将会被写入数据库中间中, 并将数据库还原回原始状态. 当回滚日志被删除时COMMIT执行.
WAL颠倒了这个过程. 原始数据被保存在数据库文件中并将改变信息放在一个单独的WAL文件中. 因此一次COMMIT可能不会修改原始数据库, 这使得读者可以在修改提交到WAL文件的同时继续对未修改的数据库操作. 多个事务可以追加到单个WAL文件尾部.
2.1 检验指示(Checkpointing)
如果想将WAL中所有事务修改的内容都写入原始数据库, 这个转移就叫做"检查点"(checkpoint)
回滚日志和预写式日志的另一个区别在于, 回滚日志方法中有两个主要操作: 读和写;然而预写式日志有三个主要操作: 读, 写和checkpointing.
默认情况下, 当WAL文件到达1000页面大小时会自动进行checkpointing(SQLITE_DEFAULT_WAL_AUTOCHECKPOINT编译时选项可用于修改默认值). 使用WAL技术的应用不需要对此做任何额外工作, 但可以修改checkpoint的临界值;也可以关闭自动checkpoint并在空闲时间主动进行checkpointing.
2.2 并发性(Concurrency)
当对于一个WAL模式下的数据库进行读操作时, 它将会找到WAL中的最后一个提交记录的位置, 叫做"结束标记"(end mark). 由于WAL会不断增长, 并且其他进程进行读连接时也会产生新的提交, 所以每个读进程都有各自的结束标记. 但对于单个读进程来说, 结束标记在整个事务中是不会变的, 因此可以确保单个读事务中只能看到某个时刻的数据库内容.
当读者需要一个页面的内容时, 首先需要检查WAL文件中是够存在该页面, 并取出WAL中的最新页面. 如果WAL中没有所需的页面, 将会直接从原始数据库中提取页面. 读者可位于一个单独的进程中, 这样可以避免每个读者都扫描完整的WAL(根据checkpoint的周期时间, WAL文件可能长到几十上百M), "WAL索引"这种数据结构保留在共享内存中, 它可以帮助读者通过更少的IO操作定位WAL中页面的位置. WAL索引极大地提升读取性能, 但共享内存的使用意味着读者必须都在一个机器上. 这就是为什么预写式日志不能用于网络文件系统.
写入者仅仅是将新的内容追加在WAL文件尾部, 因为写入者与读者不会有什么交集, 写入者和读者可以同时运行. 由于只有一个WAL文件, 所以同一时间只能有一个写入者.
checkpoint使得WAL文件中的内容写入原始数据库. checkpoint可以和读操作同时运行, 然而当checkpoint遇到当前读者所阅读的页面时就必须停止, 因为对这些页面的修改会重写读者正在读的内容. checkpoint会记录它进行到了哪里, 并在下一次调用时从上次结束点开始写入.
因此一个持续的读事务会阻断checkpoint, 但读操作终会结束, 所以checkpoint总会重新继续写入.
当写入操作发生时, 写入者会查看checkpoint进行了多少. 如果所有WAL都被写入并同步到数据库, 并且没有读者在使用WAL文件, 那么写入者将会将WAL初始化并将新的事务放在WAL头部. 这一机制能够防止WAL文件过大.
2.3 性能考虑(Performance Considerations)
由于写操作是序列化的, 并且只需要写入内容一次(回滚日志事务需要写入两次), 所以写入事务很快. 只要应用愿意在断电或重启后牺牲持久性, 内容就不需要同步. (如果将PRAGMA的synchronous设置为FULL, 写入者会在每次事务结束后同步提交的内容, 如果设置为NORMAL就会忽略同步)
另一方面, 由于每个读者都需要先从WAL文件中查找, 而且查找消耗的时间与WAL文件的大小成正比, 所以随着WAL文件体积的增加, 读操作性能会不断降低. WAL索引会提升查找内容的性能, 但依然会随着WAL提交的增加而不断变慢. 所以在间隙中运行checkpoint来减少WAL文件大小对于读取性能提升是很有必要的.
Checkpointing要求同步操作, 为了避免由于断电或系统崩溃导致的数据库崩溃. 在将WAL文件中的内容写入数据库之前, 必须先将WAL同步到硬盘中;在重置WAL之前, 必须先同步数据库文件. Checkpoint在将序列化页面写入数据库时需要进行查询操作(WAL页面以升序写入数据库). 即使这样, 在写入页面期间也要进行多次查询操作. 以上原因导致checkpoint比写入事务要慢.
默认的策略是不断写入来增加WAL文件, 直到到达1000页面终止写入. 然后对每一个子COMMIT运行checkpoint操作, 直到WAL小于1000页面. 默认情况下, 将COMMIT的内容写入WAL的线程也会自动运行checkpoint, 这就是COMMIT操作变得十分快速的原因, 但部分COMMIT也会变慢(因为触发了checkpoint). 如果不想自动checkpoint, 也可以关闭自动checkpoint并改为周期性checkpoint.
注意, PRAGMA synchronous设为NORMAL时, checkpoint或sync(UNIX的fsync()或Windows上的FlushFileBuffers())是唯一的I/O障碍. 如果一个线程或进程运行了checkpoint, 那么主线程或主进程上query或update不会阻塞. 这有助于在频繁I/O操作中防止应用中的"闭锁"(latch-up). 这种配置的缺点在于事务不再具有持久性, 断电或系统崩溃后不能回滚.
还需要注意要权衡读和写性能, 为了最大化读操作性能, 需要将WAL文件保持的尽量小, 这样可以频繁的checkpoint, 可能与COMMIT一样频繁. 为了最大化写操作性能, 需要将每一次checkpoint的损耗分摊在每一次写操作中, 这意味着不能频繁的进行checkpoint, 并且让WAL文件在checkpoint前增加到尽量大. 因此, 多久进行一次checkpoint取决于不同的应用对读写性能的要求. 默认策略是在WAL文件到达1000页面大小时进行checkpoint, 并且这个策略在测试应用和工作站中都表现不错;但根据不同平台和不同工作量, 其他策略可能运行的更好.
3. 激活并配置WAL模式
SQLite数据库连接默认为journal_mode=DELETE, 为了切换到WAL模式, 使用以下pragma:
PRAGMA journal_mode=WAL; |
journal_mode将返回一个字符串, 它代表一种新的日志模式. 如果操作成功, pragma返回"WAL";如果没能转换成WAL模式(例如, VFS不支持共享内存技术), 那么不会改变日志模式. 将会返回之前的日志模式的字符串.
3.1 自动checkpoint
SQLite默认会在WAL文件到达1000页面或更多页面时进行自动的checkpoint, 或数据库文件上的最后一个数据库连接断开. 对于大多数应用来说, 默认配置工作的很不错. 但如果应用想控制checkpoint可以使用WAL_checkpoint pragma或调用sqlite3_WAL_checkpoint()接口. 自动checkpoint的临界值可被修改数值, 也可以使用WAL_autocheckpoint pragma或sqlite3_WAL_autocheckpoint()接口彻底禁止checkpoint. 也可以使用注册一个回调, 这样每次事务提交都能调用这个回调函数. 这个回调函数可以在任何它认为合适的情况下调用sqlite3_WAL_checkpoint()或sqlite3_WAL_checkpoint_v2(). (自动checkpoint机制就是凭借sqlite3_WAL_hook()来实现)
3.2 应用开始的checkpoint
当使用可写的数据库连接时, 可调用sqlite3_WAL_checkpoint()或sqlite3_WAL_checkpoint_v2()来开始一个checkpoint. 根据checkpoint的主动性可分为三种子类型: PASSIVE、FULL和RESTART. 默认是PASSIVE, 会在不干扰其他数据库连接的基础上尽可能多的checkpoint, 如果有同时发生的读者或写入者, 那么不会checkpoint会终止. 所有的checkpoint都由sqlite3_WAL_checkpoint()初始化, 并且自动checkpoint机制就是PASSIVE模式. FULL和RESTART模式的checkpoint会尝试完整地checkpoint, 并且只能通过sqlite3_WAL_checkpoint_v2()来初始化. 关于FULL和RESET的更多信息可以查看sqlite3_WAL_checkpoint_v2()
3.3 WAL模式的持久性
不同于其他日志模式, PRAGMA journal_mode=WAL是持久的. 如果一个进程设为WAL模式, 然后关闭并重启数据库, 数据库还是WAL模式. 而如果设为PRAGMA journal_mode=TRUNCATE, 然后关闭并重启, 那么会恢复到默认的DELETE回滚模式.
WAL模式的持久性意味着应用可以在不改变的情况下使用WAL模式. 仅仅可以使用shell命令行的"PRAGMA journal_mode=WAL;"然后重启应用, 就可以切换到WAL模式.
4. 只读数据库
如果数据库位于一个只读媒介中, 并且需要恢复, 那么该数据库就没法读取了. 举个例子, 如果应用崩溃, 数据库会留一个热日志(hot journal), 除非进程又写权限, 否则没法打开数据库, 也没法打开热日志文件. 这是因为在读取数据库前由于崩溃导致数据库需要回滚, 而回滚需要对该文件夹下的文件具有写权限才可以.
WAL模式下的数据库在只读媒介下没法读取, 因为即使是读取操作也需要checkpoint, 所以还需要写权限.
WAL读操作算法的高效实现要求共享内存中有一个hash表存放WAL文件的内容, 这个hash表叫做WAL索引. 共享内存中的WAL索引在主计算机文件系统中没有一个名字. 自定义的VFS可以自由的使用共享内存;但内置的SQLite通过UNIX和Windows驱动来实现共享内存, 使用以"-shm"为后缀的mmaped文件, 该文件与数据库位于同一文件夹中. 为了开启WAL数据库, 必须有对"-shm"后缀的共享内存文件的写权限;并且需要对数据库所在的文件夹具有写权限, 因为需要在WAL索引不存在时创建它. WAL索引必须在第一次访问时重建, 即使访问者是读者. 这会让默认的UNIX和Windows在只读媒介上不能访问WAL数据库, 但这并不排除自定义VFS实现的共享内存能实现访问只读WAL数据库,
因此, SQLite数据库应在导入只读媒介前切换为PRAGMA journal_mode=DELETE.
如果多个进程访问WAL模式的数据库, 那么基于用户或组ID的所有进程都会被赋予写权限, 权限覆盖数据库文件、WAL文件、共享内存的"-shm"文件和所位于的文件夹.
5. 避免过大的WAL文件
WAL文件会一直追加新的内容, 直到WAL文件到达1000页面(大约4MB), 达到临界值后会自动checkpoint并循环使用WAL文件. checkpoint通常不会截断WAL文件(除非设置了journal_size_limit), 相反的, checkpoint只会再次从WAL文件头部开始使用. 因为重写比追加要快. 当数据库没有连接时会删除WAL和它的共享内存文件.
所以大多数情况下, 应用不需要在意WAL文件, SQLite会自动处理它们. 但也存在着可能使得WAL文件不断增长, 甚至超过硬盘额度并减慢查询速度. 下面列举了一些可能出现的情况和如何避免它们:
- 关闭了自动checkpoint机制
默认情况下, SQLite会在WAL文件超过1000页面时进行checkpoint. 编译时和运行时可选项会推迟或关闭自动checkpoint. 如果应用关闭了自动checkpoint, 那WAL文件注定会越来越大. - checkpoint饥饿
如果没有进程连接着数据库, checkpoint只能去完成并重置WAL文件. 如果有一个读事务连接到了数据库, 那么checkpoint会终止重置工作, 因为重置需要从WAL文件中删除一些内容. checkpoint只能在没有读者烦扰的情况下尽量多的工作. checkpoint会在下一次写事务结束后再次开启, 直到checkpoint完成了整个工作.
如果有多个读者不断访问数据库, 而且某一时刻总有一个活跃的读者, 那么checkpoint总不能完成任务, 因此WAL文件将不断增加.
这种情况可通过确保存在"读者间隙"来解决这个问题: 确保读者不会不间断地读取数据库, 这样就能使用间隙时间来checkpoint. 如果一个应用有多个同时存在的读者, 那么需要使用SQLITE_CHECKPOINT_RESTART或SQLITE_CHECKPOINT_TRUNCATE来手动checkpoint. 这些选项优点在于可以完整的checkpoint, 缺点在于会阻塞读者来完成checkpoint. - 大面积的写事务
checkpoint只能在没有其他事务运行时进行, 这意味着WAL文件不能在写事务进行时重置. 所以一次较大的写事务可能导致WAL文件变得很大. WAL文件可能在写事务完成后进行checkpoint(假设没有读者进行阻塞), 但在写入时WAL文件会不断增大.
注意到有时数据库的单个页面可能在一次事务中写入WAL文件多次, 因此WAL文件最后可能比原始数据库大好几倍. 当缓存大小小于数据库页面数量时, 会在事务进行中发生这种情况. 当对一个页面进行多次更改时, 这些更改会一直留在内存中, 直到事务结束时它们会被修WAL文件. 但如果在事务结束前缓存填满了, 那么可能会溢出某些页面. 如果有多个相同的页面多次改变, 它们必须都写入WAL文件中. 对于一个很大的事务和很小的缓存, 相同的页面有可能多次移出并多次写入WAL文件.
解决方案包含:
- 对于大的更新操作, 可以切换到回滚日志模式
- 确保缓存大小足够大, 这样可以存储事务更新的内容
- 通过拆分事务来保持更改页面的数量
- 在进行一次大规模插入前删除索引, 并在事务完成后重新创建索引
6. WAL索引的共享内存应用
WAL索引使用一个普通的文件来实现稳健性. WAL模式的早期实现将WAL索引放置在不稳定的共享内存中, 比如Linux上创建的/dev/shm或UNIX系统上的/tmp. 这种方法的缺点在于, 如果进程位于不同的根目录, 那么他们使用的是不同的共享内存空间, 这将导致数据库崩溃. 另一种方法就是创建无名共享内存区间, 但他们无法再众多UNIX系统中移植, 并且我们无法再Windows系统中创建无名共享内存区间. 但我们还有唯一一种方法确保所有进程都访问同一数据库文件: 在数据库所在的文件夹中通过映射一个文件来创建共享内存.
使用一个普通的硬盘文件来创建共享内存有一个缺点: 为了将共享内存中数据写入硬盘, 需要进行不必要的I/O操作. 但开发者并不认为这是个大问题, 因为WAL索引很少超过32KB, 也不会同步. 此外, WAL索引文件将会在最后的数据库连接断开时被删除, 这使得I/O操作几乎不大会发生.
对于某些应用, 默认的共享内存实现不能使用, 可以使用可选的方法来实现一个定制的VFS. 举个例子, 如果已知一个特定的数据库, 它仅能通过单个进程中的线程访问, WAL索引可通过堆内存来实现, 而不是共享内存.
7. 不用共享内存实现WAL
SQLite3.7.4版本开始, 只要在第一次访问之前将lock_mode设为EXCLUSIVE, 即使不能使用共享内存也能使用WAL模式. 换句话说, 如果同一时间只能有一个进程访问数据库, 那么就可以实现不使用共享内存来使用WAL模式. 这一特性可以让那些没有"第二版"共享内存方法xShmMap、xShmLock、xShmVarrier和xShmUnmap的VFS使用WAL模式来读写.
如果lcoking_mode在第一次访问前设为EXCLUSIVE, 那么SQLite不会调用共享内存方法, 因此不会创建共享内存的WAL索引. 在这种情况下, 只要日志模式为WAL, 那么数据库连接就会一直保持EXCLUSIVE模式. 无法使用"PRAGMA locking_mode=NORMAL;"来改变锁模式. 如果想改变EXCLUSIVE模式就必须先改变WAL日志模式. 如果第一次WAL模式的数据库访问是NORMAL锁模式, 那么就会创建共享内存中的WAL索引. 这意味着底层的VFS必须支持"第二版"共享内存. 如果VFS不支持共享内存方法, 并且尝试以WAL模式开启数据库或转换为WAL模式, 操作将会失败. 只有数据库连接使用共享内存中的WAL索引, 锁模式才能在NORMAL和EXCLUSIVE之间自由转换. 只要锁模式在第一次访问前设为EXCLUSIVE, 那么就会忽略共享内存的WAL索引, 并且锁模式会锁定在EXCLUSIVE且不可改变.
8. 向后兼容性
WAL模式下的数据库格式没有变化, 但对于旧版SQLite来说WAL文件和WAL索引是全新的概念, 所以旧版SQlite无法对WAL模式下SQLite的进行恢复. 为了防止旧版SQLite对WAL模式的数据库进行恢复, 数据库文件格式的版本号从1提升到2. 因此如果旧版的SQLite连接到该数据库时提示"文件被加密或不是一个数据库".
使用以下pragma可以抹去WAL模式的改变:
PRAGMA journal_mode=DELETE |
将数据库文件格式的版本号改回1时必须要谨慎, 因为这样旧版SQLite就能访问数据库文件了.