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一样,它的使用更加灵活,但是容易造成死锁,作者不推荐使用。

《iOS客户端SQLite多线程解决方案》 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]);
            }
        }];
    });

运行结果:

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

数据库结果:

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

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错误码。

《iOS客户端SQLite多线程解决方案》 Busy Retry 源码

优化Busy Retry方案

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

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

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唤醒对应的线程重试。

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

新的方案可以在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。

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

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

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

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

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

    该测试同时启动两个线程,分别将object的数据批量插入数据库。

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

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+行)有一定了解,才能从源码层优化性能。

参考

    原文作者:PerTerbin
    原文地址: https://www.jianshu.com/p/3d66aa58ebe6
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