1. 简介

像SQLite这种事务处理型的数据一大特性就是原子提交, 原子提交意味着每一次数据库更改要么都执行, 要么都不执行. 因为原子提交的缘故, 这就使得对数据库不同部分的写入像是立刻并且同时执行一样. 实际上硬件会有序的写入到海量存储器中, 并且每次写入消耗有限的时间. 所以对不同扇区同时或立即写入是不可能的, 但SQLite的原子提交使得事务中的数据库变更都像是立即且同时发生一样.
即使事务会因操作系统的崩溃或断电等情况打断, SQLite仍能保持着原子提交这一重要特性. 这篇文章讲述了SQLite怎样实现了原子提交.
只有在开启**回滚模式(rollback mode)或没有使用预写式日志(write-ahead log)**是才会使用"原子提交", 也可以在预写式日志开启后使用原子提交, 但这会使得原子提交以另一种机制实现.

2. 硬件假设

我们将硬盘称为海量存储器(mass storage device), 即使内存也很大容量. 硬盘中被写入的块称为"扇区"(sector), 所以不可能对比扇区更小的区域进行修改. 对小于扇区的部分进行修改, 就必须先加载整个扇区, 修改完后再写入整个扇区.
对于传统的磁头转盘, 扇区是读写, 交换的最小单位. 在内存当中, 读取的最小尺度小于写入的最小尺度, 但SQLite只关心最小写入单元大小. 在本文中, 扇区意味着我们写入硬盘的最小单元.
在SQLite3.3.14之前的版本, 扇区的大小在任何情况下都被假设为512bytes. 编译期间有一个选项可以去修改这个大小值, 但从没测到过比512bytes更大的值;最近扇区的大小提升到了4096byte, 并且内存中的扇区大小通常都大于512byte, 所以从3.3.14开始, 会有一个系统接口层的方法来从底层的文件系统寻找真正的扇区大小. 由于没有标准的方法来获得UNIX和Windows的扇区大小, 所以3.5后还是用硬编码的512byte. 但这个方法还是对嵌入式设备的制作有很大作用的, 并且有可能对未来UNIX和Windows有更有意义的作用.
SQLite假设一次扇区的写入不是原子性的, 而是线性的(linear). "线性"意味着写入操作有一个起点, 并且在写入过程中是一个个字节的写入, 直到终点. 写入可能从起点到终点或终点到起点, 如果断电发生在写入扇区的时段, 那么可能部分扇区被修改, 而另一部分未被修改. SQLite的关键性假设就是:如果发生了修改, 那么第一个字节或最后一个字节一定被修改, 所以硬盘不会在中间开始写入扇区直到结束. 这个假设不一定对, 但听着很有道理.
之前的段落提到SQLite假设扇区写入不是原子性的, 这是正确的. 但在3.5.0中, 有一个新的接口叫做虚拟文件系统(Virtual File System)接口, 这个VFS是SQLite与底层文件系统交流的唯一途径. 对于UNIX和Windows已经有一个缺省的VFS接口, 并且在运行状态会创建一个自定义VFS. 新创建的VFS中有一个xDeviceCharacteristics的方法, 他可以寻找到底层文件系统中是否存在某些属性和行为. xDeviceCharacteristics可能发现扇区的写入是原子性的, 那么SQLite就会直接利用. 但缺省的xDeviceCharacteristics方法不会指明扇区写入是原子性, 所以通常会被忽略.
SQLite假设操作系统会对写入数据进行缓存, 并且在数据还没完全写入存储器之前写入请求可能就返回, SQLite进一步假设写操作将会再次被操作系统请求, 因此SQLite会在关键点做"flush"或"fsync"函数的调用. SQLite假设flush或fsync会在所有写入操作结束后返回, 但不幸的是, 部分Windows和unix版本中缺少flush和fsync的实现, 这使得SQLite有可能在提交中时因断电而导致数据库崩溃. 并且SQLite无法对此进行测试和修复, 只能希望操作系统能像广告中表现的一样好.
SQLite假设文件增加体积指的是一块原本装有垃圾的文件空间被写入数据, 换句话说, 文件尺寸的增加会在文件内容更新之前. 因为SQLite需要做一些额外的工作来保证在文件尺寸增加和真正内容还没写入之间不会因断电而导致数据库文件被破坏.
xDeviceCharacteristics方法会指明文件系统是否会在增加文件体积之前写入数据(指的就是SQLITE_IOCAP_SAFE_APPEND属性). 当xDeviceCharacteristics方法表示文件内容的写入会在文件体积增加之前执行, 那么SQLite会弃掉一些数据库的保护步骤, 因此可以减少一些磁盘的IO操作, 但当前版本的VFS并没有这样的假设.
SQLite假设从用户进程的角度, 文件的删除是原子性的. 这意味着如果一个文件被删除的过程中遭遇了断电, 那么电力恢复后只会出现下面两种情况:被删除的文件依然完整存在;被删除的文件已全部被清理. 若数据只是部分被删除, 那么很有可能导致数据库的崩溃.
SQLite假设因宇宙射线, 热噪音, 量子波动, 硬盘驱动Bug所导致的错误统统由底层硬件和操作系统来负责保护和修正. SQLite不会为此类问题而给数据库文件增加冗余. SQLite假设文件读出和之前写入是一样的.
SQlite假设操作系统即使在断电或系统崩溃的情况下, 写入一段字节不会危及或改变写入范围外的字节. 我们将这称为"powersafe overwrite"属性. 之前的3.7.9版本没有假设这个属性, 但由于标准块大小从512比特增加为4096比特, 为维持性能水平, 必须假设这一属性. 这个属性的假设可以在运行期间或编译期间取消掉.

