OverView(概述)

​ 锁和并发控制在 pager module 中实现. pager module 负责实现SQLite的 "ACID" 特性。 pager module 确保所有更改同时发生,即要么全部成功要么全部失败,两个或两个以上的进程不要在此时试图以不兼容的方式访问数据库,一旦更改被写入就会一直存在,直到显示删除。 pager module 会缓存磁盘文件的一部分内容到内存。

pager 不关心B-Tree/字符编码/索引的细节. 从 pager的角度来看,数据库由单一文件构成,该文件包含大小一直的 ,每一块被称为 "page(页)" ,通常是1024字节. 页码从1开始计数,因此数据库文件的第一个1024字节被称为"page 1",第二个1024字节被称为"page 2",以此类推。其他所有编码细节由库的高层处理. pager使用几个module 将 与 操作系统的交互进行抽象。(例如: os_unix.cos_win.c )

pager module 有效地控制单独的线程/进程或两者的同时的访问。在本文档中,可以把 "进程" 替换成 "线程",语义不变。

Locking(锁)

​ 从单个进程的角度来看,数据库文件由五种锁定状态:

  • UNLOCKED

    数据库没有持有锁. 该数据库此时不能读也不能写。任何内部缓存的数据都被认为是可疑的,并且在使用之前需要针对数据库文件进行验证。其他进程可以在他们的锁定状态下进行数据库读写。此为默认状态。

  • SHARED

    数据库可以读但是不能写。可以存在多个进程持有 SHARED 锁,只要存在一个 SHARED 锁,其他进程就不能进行写入操作。

  • RESERVED

    该锁意味着进程即将写入数据库文件,但是目前仅从数据库中读取。该锁可以与多个 SHARED 锁共存,但是同时只能存在一个 RESERVED . RESERVEDPENDING 不同之处在于,RESERVED 存在时,可以创建新的 SHARED 锁。

  • PENDING

    该锁意味着进程马上要进行写入数据库的操作,希望 SHARED 锁的进程尽快释放,以便获得 EXCLUSIVE. 若存在该锁,则不能创建新的 SHARED 锁。

  • EXCLUSIVE

    为了写入数据库,需要获得 EXCLUSIVE 锁,该锁不能与以上四种锁共存,为了提高并发性,SQLite致力于缩小持有 EXCLUSIVE 锁的时间。

pager module 仅跟踪以上四种锁,PENDING 锁是获取 EXCLUSIVE 使用的临时锁,因 pager module 不跟踪该锁。

The Rollback Journal

​ 当一个进程想要更改数据库文件(非 WAL 模式),它首先记录数据库修改之前的数据在 Rollback Journal(回滚日志) . The Rollback Journal(回滚日志) 是一个普通的磁盘文件,它总是位于数数据库文件所在的相同目录中,并与数据库具有相同的名称,另外加一个 -journal 后缀。 The Rollback Journal(回滚日志) 还记录数据库文件的大小,以便数据库文件增长,在回滚时将其截断为原来的大小。

​ 若SQLite在多数据库场景下工作(使用 ATTACH 命令),那么每个数据库都有自己的 Rollback Journal(回滚日志) 。但是还有一个单独的 aggregate journal (聚合日志),称为 master journal (主日志). master journal (主日志) 不包含用于回滚的页数据。相反, master journal (主日志) 包含每个 **Attached Databases(附加数据库) ** 的 各个数据库 的 Rollback Journal(回滚日志) 的文件名称。各个数据库的 Rollback Journal(回滚日志) 都包含 master journal (主日志) 的名称。若没有 **Attached Databases(附加数据库) **或者 **Attached Databases(附加数据库) ** 不参与当前事务,则不会创建 master journal (主日志) ,并且在正常的 Rollback Journal(回滚日志) 中记录 master journal (主日志) 名称的位置填充一个空字符串。

​ 若需要回滚来保证数据库的完整性,此时 Rollback Journal(回滚日志)Hot Rollback Journal(热回滚日志) . 当进程在更新数据库过程中,程序或操作系统崩溃/电源故障 阻止更新完成,此时就会创建 Hot Rollback Journal(热回滚日志)Hot Rollback Journal(热回滚日志) 是一种异常情况。Hot Rollback Journal(热回滚日志) 是为了崩溃或停电 恢复而存在。若一切操作正常完成(没有崩溃,没有停电),将不会创建 Hot Rollback Journal(热回滚日志)

​ 若不涉及 master journal (主日志) ,日志存在且 含有非0的header 和 对应的数据库文件 没有 RESERVED 锁,那么该日志是 Hot Rollback Journal(热回滚日志) 。若 master journal (主日志) 被命名在 file journal(文件日志) 中,他的 master journal (主日志) 存在且对应的数据库文件没有 RESERVED 锁,那么该 file journal(文件日志)Hot Rollback Journal(热回滚日志) 。理解 journal (日志)Hot Rollback Journal(热回滚日志) 需要满足以下5个条件:

  • 日志存在

  • 文件大小超过512字节

  • header非0,格式良好

  • master journal (主日志) 存在或其名称为空字符串

  • 对应数据库文件没有 RESERVED

