小巧。快速。可靠。
三者择其二。
SQLite 中的原子提交

1. 简介

SQLite 等事务型数据库的一个重要特性是“原子提交”。原子提交意味着在一个事务中的所有数据库更改要么全部发生,要么都不发生。使用原子提交,就好像对数据库文件不同部分的许多不同写入同时发生。实际硬件按顺序写入到存储器,并且写入单个扇区需要一定时间。因此,无法真正同时或瞬时写入数据库文件的多个不同扇区。但 SQLite 中的原子提交逻辑使它看起来就像事务的更改全部同时瞬时写入一样。

SQLite 具有一个重要的属性,即使事务被操作系统崩溃或电源故障中断,事务也会看起来是原子的。

本文介绍了 SQLite 用于创建原子提交错觉的技术。

本文中的信息仅适用于 SQLite 在“回滚模式”下运行时,换句话说,当 SQLite 未使用 预写日志 时。当启用预写日志时,SQLite 仍然支持原子提交,但它是通过与本文中描述的不同机制实现原子提交的。有关 SQLite 在该上下文中如何支持原子提交的更多信息,请参阅 预写日志文档

2. 硬件假设

在本文中,我们将称存储器设备为“磁盘”,即使存储器设备可能是闪存。

我们假设磁盘按块写入,我们称之为“扇区”。无法修改小于扇区的任何部分的磁盘。要更改小于扇区的磁盘部分,您必须读入包含要更改部分的完整扇区,进行更改,然后写回完整扇区。

在传统的旋转磁盘上,扇区是读写两个方向上的最小传输单位。但是,在闪存中,读取的最小大小通常远小于写入的最小大小。SQLite 仅关心写入的最小量,因此在本文中,当我们说“扇区”时,我们指的是一次可以写入存储器的最小数据量。

在 SQLite 3.3.14 版本之前,所有情况下都假设扇区大小为 512 字节。有一个编译时选项可以更改此设置,但代码从未在更大的值上进行过测试。512 字节的扇区假设似乎合理,因为直到最近,所有磁盘驱动器都在内部使用 512 字节的扇区。但是,最近出现了将磁盘扇区大小增加到 4096 字节的趋势。此外,闪存的扇区大小通常大于 512 字节。出于这些原因,从 3.3.14 版本开始的 SQLite 版本在操作系统接口层中有一种方法可以查询底层文件系统以查找真实的扇区大小。在当前实现(3.5.0 版本)中,此方法仍然返回硬编码的 512 字节值,因为在 Unix 或 Windows 上都没有标准的方法来发现真实的扇区大小。但是,该方法可供嵌入式设备制造商根据自己的需求进行调整。并且我们已经留下了将来在 Unix 和 Windows 上填入更有意义的实现的可能性。

SQLite 传统上假设扇区写入不是原子的。但是,SQLite 始终假设扇区写入是线性的。我们所说的“线性”是指 SQLite 假设在写入扇区时,硬件从数据的一端开始,逐字节写入,直到到达另一端。写入可以从头到尾或从尾到头进行。如果在扇区写入过程中出现电源故障,则扇区的一部分可能已被修改,而另一部分可能保持不变。SQLite 的关键假设是,如果扇区中的任何部分发生更改,那么第一个或最后一个字节将会发生更改。因此,硬件永远不会从中间开始写入扇区,然后向两端移动。我们不知道这个假设是否总是正确,但它似乎合理。

上一段说明 SQLite 不假设扇区写入是原子的。默认情况下这是正确的。但是,从 SQLite 3.5.0 版本开始,有一个名为虚拟文件系统 (VFS) 接口的新接口。 VFS 是 SQLite 与底层文件系统进行通信的唯一方式。代码附带了 Unix 和 Windows 的默认 VFS 实现,并且在运行时存在创建新的自定义 VFS 实现的机制。在这个新的 VFS 接口中,有一个名为 xDeviceCharacteristics 的方法。此方法查询底层文件系统以发现文件系统可能存在或不存在的各种属性和行为。xDeviceCharacteristics 方法可能表明扇区写入是原子的,如果它确实这样指示,SQLite 将尝试利用这一事实。但是,Unix 和 Windows 的默认 xDeviceCharacteristics 方法不指示原子扇区写入,因此通常会省略这些优化。

SQLite 假设操作系统会缓冲写入,并且写入请求将在数据实际存储到存储器设备之前返回。SQLite 进一步假设写入操作将被操作系统重新排序。出于这个原因,SQLite 在关键点执行“刷新”或“fsync”操作。SQLite 假设刷新或 fsync 将不会在正在刷新的文件的待处理写入操作全部完成之前返回。我们被告知,刷新和 fsync 原语在某些版本的 Windows 和 Linux 上存在问题。这是不幸的。这会导致 SQLite 在提交过程中出现电源故障后出现数据库损坏的可能性。但是,SQLite 无能为力来测试或解决这种情况。SQLite 假设其运行的操作系统按广告宣传的方式工作。如果事实并非如此,那么希望您不会太频繁地断电。

SQLite 假设当文件长度增加时,新的文件空间最初包含垃圾,然后在以后用实际写入的数据填充。换句话说,SQLite 假设文件大小在文件内容之前更新。这是一个悲观的假设,SQLite 必须做一些额外的工作来确保如果在文件大小增加和写入新内容之间断电,它不会导致数据库损坏。 VFS 的 xDeviceCharacteristics 方法可能会表明文件系统将在更新文件大小之前始终写入数据。(对于那些查看代码的读者来说,这是 SQLITE_IOCAP_SAFE_APPEND 属性。)当 xDeviceCharacteristics 方法表明文件内容在文件大小增加之前写入时,SQLite 可以放弃它的一些严格的数据库保护步骤,从而减少执行提交所需的磁盘 I/O 量。但是,当前实现对于 Windows 和 Unix 的默认 VFS 并没有做出这样的假设。