3. 单个文件的提交

现在我们来概述一下SQLite对于单个数据库文件的原子性提交的事务步骤, 防止断电导致崩溃的文件格式的细节和多个数据库文件的原子性提交都会在后文提到.

3.1 初始化状态

当数据库连接第一次创建时, 电脑的状态如下图:
3.1

最右边表示大容量硬盘设备, 每一个矩形都是一个"块"(sector), 蓝色表示拥有原始数据;中间区域表示操作系统的硬盘缓存, 并且已经被清除为空;左边的区域表示SQLite进程所使用的内存空间. 数据库连接刚刚开启并且没有信息被读取, 所以内存为空.

3.2 请求一个读锁(Read Lock)

3.2
在SQLite写入数据库之前, 需要先读取数据库来看看有哪些内容已经存在. 即使只是为了添加一些新数据, 也需要先从sqlite_master表中读取数据库概要, 这样才知道如何解析INSERT表达式, 并发现新数据存放在数据库存放的位置.
读取数据库文件的第一步就是获得数据库文件的共享锁(shared lock), 一个共享锁允许两个或多个数据库连接来同时读取数据库文件, 但共享锁会防止在读取期间进行写入操作. 这是必要的, 因为如果一个数据库在写入数据库文件, 而同时我们在读取数据库文件, 那么我们可能会读取到部分修改、部分没修改的文件, 这使得另一过程不可能实现原子性.
需注意:共享锁位于硬盘缓存, 并不在硬盘上. 文件锁只是操作系统内核的一些标识(依赖于操作系统层的接口). 因此, 如果操作系统崩溃或电源崩溃, 那么锁会立刻消失;且创建锁的进程消失时, 所拥有的锁也会消失.

3.3 从数据库中读取信息

3.3
当拥有共享锁后, 我们就可以从数据库文件中开始读取信息. 我们假设缓存是空的, 那么需要先从大容量硬盘中先读入到缓存中, 再从缓存传输到内存中. 通常只会有部分数据被读取到缓存区, 在本例中, 我们只读取了八个页面中的三个. 在实际操作中, 数据库会拥有上千页面, 且一次查询只会使用到很少比例的页面.

3.4 获取一个保留锁(Reserved Lock)

3.4
在修改数据库之前, SQLite需要拥有一个数据库文件的保留锁. 保留锁和共享锁很像, 它们都允许其他进程来读取数据库文件. 保留锁可以与其他进程的共享锁共存, 但一个数据库文件只能有一个保留锁. 因此同一时间只能有一个进程进行写入数据库操作.
保留锁的意义在于:这表明了有一个进程会在未来的一段时间进行数据库文件修改, 但目前还没进行修改. 由于还没进行修改, 所以其他进程可以进行读取数据库, 当然, 不会再有进程去写入数据库.

3.5 创建一个回滚日志文件(rollback journal file)

3.5
在对数据库文件修改之前, SQLite需要创建一个独立的回滚日志文件, 并将数据页面中将被修改的原始信息写入到日志文件中. 回滚日志文件的意义在于存储数据库原始状态的信息.
回滚日志文件包含一个小的头部(header), 它记录着数据库文件的原始大小. 所以如果一次修改使得数据库体积增加, 我们仍能知道原始的数据库大小. 页面数量与原始状态的数据库信息一同写入日志文件中.
当一个新文件被创建时, 大多数桌面操作系统(Windows, Linux, MAC OS X)都不会立即写入硬盘. 新文件只会创建在操作系统的磁盘缓存中. 当操作系统有空闲时间时才会写入大容量硬盘中. 这会给用户一种错觉:系统的I/O操作比实际操作要快的多. 图中就展示了这个情况:回滚日志文件被放在了缓存区, 而没有进入磁盘.

3.6 在内存中修改数据库页面

3.6
在原始数据被存入到回滚日志后, 内存中的页面将会被修改. 每一次数据库连接都有自己的私有用户内存(user space)拷贝, 所以对内存的信息修改只对拥有该内存的数据库连接透明. 其他数据库连接还只能看到未修改前操作系统磁盘缓存的信息. 并且即使一个进程在修改数据库, 其他进程还可以读取原始的数据库内容.

3.7 将回滚日志文件刷入硬盘中

3.7
下一步就是讲回滚日志文件刷入不容易丢失数据的硬盘中, 我们可以看出, 这对数据库在失去电源时起到了重要的保护作用;当然这一步也需要花费很多时间, 因为写入硬盘通常是一件很慢的操作.
这一步实际上比简单的将回滚日志文件刷入硬盘复杂的多, 在大多数平台中, 这被分为两个独立的写入操作. 第一步就是写入基本的回滚日志内容, 然后修改回滚日志的头部(header), 这样就可以显示页面数量. 第二步就是将头部写入硬盘, 至于为什么我们要修改头部并额外的写入头部, 我们会在后文提到.

