SQLite的事务隔离及并发控制

达芬奇密码2018-07-30 13:56

传统的数据库必须具备“ACID”,即原子性(Atomic)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)这四个特性。下面的文章主要阐述SQLite对隔离性的支持,以及对数据库并发控制的影响。

数据库的隔离性决定了多个并发事务同时对数据库进行操作的能力,并且能够防止并发事务交叉执行而导致的数据不一致。

假设一个数据库同时存在两个连接(没有使用SQLite的shared cache机制),A连接只进行读操作,B连接只进行写操作,只有当B连接的事务提交了,A连接才能看到B连接对数据库所做的修改。当B连接没有提交事务的时候,B连接对数据库所做的部分修改对于A连接来说都是不可见的。这是由数据库的隔离性所保证的,不管这两个连接是在同一个线程、同一个进程的不同线程,还是不同进程。

事务日志模式

SQLite的隔离性和并发控制是通过事务日志来实现的。目前有两种事务日志模式:回滚模式和WAL(Write Ahead Log)模式。

回滚模式

在回滚模式中,对数据库的修改是直接写到数据库文件中的,在写数据库的过程中由于某些原因比如断电,操作可能被打断,为了保证数据的一致性,被修改的原始数据必须先备份到回滚日志文件中,当出现了数据不一致的情况还能通过回滚日志文件中的数据恢复部分修改的数据到修改前的状态。

在SQLite中回滚日志文件跟数据库文件出于同一个文件夹中,命名规则为数据库文件名加上'-journal'后缀,由于在易信项目中数据库日志模式用的是默认的回滚DELETE模式,该模式会在事务结束后删除掉回滚日志文件,所以基本上都见不到回滚日志文件。

在回滚模式中,SQLite在写数据库时通过给数据库文件加锁,阻止读操作的执行,以此实现隔离性。只有在写事务完成,并且写数据同步到了磁盘后,读操作才被允许执行,这就保证读操作不会读到处于不一致状态下的数据(写操作只执行了一部分)。具体的加锁操作将会在并发控制章节中阐述。

WAL模式

SQLite从3.7.0版本开始引入WAL模式,在该模式中,对数据库的修改并不是直接写到数据库文件中,而是写入到一个单独的WAL文件中。WAL文件中的数据会在适当的时候写回到到数据库文件中。WAL模式的这种特性使得数据库可以同时进行读写事务。

并发控制

回滚模式中的并发

在回滚模式中通过对数据库文件加锁来控制并发,一个数据库文件有可能出于下面这五种加锁状态:

  1. 无锁状态(UNLOCKED),数据库文件没有被加锁
  2. 共享状态(SHARED),数据库文件被加了共享锁,数据库可以执行读操作,但是不能执行写操作。在同一时刻可以给数据库加多个共享锁,也就是说多个读操作可以同时进行。
  3. 保留状态(RESERVED),数据库文件被加了保留锁,处于这种状态,说明持有该锁的数据库连接在将来要执行写数据库操作,但目前还在进行读操作。在同一时刻只能给数据库文件加一把保留锁,但是共享锁可以和保留锁共存,并且在数据库被加了保留锁的情况下,其他数据库连接还能给数据库加共享锁。
  4. 未决状态(PENDING),数据库文件被加了未决锁,说明持有该锁的数据库连接想要尽快对数据库进行写操作。持有该锁的数据库连接将会等待所有的共享锁解锁,并且给数据库文件加上独占锁。数据库文件被加了未决锁之后将不能再加共享锁。
  5. 独占锁(EXCLUSIVE),只有持有独占锁才能对数据库进行写操作,数据库文件只能被加一把独占锁,同时任何其他类型的锁都不能跟独占锁共存。

一个数据库连接想要对数据库进行写操作,必须执行以下步骤:

  1. 给数据库文件加共享锁
  2. 持有共享锁后,给数据库文件加保留锁
  3. 持有保留锁后,将被修改数据的原始数据备份到回滚日志中
  4. 在内存中修改数据
  5. 确保回滚日志中的数据被写到磁盘中(不是操作系统缓存),以防发生断电等意外
  6. 给数据库文件加未决锁
  7. 提交写事务
  8. 给数据库文件加独占锁
  9. 将内存中的修改写到数据库文件中
  10. 确保所有的数据库文件修改都写到磁盘中
  11. 删除日志文件
  12. 释放独占锁

