iOS客户端SQLite多线程解决方案

SQLite 凭借着轻量级、可嵌入的特性成为了很多移动端产品数据存储的首选。但由于 SQLite 是纯 C 语言开发,数据库操作的接口对于 iOS 开发人员并不友好,并且 SQLite 连接不是线程安全的,在多线程间同时使用同一个数据库连接会发生错误。基于这样的情况,市面上有很多基于 SQLite 封装的三方库,本文主要研究市面上常见的三方库在保证 SQLite 线程安全方面采取的方案,并对比各个方案的性能。

研究对比的三方库有 FMDB、ModelSQLiteKit 和 WCDB。

FMDB

FMDB 是基于 SQLite 的数据库框架,使用 Objective-C 语言对 SQLite 的 C 语言接口做了一层面向对象的封装,并通过一个 Serial 队列保证在多线程环境下的数据安全。

FMDB 提供了 FMDatabase 类,该类与数据库文件一一对应,在新建一个 FMDatabase 对象时,可以关联一个已有的数据库文件;该对象以面向对象思想封装了增、删、改、查、事务等常用的数据库操作。但是FMDatabase 不是线程安全 的,在多个线程之间使用同一个FMDatabase可能会出现数据错误。

对于线程安全 FMDB 提供了FMDatabaseQueueFMDatabasePoolFMDatabaseQueue持有 SQLite 句柄,多个线程使用同一个句柄,同时在初始化时创建了一个串行队列,当在多线程之间执行数据库操作时,FMDatabaseQueue将数据库操作以 block 的形式添加到该串行队列,然后按接收顺序同步执行,以此来保证数据库在多线程下的数据安全。 FMDatabasePool 实现原理和FMDatabaseQueue一样,它的使用更加灵活,但是容易造成死锁,作者不推荐使用。

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 提供的WCTDatabaseWCTTableWCTTransaction的所有 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方案

Busy Retry的方案虽然基本能解决问题,但性能并不高。在Retry过程中,休眠时间的长短和重试次数,是决定性能和操作成功率的关键。若休眠时间太短或重试次数太多,会空耗CPU的资源;若休眠时间过长,会造成等待的时间太长;若重试次数太少,则会降低操作的成功率。

SQLite通过两个锁来控制并发:

  1. 通过pthread_mutex_lock进行线程锁,防止其他线程介入。然后比较状态量,若当前状态不可跳转,则返回SQLITE_BUSY

  2. 通过fcntl进行文件锁,防止其他进程介入。若锁失败,则返回SQLITE_BUSY

而SQLite选择Busy Retry的方案的原因也正是在此:文件锁没有线程锁类似pthread_cond_signal的通知机制。当一个进程的数据库操作结束时,无法通过锁来第一时间通知到其他进程进行重试。因此只能退而求其次,通过多次休眠来进行尝试。

针对以上情况,WCDB对该方案做了优化:

因为iOS的单进程的,没有多进程并发的需求,所以在iOS端,可以舍弃兼容性,提高并发性。

WCDB将锁操作修改为:

  1. 通过pthread_mutex_lock进行线程锁,防止其他线程介入。然后比较状态量,若当前状态不可跳转,则将当前期望跳转的状态,插入到一个FIFO的Queue尾部。最后,线程通过pthread_cond_wait进入 休眠状态,等待其他线程的唤醒。

  2. 忽略文件锁

当解锁操作结束后:

  1. 取出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。

  1. 多线程读操作性能测试

    该测试同时启动两个线程,分别从数据库中取出所有数据,并拼装为object。

  1. 多线程读写操作性能测试

    该测试同时启动两个线程,一个线程从数据库中取出所有数据,并拼装为object;另一个将object的数据批量插入到数据库中。

  1. 多线程写操作性能测试

    该测试同时启动两个线程,分别将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+行)有一定了解,才能从源码层优化性能。

参考

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 197,597评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,053评论 2 375
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 144,583评论 0 326
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,888评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,772评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,536评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,922评论 3 388
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,554评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,838评论 1 293
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,861评论 2 314
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,677评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,483评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,928评论 3 300
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,104评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,403评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,945评论 2 343
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,156评论 2 339

推荐阅读更多精彩内容