sqlite原子提交原理
- 简介
支持事务的数据库系统如sqlite的一个重要特性是原子提交(atomic commit
)。也就是在一个事务中进行的对数据库的写操作要么全部执行,要么全部不执行。看起来像是对数据库不同部分的写操作时瞬时发生的。
实际上,对磁盘内容的改变需要一段时间,写操作不可能是瞬时发生的。为此,sqlite内部有一套逻辑保证保证事务操作的原子性,即使系统crash或掉电也不会破坏原子性。
这篇文章介绍了确保原子操作的技巧和策略,只适用于rollback mode
。如果数据库在WAL mode下运行,策略和这篇文章不同。 - 对硬件的假设
- 硬盘写入的最小单位是扇区(sector)。
不能修改小于一个扇区的数据。如果需要的话,应该读出整个扇区,然后修改一部分,再把整个扇区写入。
扇区的大小在3.3.14以前,在代码中写死是512字节。随着硬件的发展,扇区的大小发展到4k字节了。因此在3.3.15以后开始的版本中,提供了一个函数和文件系统打交道,用来获取扇区的大小。然而由于unix和Windows系统中不会返回文件扇区的大小,因此这个函数仍然返回512字节。不过这个函数可以在嵌入式系统中起作用。 - 对扇区的写操作不是原子的,却是线性的。
这里线性的意思是开始写操作时,会从扇区的一端开始,一比特一比特地写,直到扇区的另一端。写操作的方向可以是从扇区起始到结束,也可以从扇区的结束到起始。如果在写操作的过程中,系统掉电了,那么这个扇区会一部分已经改变,一部分仍然没改变。
SQLite假设的关键是如果扇区的一部分发生了改变,那么在扇区的起始或结束一定会发生变化。
在3.5.0以后的版本中,新增了一个VFS(虚拟文件系统)的接口。VFS是SQLite和文件系统交互的唯一接口。SQLite为Unix和Windows提供了缺省的VFS实现,并且可以让用户在运行时实现一个自定义的VFS实现。
VFS接口中,有一个函数叫做xDeviceCharacteristics
。这个函数和文件系统交互,并提供文件系统的一些特性,比如扇区写操作是否是原子的。如果这个扇区写操作时原子的,那么SQLite会利用这些特性。然而Unix和Windows缺省的xDeviceCharacteristics
函数不会提供这些信息。 - 操作系统会对文件写操作进行缓冲。
因此在写操作的请求返回时,数据还没有真实写入数据库文件中。此外,还假设操作系统会对写操作进行reorder。
因此,SQLite会在关键点调用flush
或fsync
操作。SQLite假设这个操作在数据被写入文件之前不会返回。
然而,有一些Windows版本和Unix版本的fluse
或fsync
操作不是这样子的。这样子,在commit的过程中发生掉电,会导致数据库文件损坏。 - 文件size的变化发生在内容变化之前。
也就是说文件的大小先发生改变,这样子文件会包含一些垃圾数据,然后会将数据写入文件。
写入文件大小之后,写入数据之前会发生掉电。SQLite做了一些其他的工作来保证这种情况下不会引起数据库文件损坏。
如果VFS的xDeviceCharacteristics
方法确定在改变文件大小之前,数据已经被写入到文件中,那么SQLite会利用这个特性。然而默认的实现没有确认这个特性。 - 文件的删除是原子的
文件在删除的过程中掉电,那么重启之后,文件要么完全没有删除,或者完全删除。
如果重启之后,文件只有部分使被删除的,那么会损坏数据库。 - Powersafe Overwrite
当程序写数据到文件中时,在所写范围之外的数据不会被改变,即使发生了crash或掉电。
假设不成立的情况:如果写操作只发生了扇区的前几个字节。由于写操作的最小单位是扇区。写完前几个字节以后就掉电,重启时,对这个扇区内的数据进行校验,发现不对,就会用全0或者全1进行覆盖。这样子就修改了写操作范围以外的数据。
现代的磁盘可以检测到掉电,然后会利用剩余的电量将这个扇区的数据写完。
- 硬盘写入的最小单位是扇区(sector)。
单个文件commit过程
初始状态
中间部分是系统的磁盘缓冲区。