SQLite 假设从用户进程的角度来看,文件删除是原子的。这意味着,如果 SQLite 请求删除文件,并且在删除操作期间断电,那么恢复供电后,文件将完整存在,其所有原始内容都不会改变,或者文件将根本无法在文件系统中看到。如果恢复供电后文件仅被部分删除,如果其某些数据已被更改或擦除,或者文件已被截断但未完全删除,那么很可能会导致数据库损坏。

SQLite 假设由宇宙射线、热噪声、量子涨落、设备驱动程序错误或其他机制引起的位错误的检测和/或纠正是底层硬件和操作系统的责任。SQLite 不会为检测损坏或 I/O 错误而向数据库文件添加任何冗余。SQLite 假设它读取的数据与之前写入的数据完全相同。

默认情况下,SQLite 假设操作系统调用写入字节范围不会损坏或改变该范围之外的任何字节,即使在写入期间发生电源故障或操作系统崩溃。我们称之为“电源安全覆盖”属性。在 3.7.9 版本 (2011-11-01) 之前,SQLite 并没有假设电源安全覆盖。但是,随着大多数磁盘驱动器上的标准扇区大小从 512 字节增加到 4096 字节,为了保持历史性能水平,必须假设电源安全覆盖,因此在最近版本的 SQLite 中默认情况下假设了电源安全覆盖。如果需要,可以在编译时或运行时禁用电源安全覆盖属性的假设。有关更多详细信息,请参阅 电源安全覆盖文档

3. 单文件提交

我们首先概述 SQLite 为对单个数据库文件执行事务的原子提交而采取的步骤。保护文件格式免受电源故障损坏的细节以及跨多个数据库执行原子提交的技术将在后面的部分中讨论。

3.1. 初始状态

当第一次打开数据库连接时,计算机的状态在右侧的图表中概念性地显示。图表最右边区域(标记为“磁盘”)表示存储在大容量存储设备上的信息。每个矩形都是一个扇区。蓝色表示扇区包含原始数据。中间区域是操作系统磁盘缓存。在我们示例的开始,缓存是冷的,这用将磁盘缓存的矩形保留为空表示。图表的左侧区域显示使用 SQLite 的进程的内存内容。数据库连接刚刚打开,还没有读取任何信息,因此用户空间为空。


3.2. 获取读锁

在 SQLite 可以写入数据库之前,它必须先读取数据库以查看其中已存在的内容。即使只是追加新数据,SQLite 仍然需要从 "sqlite_schema" 表中读取数据库模式,以便它知道如何解析 INSERT 语句并发现新信息应存储在数据库文件中的位置。

从数据库文件读取的第一步是获取数据库文件上的共享锁。 “共享”锁允许两个或多个数据库连接同时从数据库文件读取。但共享锁会阻止另一个数据库连接在我们读取数据库文件时写入数据库文件。这是必要的,因为如果另一个数据库连接在我们读取数据库文件的同时写入数据库文件,我们可能会在更改之前读取一些数据,并在更改之后读取其他数据。这将使它看起来好像其他进程所做的更改不是原子的。

请注意,共享锁是在操作系统磁盘缓存上,而不是在磁盘本身。通常,文件锁实际上只是操作系统内核中的标志。(详细信息取决于特定的操作系统层接口。)因此,如果操作系统崩溃或发生断电,锁将立即消失。通常,如果创建锁的进程退出,锁也会消失。


3.3. 从数据库中读取信息

获取共享锁后,我们可以开始从数据库文件读取信息。在这种情况下,我们假设缓存是冷的,因此必须首先从大容量存储读取信息到操作系统缓存,然后从操作系统缓存传输到用户空间。在随后的读取中,一些或所有信息可能已经存在于操作系统缓存中,因此只需要传输到用户空间。

通常只读取数据库文件中的部分页面。在本例中,我们显示了从八个页面中读取的三个页面。在典型的应用程序中,数据库将拥有数千个页面,查询通常只涉及这些页面的一小部分。


3.4. 获取保留锁

在对数据库进行更改之前,SQLite 首先在数据库文件上获取一个“保留”锁。保留锁类似于共享锁,因为保留锁和共享锁都允许其他进程从数据库文件读取。单个保留锁可以与来自其他进程的多个共享锁共存。但是,数据库文件上只能有一个保留锁。因此,一次只有一个进程可以尝试写入数据库。

保留锁的思路是,它表明一个进程打算在不久的将来修改数据库文件,但尚未开始进行修改。由于修改尚未开始,其他进程可以继续从数据库读取。但是,没有其他进程也应该开始尝试写入数据库。


3.5. 创建回滚日志文件

在对数据库文件进行任何更改之前,SQLite 首先创建一个单独的回滚日志文件,并将要更改的数据库页面的原始内容写入回滚日志。回滚日志的思路是,它包含将数据库恢复到其原始状态所需的所有信息。

回滚日志包含一个小的标头(在图表中以绿色显示),该标头记录了数据库文件的原始大小。因此,如果更改导致数据库文件增长,我们仍然知道数据库的原始大小。页面号与写入回滚日志的每个数据库页面一起存储。

当创建新文件时,大多数桌面操作系统(Windows、Linux、Mac OS X)实际上不会将任何内容写入磁盘。新文件只在操作系统磁盘缓存中创建。文件不会创建在大容量存储上,直到一段时间后,操作系统有空闲时间。这给用户一种印象,即 I/O 发生的速度远远快于进行真实磁盘 I/O 时可能的速度。我们在右侧的图表中说明了这个想法,通过显示新的回滚日志只出现在操作系统磁盘缓存中,而不在磁盘本身中。


3.6. 更改用户空间中的数据库页面

将原始页面内容保存在回滚日志中后,可以在用户内存中修改这些页面。每个数据库连接都有自己的私有用户空间副本,因此在用户空间中所做的更改仅对进行更改的数据库连接可见。其他数据库连接仍然看到操作系统磁盘缓存缓冲区中的信息,这些信息尚未更改。因此,即使一个进程忙于修改数据库,其他进程也可以继续读取其自己的原始数据库内容副本。


