小巧、快速、可靠。
选择任意三个。

本文档最初是在 2004 年初创建的,当时 SQLite 版本 2 仍在广泛使用,目的是向已经熟悉 SQLite 版本 2 的读者介绍 SQLite 版本 3 的新概念。但是如今,阅读本文档的大多数读者可能从未见过 SQLite 版本 2,只熟悉 SQLite 版本 3。尽管如此,本文档仍然是关于 SQLite 版本 3 中数据库文件锁定方式的权威参考。

本文档仅描述了旧的回滚模式事务机制的锁定。关于较新的 写后日志WAL 模式 的锁定,将在单独的文档中描述。

1.0 SQLite 3 版本中的文件锁定和并发

SQLite 版本 3.0.0 引入了新的锁定和日志记录机制,旨在提高 SQLite 版本 2 的并发性并减少写入者饥饿问题。新的机制还允许对涉及多个数据库文件的交易进行原子提交。本文档描述了新的锁定机制。目标受众是希望理解和/或修改页面代码的程序员,以及致力于验证 SQLite 版本 3 设计的审阅者。

2.0 概述

锁定和并发控制由 页面模块 处理。页面模块负责使 SQLite 成为“ACID”(原子性、一致性、隔离性和持久性)。页面模块确保更改一次发生,要么所有更改都发生,要么一个都不发生,两个或多个进程不会以不兼容的方式同时访问数据库,并且一旦更改被写入,它们将一直保留,直到显式删除。页面模块还提供了一些磁盘文件内容的内存缓存。

页面模块不关心 B 树、文本编码、索引等细节。从页面的角度来看,数据库由一个大小一致的块文件组成。每个块称为“页面”,通常大小为 1024 字节。页面从 1 开始编号。因此,数据库的前 1024 字节称为“页面 1”,第二个 1024 字节称为“页面 2”,依此类推。所有其他编码细节都由库的高层处理。页面模块使用几个模块(例如:os_unix.cos_win.c)与操作系统通信,这些模块为操作系统服务提供了一个统一的抽象。

页面模块有效地控制了单独线程或单独进程的访问。在本文档中,只要出现“进程”一词,您就可以用“线程”一词替换它,而不会改变陈述的真实性。

3.0 锁定

从单个进程的角度来看,数据库文件可以处于五种锁定状态之一。

UNLOCKED 数据库上没有锁定。数据库既不能读也不能写。任何内部缓存的数据都被认为是可疑的,并且在使用前需要与数据库文件进行验证。其他进程可以根据自己的锁定状态来读取或写入数据库。这是默认状态。
SHARED 数据库可以读取,但不能写入。任何数量的进程都可以同时持有 SHARED 锁,因此可以有多个同时读者。但是,当一个或多个 SHARED 锁处于活动状态时,不允许其他线程或进程写入数据库文件。
RESERVED RESERVED 锁表示进程计划在将来某个时候写入数据库文件,但目前只是从文件中读取。一次只能激活一个 RESERVED 锁,尽管多个 SHARED 锁可以与一个 RESERVED 锁共存。RESERVED 与 PENDING 不同,在存在 RESERVED 锁时可以获取新的 SHARED 锁。
PENDING PENDING 锁表示持有该锁的进程想要尽快写入数据库,并且只是等待所有当前 SHARED 锁清除,以便它可以获取 EXCLUSIVE 锁。如果存在 PENDING 锁,则不允许对数据库获取新的 SHARED 锁,尽管允许现有 SHARED 锁继续。
EXCLUSIVE 为了写入数据库文件,需要 EXCLUSIVE 锁。文件上只允许一个 EXCLUSIVE 锁,并且任何其他类型的锁都不允许与 EXCLUSIVE 锁共存。为了最大限度地提高并发性,SQLite 努力将 EXCLUSIVE 锁的持有时间降至最低。

操作系统接口层理解并跟踪上述五种锁定状态。页面模块仅跟踪五种锁定状态中的四种。PENDING 锁始终只是通往 EXCLUSIVE 锁的临时过渡,因此页面模块不跟踪 PENDING 锁。

4.0 回滚日志

当进程想要更改数据库文件(并且它不在 WAL 模式)时,它首先将原始未更改的数据库内容记录在回滚日志中。回滚日志是一个普通磁盘文件,它始终位于与数据库文件相同的目录或文件夹中,并且与数据库文件同名,并添加-journal后缀。回滚日志还记录数据库的初始大小,以便如果数据库文件增长,则可以在回滚时将其截断回原始大小。

