漫谈 SQLite

2,623 阅读26分钟

作为移动开发者,或多或少会与 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 种类型:integerrealtextblobnumeric

可以把 type affinity 理解为转换器,以 text 为例,如果列的 type affinity 为 text,那么 insert 数据时,内部会将插入的数据尽可能转为字符串,譬如插入 1,则存为 "1";插入 2.0,则存为 "2.0";如果插入 null,则仍然存为 nullSQLite 官方文档 中有着详细说明,本文不多赘述。

核心 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-thread
      • sqlite3_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 支持两种日志记录方式,或者说两种日志模型:RollbackWAL

这两种模型,日志的文件格式不同,更重要的是日志在事务执行过程中扮演的角色不同;换句话说,选择了日志模型,相当于选择了一种事务处理模型。

下面来分别简述这两种日志模型中的事务处理逻辑。

理解 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 exclusivebegin immediate 产生 SQLITE_BUSY 错误的概率更大。

搞清楚这三种事务以及互相的影响,有利于理解 SQLite 的事务处理逻辑,以及各种锁在哪个阶段发挥作用,限于篇幅,本文不展开赘述,直接抛结果。

下表中,将事务分为 4 类:deferred read、deferred write、immediate、exclusive;为叙述方便,将每个事务的执行分为:三个阶段:begin -> execute -> commit。表中描述某个事务执行过程中对其他事务的影响。

第一列纵坐标表示正在执行(尚未 commit)的事务类型,横坐标描述该事务对其他类型事务的影响。

Rollback 日志模式下,事务之间的影响如下表:

rollback 模式下事务之间的影响

值得注意的是:

  • 对于 immediate 事务
    • begin immediate 会尝试获取 reserved 锁
    • commit 会尝试获取 exclusive,哪怕 immediate 事务啥都没做(空事务)
  • 对于 exclusive 事务
    • begin immediate 会尝试获取 exclusive 锁

WAL 日志模式下,事务之间的影响如下图:

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 错误后,会在内部做一些重试尝试。有多种设置超时时间的方式:

基于 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),大概是因为取名没有后者好,出现时机没有后者早吧。

更多参考

原博客阅读体验更好哦:zhangbuhuai.com/post/sqlite…