一、简介
像 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 在关键点做了“flush”或“fsync”操作。SQLite 假定刷新或 fsync 不会返回,直到正在刷新的文件的所有挂起的写操作都已完成。我们被告知 flush 和 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 假定操作系统调用写入一个字节范围不会损坏或更改该范围之外的任何字节,即使在写入期间发生断电或操作系统崩溃也是如此。我们称之为“ powersafe overwrite ”属性。在版本 3.7.9 (2011-11-01) 之前,SQLite 不承担 powersafe 覆盖。但是随着大多数磁盘驱动器上的标准扇区大小从 512 字节增加到 4096 字节,为了保持历史性能水平,有必要采用 powersafe 覆盖,因此在最新版本的 SQLite 中默认采用 powersafe 覆盖。如果需要,可以在编译时或运行时禁用 powersafe 覆盖属性的假设。请参阅powersafe 覆盖文档了解更多详情。
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 然后检查回滚日志是否是“热日志”。热日志是需要回放的回滚日志,以便将数据库恢复到正常状态。只有当较早的进程在崩溃或断电时正在提交事务的过程中时,才会存在热日志。
如果满足以下所有条件,则回滚日志是“热”日志:
- 回滚日志存在。
- 回滚日志不是空文件。
- 主数据库文件上没有保留锁。
- 回滚日志的标题格式正确,特别是没有被清零。
- 回滚日志不包含超级日志文件的名称(请参阅 下面的5.5 节),或者如果包含超级日志的名称,则该超级日志文件存在。
热日志的存在表明之前的进程正在尝试提交事务,但在提交完成之前由于某种原因中止了。热日志意味着数据库文件处于不一致状态,需要在使用前修复(通过回滚)。
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辅助数据库之一)同名,并附加文本“ -mj HHHHHHHH ”,其中 HHHHHHHH是一个随机的 32 位十六进制数。每个新超级期刊的随机HHHHHHHH后缀都会发生变化。
(注意:上一段中给出的计算超级日志文件名的公式对应于 SQLite 3.5.0 版的实现。但是这个公式不是 SQLite 规范的一部分,在未来的版本中可能会发生变化。)
与回滚日志不同,超级日志不包含任何原始数据库页面内容。相反,超级日志包含参与事务的每个数据库的回滚日志的完整路径名。
构建超级日志后,在采取任何进一步操作之前将其内容刷新到磁盘。在 Unix 上,包含超级日志的目录也会同步,以确保超级日志文件在电源故障后出现在目录中。
超级日志的目的是确保多文件事务在断电时是原子的。但是,如果数据库文件有其他设置会在断电事件中损害完整性(例如PRAGMA synchronous=OFF或PRAGMA journal_mode=MEMORY),那么超级日志的创建将被省略,作为一种优化。
5.3. 更新回滚日志标题
下一步是在每个回滚日志的头部记录超级日志文件的完整路径名。在创建回滚日志时,在每个回滚日志的开头保留用于保存超级日志文件名的空间。
在将超级日志文件名写入回滚日志头之前和之后,每个回滚日志的内容都会刷新到磁盘。进行这两种冲洗很重要。幸运的是,第二次刷新通常并不昂贵,因为通常只有日志文件的一页(第一页)发生了变化。
此步骤类似于 上述单文件提交场景中的步骤 3.7 。
5.4. 更新数据库文件
一旦所有回滚日志文件都刷新到磁盘,就可以安全地开始更新数据库文件了。在写入更改之前,我们必须获得所有数据库文件的独占锁。写入所有更改后,重要的是将更改刷新到磁盘,以便在发生电源故障或操作系统崩溃时保留它们。
此步骤对应于 前面描述的单文件提交场景中的步骤3.8、 3.9和 3.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”时会发生什么。
杂注同步=完整;
默认的同步设置是完整的,所以通常会发生上述情况。但是,如果将同步设置降低为“正常”,则 SQLite 仅在写入页数后刷新回滚日志一次。这会带来损坏的风险,因为修改后的(非零)页面计数可能会在所有数据到达磁盘表面之前发生。数据将首先写入,但 SQLite 假定底层文件系统可以重新排序写入请求,并且即使写入请求最后发生,页面计数也可以首先被写入氧化物。因此,作为第二道防线,SQLite 还在回滚日志中的每一页数据上使用 32 位校验和。如第 4.4 节所述,在回滚日志期间,将针对每个页面评估此校验和 . 如果发现校验和不正确,则放弃回滚。请注意,校验和不能保证页面数据正确,因为即使数据已损坏,校验和也可能正确的可能性很小但有限。但是校验和至少使这样的错误不太可能发生。
请注意,如果同步设置为 FULL,则不需要回滚日志中的校验和。当同步降低到正常时,我们只依赖于校验和。尽管如此,校验和永远不会受到伤害,因此无论同步设置如何,它们都包含在回滚日志中。
6.3. 提交前缓存溢出
3.0 节中显示的提交过程 假定所有数据库更改都适合内存,直到提交时为止。这是常见的情况。但有时较大的更改会在事务提交之前溢出用户空间缓存。在这些情况下,缓存必须在事务完成之前溢出到数据库。
在缓存溢出开始时,数据库连接的状态如步骤 3.6所示。原始页面内容已保存在回滚日志中,页面的修改存在于用户内存中。为了溢出缓存,SQLite 执行步骤3.7到 3.9。换句话说,回滚日志被刷新到磁盘,获得独占锁,并将更改写入数据库。但是剩下的步骤会被推迟到事务真正提交之后。一个新的日志头被附加到回滚日志的末尾(在它自己的扇区中)并且独占数据库锁被保留,但是否则处理返回到步骤 3.6。当事务提交时,或者如果发生另一个缓存溢出,步骤 重复3.7和3.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:
在第一个事务之后,没有必要为事务增加数据库头中的更改计数器。这通常会将第一页的写入保存到回滚日志和主数据库文件中。
没有其他进程可以更改数据库,因此永远不需要检查更改计数器并在事务开始时清除用户空间缓存。
每个事务都可以通过用零覆盖回滚日志标头而不是删除日志文件来提交。这避免了必须修改日志文件的目录条目,也避免了必须释放与日志关联的磁盘扇区。此外,下一个事务将覆盖现有的日志文件内容而不是附加新内容,并且在大多数系统上覆盖比附加快得多。
第三个优化,将日志文件头归零而不是删除回滚日志文件,并不依赖于始终持有排他锁。可以使用journal_mode pragma独立于独占锁定模式设置此优化 ,如下面第 7.6 节所述。
7.3. 不要记录自由列表页面
当从 SQLite 数据库中删除信息时,用于保存已删除信息的页面将添加到“自由列表”。随后的插入将从该空闲列表中提取页面,而不是扩展数据库文件。
一些空闲列表页面包含关键数据;特别是其他空闲列表页面的位置。但是大多数自由列表页面都不包含任何有用的内容。后面的这些空闲列表页面称为“叶”页面。我们可以随意修改数据库中一个叶freelist页面的内容,而不会以任何方式改变数据库的含义。
因为叶空闲列表页面的内容不重要,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 导致比 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 版本包含一个 SQLite 版本,该版本已扩展为使用适用于 Apple 支持的所有网络文件系统的替代锁定策略。只要所有进程都以相同的方式访问数据库文件,Apple 使用的这些扩展就可以很好地工作。不幸的是,锁定机制并不相互排斥,因此如果一个进程正在使用(例如)AFP 锁定访问文件而另一个进程(可能在不同的机器上)正在使用点文件锁,这两个进程可能会发生冲突,因为 AFP锁不排除点文件锁,反之亦然。
9.2. 不完整的磁盘刷新
SQLite 在 Unix 上使用 fsync() 系统调用,在 w32 上使用 FlushFileBuffers() 系统调用,以便将文件系统缓冲区同步到磁盘氧化物上,如步骤 3.7和 步骤 3.10所示。不幸的是,我们收到的报告表明,这些接口在许多系统上都无法像宣传的那样工作。我们听说在某些 Windows 版本上可以使用注册表设置完全禁用 FlushFileBuffers()。我们被告知,某些历史版本的 Linux 包含 fsync() 的版本,这些版本在某些文件系统上是无操作的。即使在据说 FlushFileBuffers() 和 fsync() 正在工作的系统上,IDE 磁盘控制也经常说谎并说数据已经达到氧化物,而它仍然只保存在易失性控制缓存中。
在 Mac 上,您可以设置此 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 无法阻止它。如果您运行的系统容易受到这种文件系统名称空间损坏的影响(大多数现代日志文件系统都是免疫的,
10.未来的方向和结论
时不时有人会发现 SQLite 中原子提交机制的新故障模式,开发人员必须打补丁。这种情况发生的越来越少,故障模式也越来越模糊。但是假设 SQLite 的原子提交逻辑完全没有错误仍然是愚蠢的。开发人员致力于尽快修复这些错误。
开发人员也在寻找优化提交机制的新方法。Unix(Linux 和 Mac OS X)和 Windows 的当前 VFS 实现对这些系统的行为做出了悲观的假设。在就这些系统的工作原理咨询了专家之后,我们或许可以放宽对这些系统的一些假设,让它们运行得更快。特别是,我们怀疑大多数现代文件系统都具有安全附加属性,并且其中许多可能支持原子扇区写入。但在确定这一点之前,SQLite 将采取保守的方法并假设最坏的情况。