在线文档教程
Sqlite
其他 | Miscellaneous

Atomic Commit In SQLite

Atomic Commit In SQLite

1.介绍

2.硬件假设

3.单个文件提交

3.1. 初始状态

3.2. 获取读锁

3.3. 从数据库中读取信息

3.4. 获取预留锁

3.5. 创建回滚日记文件

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

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

3.8. 获得专有锁

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

3.10.0刷新到大容量存储

3.11.1删除回滚日志

3.12.2释放锁定

4.回滚

4.1.当出现错误...

4.2.热回滚日志

4.3.在数据库上获得一个独占锁

4.4.回滚不完整的更改

4.5.删除热日志

4.6.继续,因为如果未完成的作品从未发生过

5.多文件提交

5.1.为每个数据库单独回滚日志

5.2.主日志文件

5.3.更新回滚日记报头

5.4.更新数据库文件

5.5.删除主日记文件

5.6.清理回滚日志

6.提交过程的其他细节

6.1.总是杂志完整的部门

6.2.处理垃圾写入日记文件

6.3.缓存溢出在提交之前

7.优化

7.1.缓存保留在事务之间

7.2.独家访问模式

7.3.不要日志Freelist页面

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

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

7.6.持续回滚日志

8.测试原子提交行为

9.可能发生错误的事情

9.1.破坏的锁定实现

9.2.不完整的磁盘刷新

9.3.部分文件删除

9.4.垃圾写入文件

9.5.删除或重命名热日志

10.0未来方向和结论

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将尝试利用这一事实。

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的默认VFSes做出这样的假设。

SQLite假定文件删除从用户进程的角度来看是原子的。这意味着,如果SQLite请求在删除操作期间删除文件并且断电,则恢复供电后,如果原始内容未改变,文件将完全与所有文件一起存在,否则文件将不会在文件系统。如果恢复供电后,文件只能部分删除,如果某些数据已被更改或删除,或文件已被截断但未完全删除,则可能会导致数据库损坏。

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

默认情况下,SQLite假定操作系统调用写入一个字节范围不会损害或更改该范围以外的任何字节,即使在写入期间发生断电或操作系统崩溃。我们称之为“powersafe覆盖”属性。在版本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_master表中读入数据库模式,以便它能够知道如何解析INSERT语句并发现数据库文件中应该存储新信息的位置。

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

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

3.3.从数据库中读取信息

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

通常只读取数据库文件中的一部分页面。在这个例子中,我们展示了正在阅读的八页中的三页。在典型的应用程序中,数据库将有数千页,而查询通常只触及这些页面的一小部分。

3.4.获取预留锁

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

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

3.5.创建回滚日记文件

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

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

当创建一个新文件时,大多数桌面操作系统(Windows,Linux,Mac OS X)实际上不会将任何内容写入磁盘。新文件仅在操作系统磁盘缓存中创建。该文件不会在大量存储中创建,直到稍后的操作系统有空时。这给用户留下了一个印象,即进行实际磁盘I / O时,I / O发生的速度要快得多。我们在右图中说明了这个想法,方法是显示新的回滚日志仅出现在操作系统磁盘缓存中,而不出现在磁盘本身上。

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

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

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

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

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

3.8.获得专有锁

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

挂起的锁允许其他已有共享锁的进程继续读取数据库文件。但它阻止了建立新的共享锁。未决锁定背后的想法是为了防止由大量读者造成的作家饥饿。可能有数十个甚至数百个尝试读取数据库文件的其他进程。每个进程在开始读取之前获取共享锁,读取它需要的内容,然后释放共享锁。但是,如果有许多不同的进程都从同一个数据库读取数据,则可能会发生这样的情况,即在前一个进程释放其共享锁之前,新进程总是获取其共享锁。所以在数据库文件上没有共享锁的情况下,永远不会有瞬间,因此作者永远无法抓住排它锁。挂起的锁旨在通过允许现有的共享锁继续进行,但阻止建立新的共享锁来阻止该周期。最终,所有共享锁都将清除,然后挂起的锁将升级为独占锁。

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

一旦排除锁定,我们知道没有其他进程正在从数据库文件读取数据,并且将更改写入数据库文件是安全的。通常,这些更改只能达到操作系统磁盘高速缓存,并且不能完成大容量存储。

3.10.0刷新到大容量存储

必须进行另一次刷新以确保将所有数据库更改写入非易失性存储器。这是确保数据库在掉电时不会受到损坏的关键一步。但是,由于写入磁盘或闪存的内在缓慢,此步骤以及上述3.7节中的回滚日志文件flush占用了在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()接口打开的数据库,不是附加的辅助数据库之一)名称相同,并附有文本“ -mj HHHHHHHH ”,其中HHHHHHHHHHHHHHHH随机的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 synchronous=FULL;

默认的同步设置已满,所以以上是通常发生的情况。但是,如果同步设置降低到“正常”,SQLite仅在写入页数后刷新回退日志。这具有腐败风险,因为在所有数据执行之前,修改后的(非零)页数可能会达到磁盘表面。数据将首先写入,但SQLite假定底层文件系统可以重新排序写请求,并且页数可以先写入氧化物,即使其写入请求最后发生也是如此。因此,作为第二道防线,SQLite还在回滚日志的每个数据页上使用32位校验和。这个校验和是在回滚期间为每个页面评估的,同时按4.4节所述回滚日志。如果看到不正确的校验和,则放弃回滚。请注意,校验和并不能保证页面数据是正确的,因为即使数据被破坏,校验和也可能是正确的但概率很小但有限。但校验和至少不会造成这样的错误。

请注意,如果同步设置为FULL,则回滚日志中的校验和不是必需的。当同步降到NORMAL时,我们只依靠校验和。尽管如此,校验和从未受到伤害,因此无论同步设置如何,它们都包含在回滚日志中。

