sqlite是一款嵌入式数据库,它和MySql之类的数据库不同,没有独立运行的进程,而是以C库的形式嵌入到程序中,资源占用低,并且支持多种编程语言,比较适合在移动端使用。在项目中使用sqlite时踩过不少坑,其中最常见的问题就是数据库写入慢,要解决它题就得借助事务进行优化,本文是对于sqlite事务的工作机制学习的一个总结。
对象模型
sqlite虽小,但五脏俱全,其中有许多组件:语法分析器、词法分析器、虚拟机等,对于应用开发者来说,需要了解的是如下图所示的对象模型。
主要包含如下组件:
Connection:所有数据库操作都通过连接进行交互,一个Connection对象代表到数据库的连接和上下文,其中包含若干个statement。statement:statement代表编译的SQL语句,内部用VDBE字节码表示。vdbe:sqlite的字节码引擎,负责在虚拟机中运行字节码。cursor:即游标,VDBE内部通过游标(Cursor)遍历记录。B-Tree:sqlite内部使用BTree存储表和索引。pager:页面缓存,B树以固定大小的页面缓存从数据库文件中读取数据,减少真实的磁盘操作。
锁状态
在进一步了解事务处理机制前先简单介绍一下sqlite中的锁,从单个进程的角度来看,数据库文件可以处于以下五种锁定状态中:
- UNLOCKED,默认状态,表示没有获取数据库锁。这种状态下不能读写数据库。
- SHARED,共享锁,获取此锁后可以读数据库但不能写。程序可以同时持有多个SHARED锁,因此可以同时有多个线程读取数据库。但是在一个或多个SHARED锁被激活之后,其他线程就不能写入数据了。
- RESERVED,预留锁,获取此锁后表示此程序将在之后的某个时间点写入数据,但是现在只是读数据。同一时间只能有一把RESERVED锁被激活,多个SHARE锁可以和RESERVED锁共存。RESERVED锁和PEDDING锁的区别在于,RESERVED锁激活时可以申请新的SHARED锁。
- PEDDING,待定锁,一种过渡锁,获取共享锁或排他锁前,都需要获取该锁。在排他锁获取此锁,表示当前进程希望尽快写入数据,如果当前还有SHARED锁未释放,则等待已存在的SHARED锁释放就可以获取EXCLUSIVE 锁进行写入了。获取此锁后,仅允许已经存在的SHARED锁,不能再申请新的SHARED锁。
- EXCLUSIVE,排他锁, 表示就要写入数据库文件了,仅允许一把EXCLUSIVE锁,并且在持有EXCLUSIVE锁的时候,不能再获取其他任何锁。
其状态转换如下图所示:
事务
默认情况下,sqlite运行在自动提交模式,如果没有通过begin...commit/rollback定义事务范围,那每条独立的sql命令就是一个事务。所有成功完成的命令都会自动提交,而所有发生错误的命令都会回滚。
读事务中的锁状态转换
考虑如下sql查询:
begin;
select * from episodes;
select * from episodes;
commit;
以上sql查询的锁路径十分简单,如下所示:
UNLOCK->PEDDING->SHARED->UNLOCK
如果使用自动提交模式,连续两个select语句都需要完整的从无锁->PEDDING锁->共享锁->无锁的状态,如下:
UNLOCK->PEDDING->SHARED->UNLOCK->PEDDING->SHARED->UNLOCK
此时无法保证原子性,即两次select查询之间可能会插入修改操作,使得两次查询结果不同,如果需要保证两次select查询结果一直,请使用begin...commit/rollback手动控制事务范围。
写事务
对数据库进行写操作时的锁路径与读操作路径一样,都必须经历无锁->PEDDING->共享锁的状态,一旦开始写入数据,就需要获取RESERVED锁和EXCLUSIVE锁,其完整的锁路径如下。
UNLOCK->PEDDING->SHARED->RESERVED->PEDDING->EXCLUSIVE->UNLOCK
RESERVED、PEDDING、EXCLUSIVE分别对应保留状态、待定状态和独占状态,在这三个状态中完成数据库写入操作。
保留状态
当数据库连接准备向数据库写入内容时,必须先将共享锁升级为预留锁,此时把数据写入pager的本地内存缓存中,pager会初始化回滚日志,并将原始的数据备份到回滚日志(journal文件)中。在保留状态下所做的修改并没有保存到数据库文件中,同一时间只能有一个保留或者独占连接。
待定状态
在获取排他锁之前,写连接要先持有待定锁,此时其他连接无法获取待定锁,从而无法获取到共享锁,也就实现了禁止其他连接进入数据库的功能。当其他连接完成工作,释放已有的锁之后,就可以进入独占状态。
独占状态
进入独占状态后的主要工作就是将pager内修改的数据同步到数据库文件中,在写数据库文件之前要先检查回滚日志(journal文件)是否已经写入磁盘,由于存在操作系统缓存,可以使用synchronous确保同步完成。不过受到磁盘厂商及操作系统的限制,仍旧无法保证写入操作100%成功。
事务自动提交后,page会r清理回滚日志、清除页缓存、释放排他锁,由于每次进行sql写入操作都涉及创建、提交和清除回滚日志,当需要频繁修改数据库时可以考虑手动事务提交模式,提升写入效率。
锁定与故障恢复
sqlite的锁是基于标准的文件锁定实现的,主要分为三个部分:保留字节、待定字节和一个共享区域。
从未锁定状态到共享状态时:
- 获取pedding字节的读锁
- 获取shared区域任意字节的读锁
- 释放pedding字节的读锁
从共享状态到保留状态时,获取reserved区域的写入锁。
从保留状态到独占状态时:
- 获取pedding区域的写入锁
- 此时其他连接无法获取pedding字节的读锁,无法获取shared锁
- 等待shared区域现有的连接释放shared锁
- 获取整个shared区域的写入锁
sqlite利用回滚日志(journal)实现故障恢复,检查流程如下:
- 在每次打开数据库文件或者从数据库中读取页之前检查是否有日志文件。
- 如果有日志文件,检查数据库中是否有保留锁。
- 此时要么创建日志文件的进程发生崩溃,要么系统死机,这种情况就需要将日志文件中的数据还原到数据库中 。
- 此时直接从shared锁跳转到pedding的写入锁,这样的目的主要在于杜绝新连接进入数据库,及杜绝其他共享状态的连接进入恢复模式。
- 这种情况下,日志文件叫做热日志,它就是隐式的独占锁
pager何时从保留锁转移到独占锁
前面讲到写数据时会从保留状态转换到独占状态,这一过程是sqlite自发完成的,那何时会触发这个状态转换呢?实际上有两种情形:
- 调用commit提交事务时,此时必须进入独占状态。
- 在事务运行过程中,页面缓存已满,也会触发状态转换。并不是页面缓存刚满就会触发,由于页面缓存有已修改页和未修改页组成,缓存第一次填满时,pager会尝试将未修改页清除出去。之后当缓存再次被填满时会重复这个动作,知道缓存全由已修改页组成,此时只能进入独占状态,将页缓存中的数据写入数据库文件。