3.8 获得一个排它锁(Exclusive Lock)

3.8
在修改数据库文件之前, 我们必须先获得一个数据库文件的排它锁. 排它锁的获得过程分为两步:首先获得一个等待锁(pending lock), 然后将等待锁升级为排它锁.
等待锁允许其他已经拥有共享锁(shared lock)的进程继续读取数据库文件, 但不允许没有共享锁的进程申请共享锁, 此举的意义在于防止有不断读取的进程而导致写入操作不断被拖延. 可能会有几十、上百或上千的进程尝试去读取数据库文件, 每一个进程获得一个共享锁并读取, 然后释放共享锁. 所以会出现一种现象:原来的进程还没释放共享锁, 新的进程就获得了新的共享锁, 数据库文件也一直挂有共享锁, 因此需要写入的进程也就没法获得排他锁. 等待锁的设计就是为了防止一直存在的共享锁, 通过允许已存在的共享锁, 而限制新建的共享锁. 最后所有的共享锁都会被释放, 这时等待锁就会升级为排它锁.

3.9 将修改信息写入数据库文件

3.9
一旦获得了排它锁, 就不会有其他进程来读取数据库文件, 对于写入操作就是安全的. 通常来说这些修改信息只会写入磁盘缓存中, 不会立即写入硬盘中.

3.10 将修改信息写入硬盘

3.10
现在需要将数据库修改的信息写入硬盘中, 这一步至关重要的一点就是保证数据库在断电时还能保持完整性. 由于写入硬盘这一操作固有的慢速特性, 这一步操作和3.7中回滚日志写入操作将占用整个事务提交的大部分时间.

3.11 删除回滚日志

3.11
数据库修改文件都安全的写入硬盘后, 回滚日志文件将被删除. 在事务提交完毕后必须立即执行这一操作, 因为如果这之前断电或系统崩溃, 那么恢复程序(后续会提到)会将数据库文件恢复成原来的模样;如果在删除回滚日志文件之后断电系统崩溃, 那么默认修改信息已正确的写入硬盘. 总的来数就是:SQLite以回滚日志文件是够存在作为判断条件, 来判断修改信息是够已经写入硬盘.
删除一个文件实际上并不是一个原子操作, 但从用户进程(user precess)的角度来说, 它是的. 进程可以询问操作系统"那个文件是够存在", 而进程得到的答案只能是"Yes"或"No". 事务提交中发生了断电, 事后SQLite会询问操作系统回滚日志文件是够存在, 如果回答是"Yes", 那么就认为事务没进行完整并进行回滚操作;否则则认为事务已经提交完毕.
事务是否完成取决于回滚日志文件是否存在, 并且文件的删除与否对于用户进程来说是原子性的, 因此事务仍是原子性的.
在许多系统中, 删除一个文件开销很大. 作为一项优化, SQLite可以将日志文件的长度设为0, 或将日志文件的头部用0填满, 这样日志文件就不能实现回滚功能, 事务依然可以提交. 将文件的长度变为0相当于删除一个文件, 从用户进程的角度也是原子性的. 将日志文件的头部填充为0不是一个原子操作, 但如果头部被修改过, 那么就不能用于回滚操作. 因此, 我们可以认为只要头部被修改过就变得无效了, 因而提交就算成功.

3.12 释放锁

3.12
提交过程的最后一步就是释放排它锁, 这样其他进程才能再次获得数据库文件. 从图中我们可以发现, 内存中的信息已被清理, 但最近的SQLite版本可能会保留内存中的信息, 因为下一次事务操作可能会再次用到, 毕竟从内存中获得数据比从缓存或从硬盘中获取信息快得多, 开销更小. 数据库第一个页面有一个计数器, 每一次数据库文件的修改它都会自增一次, 这样我们就能看出来是否有其他进程修改过数据库文件. 如果数据库发生了更改, 那么内存中的缓存应该被清除并重读. 但大多数情况下不会发生修改, 所以内存中的缓存可以被重用, 这样极大地提高了性能.

4. 回滚

原子提交被设想为是瞬发的, 但第三节已经显示, 这一系列操作实际上会占用有限时间. 假设断电发生在上述操作中, 为了保持信息改变的瞬发性, 我们需要"回滚"(rollback)任何一部分修改信息, 并将数据库的状态恢复到事务进行之前.

4.1 当出现错误时

4.1
假设断电发生在3.10, 就是数据库的修改信息正在写入硬盘时. 在电源恢复后, 情况就像上图所示: 我们想修改三个页面, 但只有一个页面修改完成, 有一个修改了部分, 还有一个页面还未修改.
有一点很重要, 那就是回滚日志文件还是完整的, 并且电源恢复后它仍在硬盘中. 3.7节已经确保了回滚日志文件能在修改数据库文件之前保存在硬盘中.

4.2 热回滚日志(Hot Rollback Journals)

