本篇主要从SQLite事务执行的原理中寻找如何更高效地使用它。
本篇预备知识
我们先来了解下SQLite执行事务的基本流程,状态变化过程,再分析怎么使用才更优。SQLite定义的锁的状态有如下几种:
- UNLOCK:最初始状态,没有任何锁在数据库上;
- SHARED:共享状态,允许读取数据,但是不能写入和修改,同一时间允许有多个SHARED存在, 共享锁只是针对操作系统的磁盘缓存;
- RESERVED:这个锁意味着进程将要对数据库进行写操作。某一时刻只能有一个RESERVED锁,但是RESERVED锁和SHARED锁可以共存,而且可以对数据库加新的SHARED锁。引入这个状态是为了提高并发性,在这个状态下可以先修改缓存数据,直到将修改写入磁盘的时候再加上排他锁;
- EXCLUSIVE:真正将数据写入磁盘的过程,此时不允许其他任何写入读取操作,是排他锁;
- PENDING:可以理解为一个中间状态,从限制小的状态往限制高的状态变化的一个过程。例如从RESERVED向EXCLUSIVE转变的时候需要经过这个状态,需要等待已有的读写连接完成之后再进入EXCLUSIVE。
事务在执行过程中锁状态之间变化,如下图所示:
** SQLite一次事务过程
**
**整个详细的流程看 **👉 这里
一般来说,Reserved Lock 和日志文件是一一对应的。如果当pager 首次打开数据库,会做一次完整性检查。如果发现有日志文件但是没有Reserved Lock ,数据库会进入恢复模式。
进入恢复模式后,会直接从shared 状态到pending 状态。那么在数据库连接成功恢复数据库以前不会有其他操作。
按照正常的事务,一次操作大概要经历:
- 一次文件创建(回滚日志)
- 两次写入 (修改前把原始数据写入回滚日志/数据页的修改---数据页在系统缓存)
- 两次flush 文件 (回滚日志和数据库变更冲入本地磁盘)
- 一次回滚日志删除
- 三次加锁
我们可以根据实际的使用场景来进行优化。
优化点:
- 需要批量更新数据,可以显式使用事务
- 磁盘同步方式
- 设置高效的日志模式
- 使用事务来避免死锁
显式事务
一个新事务的创建和关闭,消耗是非常大的,因为它需要打开、修改和关闭日志文件。在默认情况下,调用Sqlite statement执行一条SQL语句,就会创建一个事务,在执行完这条语句后自动关闭事务。那如果我们连续执行很多条SQL的话,会不断创建和关闭事务,这是非常浪费的,对性能的影响是非常大。对于这种情况,我们可以使用BEGIN TRANSACTION和END TRANSACTION来自助选择事务创建和提交的时机,例如:
sqlite_exec(sqlitedb, "BEGIN TRANSACTION;",...);
...
执行N条SQL
...
sqlite_exec(sqlitedb, "END TRANSACTION;",...);
加上BEGIN END之后,N条SQL只创建了一个事务,不加的话会开N个事务来完成,效果可想而知。
磁盘同步方式
SQLite在将数据提交给系统(OSBuffers)后由系统写入磁盘,但是在这个过程中系统有可能会出现掉电或者写入失败等异常情况,如果SQLite不等待系统执行结果,可能会误认为操作已成功,但实际上数据已经不一致了。对于这种情况,SQLite提供了3种同步方式:
PRAGMA synchronous = (0 | OFF) | (1 | NORMAL) | (2 | FULL)
在FULL模式下, SQLite数据库引擎总是会暂停以确定数据已经写入磁盘,这种模式可以保证系统崩溃或者掉电后重启数据不会收到损坏,很安全但很慢。在NORMAL模式下,SQLite数据库引擎在大部分情况下会暂停,但不像FULL模式下那么频繁,这种方式比FULL模式快,但是存在极小概率在系统掉电或故障时数据库遭到损坏。在OFF模式下,SQLite将数据提交给系统之后不会等待结果,直接继续执行,也就是说在一次事务的过程中会少了两次Flush文件操作。这种模式下如果系统在写入的时候崩溃或者异常,数据库就可能会被破坏,但这种模式下有些操作可以比FULL下快一个数量级。
默认情况是NORMAL,如果对安全性要求极高的话,可以选择FULL模式,如果非常追求效率又不介意数据库损坏的话(例如定期做数据库自动备份,损坏了仍可还原),可以选择OFF。
设置高效的日志模式
日志文件是SQLite实现回滚至关重要的东西。默认情况下,SQLite在将修改写入磁盘之前,会先将修改日志刷入磁盘再将修改页面写入磁盘,写入完成之后再将日志清理掉。如果在写入的过程中Crash,SQLite能在下次启动时根据日志文件恢复。但这会增加额外的磁盘读写开销,影响整体的事务执行时间。不过Sqlite提供了多种日志模式,可以通过如下命令设置:
PRAGMA journal_mode = DELETE | TRUNCATE | PERSIST | MEMORY | WAL | OFF
DELETE是默认方式,就是在事务执行后将日志文件删除;TRUNCATE方式则是不删除文件,直接将文件内容清空(在很多系统上,这种清空比删除文件要快);PERSIST方式也不会删除文件,而是将文件头中长度字段置为0,在某些平台上这种方式会优于前两者;MEMORY方式则直接将日志放在内存,不用磁盘存储,这样速度会很快但是如果宕机,日志也会丢失,数据可能被破坏无法恢复;WAL(Write-Ahead Logging)是Sqlite3.7以后才有的一种模式,这种模式的原理是修改并不直接写入到数据库文件中,而是写入到另外一个称为WAL的文件中,在随后的某个时间才被写回到数据库文件中,这种方式可以提高事务的并发性,但是一旦进入这种模式就无法更改页面大小,不能以只读方式打开数据库,且访问数据库的所有程序必须在同一主机上并支持共享内存技术,对于读取多写入少的场景反而会更慢,像微信这种读写频繁的app,很适合用WAL;OFF则是完全禁用回滚日志的功能。
一般情况下,可选择TRUNCATE或PERSIST模式,会有性能上的帮助;对于那些实时性要求非常高但是数据一致性要求不是很高的场景,可以选择MEMORY模式;3.7以上的版本,如果修改的数据量不是特别大或者不是读取多写入少的场景,可以考虑WAL模式。
使用事务来避免死锁
事务创建的时候会对数据库文件加锁,所以在多线程情况下需要注意及时结束事务,否则会影响到其他操作。尽管SQLite有预防死锁的机制(原理是在获取锁的时候重试有限次,超过就返回SQLITE_BUSY错误),避免程序死掉,但还是会出现下面这种并非我们想看到的现象:
最终Session A和Session B都失败了,这个问题可以通过选择合适的事务类型来避免。
3种事务类型
- DEFERRED
- IMMEDIATE
- EXCLUSIVE
我们可以通过下面的BEGIN命令来指定:
BEGIN [ DEFERRED | IMMEDIATE | EXCLUSIVE ] TRANSACTION
DEFERRED是默认类型,事务起始不会获取任何锁,从UNLOCKED状态开始,直到事务需要对数据库进行读或者写的时候才会获取对应的锁;IMMEDIATE起始就尝试获取RESERVED锁,保证没有别的连接可以写数据库,但是别的连接可以对数据库进行读操作,也就是说它会阻止其它的连接BEGIN IMMEDIATE或者BEGIN EXCLUSIVE;而EXCLUSIVE事务会试着获取对数据库的EXCLUSIVE锁,一旦成功,EXCLUSIVE事务保证没有其它任何连接,所以就可对数据库进行读写操作了。
例子中,Session A和Session B都需要写数据库,如果两者创建的时候都选择的是IMMEDIATE事务,那这种失败的情况就不会发生了。