SQLite的原子提交及WAL日志模式
原子提交
原子提交(Atomic Commits)是SQLite这种事务型数据库的一个重要特性。原子提交意味着单个事务中的所有数据库更改要么全部发生,要么全部不发生,不会出现单个事务内的操作执行到一半的情况。
本文主要描述对单个数据库文件执行事务的原子提交采取的步骤。
1. 初始状态
当数据库第一次打开时计算机的状态示意图如下图所示。图中最右边("Disk”标注)表示保存在存储设备中的内容。每个矩形表示一个扇区。蓝色表示这个扇区保存了原始资料。图中中间区域是操作系统的磁盘缓冲区。初始状态下,这些缓存是还没有被使用—因此这些方框是空白的。图中左边区域显示SQLite用户进程的内存。因为这个数据库连接刚刚打开,所以还没有任何数据记录被读入,所以这些内存也是空的。
2. 申请读取锁
SQLite在写数据库之前,必须先读取该数据库以确保该数据库存在。即使只是添加新的数据,SQLite仍然必须从sqlite_master
表中读取数据库格式(sqlite_master
表中存着数据库所有表结构),这样才知道如何分析 INSERT
语句,知道在哪儿保存新的信息。
读取数据库的第一步是先申请一个共享锁(Shared Lock)。共享锁允许多个数据库连接同时读取数据库,但是不允许其他数据库连接对数据库进行写操作。因为如果允许在读取的时候去写入数据库,其他线程读取到的数据有可能会不符合预期,也不满足事务的原子性。
值得注意的是共享锁只是加载操作系统的磁盘缓冲区,而不是磁盘本身。因此,一旦操作系统崩溃或者停电,锁会立即消失。当然创建该锁的进程消失,该锁也会一起消失。
3. 从数据库中读取信息
在申请到共享锁后,我们就可以从数据库文件中读取信息。刚开始,操作系统的磁盘缓存是空的,所以信息必须从硬盘读取到磁盘缓存中,然后从磁盘缓存中传递到用户空间(如下图所示)。针对之后的读取,部分或者全部数据都可能可以从操作系统缓存中取得,所以只需要传递到用户空间即可。
到这里SQLite连接已经能够从数据库文件中读取信息。
4. 申请保留锁(Reserved Lock)
在修改数据库之前,SQLite首先得拥有一个针对数据库文件的保留锁。保留锁类似于共享锁,它们都允许其他进程从数据库文件中读取信息。单个保留锁可以与来自其他进程的多个共享锁共存。但是,数据库文件上只能有一个保留锁。因此只能有一个进程在某一时刻尝试去写一个数据库文件。
保留锁的存在是宣告一个进程打算在不久的将来修改数据库文件但尚未开始进行修改。由于修改尚未开始,其他进程可以继续从数据库中读取(可以继续往系统缓存中添加共享锁)。但是,其他进程不应该去尝试写入数据库。
5. 创建回滚日志文件
在修改数据库文件之前,SQLite会生成一个单独的回滚日志文件,并将要更改的数据库页的原始内容写入回滚日志。回滚日志文件意味它将包含了所有可以将数据库文件恢复到原始状态的数据。
回滚日志文件有一个小的头部(图中绿色标记部分)记录了数据库文件的原始大小。因此,如果一旦即使数据库文件变大,我们还是会知道它原始大小。数据库文件中被修改的页码及他们的内容都被写进了回滚日志文件中。
当一个新文件刚被创建,大部分的桌面操作系统(Windows,Linux,Mac OS X)实际并不会马上写入数据到硬盘。此文件还只是存在于操作系统磁盘缓存中。一般都会等一段时间,或者到操作系统相当空闲的时候才会将这个文件写入存储设备中。下图中我们用图例说明了这一点,当新的回滚日志文件创建之后,它还只是出现在操作系统磁盘缓存之中,还没真实在写入到磁盘上。
6. 修改用户空间中的数据
当原始的数据已经被保存到回滚日志文件中之后,用户内存的数据就可以被修改了。每个数据库连接都有自己的私有用户空间,因此用户空间中所做的更改仅对进行更改的数据库连接可见。
其他数据库连接仍然可以读取那些存在于操作系统磁盘缓存中还没有被修改的数据。所以即使一个连接忙于某些修改,其他进程还可以读取原始数据到它们各自的空间中去。
7. 刷新回滚日志文件到存储设备中
下一步是将回滚日志文件刷新到磁盘中。这是保证数据库能够承受意外断电的关键步骤,但这个步骤是相当耗费时间的,因为写入磁盘是很耗费时间的。
在这个步骤中,通常需要二个单独的flush(或fsync())。第一个flush将日志内容刷新到磁盘里,第二个flush将日志头部刷新到磁盘里。
8. 获得独占(Exclusive)锁
在更改数据库文件本身之前,我们必须获取数据库文件的独占锁。获得独享锁定实际上是需要两步。首先SQLite获得挂起(Pending)锁,然后将挂起锁升级为独占锁。
挂起锁允许已具有共享锁的其他进程继续读取数据库文件,但它不允许添加新的共享锁。挂起锁将会防止因大量连续的读操作而无法获得写入的机会。
尝试读取数据库文件的其他进程可能有数十个甚至数百个。每个进程在开始读取之前获取共享锁,读取它所需的内容,然后释放共享锁。但是,如果有许多不同的进程都从同一个数据库读取,在老的进程释放它的共享锁之前总是会有新的进程申请共享锁。因此,数据库文件永远不会有没有共享锁的瞬间,因此写入者不会拥有取得一个独占锁的机会。
挂起锁旨在通过允许现有共享锁继续读取但阻止建立新共享锁来防止该循环。等到所有共享锁被清除后,挂起锁将升级为独占锁。
9. 将修改的内容写入到系统缓存中
一旦获取了独占锁,我们就知道再也没有其他进程在读取此数据库文件了,此时修改此文件是安全的了。通常,这些变更只会发生在操作系统磁盘缓存中,并不会写入到磁盘中去。
10. 将修改的内容刷新到磁盘中
接下来就需要一次刷新,将系统缓存中的更改刷新到磁盘里。这样才能保证数据在掉电之后也将是完整无损的。但是写入到磁盘里是很耗时的,导致此步骤占据了SQLIite事务提交操作的绝大部分时间。
11. 删除回滚日志
当数据变更已经安全的写入到硬盘之后,回滚日志文件就没有必要再存在了,因此立即将日志删除。
12. 释放锁
事务提交最后一个步骤是释放独占锁,其他进程就又可以立即访问数据库文件了。
下图中显示了当锁被释放的时候用户空间所拥有的信息已经被清空了。对于老版本的SQLite确实是这样的。但最新的SQLite会保存些用户空间的缓存不会被清空,万一下一个事务开始的时候,这些数据刚好可以用上呢。重新利用这些内存要比再次从操作系统磁盘缓存或者硬盘中读取要来得轻松与快捷得多。在再次使用这些数据之前,我们必须先取得一个共享锁,同时我们还不得不去检查一下,保证还没有其他进程在我们拥有共享锁之前对数据库文件进行了修改。数据库文件的第一页中有一个计数器,数据库文件每做一次修改,这个计数器就会增长一下。我们可以通过检查这个计数器就可得知是否有其他进程修改过数据库文件。如果数据库文件已经被修改过了,那么用户内存空间的缓存就不得不清空,并重新读入。大多数情况下,这种情况不大会发生,因此用户空间的内存缓存将是有效的,这对于性能提高来说作用是显著的。
以上12个步骤比较完整的讲述了SQLite在进行原子提交时的具体操作。但这是基于SQLite处于DELETE日志模式下,当数据库处于WAL日志模式下就不完全是这样的情况。
WAL日志模式
WAL(Write-Ahead Logging)日志模式是SQLite在3.7.0版本上新增的日志模式,用于提高数据库的并发性。
相比默认的日志模式,WAL日志模式有利也有弊。优点包括:
- 在大多数情况下,WAL速度更快。
- WAL进一步提升了数据库的并发性,因为读不会阻塞写,而写也不会阻塞读。读和写可以并发执行。
- 使用WAL,磁盘I / O操作更有秩序。
- WAL减少了fsync()操作次数,因此在fsync()系统调用被破坏的系统上不易受到问题的影响。
缺点有:
- WAL通常要求 VFS 支持共享内存原语。
- 使用数据库的所有进程必须位于同一台主机上; WAL无法在网络文件系统上运行。
- 在读取操作远多于写入操作的应用程序中,WAL可能比传统的日志模式稍慢(可能慢1%或2%)。
- 每个数据库文件都关联了额外的 .wal 文件和 .shm 共享内存文件。
激活WAL日志模式
SQLite数据库连接默认为DELETE模式( journal_mode = DELETE
)。若要转换为WAL模式,可以使用以下代码:
PRAGMA journal_mode = WAL;
journal_mode pragma
返回新的日志模式的字符串。激活成功时,pragma将返回字符串wal
,如果无法完成到WAL的转换,则日志模式将保持不变,并且返回先前的日志模式。
与其他日志模式不同,WAL日志模式是持久的。如果进程设置WAL模式,然后关闭并重新打开数据库,数据库返回的日志模式仍然是WAL。相反,如果进程设置PRAGMA journal_mode = TRUNCATE
然后关闭并重新打开,则数据库将以默认的日志模式(DELETE模式)启动。
如果在一个进程上的任何一个数据库连接设置了WAL日志模式,则将在同一数据库文件的所有连接都以WAL日志模式运行。
WAL日志模式的工作机制
传统的日志模式通过将未更改的数据库内容写入单独的日志文件,然后将更改直接写入数据库文件来工作。如果发生崩溃或回滚,则将日志文件中包含的原始内容覆盖到数据库文件中,以此来将数据库文件还原为其原始状态。
WAL日志模式刚好相反。它将数据库原始内容复制到WAL日志文件中,并将数据库更改以追加(append)的方式添加到WAL文件上。因此,事务提交可以在不写入原始数据库的情况下发生,这就允许一边将数据库更改添加到WAL文件,一边从未更改的数据库进行读操作。
检查点(checkpointer)
当将数据库修改追加到WAL日志文件后,还是需要将日志文件覆盖到数据库文件,这个操作称之为检查点(checkpointer)。
默认情况下,当WAL文件达到1000页的阈值大小时,SQLite会自动执行检查点(可以修改 SQLITE_DEFAULT_WAL_AUTOCHECKPOINT
来指定不同的默认值)。使用WAL的应用程序不必执行任何操作来触发检查点。但应用程序也可以调整自动检查点阈值,或者可以在空闲时刻或单独的线程或过程中关闭自动检查点并运行检查点。
并发
在WAL模式下数据库上开始读取操作时,首先会记住WAL日志文件中最后一个有效提交记录的位置,称此为“结束标记(end-mark)”。由于WAL可以在各种读取器连接到数据库时增长并添加新的提交记录,因此每个读取器都可能具有自己的结束标记。但对于任何特定的读取操作,结束标记在事务持续期间不变,从而确保单个读取事务仅查看单个时间点存在的数据库内容。
当读取操作需要一页内容时,它首先检查WAL文件看该页面是否出现,如果有,它会在读取器结束标记之前拉入WAL文件中发生的页面的最后一个副本。如果在读取器结束标记之前WAL文件中没有页面副本,则从原始数据库文件中读取页面。读取操作可以存在于不同的进程中,为了避免每个读取操作扫描整个WAL文件来寻找页面(WAL文件可以增长到几兆字节,具体取决于检查点运行的频率),再共享内容里添加了称为“wal-index”的索引文件,帮助读取操作快速定位WAL文件中的页面。wal-index极大地提高了读取的性能,但共享内存的使用意味着所有读取操作必须存在于同一台机器上。
写操作只是将新内容追加到WAL文件的末尾。因为写操作不会影响到读操作,所以读写可以同时运行。但是,由于只有一个WAL文件,因此一次只能执行一个写操作。
检查点操作从WAL文件中获取内容并将其传回原始数据库文件。检查点可以与读操作同时运行,但检查点必须在到达WAL中超过任何当前读取器结束标记的页面时停止。检查点必须在此时停止,否则它可能会覆盖读取器正在使用的部分数据库文件。
每当发生写操作时,编写器检查checkpointer进行了多少进度,如果整个WAL已经转移到数据库并同步,并且没有读操作正在使用WAL,那么编写器将把WAL倒回到起点并在WAL的开头重新写入。此机制可防止WAL文件无限制地增长。