4.2
SQLite在第一次获取数据库文件时, 必须先获取共享锁(3.2节提到). 之后会发现有一个热回滚日志, SQlite会检查这个回滚日志是否是"热回滚日志", 热日志指的是一个需要用来回滚使得数据库恢复正常的回滚日志. 当事务提交期间发生断电情况时, 热日志才会存在.
以下情况回滚日志是热日志:

  • 回滚日志文件存在
  • 回滚日志文件不为空
  • 主数据库文件上没有保留锁
  • 回滚日志文件的头部是语法规范的, 并且不为空(证明还没进行删除操作)
  • 回滚值文件不包含主日志文件的名字, 或包含主日志文件名字且主日志文件存在

热日志文件的存在向我们展示了之前事务的提交由于某些原因未能完成, 热日志文件意味着数据库文件当前是不完整的且需要回滚来恢复到之前状态

4.3 获得数据库的排它锁

4.3
处理热日志的第一步就是获得该数据库的排它锁, 用来防止其他进程也试图回滚数据库.

4.4 回滚回不完整的修改

4.4
一旦进程拥有了排它锁, 就被允许向数据库写入信息. 接下来就是读入回滚日志文件中原始的页面数据并将这些信息写入相应的数据库文件中. 由于我们在事务进行之前, 就将数据库文件的原始大小写入了回滚日志文件的头部, SQLite就可以通过这个信息来将数据库增长的部分截取, 使之回到原始大小. 最后, 数据库应该与事务处理之前的大小和信息一模一样.

4.5 删除热日志

4.5
  热日志全部信息写入数据库文件之后, 热回滚日志文件将被删除.
  在3.11节中, 日志文件可能会被截断为0长度或头部被重写为0, 这可以作为一个系统删除文件的优化手段. 上述两个的任何一种都可以用来删除热日志文件.

4.6 好似未完成的写入操作没发生过一样继续工作

4.6
恢复操作的最后一步就是将排它锁降级为共享锁. 一旦这一步发生, 说明数据库已经回到夭折的事务发生之前的状态. 由于恢复工作是自动完成的, 所以程序看起来就像错误的事务未发生一样.

5. 多文件提交

SQlite允许一个单独的数据库连接使用ATTCH DATABASE命令与二个或多个数据库同时交流. 当多个数据库文件在一条事务中被一起修改时, 所有的文件都会自动更新. 换句话说, 要么所有数据库文件都更新, 要么都不更新. 实现多文件提交的复杂度要比单文件提交要高, 这一节描述了SQLite如何实现该功能.

5.1 每个数据库文件单独的回滚日志

5.1
当一次事务涉及多个数据库文件操作时, 每一个数据库都有其自己的回滚日志文件且被独立地锁住. 上图展示了三个不同的数据库在一次事务中被修改的场景, 这时的情况与单个文件修改是相同的, 每一个数据库文件都有一个保留锁. 对于每一个数据库来说, 都会将被修改的数据写入各自的回滚日志文件中, 但日志文件还没写入硬盘中. 尽管已经对内存中的信息进行了修改, 但硬盘中的数据库文件并未修改.

5.2 主日志文件(The Master Jounal File)

5.2
多文件提交的下一步就是创建一个"主日志文件", 主日志文件的名字和原始数据库文件名加上字符串"-mjHHHHHHHH'一样. HHHHHHHH是一个随机的32位十六进制数字, 每个随机数对应每个新的主日志不一样. (上段提到的主日志文件名的起名方法只针对SQlite3.5.0之前的版本, 这个方法并不是SQLite的规则, 所以可能在后续版本进行变更)
不同于回滚日志, 主日志文件不包含任何原始数据库的页面. 但主日志文件包含参与本次事务的所有回滚日志文件的所有完整路径.
当主日志文件创建完成后, 首先将它写入硬盘中. 在UNIX中, 为了保证断电后主日志文件依然在文件夹中, 会将包含主日志文件的文件夹一起同步.

5.3 更新回滚日志文件头部

5.3
下一步就是在每个回滚日志文件的头部中记录上主日志文件完整路径. 在回滚日志创建之时就已经腾出了放置主日志文件路径的空间. 在写入主日志文件路径之前和之后, 都要将回滚日志的内容写入硬盘中. 写入两遍是非常重要的. 幸运的是, 第二次写入是很快速的, 应为通常情况下改写一个页面就够了.

5.4 更新数据库文件

5.4
一旦所有的回滚日志文件都写入硬盘中, 那么更新数据库文件就很安全啦. 我们还是需要在修改之前获得硬盘中数据库文件的排它锁. 在写完所有的变化之后, 一定要冲刷这些更新到磁盘上, 以保证在突然断电或者系统崩溃的情况下, 它们仍然存在.

5.5 删除主日志文件

5.5
删除主日志文件是多文件事务提交的关键点, 这对应着3.11节中回滚日志文件删除的场景.
如果断电或系统崩溃发生在这时, 那么重启后即使还有回滚日志文件也不会进行回滚操作. SQlite判断日志是否为hot基于以下两点. 满足一点即可:

  • 头部没有主日志文件名
  • 主日志文件依然存在于硬盘中

5.6 清除所有回滚日志文件

