Small. Fast. Reliable.
Choose any three.
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版本在OS接口层中提供了一种方法,该方法可查询底层文件系统以找到真实的扇区大小。按照当前实施(版本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。有人告诉我们在某些版本的Windows和Linux中,flush和fsync原语已损坏。这是不幸的。它使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语句并发现新信息应存储在数据库文件中的什么位置。

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

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


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()的界面,没有一个附加文本“辅助数据库)-mj HHHHHHHH附加”,其中 HHHHHHHH是随机的32位十六进制数。随机的HHHHHHHHH后缀会随每个新的超级新闻而变化。

(注意:上一段中提供的用于计算超级新闻文件名的公式与SQLite 3.5.0版中的实现相对应。但是此公式不是SQLite规范的一部分,并且在将来的版本中可能会发生更改。)

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

超级新闻报道构建完成后,其内容将被刷新到磁盘上,然后再采取进一步的措施。在Unix上,包含超级日志的目录也会被同步,以确保超级日志文件在电源故障后会出现在目录中。

超级新闻的目的是确保跨电源损耗的多文件事务是原子的。但是,如果数据库文件具有其他设置,这些设置会在掉电事件中危及完整性(例如PRAGMAynchronized = 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在回滚日志的标题中记录回滚日志中的页数。此数字最初为零。因此,在尝试回滚不完整(并且可能已损坏)的回滚日志时,执行回滚的过程将看到该日志包含零页,因此不会对数据库进行任何更改。在提交之前,将回滚日志刷新到磁盘,以确保所有内容都已同步到磁盘,并且文件中没有“垃圾”,只有在此之后,标头中的页数才从零更改为真数回滚日志中的页面数。回滚日志头始终与任何页面数据保持在单独的扇区中,因此可以在发生断电的情况下覆盖和刷新回滚日志头,而不会冒损坏数据页的风险。请注意,回滚日志两次刷新到磁盘:一次写入页内容,第二次写入页眉中的页数。

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

PRAGMA同步=满;

默认的同步设置已满,因此通常会发生上述情况。但是,如果将同步设置降低为“正常”,则在写入页数之后,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. 对于第一个事务之后的事务,不必增加数据库标头中的更改计数器。这通常会将第一页的写操作保存到回滚日志和主数据库文件中。

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

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

第三种优化是将日志文件头清零而不是删除回滚日志文件,这并不取决于始终保持互斥锁。可以使用journal_mode杂注独立于排他锁定模式来设置此优化 ,如下面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。例如:

PRAGMA journal_mode = PERSIST;

持久日志模式的使用在许多系统上提供了显着的性能改进。当然,缺点是在事务提交后很长时间,日志文件仍会使用磁盘空间和混乱的目录保留在磁盘上。删除持久日志文件的唯一安全方法是在日志记录模式设置为DELETE的情况下提交事务:

PRAGMA journal_mode =已删除;
开始独家经营;
犯罪;

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

从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()的版本,在某些文件系统上它们是no-ops。即使在据说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. 10.0未来方向和结论

时不时有人在SQLite中为原子提交机制发现新的失败模式,开发人员必须安装补丁程序。这种情况越来越少发生,并且故障模式变得越来越模糊。但是,假设SQLite的原子提交逻辑完全没有错误,那仍然是愚蠢的。开发人员致力于尽快修复这些错误。

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