如果 SQLite 同时处理多个数据库(使用 ATTACH 命令),则每个数据库都有自己的回滚日志。但还有一个单独的汇总日志,称为超级日志。超级日志不包含用于回滚更改的页面数据。相反,超级日志包含每个 ATTACHed 数据库的各个数据库回滚日志的名称。每个单独的数据库回滚日志还包含超级日志的名称。如果没有 ATTACHed 数据库(或者如果没有 ATTACHed 数据库参与当前事务),则不会创建超级日志,并且普通回滚日志在通常用于记录超级日志名称的位置包含一个空字符串。

如果不存在超级日志,则日志是的,如果需要回滚它才能恢复数据库的完整性。当进程正在更新数据库,并且程序或操作系统崩溃或断电导致更新无法完成时,就会创建热日志。热日志是异常情况。热日志存在是为了从崩溃和断电中恢复。如果一切正常(即没有崩溃或断电),您永远不会获得热日志。

如果不存在超级日志,则日志是热日志,如果它存在并且其头不为零,并且其对应的数据库文件没有 RESERVED 锁。如果文件中命名了超级日志,则文件日志是热日志,如果其超级日志存在并且对应数据库文件没有 RESERVED 锁。理解日志何时是热日志非常重要,因此将重复前面的规则。

4.1 处理热日志

在从数据库文件读取之前,SQLite 总是检查该数据库文件是否有热日志。如果文件确实有热日志,则在读取文件之前会回滚日志。通过这种方式,我们确保数据库文件在读取之前处于一致状态。

当进程想要从数据库文件读取时,它遵循以下步骤序列:

  1. 打开数据库文件并获取 SHARED 锁。如果无法获取 SHARED 锁,则立即失败并返回 SQLITE_BUSY。
  2. 检查数据库文件是否有热日志。如果文件没有热日志,则完成。立即返回。如果有热日志,则必须通过此算法的后续步骤回滚日志。
  3. 获取 PENDING 锁,然后获取数据库文件的 EXCLUSIVE 锁。(注意:不要获取 RESERVED 锁,因为这会使其他进程认为日志不再是热的。)如果我们无法获取这些锁,则意味着另一个进程正在尝试进行回滚。在这种情况下,放弃所有锁,关闭数据库,并返回 SQLITE_BUSY。
  4. 读取日志文件并回滚更改。
  5. 等待回滚的更改写入持久存储。这可以保护数据库的完整性,以防发生另一次电源故障或崩溃。
  6. 删除日志文件(或者如果设置了 PRAGMA journal_mode=TRUNCATE,则将日志截断为零字节长度,或者如果设置了 PRAGMA journal_mode=PERSIST,则将日志头置零)。
  7. 如果安全,则删除超级日志文件。此步骤是可选的。它仅是为了防止陈旧的超级日志占用磁盘空间。有关详细信息,请参阅下面的讨论。
  8. 释放 EXCLUSIVE 和 PENDING 锁,但保留 SHARED 锁。

上述算法成功完成之后,就可以安全地从数据库文件读取。完成所有读取后,会释放 SHARED 锁。

4.2 删除陈旧的超级日志

陈旧的超级日志是不再用于任何目的的超级日志。没有要求删除陈旧的超级日志。这样做唯一的目的是释放磁盘空间。

如果没有任何单个文件日志指向它,则超级日志是陈旧的。要确定超级日志是否陈旧,我们首先读取超级日志以获取其所有文件日志的名称。然后我们检查每个文件日志。如果超级日志中列出的任何文件日志都存在并指向超级日志,则超级日志不是陈旧的。如果所有文件日志都已丢失或引用其他超级日志或根本不引用超级日志,则我们正在测试的超级日志是陈旧的,并且可以安全地删除。

5.0 写入数据库文件

要写入数据库,进程必须首先获取如上所述的 SHARED 锁(如果存在热日志,则可能需要回滚未完成的更改)。获取 SHARED 锁后,必须获取 RESERVED 锁。RESERVED 锁指示进程打算在将来某个时候写入数据库。一次只能有一个进程持有 RESERVED 锁。但其他进程可以在持有 RESERVED 锁时继续读取数据库。

如果想要写入的进程无法获取 RESERVED 锁,则意味着另一个进程已经拥有 RESERVED 锁。在这种情况下,写入尝试失败并返回 SQLITE_BUSY。

获取 RESERVED 锁后,想要写入的进程会创建一个回滚日志。日志头会初始化为数据库文件原始大小。日志头中还会预留一个超级日志名称的空间,尽管超级日志名称最初为空。

在更改数据库任何页之前,进程会将该页的原始内容写入回滚日志。页面的更改最初保存在内存中,不会写入磁盘。原始数据库文件保持不变,这意味着其他进程可以继续读取数据库。