3.7. 将回滚日志文件刷新到大容量存储

下一步是将回滚日志文件的内容刷新到非易失性存储。正如我们将在后面看到的那样,这是确保数据库能够在意外断电情况下存活的关键步骤。此步骤也需要很长时间,因为写入非易失性存储通常是一个缓慢的操作。

此步骤通常比简单地将回滚日志刷新到磁盘更复杂。在大多数平台上,需要两个独立的刷新(或 fsync())操作。第一个刷新写入基本回滚日志内容。然后修改回滚日志的标头以显示回滚日志中的页面数。然后将标头刷新到磁盘。有关我们为什么进行此标头修改和额外刷新的详细信息将在本文的后面部分提供。


3.8. 获取独占锁

在对数据库文件本身进行更改之前,我们必须在数据库文件上获取一个独占锁。获取独占锁实际上是一个两步过程。首先,SQLite 获取一个“挂起”锁。然后它将挂起锁升级为独占锁。

挂起锁允许已经拥有共享锁的其他进程继续读取数据库文件。但它会阻止建立新的共享锁。挂起锁的思路是防止由大量读者引起的写入者饥饿。可能还有数十个,甚至数百个其他进程尝试读取数据库文件。每个进程在开始读取之前都会获取一个共享锁,读取它需要的内容,然后释放共享锁。但是,如果有很多不同的进程都从同一个数据库读取,那么可能会发生一个新进程始终在之前进程释放其共享锁之前获取其共享锁。因此,从未有过没有共享锁在数据库文件上的时刻,因此从未有机会让写入者获取独占锁。挂起锁旨在通过允许现有共享锁继续进行但阻止建立新的共享锁来防止这种循环。最终,所有共享锁都将清除,然后挂起锁将能够升级为独占锁。


3.9. 将更改写入数据库文件

一旦拥有独占锁,我们就知道没有其他进程正在从数据库文件读取,并且可以安全地将更改写入数据库文件。通常,这些更改只会达到操作系统磁盘缓存,而不会一直到达大容量存储。


3.10. 0 将更改刷新到大容量存储

必须进行另一个刷新以确保所有数据库更改都写入非易失性存储。这是确保数据库能够在断电情况下存活而不会损坏的关键步骤。但是,由于写入磁盘或闪存的固有缓慢性,此步骤以及上面第 3.7 节中的回滚日志文件刷新占据了完成 SQLite 事务提交所需的大部分时间。


3.11. 1 删除回滚日志

在所有数据库更改安全地位于大容量存储设备上后,回滚日志文件将被删除。这是事务提交的时刻。如果在这一点之前发生断电或系统崩溃,那么后面将描述的恢复过程将使其看起来好像从未对数据库文件进行过任何更改。如果在删除回滚日志之后发生断电或系统崩溃,那么看起来好像所有更改都已写入磁盘。因此,SQLite 会根据回滚日志文件是否存在,呈现出没有对数据库文件进行任何更改或对数据库文件进行了完整更改集的表象。

删除文件实际上不是一个原子操作,但从用户进程的角度来看,它看起来像原子操作。进程始终能够询问操作系统“此文件是否存在?”,并且进程将得到一个是或否的答案。在事务提交期间发生的断电之后,SQLite 将询问操作系统回滚日志文件是否存在。如果答案是“是”,则事务未完成,将回滚。如果答案是“否”,则意味着事务已提交。

事务的存在取决于回滚日志文件是否存在,而从用户空间进程的角度来看,删除文件似乎是一个原子操作。因此,事务看起来像一个原子操作。

在许多系统上,删除文件的行为很昂贵。作为优化,SQLite 可以配置为将日志文件截断为零字节长度或用零覆盖日志文件标头。在任何情况下,生成的日志文件都将无法回滚,因此事务仍然会提交。将文件截断为零长度,就像删除文件一样,从用户进程的角度来看,它被认为是一个原子操作。用零覆盖日志文件的标头不是原子的,但是如果标头的任何部分格式错误,日志将不会回滚。因此,可以说提交发生在标头发生足够的变化使其无效的时刻。通常,这发生在标头的第一个字节被归零的时刻。


3.12. 2 释放锁

提交过程的最后一步是释放独占锁,以便其他进程可以再次开始访问数据库文件。

在右侧的图中,我们展示了当释放锁时,用户空间中保存的信息会被清除。在旧版本的 SQLite 中,这是字面上的意思。但在较新的 SQLite 版本中,用户空间的信息会保留在内存中,以备下次事务开始时可能需要再次使用。重复使用已在本地内存中的信息比从操作系统磁盘缓存中传输信息或再次从磁盘驱动器中读取信息要便宜。在重复使用用户空间中的信息之前,我们必须首先重新获取共享锁,然后我们必须检查在没有持有锁的情况下,是否有其他进程修改了数据库文件。数据库的第一页中有一个计数器,每次修改数据库文件时都会递增该计数器。我们可以通过检查该计数器来确定其他进程是否修改了数据库。如果数据库被修改,则用户空间缓存必须被清除并重新读取。但通常情况下,没有进行任何更改,用户空间缓存可以被重复使用,从而节省大量的性能。


4. 回滚

原子提交应该瞬间发生。但上面描述的处理过程显然需要一定的时间。假设在上面描述的提交操作过程中,计算机电源被切断。为了保持更改是瞬间发生的错觉,我们必须“回滚”任何部分更改,并将数据库恢复到事务开始之前的状态。

4.1. 当出现错误时...

假设电源丢失发生在上面的步骤 3.10 中,而数据库更改正在写入磁盘。恢复电源后,情况可能类似于右侧所示。我们试图更改数据库文件的三个页面,但只有一个页面成功写入。另一个页面部分写入,而第三个页面根本没有写入。

当电源恢复时,回滚日志完整无损地保存在磁盘上。这是一个关键点。在步骤 3.7 中进行刷新操作的原因是,为了确保在对数据库文件本身进行任何更改之前,所有回滚日志都安全地保存在非易失性存储器中。