5.6
最后一步就是删除所有独立的回滚日志文件, 并解除数据库文件上的排它锁, 这样其他进程才能读取. 这对应着单文件提交流程中3.12节.

6. 提交过程中的其他细节

3.0节已经给出了SQLite实现原子提交的概述, 但忽略了一些重要的细节. 接下的几节用来填坑.

6.1 始终日志完成扇区(Sectors)

在3.5节中, 当数据库页面的原始内容写入回滚日志文件中时, SQLite会写入完整的扇区(sector), 即使数据库的页面大小小于扇区尺寸. 从历史角度来说, SQLite的扇区大小被硬编码为512比特, 并且由于页面最小为512比特, 所以这从来不是一个问题. 从3.3.14开始, SQlite便有可能用大于512比特的大容量储存器. 所以从3.3.14开始, 只要扇区中的任何一个页面需要写入日志文件, 那么这个扇区的所有页面都会写入.
将扇区中的所有页面存起来是十分有必要的, 因为这可以写入扇区时的断电情况. 假设扇区1(sector 1)中页面2需要被修改, 但这个扇区上有4个页面(page1,2,3,4). 为了将页面2的信息写入数据库文件, 底层硬件还需要将页面1,3,4一起重写. 如果写入操作由于断电中断, 导致页面1,3,4中的一个或多个含有错误信息. 因此为了防止这种数据库崩溃, 需要将所有页面的原始信息都写入回滚日志文件.

6.2 处理写入日志文件的垃圾

当向回滚日志文件追加数据库时, SQLite会悲观地假设文件先被没用的"垃圾"充满, 然后再用正确的数据替代. 换句话说, SQLite假设文件是先增加体积, 再写入数据. 如果断电发生在文件增长体积之后, 而在写入数据之前, 回滚日志文件会被垃圾充满. 当恢复电力后, SQLite进程发现回滚日志文件装满垃圾数据, 紧接着回滚写入原始数据库文件中, 这样会使数据库文件中存有垃圾数据, 崩溃.
SQLite对此问题有两层防线. 第一层, SQLite将回滚日志文件的页面数量记录到日志文件的头部, 这个数量被初始化为0. 当尝试回滚一个不完整的日志文件时, 进程发现日志文件中没有页面, 就不会对数据库文件做修改. 在提交之前, 回滚日志文件将被写入硬盘中, 来确保信息已经同步到硬盘中, 并且文件中没有垃圾信息, 这时才会将头部的页面数量从0变为回滚日志文件的页面数量. 头部与数据库信息位于不同的扇区, 这样写入头部时断电不会影响到数据部分. 回滚日志文件被写入了两次: 一次是页面内容, 一次是头部写入页面数量.
上述的情况描述了synchronous pragma设为full的情况

PRAGMA synchronous=FULL;

默认synchronous是为full, 并且情况像上述一样. 如果synchronous降级为normal, 那么SQLite会在页面数量写入后只会写入回滚日志文件一次. 这会带来一个风险, 那就是修改页面数量的操作发生在写入数据之前, 写入数据库虽然是提前发生的, 但SQLite假设实际的文件系统会重新调整写入顺序, 所以即便页面数量的写入发生在后面, 也有可能会提前写入. 第二道防线就是SQLite会为日志文件记录每一个页面的32校验值. 当进行回滚操作时, 这些校验值会告诉SQLite页面是否有效, 如果发现不正确的值, SQLite会放弃修改. 由于校验值比较小, 所以存在误判率, 但也不用过多担心, 因为误判率实在很低, 校验值还是起到了很强的保护作用的.
注意: 当synchronous设为FULL时, 校验值对于回滚日志文件就没有意义了. 只有设为NORMAL时我们才依赖于校验值. 尽管如此, 校验值还是无害的, 所以无论synchronous设为任何值, 都讲校验值保存在日志文件中.

6.3 提交前流出缓存

3.0节的提交过程假设所有的数据库修改信息都会保存在内存中, 直到提交. 有时一个较大的改动可能会在提交前移出用户缓存, 那样的话在必须在事务完成前将缓存移到数据库文件中.
在移出缓存之前, 数据库连接状态和3.6节相同. 初始的页面内容需要保存在回滚日志文件中, 并且页面的修改保留在用户内存中. 为了将缓存移出, SQlite执行3.7节和3.8节的操作, 换句话说, 回滚日志文件被写入硬盘, 这时就需要一个排它锁, 并将修改的信息写入数据库文件中. 但剩下的步骤将推迟到事务彻底提交为止才进行. 一个新的日志头部将追加到回滚日志文件的尾部, 并且有需要的获得一个排它锁, 进程又回到3.6步骤. 当事务真正提交或需要移出缓存时, 又要重复3.7和3.8步骤. (3.8在第二次和接下来的执行中都被忽略了, 因为数据库的排它锁会一直持有. )
一次移出缓存导致数据库文件上的保留锁升级为排它锁. 这会降低并发性. 一次移出缓存也会导致额外的硬盘写入和同步操作. 因此移出缓存会严重降低性能. 综上所述, 应该在任何情况下都避免移出缓存.

7. 优化