最终,写入进程将希望更新数据库文件,这可能是因为其内存缓存已满,也可能是因为它已准备好提交其更改。在此之前,写入者必须确保没有其他进程正在读取数据库,并且回滚日志数据已安全地写入磁盘表面,以便在发生电源故障时可以将其用于回滚未完成的更改。步骤如下:

  1. 确保所有回滚日志数据实际上都已写入磁盘表面(而不仅仅是保存在操作系统或磁盘控制器缓存中),以便如果发生电源故障,数据在恢复电源后仍然存在。
  2. 获取 PENDING 锁,然后获取数据库文件的 EXCLUSIVE 锁。如果其他进程仍拥有 SHARED 锁,写入者可能需要等待这些 SHARED 锁清除,然后才能获取 EXCLUSIVE 锁。
  3. 将内存中所有页面的修改写入原始数据库磁盘文件。

如果写入数据库文件的目的是因为内存缓存已满,那么写入者不会立即提交。相反,写入者可能会继续更改其他页面。在将后续更改写入数据库文件之前,必须再次将回滚日志刷新到磁盘。另请注意,写入者为了写入数据库而获得的 EXCLUSIVE 锁必须保留到所有更改提交为止。这意味着在内存缓存首次溢出到磁盘直到事务提交期间,没有其他进程能够访问数据库。

当写入者准备好提交其更改时,它执行以下步骤:

  1. 获取数据库文件的 EXCLUSIVE 锁,并确保所有内存更改已使用上述步骤 1-3 的算法写入数据库文件。
  2. 将所有数据库文件更改刷新到磁盘。等待这些更改实际写入磁盘表面。
  3. 删除日志文件。(或者,如果 PRAGMA journal_mode 为 TRUNCATE 或 PERSIST,则分别截断日志文件或将日志文件的头清零。)这是更改提交的时刻。在删除日志文件之前,如果发生电源故障或崩溃,打开数据库的下一个进程将看到它有一个热日志,并将回滚更改。删除日志后,将不再有热日志,并且更改将持久化。
  4. 从数据库文件中解除 EXCLUSIVE 和 PENDING 锁。

一旦从数据库文件中释放 PENDING 锁,其他进程就可以再次开始读取数据库。在当前实现中,RESERVED 锁也被释放,但这对于正确操作不是必需的。

如果事务涉及多个数据库,则使用更复杂的提交序列,如下所示

  1. 确保所有单独的数据库文件都拥有 EXCLUSIVE 锁和有效的日志。
  2. 创建一个超级日志。超级日志的名称是任意的。(当前实现将随机后缀附加到主数据库文件的名称,直到找到一个不存在的名称。)用所有单独日志的名称填充超级日志,并将其内容刷新到磁盘。
  3. 将超级日志的名称写入所有单独的日志(在单独日志头中预留的用于此目的的空间中),并将单独日志的内容刷新到磁盘,并等待这些更改到达磁盘表面。
  4. 将所有数据库文件更改刷新到磁盘。等待这些更改实际写入磁盘表面。
  5. 删除超级日志文件。这是更改提交的时刻。在删除超级日志文件之前,如果发生电源故障或崩溃,则尝试读取它们的下一个进程将认为单独文件日志是热的,并将回滚这些更改。删除超级日志后,文件日志将不再被认为是热的,并且更改将持久化。
  6. 删除所有单独的日志文件。
  7. 从所有数据库文件中解除 EXCLUSIVE 和 PENDING 锁。

5.1 写入者饥饿

在 SQLite 版本 2 中,如果许多进程正在从数据库读取,则可能永远不会有时间没有活动的读取器。如果数据库上始终至少有一个读取锁,则任何进程都无法对数据库进行更改,因为无法获取写入锁。这种情况称为 _写入者饥饿_。

SQLite 版本 3 通过使用 PENDING 锁来避免写入者饥饿。PENDING 锁允许现有读取器继续,但阻止新读取器连接到数据库。因此,当进程想要写入繁忙的数据库时,它可以设置 PENDING 锁,这将阻止新读取器进入。假设现有读取器最终完成,所有 SHARED 锁最终将清除,并且写入者将有机会进行更改。

6.0 如何损坏您的数据库文件