4.2. 热回滚日志

任何 SQLite 进程第一次尝试访问数据库文件时,都会获取一个共享锁,如上面的部分 3.2 所述。但随后它会注意到存在一个回滚日志文件。然后 SQLite 会检查回滚日志是否为“热日志”。热日志是一个回滚日志,需要回放才能将数据库恢复到正常状态。热日志仅在之前进程在提交事务过程中崩溃或断电时才存在。

如果以下所有条件都为真,则回滚日志为“热”日志

热日志的存在表明,之前的进程试图提交事务,但在提交完成之前由于某种原因中止了。热日志意味着数据库文件处于不一致状态,需要在使用之前修复(通过回滚)。


4.3. 获取数据库的独占锁

处理热日志的第一步是获取数据库文件的独占锁。这可以防止两个或多个进程同时尝试回滚同一个热日志。


4.4. 回滚不完整的更改

一旦进程获取独占锁,它就可以写入数据库文件。然后它继续从回滚日志中读取页面的原始内容,并将该内容写回数据库文件中它来自的位置。回想一下,回滚日志的报头记录了事务开始之前数据库文件的原始大小。SQLite 使用此信息将数据库文件截断回其原始大小,以防不完整的交易导致数据库增长。在此步骤结束时,数据库应该与中止事务开始之前的大小和内容相同。


4.5. 删除热日志

将回滚日志中的所有信息回放回数据库文件(并刷新到磁盘,以防我们遇到另一个电源故障)后,可以删除热回滚日志。

部分 3.11 所述,日志文件可能会被截断为零长度,或者其报头可能会被零覆盖,作为对文件系统上删除文件开销大的系统的优化。无论哪种方式,日志在此步骤之后不再是热日志。


4.6. 继续,就像未完成的写入从未发生过一样

最终的恢复步骤是将独占锁降级为共享锁。一旦发生这种情况,数据库将恢复到中止事务从未开始时的状态。由于所有这些恢复活动都是完全自动且透明地发生的,因此对于使用 SQLite 的程序来说,它看起来就像中止事务从未开始一样。


5. 多文件提交

SQLite 允许单个数据库连接通过使用ATTACH DATABASE 命令同时与两个或多个数据库文件进行通信。当在单个事务中修改多个数据库文件时,所有文件都会原子地更新。换句话说,要么所有数据库文件都被更新,要么所有数据库文件都不被更新。跨多个数据库文件实现原子提交比对单个文件实现原子提交更复杂。本节描述了 SQLite 如何实现这一功能。

5.1. 每个数据库的独立回滚日志

当事务涉及多个数据库文件时,每个数据库都有自己的回滚日志,每个数据库都单独锁定。右侧的图示显示了一个场景,其中三个不同的数据库文件在同一个事务中被修改。此步骤中的情况类似于步骤 3.6 中的单文件事务场景。每个数据库文件都有一个保留锁。对于每个数据库,正在更改的页面的原始内容都已写入该数据库的回滚日志,但日志的内容尚未刷新到磁盘。尚未对数据库文件本身进行任何更改,但可能在用户内存中保留了更改。

为了简洁起见,本节中的图示简化了之前的图示。蓝色仍然表示原始内容,粉色仍然表示新内容。但是,回滚日志和数据库文件中的各个页面没有显示,我们也没有区分操作系统缓存中的信息和磁盘上的信息。所有这些因素在多文件提交场景中仍然适用。它们只是在图表中占用了很多空间,而且没有添加任何新的信息,因此在这里省略。


5.2. 超级日志文件

多文件提交的下一步是创建“超级日志”文件。超级日志文件的名称与原始数据库文件名相同(使用sqlite3_open() 接口打开的数据库,而不是ATTACHed 的辅助数据库),并附加文本“-mjHHHHHHHH”,其中HHHHHHHH 是一个随机的 32 位十六进制数。对于每个新的超级日志,随机的HHHHHHHH 后缀都会改变。

(请注意:上一段中给出的根据超级日志文件名计算超级日志文件名的公式对应于 SQLite 版本 3.5.0 的实现。但这个公式不是 SQLite 规范的一部分,可能会在将来的版本中更改。)

与回滚日志不同,超级日志不包含任何原始数据库页面内容。相反,超级日志包含参与事务的每个数据库的回滚日志的完整路径名。

构建超级日志后,会在采取任何进一步的操作之前将其内容刷新到磁盘。在 Unix 上,还会同步包含超级日志的目录,以确保在电源故障后超级日志文件会出现在该目录中。

超级日志的目的是确保跨电源丢失的多文件事务是原子的。但是,如果数据库文件具有其他设置,这些设置会损害跨电源丢失事件的完整性(例如PRAGMA synchronous=OFFPRAGMA journal_mode=MEMORY),则会省略创建超级日志,作为一种优化。

5.3. 更新回滚日志报头

下一步是在每个回滚日志的报头中记录超级日志文件的完整路径名。在创建回滚日志时,在每个回滚日志的开头预留了用于保存超级日志文件名的空间。

在将超级日志文件名写入回滚日志报头之前和之后,都会将每个回滚日志的内容刷新到磁盘。重要的是要执行这两个刷新操作。幸运的是,第二次刷新通常开销很小,因为通常只有一页日志文件(第一页)发生了更改。

此步骤类似于上面描述的单文件提交场景中的步骤 3.7


5.4. 更新数据库文件

一旦所有回滚日志文件都被刷新到磁盘,就可以安全地开始更新数据库文件。我们必须在写入更改之前获取所有数据库文件的独占锁。写入所有更改后,重要的是将更改刷新到磁盘,以便在电源故障或操作系统崩溃的情况下保留更改。

此步骤对应于之前描述的单文件提交场景中的步骤3.83.93.10


5.5. 删除超级日志文件

下一步是删除超级日志文件。这是多文件事务提交的时刻。此步骤对应于单文件提交场景中的步骤 3.11,其中回滚日志被删除。