获取读锁
在写操作之前需要获取读锁,获取数据库的基本数据,这样子才能解析SQL语句。
注意共享锁只针对系统的磁盘缓存,而不是磁盘文件。文件锁其实就是系统内核的一些flag。在系统crash或掉电之后,锁会失效。通常创建锁的进程退出也会导致锁失效。

从数据库中读数据
先读到系统磁盘缓存,再读到用户空间。如果命中了缓存,那么会直接从磁盘缓存中读到用户空间。
注意不是把整个数据库读入内存。

获取reserved lock
在对数据库进行改变之前,要先获取reserved lock。允许其他拥有共享读锁的进程操作,但是一个数据库文件只能有要给reserved lock。保证了只有同时最多一个进程可以进行数据库写操作。

创建回滚日志文件
日志文件就是要改变的数据库文件中原有的page。
此外还包括一个头部,记录了原有数据库文件的大小。page的number被写入到了每一个数据库的page中。
注意此时并没有写文件到磁盘中。

在用户空间改变数据库的page
每一个数据库链接都有自己的数据库文件拷贝。所以,此时其他数据库连接仍然可以正常进行读操作。

把日志文件刷入磁盘中
在大多数系统中,需要进行两次flush或fsync操作。第一次将文件数据刷入文件中,第二次用来更改header中的记录日志文件的page数目,并把header刷入文件。

获取exclusive锁
获取exclusive锁分两步。首先获取一个pending锁,保证不会有新的写操作和读操作。然后等待其他的读进程结束,释放读锁,最后获取exclusive锁。

将用户空间中的数据写入数据库文件
此时可以确定没有其他数据库连接在从数据库中读文件。这一步通常只会写入到磁盘缓存中,不会写到数据库文件。

将更改写入磁盘文件中
调用fsync或flush操作。这一部和写日志文件到磁盘中占用了一个transaction中最多的时间。

删除日志文件
SQLite gives the appearance of having made no changes to the database file or having made the complete set of changes to the database file depending on whether or not the rollback journal file exists.
删除日志文件不是原子的,但是从用户看来,这个操作是原子的。询问操作系统这个文件是否存在,回答是yes或no。
在一些系统中,删除文件时一个耗时的操作。SQLite可以配置为将文件的大小改为0或用0来覆盖日志文件的头部。在这两种情况下,日志文件都不可能进行恢复,因此SQLite认为commit已经完成。

释放锁
在这张图里,用户空间的数据库内容已经被清空。在最新版本中,做了优化。在数据库第一个page中,维护了一个计数器,每一次写操作,都会对这个计数器加一。如果计数器不变,这个数据库连接就可以重复利用用户空间中的数据库内容。

回滚
由于一个commit操作需要时间。在这个过程中,如果发生了crash或掉电,就需要进行回滚以保证数据库事务的完成是『瞬时』的。利用数据库日志文件回滚到这个数据库事务发生之前。- 初始情况
假设在第10步时,发生了断电。在重启之后,数据库文件其实只写入了1个半page,但是我们有完整的journal文件。

- hot rollback journal
当一个新的数据库连接建立时,会尝试获取共享read锁,也会注意到有一个回滚用的日志文件。接下来这个数据库连接就会检验这个数据库文件是不是"hot journal"。当一个事务在commit时发生掉电或者crash,就会产生"hot journal"。 判断标准如下:
- 存在回滚日志文件
- 回滚日志文件不是空文件
- 在数据库文件中不存在reserved lock(掉电以后会丢失)
- 日志文件的头部格式没有被破坏
- 日志文件中不包含主日志文件的名字(用户多个文件提交) 或包含主日志文件,主日志文件存在 有了日志文件,我们就可以来恢复数据库。
- 获取exclusive锁 用来防止其他进程同时用这个日志文件回滚数据库。
- 回滚没有完成的变更
把日志文件从磁盘文件中读入内存,然后写入数据库。
日志文件头部存储了原来数据库的大小信息。如果原有的操作使得数据库文件变大,这个信息用来截断数据库。
这一步之后,数据库的大小、内容都和这个事务发生之前是一致的。