6.3.缓存溢出在提交之前

3.0节中所示的提交过程假定所有数据库更改都适合内存,直到需要提交为止。这是常见的情况。但有时在事务提交之前,更大的更改会溢出用户空间高速缓存。在这些情况下,在事务完成之前,缓存必须溢出到数据库。

在缓存溢出开始时,数据库连接的状态如步骤3.6所示。原始页面内容已保存在回滚日志中,并且页面的修改存在于用户内存中。为了溢出缓存,SQLite执行步骤3.7到3.9。换句话说,回滚日志刷新到磁盘,获得排他锁,并将更改写入数据库。但是其余的步骤被推迟到事务真正提交之前。一个新的日志标题被追加到回滚日志的末尾(在它自己的扇区中),并保留排他的数据库锁定,但否则处理返回到步骤3.6。当事务提交时,或者发生另一次缓存溢出时,重复步骤3.7和3.9。(第3步。

缓存溢出导致数据库文件的锁定从保留升级为独占。这降低了并发性。缓存溢出也会导致额外的磁盘刷新或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:

  • 在第一次事务之后,不必为数据库头增加变更计数器。这通常会将第一页写入回滚日志和主数据库文件。

  • 没有其他进程可以更改数据库,因此从不需要检查更改计数器并在事务开始时清除用户空间高速缓存。

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

第三次优化,归档日志文件头而不是删除回滚日志文件,不依赖于始终保持排它锁。如下面7.6节所述,可以使用journal_mode编译指示独立于独占锁定模式来设置此优化。

7.3.不要日志Freelist页面

当从SQLite数据库中删除信息时,用于保存已删除信息的页面将被添加到“freelist”中。后续插入操作将从该freelist中抽取页面,而不是展开数据库文件。

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

由于叶freelist页面的内容不重要,SQLite避免在提交过程的步骤3.5中将叶freelist页面内容存储在回滚日志中。如果叶freelist页面发生更改,并且该更改在事务恢复过程中未得到回滚,那么数据库不会因该省略而受到损害。同样,在步骤3.9,新的freelist页面的内容不会写回数据库,也不会在步骤3.3从数据库中读取。这些优化可以极大地减少更改包含可用空间的数据库文件时发生的I / O数量。

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

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

回想一下,默认情况下,SQLite假定扇区写入是线性的,但不是原子的。线性写入从扇区的一端开始,逐字节地改变信息,直到它到达扇区的另一端。如果在线性写入过程中发生功率损耗,则部分扇区可能会被修改,而另一端不变。在原子扇区编写中,整个扇区都被覆盖,否则扇区中没有任何变化。

我们相信大多数现代磁盘驱动器都实现原子扇区写入。当电源丢失时,驱动器使用存储在电容器中的能量和/或磁盘盘片的角动量来提供电力以完成正在进行的任何操作。尽管如此,在写入系统调用和板载磁盘驱动器电子元件之间还有很多层,我们在Unix和w32 VFS实现中采用安全方法,并假设扇区写入不是原子的。另一方面,对其文件系统有更多控制的设备制造商可能会考虑启用xDeviceCharacteristics的原子写入属性(如果他们的硬件确实进行了原子写入)。

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

7.5。具有安全附加语义的文件系统

Another optimization introduced in SQLite version 3.5.0 makes use of "safe append" behavior of the underlying disk. Recall that SQLite assumes that when data is appended to a file (specifically to the rollback journal) that the size of the file is increased first and that the content is written second. So if power is lost after the file size is increased but before the content is written, the file is left containing invalid "garbage" data. The xDeviceCharacteristics method of the VFS might, however, indicate that the filesystem implements "safe append" semantics. This means that the content is written before the file size is increased so that it is impossible for garbage to be introduced into the rollback journal by a power loss or system crash.

当为文件系统指示安全附加语义时,SQLite总是在回滚日志的标题中存储页数的特殊值-1。-1页计数值告诉任何试图回滚日志的进程,应该根据日志大小计算日志中的页数。这-1值永远不会改变。因此,当发生提交时,我们会保存日志文件第一页的单次刷新操作和扇区写入操作。此外,当发生缓存溢出时,我们不再需要将新的日志标题附加到日志的末尾; 我们可以简单地继续将新页面附加到现有日记帐的末尾。

7.6.持续回滚日志

在许多系统上删除文件是一项昂贵的操作。所以作为优化,可以配置SQLite以避免第3.11节的删除操作。为了提交事务而不是删除日志文件,文件或者被截断为零字节或者其头被零覆盖。将文件截断为零长度可省去对包含文件的目录进行修改,因为文件不会从目录中删除。覆盖头部有额外的节省,无需更新文件的长度(在许多系统的“inode”中),而不必处理新释放的磁盘扇区。此外,在下一次交易中,将通过覆盖现有内容而不是将新内容附加到文件末尾来创建日志,

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

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版本包含一个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.0未来方向和结论

每隔一段时间,有人会发现SQLite中原子提交机制的新失败模式,并且开发人员必须提供补丁。这种情况越来越少,失效模式变得越来越模糊。但假设SQLite的原子提交逻辑完全没有bug,这仍然是愚蠢的。开发人员致力于尽快解决这些错误。

开发人员也在寻找优化提交机制的新方法。Unix(Linux和Mac OS X)和Windows的当前VFS实现对这些系统的行为做出了悲观的假设。在向专家咨询这些系统的工作方式后,我们可以放松这些系统的一些假设,并让它们运行得更快。特别是,我们怀疑大多数现代文件系统都显示出安全附加属性,并且其中许多可能支持原子扇区写入。但是,直到这一点已知,SQLite将采取保守的方法,并假设最糟糕的。

SQLite在公共领域。