如果在此步骤中发生电源故障或操作系统崩溃,则即使存在回滚日志,事务也不会在系统重启时回滚。区别在于回滚日志报头中的超级日志文件名。重启后,SQLite 仅将日志视为热日志,并且仅在报头中没有超级日志文件名(这是单文件提交的情况)或超级日志文件仍然存在于磁盘上时才会回放该日志。


5.6. 清理回滚日志

多文件提交的最后一步是删除各个回滚日志,并释放数据库文件上的独占锁,以便其他进程可以查看更改。这对应于步骤 3.12 单文件提交序列。

此时事务已经提交,因此回滚日志的删除时间并不关键。当前实现删除单个回滚日志,然后解锁相应的数据库文件,然后继续处理下一个回滚日志。但在将来,我们可能会更改此操作,以便在解锁任何数据库文件之前删除所有回滚日志。只要在解锁相应的数据库文件之前删除回滚日志,回滚日志的删除顺序或数据库文件的解锁顺序并不重要。

6. 提交流程的更多细节

第 3.0 节 以上概述了 SQLite 中原子提交的工作原理。但它忽略了许多重要的细节。以下小节将尝试填补这些空白。

6.1. 始终日志完整扇区

当将数据库页面的原始内容写入回滚日志时(如第 3.5 节 所示),SQLite 始终写入完整的扇区数据,即使数据库的页面大小小于扇区大小。历史上,SQLite 中的扇区大小一直硬编码为 512 字节,并且由于最小页面大小也是 512 字节,因此这从未成为问题。但从 SQLite 版本 3.3.14 开始,SQLite 可以使用扇区大小大于 512 字节的大容量存储设备。因此,从 3.3.14 版本开始,只要扇区内的任何页面被写入日志文件,该扇区中的所有页面都会与之一起存储。

将扇区的所有页面存储在回滚日志中对于在写入扇区时防止断电后数据库损坏至关重要。假设页面 1、2、3 和 4 都存储在扇区 1 中,并且页面 2 被修改。为了将对页面 2 的更改写入,底层硬件还必须重写页面 1、3 和 4 的内容,因为硬件必须写入完整的扇区。如果此写入操作因断电而中断,页面 1、3 或 4 中的一个或多个可能会保留不正确的数据。因此,为了避免对数据库造成持久损坏,所有这些页面的原始内容必须包含在回滚日志中。

6.2. 处理写入日志文件中的垃圾数据

当数据附加到回滚日志末尾时,SQLite 通常悲观地假设该文件首先使用无效的“垃圾”数据进行扩展,然后正确的数据替换垃圾数据。换句话说,SQLite 假设文件大小首先增加,然后之后内容被写入文件。如果在文件大小增加后但文件内容写入之前发生断电,回滚日志可能会保留垃圾数据。如果在恢复电源后,另一个 SQLite 进程看到回滚日志包含垃圾数据并尝试将其回滚到原始数据库文件,它可能会将一些垃圾复制到数据库文件并因此损坏数据库文件。

SQLite 使用两种防御措施来应对这个问题。首先,SQLite 在回滚日志的标头中记录回滚日志中的页面数。该数字最初为零。因此,在尝试回滚不完整(可能已损坏)的回滚日志期间,执行回滚的进程将看到该日志包含零页,因此不会对数据库进行任何更改。在提交之前,回滚日志被刷新到磁盘以确保所有内容已同步到磁盘并且文件中没有留下任何“垃圾”,并且只有在那之后,标头中的页面计数才从零更改为回滚日志中的实际页面数。回滚日志标头始终与任何页面数据位于不同的扇区中,以便它可以在不冒断电时损坏数据页的风险的情况下被覆盖和刷新。请注意,回滚日志被刷新到磁盘两次:一次写入页面内容,第二次写入标头中的页面计数。

上一段描述了同步 pragma 设置为“full”时发生的情况。

PRAGMA synchronous=FULL;

默认同步设置是 full,因此上面是通常发生的事情。但是,如果将同步设置降低到“normal”,SQLite 仅在写入页面计数之后刷新一次回滚日志。这存在损坏的风险,因为可能发生修改后的(非零)页面计数在所有数据到达磁盘表面之前到达磁盘表面。数据将首先被写入,但 SQLite 假设底层文件系统可以重新排序写入请求,并且页面计数可以首先被刻录到氧化层中,即使它的写入请求是最后发生的。因此,作为第二道防线,SQLite 还对回滚日志中每个数据页面使用 32 位校验和。在回滚日志时,在回滚日志时,如第 4.4 节 所述,会对每个页面进行校验和评估。如果看到不正确的校验和,则回滚将被放弃。请注意,校验和不能保证页面数据是正确的,因为存在很小的但有限的可能性,即即使数据已损坏,校验和也可能是正确的。但至少校验和使这种错误不太可能发生。

请注意,如果同步设置是 FULL,则回滚日志中的校验和不是必需的。我们仅在同步降低到 NORMAL 时依赖校验和。然而,校验和从不会造成伤害,因此无论同步设置如何,它们都包含在回滚日志中。

6.3. 提交前的缓存溢出

第 3.0 节 中显示的提交过程假设所有数据库更改都适合内存,直到提交时。这是常见情况。但有时,较大的更改将在事务提交之前溢出用户空间缓存。在这些情况下,缓存必须溢出到数据库,然后才能完成事务。

在缓存溢出开始时,数据库连接的状态如步骤 3.6 所示。原始页面内容已保存在回滚日志中,页面的修改存在于用户内存中。为了溢出缓存,SQLite 执行步骤3.73.9。换句话说,回滚日志被刷新到磁盘,获取独占锁,并将更改写入数据库。但其余步骤推迟到事务真正提交时。一个新的日志标头被附加到回滚日志的末尾(在其自己的扇区中),并且独占数据库锁被保留,但否则处理返回到步骤 3.6。当事务提交或发生另一个缓存溢出时,步骤3.73.9 被重复。(步骤3.8 在第二次及后续传递中被省略,因为由于第一次传递而已经持有独占数据库锁。)