页面模块非常健壮,但可以被破坏。本节试图识别和解释风险。(另请参阅 可能出错的事情 部分,该部分介绍了有关 原子提交 的文章。

显然,导致数据库文件或日志中间出现错误数据的硬件或操作系统故障会导致问题。同样,如果一个恶意的进程打开数据库文件或日志,并将格式错误的数据写入其中,则数据库将损坏。对于这些类型的错误,没有什么可以做的,因此不再关注它们。

SQLite 在 Unix 上使用 POSIX 咨询锁来实现锁定。在 Windows 上,它使用 LockFile()、LockFileEx() 和 UnlockFile() 系统调用。SQLite 假设这些系统调用都按预期工作。如果事实并非如此,则可能导致数据库损坏。需要注意的是,POSIX 咨询锁定在许多 NFS 实现(包括最新版本的 Mac OS X)中已知存在错误或甚至未实现,并且有关于在 Windows 下网络文件系统中锁定问题的报告。您最好的防御措施是不在网络文件系统上使用 SQLite。

SQLite 在 Unix 下使用 fsync() 系统调用将数据刷新到磁盘,并在 Windows 下使用 FlushFileBuffers() 来执行相同操作。同样,SQLite 假设这些操作系统服务按预期工作。但据报道,fsync() 和 FlushFileBuffers() 并非总是能正常工作,尤其是在某些网络文件系统或廉价 IDE 磁盘上。显然,某些 IDE 磁盘制造商的控制器芯片报告数据已到达磁盘表面,而实际上数据仍保存在磁盘驱动器电子设备的易失性缓存中。还有报道称 Windows 有时会出于未指定的原因忽略 FlushFileBuffers()。作者无法验证任何这些报告。但是,如果它们是真的,则意味着在意外断电后数据库损坏是可能的。这些是 SQLite 无能为力解决的硬件和/或操作系统错误。

如果 Linux ext3 文件系统在 /etc/fstab 中未安装“barrier=1”选项,并且磁盘驱动器写缓存已启用,则可能在断电或操作系统崩溃后发生文件系统损坏。损坏是否发生取决于磁盘控制硬件的细节;对于廉价的消费者级磁盘,损坏可能性更大,而对于具有高级功能(例如非易失性写缓存)的企业级存储设备来说,这个问题更少。各种 ext3 专家 确认了这种行为。我们被告知,大多数 Linux 发行版不使用 barrier=1 并且没有禁用写缓存,因此大多数 Linux 发行版容易受到此问题的困扰。请注意,这是一个操作系统和硬件问题,SQLite 无能为力解决它。 其他数据库引擎 也遇到了同样的问题。

如果发生崩溃或电源故障,导致热日志,但该日志被删除,则打开数据库的下一个进程将不知道它包含需要回滚的更改。回滚不会发生,数据库将处于不一致状态。

上面最后一条(第四条)要点值得进一步说明。当 SQLite 在 Unix 上创建日志文件时,它会打开包含该文件的目录,并在目录上调用 fsync(),以尝试将目录信息推送到磁盘。但是,假设在电源故障的时刻,另一个进程正在向包含数据库和日志的目录添加或删除无关文件。此其他进程的看似无关的操作可能会导致日志文件从目录中删除并移至“lost+found”。这种情况不太可能发生,但它可能会发生。最好的防御措施是使用日志文件系统或将数据库和日志保存在单独的目录中。

对于涉及多个数据库和超级日志的提交,如果各个数据库位于不同的磁盘卷上,并且在提交期间发生电源故障,那么当机器重新启动时,磁盘可能会以不同的名称重新挂载。或者,某些磁盘可能根本没有挂载。在这种情况下,单独文件日志和超级日志可能无法找到彼此。这种场景的最糟糕后果是提交不再是原子的。某些数据库可能会回滚,而另一些则不会。所有数据库将继续保持自我一致。为了防止此问题,请将所有数据库保留在同一磁盘卷上,或者在电源故障后使用完全相同的名称重新挂载磁盘。

7.0 SQL 级别的交易控制

SQLite 版本 3 中对锁定和并发控制的更改还在 SQL 语言级别上引入了某些微妙的更改。默认情况下,SQLite 版本 3 运行在 _自动提交_ 模式下。在自动提交模式下,只要与当前数据库连接相关的所有操作完成,数据库的所有更改就会提交。

SQL 命令 "BEGIN TRANSACTION"(TRANSACTION 关键字可选)用于将 SQLite 从自动提交模式中移除。请注意,BEGIN 命令不会对数据库获取任何锁。在执行第一个 SELECT 语句后,将获取 SHARED 锁。执行第一个 INSERT、UPDATE 或 DELETE 语句后,将获取 RESERVED 锁。直到内存缓存填满并必须写入磁盘,或直到事务提交,才会获取 EXCLUSIVE 锁。通过这种方式,系统延迟阻止对文件的读访问,直到最后可能的时间。

SQL 命令 "COMMIT" 实际上不会将更改提交到磁盘。它只是将自动提交模式重新打开。然后,在命令执行结束时,常规的自动提交逻辑接管并导致实际的提交到磁盘发生。SQL 命令 "ROLLBACK" 也是通过将自动提交模式重新打开来操作的,但它还会设置一个标志,告诉自动提交逻辑回滚而不是提交。

如果 SQL COMMIT 命令打开了自动提交,而自动提交逻辑随后尝试提交更改但失败,因为其他进程持有 SHARED 锁,则自动提交会自动关闭。这允许用户在 SHARED 锁有机会清除后,稍后重试 COMMIT。