有两个时机可以触发加独占锁的行为,一是写缓存满了需要写回到数据库文件中,二是写事务提交了,从上面的分析可知如果获取独占锁的时候还有共享锁那么操作就会失败,也就是说如果独占锁的获取操作是在事务提交的时候触发的,那么写事务提交是有可能失败的。

从以上可知在数据库文件被独占锁锁住的期间里,数据库的读写是不能并发的。

WAL模式中的并发控制

在WAL模式中,一个读操作的流程如下:

  1. 记录WAL日志文件中最后一个提交记录的位置,标记为“end mark”。由于在WAL模式中读操作并不会影响写操作,在有多个连接在读取的同时,WAL日志文件仍然可以写入新的日志,每个读事务都记录了自己的“end mark”,并且在执行事务期间“end mark”是不变的。
  2. 读取数据时,首先确认在WAL日志中是否有需要的数据,如果有则读取位于“end mark”之前的最后一份数据(日志中可能存在多份该数据,因为可能存在多次修改,每次修改都会记录到WAL日志中),如果没有则从数据库文件中读取。

写操作流程:直接将新的数据添加到WAL日志文件中。因为写操作不会影响到读操作,所以读操作和写操作能在同一时刻实行。但需要注意的是一个数据库只有一个WAL日志文件,所以同一时刻只能有一个写操作。

随着写操作的不断执行,WAL日志会越来越大,这时候可以通过执行checkpoint操作将WAL日志的数据写回到数据库文件中,从而调整WAL日志文件的大小。在默认情况下checkpoint操作会自动执行,也可以通过执行“PRAGMA wal_checkpoint”强制执行。

WAL模式性能

写事务非常快,因为只有一次写操作(回滚模式需要两次写操作,一次是将旧数据写入到回滚日志中,一次是将新数据写入到数据库文件中),并且每次写操作都是顺序的,没有寻址等消耗。

读事务性能会随着WAL日志文件变大而下降,每次读数据都需要检查WAL日志是否有该数据,查找所花的时间是跟日志文件大小成正比的。为了减少WAL日志查找性能损耗,SQLite额外建立了一个WAL索引,“-shm”结尾的文件就是索引文件。官方文档显示在一个基本上都是读操作很少写操作的情况下,性能会降低1%或者2%。

SQLite使用Tip

多线程

SQLite可以运行在三种线程模式下:

  1. 单线程模式,SQLite所有的锁都被禁用,只能在单线程中使用SQLite
  2. 多线程模式,可以有多个数据库连接同时使用,但是一个数据库连接只能在一个线程中使用,在多个线程中同时使用同一个数据库连接是不安全的
  3. 串行模式,在多线程环境下使用没有任何限制

事务

长时间执行一个事务是要避免的:

  1. 在回滚模式下一个长写事务会阻塞所有其他的读事务,一个长读事务会阻塞写事务
  2. 在WAL模式下,长写事务可能会导致WAL日志文件过大,长读事务也可能导致WAL日志文件过大(读事务可能会阻止checkpoint操作的执行)

SQL执行

SQL语句执行之前都要使用sqlite3_prepare系列的函数编译成字节码。该编译的字节码可以被复用,在需要重复执行相同SQL语句的情况下(注意参数是可以不同的),复用该字节码能够节约编译消耗。

sqlite3_exec是对sqlite3_prepare,sqlite3_step,sqlite3_finalize这一流程的封装,SQL只执行几次的情况下可以直接调用。

在FMDatabase中如果想要复用字节码需要执行setShouldCacheStatements:显示启用。

可以将多个SQL语句放在“BEGIN”,"COMMIT"中执行,减少自动事务提交带来的消耗。默认情况下每次SQL语句的执行都是一个事务操作,事务操作包括数据库加锁,数据持久化等一些列操作,“BEGIN”其实就是关闭了自动事务,减少加锁、持久化这些行为执行次数,已达到提高性能的目的。

本文来自网易实践者社区,经作者徐晖权发布。