分析可知, 对于绝大部分的操作系统和环境, SQLite的大部分时间都花在IO操作. 所以减少IO操作势必会极大地提高SQLite性能, 这一节就描述了SQLite在保证原子性的同时, 对于如何减少IO操作数量提出的一些技术方法.

7.1 事务间保留缓存

3.12节说明了原子提交的过程, 一旦释放共享锁(shared lock), 所有数据库的缓存镜像都要被清除. 因为如果进程没有了共享锁, 其他进程可能会修改内存中的镜像, 这样内容就必须被废弃. 所以每一次新事务都会尝试重读之前读过的数据, 这听起来并不那么糟糕, 因为第一次读取过的数据可能还存在在缓存中. 所以读操作只是将数据从内核空间(kernel space)移动到用户空间(user space), 但尽管如此, 还是需要花费时间.
3.3.14版本后的SQLite开始添加了一种机制, 能够减少数据重读次数. 在更新的版本中, 即使数据库文件上的锁被释放, 用户空间的页面缓存依然不清除. 在下次事务请求共享锁时, SQlite会检查数据库文件是够被其他进程修改过:如果在上次锁释放后被修改过, 那么用户空间的缓存将被清楚. 但通常情况下是未被修改的, 并且用户空间的缓存依然存在, 这样就能避免不必要的读取操作.
为了判断数据库文件是够被修改, SQLite在数据库文件头部使用一个计数器, 每做一次修改都会加一. SQLite会在释放数据库锁之前拷贝计数器的数值, 在下一次请求数据库锁时会对比现有计数器的数值和上一次存储的值, 如果不同则刷新缓存, 如果相同则重用.

7.2 独占访问模式(Exclusive Access Mode)

SQLite3.3.14添加了"独占访问模式"的概念, 在该模式中, SQLite会在事务结束后仍保留排它锁. 这阻止了其他进程访问数据库, 在许多部署中只有一个进程使用数据库, 所以这不是什么大问题. 独占访问模式的优势在于IO操作可以在以下三个方面减少:

  1. 没必要在事务结束后改变改变数据库头部的计数器. 这将会在回滚日志文件和主数据库文件上省下两次页面的写入操作
  2. 由于没有其他进程来修改数据库, 所以不必检查计数器来查看是否有人修改了数据库, 也不需要清空用户空间中的缓存.
  3. 每一次事务提交可以直接将回滚日志文件的头部填充为0, 不用删除日志文件. 这样避免了修改日志文件的目录项, 也不用释放日志文件所占用的磁盘扇区. 更重要的是, 下一次事务将在原来日志文件的内容上直接重写, 而不是追加一块新的内容. 因为对于大部分操作系统来说, 重写比追加快的多.

理论上我们可以在任何时刻进行第三步的优化, 而不局限于在独占访问模式下. 可使用journal mode pragma直接进行优化.

7.3 不要将空白列表页面写入日志中

当从SQLite数据库中删除信息时, 会将被删除内容的所有页面添加到"空白列表"(freelist)中, 随后的插入操作会先使用空白列表中的页面.
有一些空白列表的页面含有重要数据, 尤其是其他空白列表页面的位置. 但大多数空白列表的页面内容没什么用, 这些页面被称为"叶子"(leaf)页面. 我们可以在不改变数据库本身的条件下随意修改叶子页面的内容.
由于叶子页面的内容不重要, SQLite会避免将它们在3.5节的提交过程中写入回滚日志文件中. 如果在事务恢复中, 一个原来被修改的叶子页面被修改过但没被回滚, 数据库不会受影响. 同样的, 新的空白列表页面的内容不会写入数据库, 也不会从数据库中读出来. 在对拥有空白空间的数据库文件进行修改时, 这些优化都会很大程度上减低IO操作.

7.4 单个页面更新和原子扇区写入

从SQLite3.5.0开始, 新的虚拟文件系统(Virtual File System)接口包含了一个叫做xDeviceCharacteristic的方法, 它呈现了一下底层大容量存储设备所拥有的特殊属性. 这些特殊属性显示了是否有能力去做原子扇区写入操作.
SQLite默认扇区的写入是线性的而不是原子性的, 一次线性写入操作从一个扇区开始一个字节一个字节的修改信息. 如果断电发生在写入操的中途, 那么就会出现一半修改而一半没修改. 在原子扇区写入中, 整个扇区要么全被重写, 要么一点都未改变.
我们相信大多数的磁盘驱动使用原子扇区写入, 当断电时, 驱动使用电容器中的能源, 并利用盘片旋转的角动量来完成正在进行的操作. 然而写入系统的调用和单板硬盘驱动的电子设备之间有太多层, 所以在UNIX和w32的VFS应用中我们选择更稳妥的方式:假设扇区的写入不是原子性的. 另一方面, 随着文件系统有更多的控制选项, 设备供应商可以考虑在硬件能够实现原子写入的前提下开启xDeviceCharacterisitics中的原子写入属性.
当扇区的写入操作为原子性, 并且数据库的页面大小与扇区大小相同时, 数据库只修改一个页面不会引起SQLite的日志文件和同步操作, 只会直接写入修改的信息. 数据库文件头部的计数器会被单独修改, 因为即使更新前发生停电也不会对数据库产生影响.