- 删除hot journal
也可能大小被改为0,也可能文件的header用0覆盖。总之,不在是hot journal了。

- 继续进行其他操作
此时数据库文件已经恢复正常,可以正常使用了。
- 初始情况
假设在第10步时,发生了断电。在重启之后,数据库文件其实只写入了1个半page,但是我们有完整的journal文件。
多文件提交
commit过程的重要细节
- 总是记录整个扇区
如果page的大小是1k,扇区的大小是4k。为了更改某一个page的数据,必须把整个扇区的数据计入日志文件;写数据到数据库文件时,也必须将整个扇区写入。 - 处理写日志文件时的垃圾数据
在向数据库日志文件追加数据是,SQLite假设数据库日志文件的size会先变大,然后才会写入数据。如果在这两步之间发生了掉电,那么日志文件中会留有垃圾数据。如果利用这个日志文件进行恢复,就覆盖原有数据库中的正确内容。
SQLite使用两种策略来应对这种情况。- 在日志文件的头部加入日志中page的数目
把日志文件的page数据写入头部,初始值是0。因此利用不完整的日志文件进行回滚时,会发现头部是0,也就不会进行任何操作。
在commit之前,日志文件的内容会被刷入磁盘中,并保证没有垃圾数据。此时,才会将日志文件中page的数目再次刷入磁盘。日志文件头和文件中的page不在同一个扇区,因此即使掉电,也不会破坏日志文件中的page。
上面说的情况仅仅发生在"synchronous pragma"是FULL。如果是normal,那么page的数目和page的内容会同步刷入磁盘文件。即使在代码中先刷入page的内容,再刷入page的数目,由于系统会改变操作顺序,也有可能会导致page的数目正确写入了磁盘,page的内容却没有被正确写入磁盘。 - 每一个page使用校验和
SQLite在每一个page都准备了一个32bit的校验和。如果有一个page的校验和不满足,那么整个回滚过程就不进行。
如果synchronous pragma是FULL,理论上就不需要校验和。然而检验和是没有副作用的,因此无论synchronous pragma是什么,在日志文件中都有校验和的存在。
- 在日志文件的头部加入日志中page的数目
- 提交前缓存溢出
如果提交前,修改的内容已经超过了用户空间的缓存,那么必须先把已经完成的操作写入数据库文件中,再进行其他操作。
缓存溢出会将reserved锁提升为exclusive锁,因此降低了并发性。还有引起额外的flush或fsync操作,这些操作是十分耗时的。要尽量避免缓存溢出。
- 总是记录整个扇区
优化
性能分析表示SQLite将大多数时间花费在了磁盘IO。因此如果可以减少磁盘IO的话,就可以提升SQLite的性能。下面介绍一些SQLite采用的在保证事务原子性的前提下提升性能的一些方法。- 在事务之间缓存
旧版本的SQLite中,在事务结束以后,会把SQLite的内容从用户空间中移除。原因是因为其他操作会改变数据库的内容。下次读取相同的内容时,仍然需要从磁盘缓存或磁盘中读数据到用户空间。
在新版本(3.3.14)之后,用户控件内的数据库缓存会保留。同时在数据库的header(24到27字节)中维护计数器,每次改变加一。下次这个进程读取数据库时,只需要判断是否计数器有变化,如果没有变化,那么使用缓存即可。 - 独享访问模式(Exclusive Access Mode)
3.3.14版本之后新增,SQLIte在事务被关闭以后,仍然保留互斥锁,因此数据库只能被一个数据库连接(sqlite connection)访问,但是没有被默认打开。(个人认为,在只有一个数据库连接的iOS应用中,打开这个模式比较靠谱。)在这个模式下,有以下优点。- 在事务结束之后不必改变数据库header中的计数器。为日志文件和数据库主文件减少一个文件写入。
- 在事务开始和结束时不必检测header中的计数器,也不必清空缓存。
- 事务结束后,可以覆盖日志文件header的方法而不是删除日志文件。减少了一些文件操作,比如更改数据库文件的目录项、释放日志文件对应的磁盘扇区等。
- 不将空闲页记录到日志文件中(3.5.0以后新增)
当从数据库中删除信息时,会将原本记录被删除内容的page计入空白列表(freelist)中。当后续有新增操作时,会从空白列表中取数据,而不是扩展数据库文件。
一些空闲页中包含重要信息,比如其他空闲页的位置。但是大部分空闲页不包含有用信息,被称为叶子(leaf)空闲页(我理解是空闲页用树来存储,空闲页即叶子节点)。
叶子空闲页是不重要的,因此SQLite避免将空闲页写入日志文件中,可以大大减少IO数目 - 单页更新和原子扇区写
现代磁盘一般都可以保证对单个扇区的写是原子的。当掉电时,磁盘可以利用电容中的电量或磁盘转动的角动量完成当前扇区的写操作。
假如扇区的写是原子的,数据库page的大小和扇区的大小是一致的,并且数据库的写操作只涉及一个page,那么数据库会跳过所有的日志及刷新操作,直接将更改的内容写入数据库文件。
数据库首页的变更计数器会被单独修改,因为不会对数据库有任何影响,即使在计数器更新之前发生了掉电。 - 安全追加(Safe Append)的文件系统(3.5.0以后新增)
SQLite假设当追加数据到文件时,文件的大小先改变,然后内容才改变。这样子掉电以后会导致日志文件中包含垃圾数据。
如果文件系统支持文件的size更新以前,文件的content一定已经更新,那么在掉电或者系统crash以后,日志文件也不会有垃圾数据。
为了支持这种文件系统,SQLite在日志文件的头部用来存储page数目的地方存储-1。SQLite使用文件的size来计算文件中page的数目。
当commit时,我们节省了一次flush或fsync操作。此外,当缓存溢出时,不必将新的page数目写入数据库日志文件 - 持久的日志文件
即在数据库事务结束时不删除日志文件,这样子可以节省一次文件删除和一次文件创建的工作。
启用方法PRAGMA journal_mode=PERSIST;
这样子会导致一直有数据库日志文件存在。 还可以把mode设为TRUNCATE,PRAGMA journal_mode=TRUNCATE;
PERSIST是把日志文件的头部置为0,以后的对数据库文件的操作是覆盖。TRUNCATE是把日志文件的size置为0,不需要调用fsync操作,以后对数据库文件的操作是append。 在具有同步文件系统的嵌入式系统中,append操作比overrite慢一些,因此TRUNCATE会导致比PERSIST较慢的行为。
- 在事务之间缓存
测试提交行为原子性
导致数据库损坏的可能性
- 不正确的锁实现
在网络操作系统中,实现锁机制是很困难的。因此,尽量不要在网络操作系统中使用SQLite。
当使用不同的锁机制来获取同一个文件,而这两种锁机制又不是互斥时,也会发生错误。 - 不完整的磁盘刷新
Unix上的fsync()系统调用或Windows上的FlushFileBuffers()调用工作不正常。 - 部分文件被删除
SQLite假设文件的删除是原子的。如果SQLite删除的文件在掉电重启以后部分恢复,就会发生故障。 - 被写入垃圾数据
其他程序可以向SQLite文件中写入垃圾数据。
操作系统的bug。 - 删除或重命名hot journal
- 不正确的锁实现
总结及未来的路