Dealing with hot journals

​ 在读取数据库文件之前,SQLite总是先检查是否存在 Hot Rollback Journal(热回滚日志) 。若存在,将会在读取数据库之前,执行回滚操作,以确保读取数据库文件之前,它是处于一致性状态。

​ 当一个进程想要读取数据库文件,将会顺序执行以下步骤:

  1. 打开数据库文件并获取 SHARED 锁,若不能获取 SHARED 锁,立刻失败并返回 SQLITE_BUSY

  2. 检查数据库文件是否存在 Hot Rollback Journal(热回滚日志)

    1. 不存在 Hot Rollback Journal(热回滚日志) ,立刻返回。
    2. 存在 Hot Rollback Journal(热回滚日志) ,则必须用下列步骤执行回滚操作。
    3. 请求获取该数据库文件的 PENDING 锁,成功之后获取 EXCLUSIVE 锁(注意: 不能获取 RESERVED 锁,因为这样会让其他进程认为 日志 不再是热的回滚日志 ),若当前进程获取锁失败,则说明其他进程已经做了这一步, 直接返回 SQLITE_BUSY
    4. 读取回滚日志并回滚这些更改。
    5. 等待回滚更改的持久化操作完成,这样可以在 再次发生崩溃或停电 的情况下,保证数据库的完整性。
    6. 删除 回滚日志 文件
    7. 如果安全的话,删除 master journal (主日志) 文件. 这个步骤是可选的,他在这里只是为了防止失效的 master journal (主日志) 混乱磁盘驱动器. 详情参阅下节讨论。
    8. 释放 EXCLUSIVE 锁和 PENDING 锁,但是仍然持有 SHARED 锁。

在以上算法执行完成之后,即可安全读取数据库文件,读取全部完成之后,释放 SHARED 锁。

Deleting stale master journals

​ 失效的主日志 代表不存在任何用途,不删除只会浪费磁盘空间。判断主日志是否失效的流程如下:

  1. 读取主日志内的日志文件列表
  2. 遍历该列表,若存在一个日志文件且指向该主日志,那么主日志是有效的
  3. 若日志列表的文件都不存在或都不指向主日志文件,那么主日志是失效的

Writing to a database file

​ 要想写入数据库文件,进程需要先获取一个 SHARED 锁(若存在热回滚日志,可能会回滚未完成的更改)。获取 SHARED 锁之后,继续获取 RESERVED 锁,RESERVED 通知进程在将来某个时刻要执行数据库写入操作. 同一时刻只能存在一个 RESERVED 锁,其他进程可以继续读取数据库文件,在持有 RESERVED 锁期间。

​ 若想写的进程无法获取 RESERVED 锁,意味着另一个进程已经持有一个 RESERVED 锁. 此时,直接返回 SQLITE_BUSY.

​ 获取 RESERVED 锁之后,writer进程会创建回滚日志。日志文件的header被初始化为数据库文件的大小。尽管主日志名称为空,但是依然在回滚日志的header中保留主日志名称的空间。

​ 在数据库文件的任何page被标记改变之前,writer进程会将那些page的原始内容写入到回滚日志中。改变的page首先在内存中而不是直接写入到磁盘。原始数据库保持不变,意味着其他进程可以继续读取数据库。

​ 最终,writer进程将要更新数据库文件,要么是page缓冲区满,要么是手动提交。更新之前,必须确保没有其他进程正在读取数据库,并且回滚日志在磁盘表面是安全的,以便在崩溃/停电时使用它进行回滚。步骤如下:

  1. 确保所有回滚日志数据已写入到磁盘表面(而不是存在操作系统活磁盘控制器缓存),这样如果发生崩溃/停电,下次重启时,数据依然存在。

  2. 获取该数据库文件的 PENDING 锁,成功之后获取 EXCLUSIVE 锁。如果这期间存在其他进程持有 SHARED 锁,那么该writer进程需要等待 SHARED 锁全部释放。

  3. 将内存中有变更的 page 写入到数据库文件中。

​ 若写入数据库文件的原因是内存缓存池满,writer进程不会立刻提交。相反,writer进程可能会继续对其他page进行更改。将后续更新写入数据库文件之前,必须先将回滚日志再次刷新到磁盘。还要注意,writer进程会一直持有 EXCLUSIVE 锁,直到 COMMIT 操作。这意味着从内存缓存第一次溢出到磁盘,直到事务 COMMIT ,其他进程都不能访问数据库。