缓存溢出会导致数据库文件上的锁从保留升级到独占。这会降低并发性。缓存溢出还会导致额外的磁盘刷新或 fsync 操作发生,而这些操作速度很慢,因此缓存溢出会严重降低性能。出于这些原因,应尽可能避免缓存溢出。

7. 优化

性能分析表明,对于大多数系统和大多数情况,SQLite 将大部分时间花在磁盘 I/O 上。因此,我们可以做任何减少磁盘 I/O 量的事情,都可能会对 SQLite 的性能产生很大的积极影响。本节描述了 SQLite 使用的一些技术,试图将磁盘 I/O 量减少到最低限度,同时仍然保持原子提交。

7.1. 在事务之间保留缓存

步骤 3.12 的提交过程表明,一旦共享锁被释放,所有用户空间数据库内容缓存映像都必须被丢弃。这样做是因为如果没有共享锁,其他进程可以自由修改数据库文件内容,因此任何用户空间该内容的映像都可能变得过时。因此,每个新的事务都将从重新读取以前已读取的数据开始。这乍一看并没有那么糟糕,因为被读取的数据仍然可能在操作系统文件缓存中。因此,“读取”实际上只是从内核空间复制数据到用户空间。但即使这样,仍然需要时间。

从 SQLite 版本 3.3.14 开始,添加了一种机制来尝试减少不必要的重新读取数据。在较新版本的 SQLite 中,当数据库文件上的锁被释放时,用户空间页面缓存中的数据被保留。之后,在下一事务开始时获取共享锁之后,SQLite 检查是否有其他进程修改了数据库文件。如果数据库自上次释放锁以来以任何方式发生更改,则用户空间缓存将在此时被清除。但通常数据库文件保持不变,用户空间缓存可以被保留,并且可以避免一些不必要的读取操作。

为了确定数据库文件是否发生了更改,SQLite 使用数据库标头中的一个计数器(在字节 24 到 27 中),该计数器在每次更改操作期间都会递增。SQLite 在释放其数据库锁之前保存了该计数器的副本。然后,在获取下一个数据库锁之后,它将保存的计数器值与当前计数器值进行比较,如果值不同,则清除缓存,如果值相同,则重新使用缓存。

7.2. 独占访问模式

SQLite 版本 3.3.14 添加了“独占访问模式”的概念。在独占访问模式下,SQLite 在每个事务结束时保留独占数据库锁。这会阻止其他进程访问数据库,但在许多部署中只有一个进程使用数据库,因此这不是一个严重的问题。独占访问模式的优势是磁盘 I/O 可以通过三种方式减少

  1. 对于第一个事务之后的交易,没有必要递增数据库标头中的更改计数器。这通常会保存将页面 1 写入回滚日志和主数据库文件的操作。

  2. 没有其他进程可以更改数据库,因此在事务开始时永远不需要检查更改计数器和清除用户空间缓存。

  3. 每个事务都可以通过用零覆盖回滚日志标头来提交,而不是删除日志文件。这避免了必须修改日志文件的目录条目,并且避免了必须释放与日志关联的磁盘扇区。此外,下一个事务将覆盖现有的日志文件内容,而不是附加新内容,并且在大多数系统上,覆盖比附加快得多。

第三个优化,即用零覆盖日志文件标头而不是删除回滚日志文件,根本不依赖于始终持有独占锁。这种优化可以独立于独占锁模式进行设置,使用journal_mode pragma,如以下第 7.6 节 所述。

7.3. 不要日志化空闲列表页

当从 SQLite 数据库中删除信息时,用于保存已删除信息的页面将被添加到 "空闲列表"。后续的插入操作将从该空闲列表中获取页面,而不是扩展数据库文件。

一些空闲列表页面包含关键数据;特别是其他空闲列表页面的位置。但大多数空闲列表页面不包含任何有用信息。这些后者的空闲列表页面被称为“叶”页面。我们可以自由地在数据库中修改叶空闲列表页面的内容,而不会以任何方式改变数据库的含义。

由于叶空闲列表页面的内容无关紧要,因此 SQLite 在提交过程的 步骤 3.5 中避免将叶空闲列表页面内容存储在回滚日志中。如果叶空闲列表页面被修改,并且该修改在事务恢复期间没有被回滚,则数据库不会因遗漏而受到损害。类似地,新空闲列表页面的内容永远不会在 步骤 3.9 中写回数据库,也不会在 步骤 3.3 中从数据库中读取。这些优化可以大大减少对包含空闲空间的数据库文件进行更改时发生的 I/O 量。

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

从 SQLite 3.5.0 版本开始,新的虚拟文件系统 (VFS) 接口包含一个名为 xDeviceCharacteristics 的方法,该方法报告底层大容量存储设备可能具有的特殊属性。xDeviceCharacteristics 可能报告的特殊属性包括执行原子扇区写入的能力。

回想一下,默认情况下,SQLite 假设扇区写入是线性的,但不是原子的。线性写入从扇区的末端开始,逐字节更改信息,直到到达扇区的另一端。如果在线性写入的中间发生断电,则扇区的一部分可能会被修改,而另一端则保持不变。在原子扇区写入中,要么整个扇区被覆盖,要么扇区中没有任何内容被更改。

我们认为大多数现代磁盘驱动器都实现了原子扇区写入。当断电时,驱动器使用存储在电容器和/或磁盘盘片角动量中的能量来为完成正在进行的任何操作提供能量。然而,在写入系统调用和板载磁盘驱动器电子设备之间有如此多的层,以至于我们在 Unix 和 w32 VFS 实现中都采取了安全的做法,并假设扇区写入不是原子的。另一方面,对文件系统有更多控制的设备制造商可能希望考虑启用 xDeviceCharacteristics 的原子写入属性,如果他们的硬件确实执行了原子写入。

