作为移动开发者,或多或少会与 SQLite 直接或间接打过交道,在使用过程中可能有如下疑问:
- SQLite 是线程安全的吗?
- SQLite 支持并发读写吗?
- 常在数据库文件目录看到的 -wal 文件是什么?
- 常看到的概念 checkpoint 是什么?
本文是浅层次但较系统学习 SQLite 后的总结笔记,看完或许能解答上述问题;本文叙述的出发点是从设计一个简单的 SQLite framework 开始;关于 SQLite 的第三方库有很多,对于 iOS 生态,知名的包括 FMDB、WCDB、GRDB、SQLite.swift 等,学习它们也是本文的一个任务之一。
以熟悉 Swift 和 SQLite 为目的,写了一个类似于 FMDB 的 SQLite wrapper,详见 SBDB
在设计一个 SQLite framework 过程中,需要理解 SQLite APIs 的使用,以及一些核心概念,包括 SQLite 的线程安全模型、所支持的并发模型等等。编写一个 SQLite 工具库并非常见需求,但私以为此过程有助于帮助更全面理解 SQLite 以及更好地使用 SQLite。
概述
关于 SQLite 的介绍可以从官方的 About SQLite 开始,本文罗列一些重要的点:
- 发明人:D. Richard Hipp
- 本质上是一个 ANSI-C 库,轻量(百 KB 级别),开源
- 兼容性好,所有系统都可以调用 C 语言写的库
- 低依赖,在最小配置下,只使用了 memcmp、strcmp 等少数几个标准库 API
- 主流移动操作系统(iOS & Android)已内置
- 数据库文件格式稳定,向后兼容,跨平台;坚持 50 年不动摇(2000-2050)
- 本地存储,不支持网络访问
- 无权限管理机制
- 不支持加密
- 可以使用开源的加密库代替系统内置的动态库实现加密,譬如 SQLCipher
- 支持全文搜索(FTS)
- 当前主流版本是 v3,这也是本文内容的参考版本
- 变长记录存储,即删除数据也不会减小数据库文件,除非使用
vacuum
命令 rebuild 数据库文件
数据类型
把 SQLite 数据类型专门拧出来介绍是因为它相对于其他 SQL 数据库有一些特别之处...
SQLite 支持 5 种存储类型:
- integer: 有符号整型,根据数值大小,可能存 1/2/3/4/6/8 bytes
- real: 浮点类型,8 bytes
- text: 字符串(支持 utf-8、utf-16)
- blob: 二进制数据,输入啥,就存啥
- null: NULL 值
和其他 SQL 数据库不太一样的是,SQLite 的列没有真正的类型约束;作为对比,其他数据库譬如 MySQL 在创建数据表定义字段时,一定要指定列(字段)类型,之后插入数据时,得确保值和列类型匹配。SQLite 没有这样的约束,任何列(除了 integer 型主键列 )都可以同时存储如上 5 种类型值。
> create table foo (bar); -- 定义字段 bar,但没有指定类型
> insert into foo (bar) values (42); -- 插入整型值
> insert into foo (bar) values (NULL); -- 插入 null
> insert into foo (bar) values ("42"); -- 插入字符串
> insert into foo (bar) values (42.0); -- 插入浮点值
> select typeof(bar), bar from foo; -- 其中 `typeof` 返回值类型
42 | integer
| null
42 | text
42.0 | real
然而,SQLite 定义数据表时也是可以为字段指定类型的,譬如: create table foo (field1 numeric, field2 blob, field3)
,但这些类型并不起约束作用,它们在 SQLite 语义中被称为:type affinity,常译为「类型相像」。也有 5 种类型:integer、real、text、blob、numeric。
可以把 type affinity 理解为转换器,以 text 为例,如果列的 type affinity 为 text,那么 insert 数据时,内部会将插入的数据尽可能转为字符串,譬如插入 1
,则存为 "1"
;插入 2.0
,则存为 "2.0"
;如果插入 null
,则仍然存为 null
。SQLite 官方文档 中有着详细说明,本文不多赘述。
核心 APIs
SQLite 的 APIs 超过 200 个,但查看使用 SQLite 的知名库(譬如 FMDB、YYCache 等)的源码,可以发现它们使用的 API 都非常少。
实际上,基于 2 个类型,8 个核心 APIs 就能完成基本功能:
{
// 两个核心类型:
// - sqlite3: 句柄,代表连接
// - sqlite3_stmt: statement,可以简单理解为 sql 语句的抽象
sqlite3 *db = NULL; sqlite3_stmt *stmt = NULL;
// 八个核心 APIs:
// - sqlite3_open/sqlite3_close: 用于打开/关闭连接
// - sqlite3_prepare/sqlite3_finalize: 创建/销毁 statement
// - sqlite3_bind 系列: 为 statement 绑定参数
// - sqlite3_step: 执行 statement,对于 select 语句,可能要执行多次
// - sqlite3_reset: 将 statement 恢复到初始状态(譬如解除绑定的参数),以便重复使用
// - sqlite3_exec: sqlite3_prepare/sqlite3_step/sqlite3_finalize 的 wrapper
sqlite3_open("path/to/db", &db);
sqlite3_prepare(db, "select * from someTable where id = ?", -1, &stmt, NULL);
sqlite3_bind_int(stmt, 1, 42);
while (sqlite3_step(stmt) == SQLITE_ROW) {
// 使用 sqlite3_column 系列 API 提取数据
}
sqlite3_finalize(stmt); // 或者使用 `sqlite3_reset(stmt);`
sqlite3_exec(db, "drop table someTable", nil, NULL, NULL);
sqlite3_close(db);
}
// 如上 API 中,除了 sqlite3_stmt 和相关 API,都比较容易理解;
// 对于 sqlite3_stmt 和相关 API,花太多文字描述感觉意义不大,写点 demo 就能很快理解了
看起来挺简单?然而,实际操作中有不少问题要处理,包括但不限于:
- 基本问题
- 类型适配、API 封装
- 线程安全:选择合适的线程模型
- 事务与并发:设计合适的事务管理模式
- 提高易用性:ORM、避免用户写 SQL 语句
- 防注入
- 性能与可靠性
- 性能监控
- 损坏修复
- 性能提升
本文没打算将上述所有点都涉及到,将内容主要收敛在基础方面,讨论:线程安全、事务、并发。
线程安全
不同场景下,讨论线程安全的关注点可能不一样,譬如死锁、非主线程执行 UI 操作等。对于 SQLite,本文讨论的点是:是否可以在任何线程使用 SQLite 的 API,且不会带来数据安全问题。
SQLite 的 API 是支持多线程访问的,多线程访问必然带来数据安全问题。
为了确保数据库安全,SQLite 内部抽象了两种类型的互斥锁(锁的具体实现和宿主平台有关)来应对线程并发问题:
- fullMutex
- 可以理解为 connection mutex,和连接句柄(上问描述的 sqlite3 结构体)绑定
- 保证任何时候,最多只有一个线程在执行基于连接的事务
- coreMutex
- 当前进程中,与文件绑定的锁
- 用于保护数据库相关临界资源,确保在任何时候,最多只有一个线程在访问
下面画了一张图用来描述 fullMutex 和 coreMutex 所起到的作用:
如何理解 fullMutex?SQLite 中与数据访问相关的 API 都是通过连接句柄 sqlite3 进行访问的,基于 fullMutex 锁,如果多个线程同时访问某个 API -- 譬如 sqlite3_exec(db, ...)
,SQLite 内部会根据连接的 mutex 将该 API 的逻辑给保护起来,确保只有一个线程在执行,其他线程会被 mutex 给 block 住。
对于 coreMutex,它用来保护数据库相关临界资源,包括本文将要介绍的文件锁。
用户可以配置这两种锁,对这两种锁的控制衍生出 SQLite 所支持的 三种线程模型:
- single-thread
- coreMutex 和 fullMutex 都被禁用
- 用户层需要确保在任何时候只有一个线程访问 API,否则报错(crash)
- multi-thread
- coreMutex 保留,fullMutex 禁用
- 可以多个线程基于不同的连接并发访问数据库,但单个连接在任何时候只能被一个线程访问
- 单个 connection,如果并发访问,会报错(crash)
- 报错信息:illegal multi-threaded access to database connection
- serialized
- coreMutex 和 fullMutex 都保留
如何配置线程模型呢?有三个阶段可以配置线程模型:
- compile-time
- 相当于全局设置
- 在编译时设置编译选项 SQLITE_THREADSAFE 的值指定线程模型:
0
: single-thread,1
: serialized,2
: multi-thread
- 通过
sqlite3_threadsafe()
可以在运行时知道所用的 sqlite3 库的 SQLITE_THREADSAFE 编译选项值
- start-time
- 相当于应用级设置,会覆盖 SQLITE_THREADSAFE 的选项配置
- 在第一次使用 SQLite API 之前,通过 sqlite3_config 指定线程模型
sqlite3_config(SQLITE_CONFIG_SINGLETHREAD
: 设置 single-thread- 经测试验证,iOS 和 macOS 内置的 SQLite 不允许该模式
sqlite3_config(SQLITE_CONFIG_MULTITHREAD)
: 设置 multi-threadsqlite3_config(SQLITE_CONFIG_SERIALIZED)
: 设置 serialized
- run-time
- 使用
sqlite3_open_v2()
建立连接时为第三个参数(flags 参数)指定值,即为每个连接配置 fullMutex 的使能开关:SQLITE_OPEN_NOMUTEX
,关闭 fullMutex,即 multi-thread 模式SQLITE_OPEN_FULLMUTEX
,打开 fullMutex,即 serialized 模式
- 使用
来个 Demo 直观感受这几种线程模型:
// multi-thread.c
void* access_database(void *db) {
for (int i = 0; i < 1000; i++) {
sqlite3_exec((sqlite3 *)db, "begin", NULL, NULL, NULL);
sqlite3_exec((sqlite3 *)db, "end", NULL, NULL, NULL);
}
return (void *)NULL;
}
int main() {
if (sqlite3_config(SQLITE_CONFIG_MULTITHREAD) == SQLITE_OK)
printf("设置 multi-thread 模式成功\n");
sqlite3 *db = NULL;
sqlite3_open("./test.db", &db);
{
// 模拟主线程和子线程并发访问数据库
pthread_t p;
pthread_create(&p, NULL, access_database, db);
access_database(db);
}
sleep(2);
sqlite3_close(db);
return 0;
}
// gcc multi-thread.c -lsqlite3
上述 demo 代码中,两个线程基于同一个连接访问数据库,分别访问了 1000 次,基本上能够触发两个线程同时访问数据库的场景,程序的执行会以 crash 结束,因为在一开始通过 sqlite3_config()
配置了 multi-thread 线程模式。
如果将 SQLITE_CONFIG_MULTITHREAD
改为 SQLITE_CONFIG_SERIALIZED
,即将 multi-thread 线程模式改为 serialized 模式,程序就可以正常运行。
另外,如果上述 demo 中两个线程使用的是独立的连接,又是不一样的结果:multi-thread 模式下,SQLite 允许多个线程使用访问数据库,只要不是同一个连接就 ok 了。
搞清楚了 fullMutex 和 coreMutex 的作用,理解 SQLite 提供的这三种线程模型并不难,此处稍作总结。
single-thread 模式下,使用者要承担较多线程安全职责:确保在任何时候,只有一个线程访问数据库,对于移动端而言,实在想不到使用它的收益,对于别的场景,譬如单线程模式下的嵌入式设备,或许有它存在的价值。经测试,macOS、iOS 内置 SQLite 禁掉了该模型。
Serialized 模式看似是最省心的,用户完全不用担心「illegal multi-threaded access to database connection」crash;但它付出的代价是,任何基于连接句柄的 API 操作都会有一个锁检测逻辑,对效率有所折损。
关于 fullMutex 锁的效率折损,经 demo 测试:上千次连续读写,发现并不明显。
在实际使用中,选择最多的是 multi-thread 模式,它也是 iOS/macOS 内置 sqlite 库的默认模式;在multi-thread 模式,需要在用户层保证任何时候只有线程在使用连接句柄(sqlite3 实例)访问数据库。
事务与并发
SQLite 的线程安全问题相对来说是比较容易搞定的,毕竟可以通过配置合适的线程模型,或者在应用层通过队列等手段来规避;但并发问题就复杂得多。
不同语境下并发所指的意义可能不一样,对于 SQLite 而言,讨论并发的粒度是事务;也就 SQLite 是否支持并发事务;所以本文将事务和并发放在一起讨论。先抛出结论:
- SQLite 支持并发执行读事务,即可以同时开启多个进程/线程从数据库读数据
- SQLite 不支持并发执行写事务,即不能多个进程/线程同时往数据库写数据
如上并非完整的结论,SQLite 对并发读写 -- 也即同时进行读事务和写事务 -- 的支持如何?这个问题的答案与用户所选择的日志模型有关,下文有分析。
上文介绍了 coreMutex、fullMutex 两种锁,可能对理解并发造成一些干扰,此处补充一些说明。在 serialized 和 multi-thread 模式下,用户可以并发访问 SQLite API,但并不意味着可以读写成功,「支持并发访问 API」和「支持并发执行事务」完全是两回事。
接下来的重点叙述包括:理解 SQLite 事务,理解 SQLite 事务的实现原理。
事务
事务是 SQL 数据库里的通用概念,它描述的是一个或一组数据库操作指令的执行单元;具有四个属性:原子性、一致性、隔离性、持久性,即所谓 ACID,关于它的概念本文不过多赘述。
默认情况下,SQLite 数据库的所有操作都是事务的,即所谓隐式事务(implicit transaction)。如下就是一个隐式事务:
sqlite3_stmt *stmt = NULL;
sqlite3_prepare(db, "select * from table_name", -1, &stmt, NULL);
while (sqlite3_step(stmt) == SQLITE_ROW) {
// 使用 sqlite3_column 系列 API 提取数据
}
sqlite3_finalize(stmt); // 或者使用 `sqlite3_reset(stmt);`
用户还可以自行指定事务的开始与结束,即所谓显式事务(explicit transaction),语法详见 这里,如下是一个小 demo:
sqlite3_exec(db, "begin", nil, NULL, NULL);
while (i < 5000) {
sqlite3_exec(db, "insert into table_name (column_name) values (42)", nil, NULL, NULL);
i++;
}
sqlite3_exec(db, "end", nil, NULL, NULL);
无论显式事务还是隐式事务,根据是否会对数据库进行修改,可以分为:读事务 和 写事务。理清楚读事务和写事务这两个概念对于分析事务并发非常重要。
drop、update、insert 等 SQL 语句,因为都涉及数据库变更,所以包含这些语句的事务都是写事务;如果事务中只有 select 语句,那么它属于读事务。
对事务有一个基本的了解后,现在将注意力集中在问题中:SQLite 是如何实现事务的呢?
这个问题涉及太多细节,难以用几段文字把它描述清楚;但如果想用好 SQLite,这个问题又不得不理清楚。我们先从 SQLite 的日志模型开始讨论。
两种日志模型
一个很重要的事实是,要想实现事务,单靠数据库文件是难以完成的,需要借助一个文件辅助完成,这个辅助文件被称为日志文件(journal)。
SQLite 支持两种日志记录方式,或者说两种日志模型:Rollback 和 WAL。
这两种模型,日志的文件格式不同,更重要的是日志在事务执行过程中扮演的角色不同;换句话说,选择了日志模型,相当于选择了一种事务处理模型。
下面来分别简述这两种日志模型中的事务处理逻辑。
理解 Rollback
在 rollback 日志模型中,当执行写事务的时候,会在数据库文件(本文称为 .db)所在目录下产生一个日志文件(本文称为 .db-journal),下图简单描述了写事务执行过程中,.db-journal 所起到的作用:
如下补充一些文字说明:
- 1 初始状态,此时只有数据库文件
- 2 执行写事务,SQLite 检测到要修改 page 1 和 page 3;创建 .db-journal 文件,将 page 1 和 page 3 的内容拷贝到其中,作为备份
- 3 在数据库文件中直接修改
- 4 Commit 或者 Rollback(只能二选一)
- 4.1 提交修改,删除 .db-journal 文件
- 4.2 放弃修改,即回滚,使用 .db-journal 里的拷贝将数据库文件恢复到事务发生之前的模样
SQLite 官方在 Atomic Commit In SQLite 花了相当多的笔墨介绍 rollback 日志模式下事务处理的逻辑细节。上图省掉了很多细节(锁管理、内存-磁盘交互等),将重点放在了描述日志文件本身上,可以看出:
- 写操作是直接在数据文件 .db 上进行的
- .db-journal 在写事务中起到了备份作用。备份要修改的 pages、.db 文件原大小,在写的过程中可以回退,即将备份信息给还原回去:恢复 pages 的原数据,或将 .db 文件切回之前的 size
- .db-journal 是一个临时文件。当写事务完成提交(commit)或回退(rollback)时,该文件会被清理掉(有多种清理方式)
- 在任何时候一个数据库文件最多只对应一个 .db-journal 文件
- .db-journal 文件是否存在且有效是描述 .db 文件是否稳定完整的核心依据
- 创建 .db-journal 后,如果发生了断电或者程序崩溃退出等异常情况,下次重新访问数据库时,会首先根据 .db-journal 将数据库恢复到原来的样子,这保证了数据库的一致性(consistency)
接下来谈谈 rollback 模式下的锁逻辑。SQLite 使用文件锁来保证事务之间的隔离性(isolation)和原子性(atomicity)。
所谓文件锁,并不是一个计算机原语,也即没有所谓的 API 来直接控制它;它是 SQLite 抽象的一个概念,具体的实现和宿主有关,其实现细节并非本文讨论重点;需要注意的是它的 feature:
- 和数据库文件关联。这意味它不仅可以实现线程阻塞,也可以实现进程阻塞
- 有五种状态。UNLOCKED、SHARED、RESERVED、PENDING、EXCLUSIVE
- 关于这五种状态的叙述参考 这里
SQLite 文件里专门有一段数据区域与锁有关,详见 Database File Format 的「The Lock-Byte Page」;文件锁的具体实现与宿主有关,对于 Unix 而言,详见 src/os_unix.c 里 unixLock() 函数。根据官方文档的说法,文件锁相关数据不会回写到磁盘,所以不用担心某个进程持有该锁后,因为异常无法释放导致永久死锁。
针对文件锁五种状态的转换,Understanding SQLITE_BUSY 画了一张非常棒的图,copy 如下:
- .db 文件锁的初始状态是 unlocked
- 任何连接想要开启读操作,需要获取 shared 锁
- 可以有多个连接获取 shared 锁
- 任何连接想要开启写操作,需要获取 reserved 锁
- 只能有一个连接获取 reserved 锁
- 获取到 reserved,transaction 可以写数据,但只是写在用户空间
- 将修改的数据同步到数据库文件中(提交事务),需要获取 exclusive 锁
- 如果此时有连接在读数据,锁会变成 pending 状态,其他连接都退出读状态后,才进入 exclusive 状态
- 当文件锁处于 pending 状态,或者 exclusive 状态,表示磁盘中的 .db 文件即将或正在发生变化,此时是不可读的
下面从代码层面进一步理解一下:
sqlite3 *db = NULL;
sqlite3_open("path/to/db", &db);
// 开始事务
sqlite3_exec(db, "begin", nil, NULL, NULL);
// 获取 shared 锁
// 如果文件锁处于 pending 或 exclusive 状态,则失败,返回:SQLITE_BUSY 错误码
sqlite3_exec(db, "select * from table_name", nil, NULL, NULL);
// 获取 reserved 锁
// 仅当文件锁处于 shared 或 unlocked 状态,才能成功;否则失败,返回:SQLITE_BUSY 错误码
sqlite3_exec(db, "drop table table_name", nil, NULL, NULL);
// 获取 pending 锁,等待升级为 exclusive 锁
// 仅当 shared 锁全部被释放,才能成功执行,否则失败返回:SQLITE_BUSY 错误码
sqlite3_exec(db, "commit", nil, NULL, NULL);
有些类似于 2PL 并发控制机制;但 SQLite 做得更复杂一些,以规避 dead lock。
此处可以对 rollback 日志模式稍作总结:
- 每次写事务都有两个写 IO 的操作(一次是创建 .db-journal,一次修改数据库)
- 可以同时执行多个读事务
- 不能同时执行多个写事务
- 读事务会影响写事务,如果读事务较多,写事务在提交阶段(获取 exclusive 锁)常会遇到 SQLITE_BUSY 错误
- 写事务会影响读事务,在写事务的提交阶段,读事务是无法进行的
- 写事务遇到 SQLITE_BUSY 错误的节点较多
使用 Rollback 模式
激活 rollback 日志模式可以在连接数据库后使用 pragma journal_mode
开启:
sqlite3 *db = NULL;
sqlite3_open("path/to/db", &db);
sqlite3_exec(db, "pragma journal_mode=delete", nil, NULL, NULL);
其中 rollback 模式下 journal_mode 的可选值包括如下值,它们用于指定 .db-journal 的清理方式:
- delete: 清理 .db-journal 的方式是直接删除
- truncate: 清理 .db-journal 的方式是清空文件内容(不删除,保留文件留作下次使用)
- persist: 保留 .db-journal 文件,但会对文件的 header 做一些处理,以便 SQLite 能识别该文件是否有效
- memory: .db-journal 文件不写磁盘,而是放在内存中;这种模式下,如果程序崩溃、断电,数据库可能就 gg 了
SQLite 默认的日志模式是 rollback,清理模式为 delete。
理解 WAL
WAL 的全称是 Write-Ahead Logging。
在 rollback 日志模式中,写操作是直接发生在数据库文件上的,日志充当备份用,主要用于确保数据库的一致性;正常完成写事务后,它就被销毁了。
wal 日志模式中,提供了另一种日志类型,常称为 wal 文件,记为 .db-wal,在这个模型中,写操作都发生在 wal 文件中;另一个不同点是,.db-wal 文件是持久存储的,它是数据库完整的重要组成部分。
SQLite 官方对 rollback 日志模式有着非常详细的图文并茂的 介绍,但 wal 日志模式的待遇没那么好,介绍信息 相对来说没那么生动,于是动手根据自己的理解分别针对写操作和读操作画了两张图。
先看看写事务:
从上图可以看出,数据库的全局数据可能分布在两个地方:.db 和 .db-wal 中,那么读操作是怎样读数据的呢?详见下图:
对 WAL 模式稍作一些总结:
- wal 模式比 rollback 模式有更高的并发性:读写互相不影响
- wal 模式比 rollback 模式降低了损坏率(写数据库操作频率极大降低)
- 使用好 wal 模式的关键点在于设计良好的 checkpoint 策略
- wal 文件记录了更改的全量,会膨胀得非常快
- checkpoint 频率过低,导致 wal 文件过大,消耗磁盘空间,且影响 read 的效率
- checkpoint 频率过高,增大数据库的损坏率
使用 WAL 模式
激活 wal 模式可以在连接数据库后使用 pragma 开启:
sqlite3 *db = NULL;
sqlite3_open("path/to/db", &db);
sqlite3_exec(db, "pragma journal_mode=wal", nil, NULL, NULL);
// `pragma journal_mode=wal` 语句会有一个返回值,返回当前 journal mode
// 如果不为 wal,表示失败
三种事务类型
再回过头来补充一些事务类型相关内容,使用 SQLite 时可能常会和它们打交道。开启显式事务时,可以指定三种类型:
begin deferred ... end
begin immediate ... end
begin exclusive ... end
默认情况下,SQLite 选用 deferred 类型。该类型下,调用 begin deferred
并不会立马开始一个 transaction,而是延迟到第一次访问数据库时,如果事务的所有指令都是读操作,那么这一个事务被认为是读事务;只要其中包括写事务,那么它就升级为写事务。如下 demo:
sqlite3_exec(db, "begin deferred", nil, NULL, NULL);
// 这是一个读事务,获取 shared 锁
sqlite3_exec(db, "select * from table_name", nil, NULL, NULL);
// 升级为写事务,获取 reserved 锁
sqlite3_exec(db, "drop table table_name", nil, NULL, NULL);
sqlite3_exec(db, "commit", nil, NULL, NULL);
Immediate 类型也被较多使用,它相当于直接告诉 SQLite 开始写事务了,即便包含的 SQL 语句全部是读操作,甚至不执行任何 SQL 语句;它会尝试获取 reserved 锁,因为 reserved 锁有唯一约束,所以在执行 begin immediate
时可能会产生 SQLITE_BUSY 错误。
对于 exclusive 类型事务,两种日志模式下的表现不一样。在 wal 模式下,它和 immediate 类型一样。在 rollback 日志模式下,它会尝试获取 exclusive 锁,要求独占数据库,这意味着它成功的前提是:当前数据库是完全闲置的,没有其他的读事务或写事务在进行;换句话说,执行 begin exclusive
比 begin immediate
产生 SQLITE_BUSY 错误的概率更大。
搞清楚这三种事务以及互相的影响,有利于理解 SQLite 的事务处理逻辑,以及各种锁在哪个阶段发挥作用,限于篇幅,本文不展开赘述,直接抛结果。
下表中,将事务分为 4 类:deferred read、deferred write、immediate、exclusive;为叙述方便,将每个事务的执行分为:三个阶段:begin -> execute -> commit。表中描述某个事务执行过程中对其他事务的影响。
第一列纵坐标表示正在执行(尚未 commit)的事务类型,横坐标描述该事务对其他类型事务的影响。
Rollback 日志模式下,事务之间的影响如下表:
值得注意的是:
- 对于 immediate 事务
begin immediate
会尝试获取 reserved 锁commit
会尝试获取 exclusive,哪怕 immediate 事务啥都没做(空事务)
- 对于 exclusive 事务
begin immediate
会尝试获取 exclusive 锁
WAL 日志模式下,事务之间的影响如下图:
注意:exclusive 类型在 wal 模式下不起作用,和 immediate 效果一样。
「无影响」 表格即代表着所支持的并发情况,显然,wal 日志模式下的并发支持要比 rollback 模式下支持得好得多。
上述两个表格的 cases 可以从:Rollback 日志模式下事务之间的影响 和 WAL 日志模式下事务之间的影响 验证得到。
Busy 错误与处理
上文频繁出现 SQLITE_BUSY,它是 SQLite 内部用来描述并发错误的错误码,详见 这里;从上文对事务逻辑的分析可以看出,SQLite 事务执行的过程中,可能出现 SQLITE_BUSY 错误的节点非常多,在 rollback 日志模式下尤其如此。
对于任何一个 SQLite 库,处理 busy 错误是必不可少的。
对于 SQLite 本身而言,它提供了简单的 busy retry 方案,即设置超时时间,设超时后,SQLite 在遇到 SQLITE_BUSY 错误后,会在内部做一些重试尝试。有多种设置超时时间的方式:
- sqlite3_busy_timeout(): 设置 busy retry 的休眠时间
- PRAGMA busy_timeout: 设置 busy retry 的休眠时间
- sqlite3_busy_handler(): 注册 busy handler;通常在 busy handler 里做一些休眠然后再返回
基于 SQLite 提供的 busy retry 方案,在 retry 过程中,休眠时间的长短和重试次数,是决定性能和操作成功率的关键。Retry 超时时间的设置因不同操作不同场景而不同。若休眠时间太短或重试次数太多,会空耗CPU的资源;若休眠时间过长,会造成等待的时间太长;若重试次数太少,则会降低操作的成功率。
即便有了 busy retry 方案,SQLITE_BUSY 错误还可能还是会出现,使用过程中不应该忽视该问题的存在,在知名的 SQLite 库的里都能看到大量的 APIs 返回布尔值:
/** FMDB 库的一些 APIs **/
- (BOOL)executeUpdate:(NSString*)sql, ...;
/** WCDB 库的一些 APIs **/
- (BOOL)insertObject:(WCTObject *)object into:(NSString *)tableName;
- (BOOL)beginTransaction;
- (BOOL)commitTransaction;
像 beginTransaction、commitTransaction 这种 API 返回 false 的原因基本上就是 SQLITE_BUSY 错误导致的。
从框架的角度来看,似乎很难在内部规避掉 SQLITE_BUSY 错误,因为很难去约束用户的使用姿势、譬如日志模型、线程管理等。
对于 iOS 生态,因为每个应用都是独立进程,无需担心多进程引起的并发问题,问题相对简单了一些。如果遵循如下姿势使用 SQLite 数据库,应该能基本上规避 busy 问题:
- 日志模式使用 wal 模式
- 用串行队列管理写操作
这是根据先验知识所总结的,不具备权威性,实际上是否还会存在一些其他边界没考虑到,得经过充分的实践才能知道。
一些第三方库
这一部分简单介绍 iOS 平台中一些知名第三方 SQLite 库。
FMDB
FMDB 可能是 Objective-C 社区使用最多的 SQLite 第三方库,它对 sqlite3 C API 比较薄地包了一层,非常轻量级,没有任何限制,有三个主要类:
- FMDatabase: 对 API 的直接封装,每个对象对应一个连接句柄,没有做线程管理、日志模型约束等
- FMDatabaseQueue: 对 FMDatabase 进行封装,每个 FMDatabaseQueue 对象对应一个 FMDatabase 实例;约束所有数据库操作都在一个串行队列上进行,避免并发
- FMDatabasePool: 管理多个 FMDatabase,即维护一个连接池,支持并发;但仍然没有约束用户选择日志模型
使用 FMDatabaseQueue 比较安全,但效率显然较低,如果是时间敏感型业务,用它可能有些捉急。
但如果使用 FMDatabasePool,作者在 FMDatabasePool.h 里留下的 comment 可能让用户有些紧张:
这段 comment 恐吓如果使用不当可能会导致死锁;然而,作者给的死锁 case 不太靠谱,经测试没用,作者似乎也认识到了,详见 这里;个人感觉,FMDatabasePool 还是值得一用的。
总之,FMDB 是一个没有态度的 SQLite 三方库,没有约束日志模型、checkpoint 策略等,只是提供了最简单直接的使用姿势。
没有提供 ORM 功能,除了易用性上有些捉急之外,我认为 FMDB 还有一个不足点:缺乏日志收集、性能监控相关 API;对于深度使用 SQLite 的功能,这是一个非常大的不足。
WCDB
WCDB 是微信团队出品的,经得起检验。相对于 FMDB,WCDB 要重得多。对于 iOS,如果使用 WCDB,意味着要引入两个库:WCDBOptimizedSQLCipher 和 WCDB。
WCDBOptimizedSQLCipher 是从 SQLCipher fork 的一个库,后者提供了加密功能,微信团队在此基础上做了大量的性能优化。WCDB 提供的 性能报告,在多方面都吊打 FMDB,就是因为 WCDBOptimizedSQLCipher 从 SQLite 源码层面做了一些优化工作,详见 微信 iOS SQLite 源码优化实践。
WCDB 库本身而言,最大的特色是提供了 ORM 等易用性方面的功能,使用体验挺不错。支持 ORM 对于很多高级语言(譬如 Swift)而言,是一件挺简单的事情;但对于 Objective-C 而言挺为难,语言 feature 太少了,譬如不支持符号重载,这在支持 ORM 中可能用得较多。
微信读书团队基于 FMDB 做了一个 ORM 库:GYDataCenter,但看起来不太好用。
WCDB 主要代码都是 C++,基于 C++ 的 feature 实现的 ORM 使用起来要好用得多。此外,WCDB 还针对数据库损坏,提供了修复功能。
WCDB 默认使用 wal 日志模型,和 FMDatabasePool 类似,在内部维护了一个连接池,提供了良好的并发性。
总之,WCDB 是一个非常牛 x 的库,如果开发比较重的、强依赖 SQLite 的业务,它可能是一个不错的选择。
SQLite.swift
SQLite.swift 是 Swift 生态中,stars 最多的第三方库。但个人观感,虽然 stars 多,但质量一般,在学习分析过程中,发现了好几处 bug;更新频次低;issues 较多但大多没有响应,给人感觉这个库没人维护了。
GRDB
GRDB 是另一个 Swift 生态中的 SQLite 三方库;维护良好,感觉比 SQLite.swift 要好很多。但 stars 比后者少得多(2.5k v.s 6.5k),大概是因为取名没有后者好,出现时机没有后者早吧。
更多参考
- SQLite 官方文档
- SQLite 官方资料质量很高,作为入门资料足以
- 微信终端开发团队系列 SQLite 相关博客
- Understanding SQLITE_BUSY
原博客阅读体验更好哦:zhangbuhuai.com/post/sqlite…