7.5 带有安全附加语义(Safe Append Semantics)的文件系统

另一个在3.5.0版本加入的优化就是在底层硬盘中使用"安全附加"行为. 之前提到SQLite会假设当数据追加到文件中时, 文件先增大体积, 再填写内容. 所以若在两者中间发生断电, 文件会含有非法的"垃圾"数据. VFS的xDeviceCharacteristics方法可能会表明文件系统支持"安全附加"语义, 这意味着文件大小的增加会在内容写入之后发生, 这保证了即使断电数据库文件中不会有垃圾数据.
当安全附加语义在文件系统中被声明时, SQLite会将回滚日志文件头部的页面计数器永久置为-1, 这个值告诉其他想要回滚这个日志的进程, 页面的数量应该从日志文件的体积中计算得出. -1这个值不会被改变, 所以在一次提交过程中, 我们节约一个flush操作和日志文件首个页面的扇区写入. 并且当一个扇区溢出时, 我们不必将一个新的日志文件头部追加到另一个日志文件的尾部;我们可以直接将新的页面追加到已存在的日志文件的尾部.

7.6 持久性回滚日志文件

在许多操作系统中, 删除文件是很消耗时间的一件事. 作为优化, SQlite可以配置来避免3.11中的删除操作, 可以在提交事务时不删除日志文件, 而是将长度截断为0字节, 或将头部填充为0. 将长度截断为0节省了对文件所在目录的修改, 因为文件依然在该目录中. 填充文件头部的额外好处就是不必更新文件长度, 而且不必处理新释放的磁盘扇区. 此外, 在下一次事务中, 日志文件可以直接覆盖原来的内容, 而不是追加到文件末尾. 重写的速度往往比追加快得多.
SQLite可以通过使用journal_mode PRAGMA来设置"PERSIST"日志模式, 从而使得提交事务可通过重写日志头部为0, 而不是删除日志文件:

PRAGMA jounal_mode = PERSIST;

持久性日志模式为我们提供了一种显而易见的性能提升方式. 缺点就是日志文件会在事务提交后的很久一直停留在硬盘中, 占用硬盘的空间并弄乱文件目录. 唯一一种删除持久性日志文件的方法就是将日志模式设置为DELETE:

PRAGMA journal_mode=DELETE;
BEGIN EXCLUSIVE;
COMMIT;

由于删除的日志文件可能为hot, 所以突然的删除可能会导致对应的数据库文件崩溃. 从3.6.4开始, TRUNCATE日志模式也启动了:

PRAGMA journal_mode=TRUNCATE;

在截断日志模式中, 事务的提交通过将日志文件的长度截断为0, 而不是删除日志文件(DELETE模式)或填充0(PERSIST模式). 截断模式具有PERSIST模式中拥有日志文件的文件夹和数据库都不需要更新的优点. 因此对一个文件的截断比删除要快得多. 截断还有一个额外的好处, 它不用后面跟着一个系统调用来将修改的信息同步到硬盘种. 在现代的文件系统中, 截断是一个原子性的同步操作, 并且我们认为截断在面临断电时更安全. 如果你不确定你的文件系统中截断操作是否是原子性和同步性的, 并且断电和系统崩溃时你很在意数据库的安全性, 那么要考虑使用其他模式.

8. 测试原子提交行为

SQLite的开发者对于数据库面对断电和系统崩溃是很有自信的, 因为自动化测试流程对于SQLite从模拟的断电中恢复做了大量检查, 我们将之称为"崩溃测试"(crash tests).
SQLite中的崩溃测试使用了一种修正的VFS, 它可以模拟发生断电或操作系统崩溃导致的文件系统损伤. 用于崩溃测试的VFS可模拟不完整的磁盘写入, 由于写入中断导致的页面被充满垃圾数据, 或没有按顺序写入, 发生在测试环境中的不同情况. 崩溃测试不断地执行事务, 更改断电的时间和数据库损伤的位置. 每一次模拟崩溃后斗湖重新打开数据库并确保事务要么发生完毕, 要不一点没变, 总结起来就是保证数据库的完整统一的状态.
SQLite的崩溃测试发现了恢复机制中的许多微小bug. 其中一些bug是不易觉察的, 即使使用代码审查和分析技术也不容易找到这些bug. 因此SQLite的开发者对于那些没有崩溃测试系统的数据库系统含有不为人知的bug很有自信, 这些bug很可能在某次断电或系统崩溃后导致数据库的崩溃.

9. 一些能导致崩溃的事情

SQLite的原子提交机制被证明是很健壮的, 但还是有可能被一些极具创新性的对手或不完整的操作系统实现坑. 这一节讲述了SQLite会在哪些情况下因为断电或系统崩溃导致数据库崩溃.

9.1 缺乏锁的实现