当扇区写入是原子性的,并且数据库的页面大小与扇区大小相同时,并且当只触及单个数据库页面的数据库更改时,SQLite 将跳过整个日志记录和同步过程,而是简单地将修改后的页面直接写入数据库文件。数据库文件第一页中的更改计数器将单独修改,因为如果在更改计数器更新之前发生断电,则不会造成任何损害。

7.5. 具有安全追加语义的文件系统

SQLite 3.5.0 版本中引入的另一个优化利用了底层磁盘的“安全追加”行为。回想一下,SQLite 假设当将数据追加到文件(特别是回滚日志)时,文件的大小会先增加,然后才会写入内容。因此,如果在文件大小增加后但内容写入之前发生断电,则文件将保留包含无效“垃圾”数据的状态。但是,VFS 的 xDeviceCharacteristics 方法可能表明文件系统实现了“安全追加”语义。这意味着内容是在文件大小增加之前写入的,因此不可能通过断电或系统崩溃将垃圾数据引入回滚日志。

当文件系统指示安全追加语义时,SQLite 始终在回滚日志的标头中将页面计数存储为特殊值 -1。-1 页面计数值告诉任何尝试回滚日志的过程,日志中的页面数量应从日志大小计算得出。此 -1 值永远不会更改。因此,当发生提交时,我们保存单个刷新操作和日志文件第一页的扇区写入。此外,当发生缓存溢出时,我们不再需要将新的日志标头追加到日志的末尾;我们可以简单地继续将新页面追加到现有日志的末尾。

7.6. 持久回滚日志

在许多系统上,删除文件是一个代价高昂的操作。因此,作为优化,SQLite 可以配置为避免 部分 3.11 中的删除操作。不是为了提交事务而删除日志文件,而是将文件截断为零字节长度或将其标头覆盖为零。将文件截断为零长度可以节省对包含该文件的目录进行修改,因为该文件不会从目录中删除。覆盖标头还可以节省不必更新文件长度(在许多系统上的“inode”中)以及不必处理新释放的磁盘扇区。此外,在下一个事务中,日志将通过覆盖现有内容而不是将新内容追加到文件的末尾来创建,而覆盖通常比追加快得多。

SQLite 可以配置为通过将日志标头覆盖为零而不是删除日志文件来提交事务,方法是使用 journal_mode PRAGMA 设置“PERSIST”日志模式。例如

PRAGMA journal_mode=PERSIST;

使用持久日志模式在许多系统上提供了明显的性能提升。当然,缺点是日志文件仍然保留在磁盘上,使用磁盘空间并使目录混乱,即使在事务提交很久之后也是如此。删除持久日志文件的唯一安全方法是使用设置为 DELETE 的日志模式提交事务。

PRAGMA journal_mode=DELETE;
BEGIN EXCLUSIVE;
COMMIT;

注意不要通过任何其他方式删除持久日志文件,因为日志文件可能很热,在这种情况下删除它会损坏相应的数据库文件。

从 SQLite 3.6.4 版本(2008-10-15)开始,也支持 TRUNCATE 日志模式。

PRAGMA journal_mode=TRUNCATE;

在截断日志模式下,事务通过将日志文件截断为零长度来提交,而不是删除日志文件(如 DELETE 模式)或将标头置零(如 PERSIST 模式)。TRUNCATE 模式具有 PERSIST 模式的优点,即包含日志文件和数据库的目录不需要更新。因此,截断文件通常比删除文件快。TRUNCATE 具有额外的优点,即它不会被系统调用(例如:fsync())跟随,以将更改同步到磁盘。如果确实如此,它可能会更安全。但是在许多现代文件系统中,截断是一个原子且同步的操作,因此我们认为 TRUNCATE 在断电的情况下通常是安全的。如果您不确定 TRUNCATE 是否会在您的文件系统上同步且原子,并且您希望数据库能够在截断操作期间发生的断电或操作系统崩溃中存活下来,那么您可能需要考虑使用其他日志模式。

在具有同步文件系统的嵌入式系统上,TRUNCATE 会导致比 PERSIST 更慢的行为。提交操作的速度相同。但是,在 TRUNCATE 之后,后续的事务会变慢,因为覆盖现有内容比追加到文件的末尾快。新的日志文件条目将在 TRUNCATE 后始终被追加,但在 PERSIST 中通常会覆盖。

8. 测试原子提交行为

SQLite 的开发人员相信它在断电和系统崩溃的情况下是稳健的,因为自动测试过程对 SQLite 从模拟断电中恢复的能力进行了广泛的检查。我们称之为“崩溃测试”。

SQLite 中的崩溃测试使用修改后的 VFS,可以模拟断电或操作系统崩溃期间发生的各种文件系统损坏。崩溃测试 VFS 可以模拟不完整的扇区写入、由于写入未完成而导致页面填充垃圾数据,以及乱序写入,所有这些都在测试场景中的不同点发生。崩溃测试一遍又一遍地执行事务,改变模拟断电发生的时间以及造成的损坏属性。然后,每个测试都会在模拟崩溃后重新打开数据库,并验证事务是完全发生还是根本没有发生,并且数据库处于完全一致的状态。

SQLite 中的崩溃测试发现恢复机制中存在一些非常细微的错误(现在已修复)。其中一些错误非常模糊,不太可能仅使用代码检查和分析技术发现。从这种经验中,SQLite 的开发人员确信,任何其他没有使用类似崩溃测试系统的数据库系统可能包含未检测到的错误,这些错误会导致系统崩溃或断电后数据库损坏。

9. 可能出错的事情

SQLite 中的原子提交机制已被证明是稳健的,但它可以被足够有创意的攻击者或足够破损的操作系统实现绕过。本节描述了 SQLite 数据库可能会因断电或系统崩溃而损坏的几种方式。(另请参见:如何损坏您的数据库文件。)

9.1. 锁定实现有缺陷