​ 当writer进程执行 COMMIT 操作时,它将执行一下步骤:

  1. 获取 EXCLUSIVE 锁,并确保所有内存page的变更全部写入到数据库文件中,使用以上步骤1-3。

  2. 刷新所有数据库文件到磁盘,等待这些数据被写入到磁盘表面才返回。

  3. 删除回滚日志文件。

  4. 释放 EXCLUSIVE 锁和 PENDING 锁。

​ 紧接着很快会释放 PENDING 锁,然后其他进程可以开始读取数据库。在当前实现中,RESERVED 锁也被释放了,这对正确操作来说不是必要的。

​ 若一个事务设计多个数据库,则使用更复杂的提交序列,如下所示:

  1. 确保各个数据库持有 EXCLUSIVE 锁和有效的回滚日志。
  2. 创建一个 master-journal(主日志)。主日志的名称是随意的(在当前实现中,添加随机后缀到主数据库文件的名称末尾,直到找到以前不存在的名称为止)。将各个数据库文件的回滚日志文件名称写入到主日志文件中,确保落到磁盘表面。
  3. 写入主日志文件名称到各个数据库的回滚日志文件的header的预留空间中, 并且要全部落到磁盘表面。
  4. 刷新所有数据库文件变更到磁盘,等待落到磁盘表面后返回。
  5. 删除主日志文件。这是事务提交的瞬间。在删除主日志文件之前,若发生崩溃/停电,将认为各个数据库日志文件是热的,并将由下一个读取他们的进程执行回滚。在删除主日志文件之后,各个数据库日志文件将不再会是热的,变更将持久化。
  6. 删除各个数据库的回滚日志文件。
  7. 释放所有数据库文件的 EXCLUSIVE 锁和 PENDING 锁。

Writer starvation(写饥饿)

​ 在SQLite2中,如果存在许多进程正在读取数据库文件,那么可能reader进程一直存在。若是这种情况,那么任何进程都不能对数据库文件进行更改,因为无法获取到 EXCLUSIVE 锁。

​ SQLite3试图通过 PENDING 锁来避免 writer 进程饥饿。 PENDING 锁允许现有的 reader继续执行,但阻止新的reader进程链接。因此,当一个进程想要写入一个 busy database 时,它可以设置一个 PENDING 锁来阻止新的reader进程进入。假设现有的reader进程全部完成,那么所有reader将会释放 SHARED 锁,writer 进程将由机会进行更改。

How To Corrupt Your Database Files

​ 尽管 pager module 非常健壮,但也不是没有漏洞。本节将会尝试标识和解释这些风险(还可以参阅 Atomic Commit 中的 Things That Can Go Wrong )。

​ 显然,硬件或操作系统故障会在数据库文件或回滚日志文件中引入不正确(畸形)的数据。同样,若恶意进程打开数据库文件或日志,并在其中写入畸形的数据,那么数据库就会损坏。对于这类问题,代码层面无法防止,因此无需过多关心。

// TODO

Transaction Control At The SQL Level

​ SQLite3对锁定和并发控制引入了SQL语言级别的事务工作方式的微妙变化。默认情况下,SQLite3是 AUTOCOMMIT 模式。在 AUTOCOMMIT 模式中,所有当前数据库相关的操作完成后,很快就会提交对数据库的所有更改。

"BEGIN TRANSACTION" 命令(TRANSACTION可以省略)可以关闭 AUTOCOMMIT 模式。注意,BEGIN 命令并未获取任何数据库锁。在 BEGIN 命令之后,在执行第一个 SELECT 语句时将获取一个 SHARED 锁;在执行第一个 INSERT/UPDATE/DELETE 语句时将获取一个 RESERVED 锁。直到内存池满或者事务提交才会获取 EXCLUSIVE 锁。通过这种方式,系统将延迟阻塞访问数据库到最后一个可能的时刻。

"COMMIT" 命令实际上并不将更改提交到磁盘。它只是将 链接设置为 AUTOCOMMIT 模式。然后,命令结束后,常规自动提交逻辑接管并导致对磁盘的实际提交。

"ROLLBACK" 命令也是将链接设置为 AUTOCOMMIT 模式,但是它还设置了一个标记,告知自动提交逻辑回滚而不是提交。

​ 如果 "COMMIT" 命令打开 AUTOCOMMIT 模式后, AUTOCOMMIT 模式逻辑尝试提交更改,但是由于此时还存在reader进程,本次提交可能会失败,那么 AUTOCOMMIT 模式将会自动关闭。这种情况允许用户在共享锁清楚之后的一段时间重试 "COMMIT" 命令。

​ 若对同一个SQLite连接执行多个命令,则自动提交逻辑将推迟到最后一个命令完成时再执行。例如,正在执行SELECT 语句,当每一行返回时,命令的执行将被暂停。在此暂停期间,可以对其他表执行 INSERT/UPDATE/DELETE 。但是原来的 SELECT 语句完成之前,这些更改都不会提交。