SQLite通过同一时间只有一个进程和数据库连接来修改数据库信息. 文件系统的锁机制应用在VFS层中, 并且在每个操作系统中锁机制不同. SQLite需要保证锁机制的应用是正确的. 如果有两个或多个进程可以同时写一个数据库文件, 那么必然会造成严重损害.
我们接受到了一些报告, Windows网络文件系统和NFS会有锁机制的缺失. 我们不能验证这些报告, 但是锁在网络文件系统中的确很难实现, 所以我们没有理由怀疑它们. 首先你应该避免使用网络文件系统, 因为性能很差. 如果你不得不使用一个锁机制不健全的网络文件系统来存储SQLite数据库文件, 那么最好采用其他的锁机制来避免同时对一个数据库的写入.
苹果Mac OS X预装的SQLite版本已经扩展了一种可以在网络文件系统工作的可选锁策略. 只要所有的进程用相同的方式来访问数据库文件, 那么苹果上的扩展是很有效的. 不幸的是, 这些锁机制并不互相排斥, 如果一个进行通过AFP锁, 而另一个进程通过dot-file锁访问文件, 那么两个进程可能同时修改一个数据库, 因为AFP锁和dot-file锁并不互相排斥.

9.2 不完整的硬盘刷新

SQLite在UNIX中使用fsync()系统调用, 在Win32中使用FlushFileBuffers()系统调用来保持系统缓存和硬盘上的同步, 就像3.7和3.10一样. 不幸的是, 在大多数系统中, 这些接口不能像广告中一样工作. 我们听说可以在Windows版本中修改注册表来完全关闭FlushFileBuffers()的功能. 在Linux的早期版本中, 文件系统中的fsync()是空操作. 即使FlushFileBuffers()和fsync()都工作, IDE磁盘控制会撒谎说数据已经写入磁盘, 其实依旧在已更改的磁盘控制缓存中.
在Mac中, 你可以设置pragma:

PRAGMA fullfsync=ON;

在Mac中设置fullsync可以保证数据在刷新时进入磁盘中, 但fullsync的应用会涉及到重新设置硬盘控制器. 并不只是让自身变慢, 还会让不相关的硬盘IO操作变慢, 所以这一应用并不推荐.

9.3 局部文件删除

SQLite假设从用户进程的角度来看, 文件删除是原子操作. 如果在删除文件的中途断电, 那么在恢复电力后, SQLite希望看到以下两种情况之一:要么完整的数据还在, 要么文件全部被删除. 如果不是上述情况, 那么事务就不是原子性的.

9.4 垃圾写入文件中

SQLite数据库文件都是普通的磁盘文件, 它们都可以被用户进程访问和写入. 流氓进程也可以打开SQLite数据库并填充垃圾数据. 垃圾数据也可能是由于操作系统或硬盘控制器的bug导致其进入SQLite数据库, 尤其是断电引起的bug. SQLite对此无能为力.

9.5 删除或重命名热日志文件

如果发生了系统崩溃或断电, 且热日志文件保留在硬盘中, 那么数据库文件和热日志文件在另一个SQLite进程进行回滚之前保持原名十分重要. 在4.2的恢复操作中, SQLite会通过在被打开的数据库同一目录下, 查找由数据库文件名派生出的文件名来定位热日志文件. 如果原始数据库文件或热日志文件被移走或重命名, 那么就定位不到热日志文件, 也就不会发生回滚操作.
我们怀疑SQLite恢复失败的样例如下:发生断电, 恢复电力后一个好心的用户或系统管理员开始查看硬盘是否受损, 他们发现名称为"important.data"的数据库文件, 或许其他相似的文件. 但在崩溃后, 多了一个名为"important.data-journal"的热日志文件. 用户可能为了清理系统就删除了该热日志文件. 除了用户培训, 我们没有其他方法来防范这种情况发生.
如果有多个链接指向一个数据库文件, 日志文件将用名字链接来指向被打开的文件. 如果发生崩溃并且数据库被再次打开时使用其他链接, 那么热日志文件就不会被定位, 且不会发生回滚.
有时断电会造成文件系统崩溃, 例如刚被修改的文件名丢失, 文件被移到"/lost+found"文件夹中. 当这些情况发生时, 热日志不会被发现并回滚. SQLite同步含有回滚日志文件的文件夹时也会同步日志文件本身, 这样就避免了上述情况发生. 然而有可能是不相关的进程使得文件移入"/lost+found"文件夹中, 并在相同文件夹中产生作为主数据库文件的不相关文件. 由于这些已经超出了SQLite的管辖范围, 所以不能避免此类情况发生. 如果你运行的操作系统很容易发生文件系统命名空间崩溃, 那你应该考虑将每个SQLite数据库文件放在各自私有的子文件夹中.

10. 未来的方向与总结

一旦有人发现了SQLite中原子提交机制的失败案例, 那么开发者就需要打补丁. 虽然这种情况越来越少, 并且失败案例越来越不容易觉察出来, 但还是不能认为SQLite中的原子提交是无bug的. 开发者将尽快地将发现的bug修补好.
开发者也盼望着有新的优化方式来改进提交机制. UNIX和Windows上现有的VFS应用都对系统行为作出了悲观假设. 在与专家咨询了这些系统的行为后, 我们可能会放松某些假设并使数据库跑的更快. 我们猜测现代文件系统可能已经展示出安全追加属性, 并支持了原子扇区写入. 但现在尚不确定, 所以SQLite仍会采取保守的方式并作出最坏的打算.