SQLite 使用文件系统锁来确保一次只有一个进程和数据库连接尝试修改数据库。文件系统锁定机制是在 VFS 层实现的,并且每个操作系统都不相同。SQLite 依赖于此实现是正确的。如果出现问题,并且两个或多个进程能够同时写入同一个数据库文件,则会导致严重损坏。

我们已经收到关于 Windows 网络文件系统和 NFS 实现的报告,其中锁定存在细微的错误。我们无法验证这些报告,但由于在网络文件系统上正确锁定很困难,因此我们没有理由怀疑它们。建议您首先避免在网络文件系统上使用 SQLite,因为性能会很慢。但是,如果您必须使用网络文件系统来存储 SQLite 数据库文件,请考虑使用辅助锁定机制来防止同时写入同一个数据库,即使本机文件系统锁定机制出现故障。

Apple Mac OS X 计算机预装的 SQLite 版本包含已扩展为使用在 Apple 支持的所有网络文件系统上都能正常工作的替代锁定策略的 SQLite 版本。Apple 使用的这些扩展只要所有进程以相同的方式访问数据库文件,效果就很好。不幸的是,锁定机制不会相互排斥,因此,如果一个进程使用(例如)AFP 锁定访问文件,而另一个进程(可能在另一台机器上)使用点文件锁,则这两个进程可能会发生冲突,因为 AFP 锁定不排除点文件锁,反之亦然。

9.2. 不完整的磁盘刷新

SQLite 使用 Unix 上的 fsync() 系统调用和 w32 上的 FlushFileBuffers() 系统调用来将文件系统缓冲区同步到磁盘氧化物,如 步骤 3.7步骤 3.10 所示。不幸的是,我们已经收到关于这些接口中的任何一个都没有按预期在许多系统上工作。我们听说 FlushFileBuffers() 可以使用一些 Windows 版本上的注册表设置完全禁用。我们听说,一些历史版本的 Linux 包含一些文件系统上的 fsync() 版本,这些版本是无操作的。即使在据说 FlushFileBuffers() 和 fsync() 正在工作的系统上,IDE 磁盘控制通常也会撒谎,声称数据已到达氧化物,而实际上它只保留在易失性控制缓存中。

在 Mac 上,您可以设置此 pragma

PRAGMA fullfsync=ON;

在 Mac 上设置 fullfsync 将保证数据在刷新时确实被推送到磁盘磁片。但是,fullfsync 的实现涉及重置磁盘控制器。因此,它不仅速度极慢,而且还会减慢其他无关的磁盘 I/O 速度。因此,不建议使用它。

9.3. 部分文件删除

SQLite 假设从用户进程的角度来看,文件删除是一个原子操作。如果在文件删除过程中断电,那么在恢复电源后,SQLite 预计会看到整个文件及其所有原始数据都完好无损,或者根本找不到该文件。如果系统无法正常工作,则事务可能不会原子化。

9.4. 写入文件的垃圾数据

SQLite 数据库文件是普通的磁盘文件,可以由普通用户进程打开和写入。一个流氓进程可以打开 SQLite 数据库并用损坏的数据填充它。操作系统或磁盘控制器中的错误也可能会将损坏的数据引入 SQLite 数据库;特别是由电源故障触发的错误。SQLite 无法防御此类问题。

9.5. 删除或重命名热日志

如果发生崩溃或断电,并且热日志保留在磁盘上,那么在数据库文件被另一个 SQLite 进程打开并回滚之前,原始数据库文件和热日志必须保留在磁盘上,并保留其原始名称。在 步骤 4.2 恢复期间,SQLite 通过在与正在打开的数据库相同的目录中查找一个文件来定位热日志,该文件的名称是从正在打开的文件的名称派生的。如果原始数据库文件或热日志已被移动或重命名,则不会看到热日志,并且数据库不会回滚。

我们怀疑 SQLite 恢复的常见故障模式是这样的:发生电源故障。恢复电源后,一位好心的用户或系统管理员开始在磁盘上寻找损坏的文件。他们看到了他们的数据库文件,名为“important.data”。这个文件可能对他们很熟悉。但在崩溃之后,还有一个名为“important.data-journal”的热日志。然后,用户删除了热日志,认为他们在帮助清理系统。除了用户教育之外,我们不知道如何防止这种情况发生。

如果存在指向数据库文件的多个(硬链接或符号链接),则将使用通过其打开文件的链接的名称创建日志。如果发生崩溃并且数据库使用不同的链接再次打开,则将找不到热日志,并且不会发生回滚。

有时,电源故障会导致文件系统损坏,从而导致最近更改的文件名被遗忘,并且文件被移动到“/lost+found”目录中。发生这种情况时,将找不到热日志,并且不会发生恢复。SQLite 尝试通过在同步日志文件本身的同时打开和同步包含回滚日志的目录来防止这种情况。但是,将文件移动到 /lost+found 可能会由与主数据库文件相同的目录中创建无关文件的无关进程引起。由于这是 SQLite 无法控制的,因此 SQLite 无法做任何事情来阻止它。如果您在容易受到此类文件系统命名空间损坏的系统上运行(我们认为大多数现代日志文件系统都是免疫的),那么您可能需要考虑将每个 SQLite 数据库文件放在自己的私有子目录中。

10. 未来方向和结论

偶尔,有人会发现 SQLite 中原子提交机制的新的故障模式,开发人员不得不打补丁。这种情况越来越少,故障模式也越来越模糊。但仍然认为 SQLite 的原子提交逻辑完全没有错误是不明智的。开发人员致力于尽快修复这些错误。

开发人员还在寻找优化提交机制的新方法。Unix(Linux 和 Mac OS X)和 Windows 的当前 VFS 实现对这些系统的行为做出了悲观的假设。在咨询了关于这些系统如何工作的专家之后,我们可能能够放宽对这些系统的一些假设,并允许它们更快地运行。特别是,我们怀疑大多数现代文件系统都具有安全追加属性,并且其中许多可能支持原子扇区写入。但在此之前,SQLite 将采取保守的方法,假设最坏的情况。

此页面最后修改于 2022-12-31 21:51:03 UTC