SQLite 凭借着轻量级、可嵌入的特性成为了很多移动端产品数据存储的首选。但由于 SQLite 是纯 C 语言开发,数据库操作的接口对于 iOS 开发人员并不友好,并且 SQLite 连接不是线程安全的,在多线程间同时使用同一个数据库连接会发生错误。基于这样的情况,市面上有很多基于 SQLite 封装的三方库,本文主要研究市面上常见的三方库在保证 SQLite 线程安全方面采取的方案,并对比各个方案的性能。
研究对比的三方库有 FMDB、ModelSQLiteKit 和 WCDB。
FMDB
FMDB 是基于 SQLite 的数据库框架,使用 Objective-C 语言对 SQLite 的 C 语言接口做了一层面向对象的封装,并通过一个 Serial 队列保证在多线程环境下的数据安全。
FMDB 提供了 FMDatabase
类,该类与数据库文件一一对应,在新建一个 FMDatabase 对象时,可以关联一个已有的数据库文件;该对象以面向对象思想封装了增、删、改、查、事务等常用的数据库操作。但是FMDatabase
不是线程安全 的,在多个线程之间使用同一个FMDatabase
可能会出现数据错误。
对于线程安全 FMDB 提供了FMDatabaseQueue
和 FMDatabasePool
。FMDatabaseQueue
持有 SQLite 句柄,多个线程使用同一个句柄,同时在初始化时创建了一个串行队列,当在多线程之间执行数据库操作时,FMDatabaseQueue
将数据库操作以 block 的形式添加到该串行队列,然后按接收顺序同步执行,以此来保证数据库在多线程下的数据安全。 FMDatabasePool
实现原理和FMDatabaseQueue
一样,它的使用更加灵活,但是容易造成死锁,作者不推荐使用。
示例:
创建FMDatabaseQueue:
NSString *documentPath = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) lastObject];
NSString *path = [documentPath stringByAppendingPathComponent:@"demoDataBase.sqlite"];
_database = [FMDatabase databaseWithPath:path];
多线程操作数据库:
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_async(queue, ^{
[self.databaseQueue inDatabase:^(FMDatabase *db) {
BOOL result = [db executeUpdate:@"INSERT INTO Person (name, sex) VALUES ('张三', '男')"];
if (result) {
NSLog(@"插入成功 - %@", [NSThread currentThread]);
}
}];
});
dispatch_async(queue, ^{
[self.databaseQueue inDatabase:^(FMDatabase *db) {
BOOL result = [db executeUpdate:@"INSERT INTO Person (name, sex) VALUES ('李四', '男')"];
if (result) {
NSLog(@"插入成功 - %@", [NSThread currentThread]);
}
}];
});
dispatch_async(queue, ^{
[self.databaseQueue inDatabase:^(FMDatabase *db) {
BOOL result = [db executeUpdate:@"INSERT INTO Person (name, sex) VALUES ('王五', '男')"];
if (result) {
NSLog(@"插入成功 - %@", [NSThread currentThread]);
}
}];
});
运行结果:
数据库结果:
ModelSQLiteKit
ModelSQLiteKit 是基于 SQLite 封装的 ORM 数据库操作开源库,支持直接将 Model 存入数据库,无需开发人员手动拼接 SQL 语句。
ModelSQLiteKit 封装了所有的常见数据库操作,在进行数据库操作时通过控制信号量来保证线程安全。
ModelSQLiteKit 创建了一个值为1的信号量:
self.dsema = dispatch_semaphore_create(1);
数据库操作时通过信号量控制并发量:
+ (NSArray *)queryModel:(Class)model_class conditions:(NSArray *)conditions queryType:(WHC_QueryType)query_type {
dispatch_semaphore_wait([self shareInstance].dsema, DISPATCH_TIME_FOREVER);
NSArray *model_array = [self startQuery:model_class conditions:conditions queryType:query_type];
dispatch_semaphore_signal([self shareInstance].dsema);
return model_array;
}
示例:
dispatch_async(dispatch_get_global_queue(0, 0), ^{
Person *person = [Person new];
person.name = @"张三";
person.age = 25;
[WHCSqlite insert:person];
});
dispatch_async(dispatch_get_global_queue(0, 0), ^{
Person *person = [Person new];
person.name = @"李四";
person.age = 28;
[WHCSqlite insert:person];
});
WCDB
WCDB 是微信团队推出的一个高效、完整、易用的移动数据库框架,基于 SQLCipher(SQLite的加密扩展),支持 iOS,mac OS 和 Android。
WCDB 通过 SQLite 多句柄 和 WAL 日志模式 来支持线程间读与读、读与写操作并发执行,并通过优化 Busy Retry 方案 来提升线程间写与写操作串行执行的效率。
SQLite的多句柄方案
SQLite 支持三种线程模式:
单线程(Single-thread) ,在此模式下,所有互斥锁都被禁用,并且SQLite连接不能在多个线程中使用。
多线程(Multi-thread),在此模式下,SQLite可以安全地由多个线程使用,前提是在两个或多个线程中不同时使用单个数据库连接。
串行(Serialized),在此模式下,SQLite可以被多个线程安全地使用而没有任何限制。
SQLite 本身是支持多线程并发操作的,WCDB 通过设置PRAGMA SQLITE_THREADSAFE=2
将 SQLite 的线程模式设置为多线程(Multi-thread)模式,并且保证同一个句柄在同一时间只有一个线程在操作。
WCDB 内置一个句柄池HandlePool
,由它管理和分发 SQLite 句柄。WCDB 提供的WCTDatabase
、WCTTable
和WCTTransaction
的所有 SQL 操作接口都是线程安全,它们不直接持有数据库句柄,而是由HandlePool
根据数据库访问所在的线程、是否处于事务、并发状态等,自动分发合适的 SQLite 连接进行操作,以此来保证同一个句柄在同一时间只有一个线程在操作,从而达到读与读、读与写并发的效果。
WAL日志模式
WCDB开启了 SQLite 的 WAL模式(Write-Ahead-Log),来进一步提升多线程的并发性。
SQLite主要有两种日志模式:DELETE模式和WAL模式,默认是DELETE模式。
DELETE模式下,日志文件记录的是数据页变更前的内容。当事务开启时,将db-page的内容写入日志,写操作直接修改db-page,读操作也是直接读取db-page,db-page存储了事务最新的所有更新,当事务提交时直接删除日志文件即可,事务回滚时将日志文件覆盖db-page文件,恢复原始数据。
WAL模式下,日志文件记录的是数据变更后的内容。当事务开启时,写操作不直接修改db-page,而是以append的方式追加到日志文件末尾,当事务提交时不会影响db-page,直接将日志文件覆盖到db-page即可,事务回滚时直接将日志文件去掉即可。读操作也是读取日志文件,开始读数据时会先扫描日志文件,看需要读的数据是否在日志文件中,如果在直接读取,否则从对应的db-page读取,并引入.shm文件,建立日志索引,采用哈希索引来加快日志扫描。
DELETE模式下因为读写操作都是直接在db-page上面进行,因此读写操作必须串行执行。而在WAL模式下,读写操作都是在日志文件上进行,写操作会先append到日志文件末尾,而不是直接覆盖旧数据。而读操作开始时,会记下当前的日志文件状态,并且只访问在此之前的数据。这就确保了多线程读与读、读与写之间可以并发地进行。更多关于WAL模式的内容可以阅读SQLite官方文档。
WCDB 通过句柄池和开启WAL模式来支持读与读、读与写操作并发执行,但是阻塞的情况也还是会发生。
- 当多线程写操作并发时,后来者还是必须在源码层等待之前的写操作完成后才能继续。
对于此现象SQLite提供了Busy Retry的方案,即发生阻塞时,会触发Busy Handler,此时可以让等待的线程休眠一段时间后,重新尝试操作。重试一定次数依然失败后,则返回SQLITE_BUSY
错误码。
优化Busy Retry方案
Busy Retry的方案虽然基本能解决问题,但性能并不高。在Retry过程中,休眠时间的长短和重试次数,是决定性能和操作成功率的关键。若休眠时间太短或重试次数太多,会空耗CPU的资源;若休眠时间过长,会造成等待的时间太长;若重试次数太少,则会降低操作的成功率。
SQLite通过两个锁来控制并发:
通过
pthread_mutex_lock
进行线程锁,防止其他线程介入。然后比较状态量,若当前状态不可跳转,则返回SQLITE_BUSY
通过
fcntl
进行文件锁,防止其他进程介入。若锁失败,则返回SQLITE_BUSY
而SQLite选择Busy Retry的方案的原因也正是在此:文件锁没有线程锁类似pthread_cond_signal的通知机制。当一个进程的数据库操作结束时,无法通过锁来第一时间通知到其他进程进行重试。因此只能退而求其次,通过多次休眠来进行尝试。
针对以上情况,WCDB对该方案做了优化:
因为iOS的单进程的,没有多进程并发的需求,所以在iOS端,可以舍弃兼容性,提高并发性。
WCDB将锁操作修改为:
通过
pthread_mutex_lock
进行线程锁,防止其他线程介入。然后比较状态量,若当前状态不可跳转,则将当前期望跳转的状态,插入到一个FIFO的Queue尾部。最后,线程通过pthread_cond_wait
进入 休眠状态,等待其他线程的唤醒。忽略文件锁
当解锁操作结束后:
- 取出Queue头部的状态量,并比较状态是否能够跳转。若能够跳转,则通过
pthread_cond_signal_thread_np
唤醒对应的线程重试。
新的方案可以在DB空闲时的第一时间,通知到其他正在等待的线程,最大程度地降低了空等待的时间,且准确无误。此外,由于Queue的存在,当主线程被其他线程阻塞时,可以将主线程的操作“插队”到Queue的头部。当其他线程发起唤醒通知时,主线程可以有更高的优先级,从而降低用户可感知的卡顿。
微信称该方案上线后:
等待线程锁造成的卡顿下降超过90%
SQLITE_BUSY的发生次数下降超过95%
WCDB在多线程并发方面主要采取了以上方案,除了多线程方面的优化,WCDB还做了如mmap优化、禁用内存统计锁、保留WAL文件大小等优化来进一步提高SQLite的性能。
示例:
创建WCTDatabase:
NSString *documentPath = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) lastObject];
NSString *path = [documentPath stringByAppendingPathComponent:@"demoDataBase.sqlite"];
_database = [[WCTDatabase alloc] initWithPath:path];
多线程操作数据库:
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0);
dispatch_async(queue, ^{
NSArray *messages = [_database getAllObjectsOfClass:Message.class fromTable:@"message"];
/// ...
});
dispatch_async(queue, ^{
[_database insertObjects:messages into:@"message"];
});
性能对比
ModelSqliteKit 在多线程方面的实现思路和 FMDB 类似,都是让各个线程的数据库操作按顺序同步执行,这里主要对比 FMDB 和 WCDB。
-
多线程读操作性能测试
该测试同时启动两个线程,分别从数据库中取出所有数据,并拼装为object。
-
多线程读写操作性能测试
该测试同时启动两个线程,一个线程从数据库中取出所有数据,并拼装为object;另一个将object的数据批量插入到数据库中。
-
多线程写操作性能测试
该测试同时启动两个线程,分别将object的数据批量插入数据库。
WCDB 的多线程读写操作性能优于 FMDB 62% ,而多线程读操作基本与 FMDB 持平(FMDB 只对 SQLite 做了最简单的封装, 而 WCDB 还包括ORM、WINQ等操作,执行的指令会比 FMDB 多,因此在多线程读测试中没有表现出明显的优势)。
FMDB在多线程写测试中,直接触发了 Busy Retry ,返回错误SQLITE_BUSY
,因此无法比较。而WCDB通过优化Busy Retry,多线程写操作实质也是串行执行,但不会出错导致操作中断。
总结
FMDB 采用串行队列来保证线程安全,并且采用单句柄方案,即所有线程共用一个SQLite Handle。在多线程并发时,虽然能够使各个线程的数据库操作按顺序同步进行,保证了数据安全,但正是因为各线程同步进行,导致后来的线程会被阻塞较长时间,无论是读操作还是写操作,都必须等待前面的线程执行完毕,使得性能无法得到更好的保障。
ModelSqliteKit 在线程安全方面的原理和 FMDB 大同小异,也是采用单句柄方案,只是将串行队列改成用信号量控制并发,但结果也是各个线程的数据库操作按顺序同步进行。ModelSqliteKit 比 FMDB 好的地方在于ModelSqliteKit 支持ORM,无需开发人员手写SQL语句,可以减少很多用来拼接SQL语句的胶水代码。
WCDB 内置了一个句柄池,根据各个线程的情况派发数据库句柄,通过多句柄方案来实现线程间读与读、读与写并发执行,并开启SQLite的WAL日志模式进一步提高多线程的并发性。同时 WCDB 修改了SQLite的内部实现,优化了 Busy Retry 方案,禁用了文件锁并添加队列来支持主动唤醒等待的线程,以此来提高线程间写与写串行执行的效率。
WCDB 在多线程方面明显优于 FMDB 和 ModelSqliteKit,通过 WCDB 的改造,使得SQLite的性能发挥到极致。但是使用 WCDB 的方案要求开发人员对于 SQLite 控制并发原理和运行机制有比较深入的了解,同时也要对 SQLite 的源码(源码21w+行)有一定了解,才能从源码层优化性能。