SQLite线程模式探讨

背景

最近微信 iOS 团队发表了一篇文章《微信iOS SQLite源码优化实践》,该文章介绍了微信 iOS 客户端对 SQLite 进行的源码层级的优化,以及其所取得的成果。优化点包括:

  1. 多线程并发优化(Busy Retry 的优化)
  2. I/O 性能优化(保留 WAL 文件大小,mmap 优化)
  3. 其他优化(禁用文件锁,禁用内存统计锁)

其中,单是禁用内存统计锁这点优化,就取得了非常显著的效果,这里引用原文:

该优化上线后,卡顿监控系统监测到

  • DB写操作造成的卡顿下降超过80%
  • DB读操作造成的卡顿下降超过85%

看到这结果,首先是满怀惊喜,仅仅是禁用 SQLite 的内存统计这一点,就能使 DB 的卡顿下降超过80%,如果再加上其他优化,那么 SQLite 的性能将实现一次突破!但冷静下来之后,也很快产生怀疑,SQLite 这么成熟的开源数据库,怎么会为了一个内存统计牺牲这么大的性能,要知道,性能是任何一个数据库都极力追求的目标

所有的猜测都是没用的,实际测一下便知道孰是孰非。为了测试方便,使用自己的开源框架 GYDataCenter,每一行数据包括 NSInteger,double,BOOL,NSDate(double),NSString 5列,测试机器为 iPhone 6s 64G,测试过程如下:

  1. 打开 SQLite 内存统计,分别测试写入 10, 100, 1000,10000,100000 条数据所需时间;
  2. 关闭 SQLite 内存统计,分别测试写入 10, 100, 1000,10000,100000 条数据所需时间;
  3. 对比结果如下,横轴是数据量,纵轴是时间,单位秒;

从结果我们可以看出,打开或关闭 SQLite 的内存统计,性能基本没差别,仅有的一点点差别都是在误差范围内的。我们又对读操作进行了同样的测试,结果依然没有差别。

问题出在于哪?究竟是哪个数据有问题?又或者是两个数据都没问题,只是我们打开的方式不对?于是,又重读了一遍微信的文章,发现该文章给出了这样的解释:

多线程并发时,各线程很容易互相阻塞。
阻塞虽然也很短暂,但频繁地切换线程,却是个很影响性能的操作,尤其是单核设备。
因此,如果不需要内存统计的特性,可以通过sqlite3_config(SQLITE_CONFIG_MEMSTATUS, 0)进行关闭。

加锁和释放锁本身是有性能损耗,但这种损耗是很小的,基本上可以忽略,通常锁所带来的性能损耗正是在于等待其他线程释放锁的时间上。这正是两者的区别所在:

  1. GYDataCenter 使用的是单线程单句柄的模式。对于同一个 db 的所有操作,都放到同一个队列同一个数据库句柄上,排队执行。并通过定时事务自动地把多个操作包在一起,批量地写入磁盘。
  2. 微信 iOS 客户端采用的是多线程多句柄的模式。对于每一个 db,会开多个数据库句柄。对于同一个数据库句柄,同一时间只能在一个队列运行。多个句柄间可以做到读与读,读与写的并发。

GYDataCenter 在关闭 SQLite 的内存统计后,性能没有得到提升,正是因为 GYDataCenter 使用的是单线程模式,不会有多线程间等待锁的问题。GYDataCenter 从设计上根本地避免了多线程间等待锁的问题。而反观微信的文章,大部分的优化都是在解决多线程间等待锁所引起的性能损耗问题,解决 Busy Retry 使等待线程锁的造成的卡顿下降超过90%,解决内存统计的锁使卡顿下降超过 80%。这似乎也说明了多线程模式带来了很严重的竞争锁的问题。并且,Busy Retry 的线程锁,内存统计的锁只是其中两种锁,可能还有其他各种各样的锁急需优化。

然而,单从这点就说单线程单句柄模式比多线程多句柄模式好是不正确的,多线程多句柄模式有它自己的优点,其中最明显的一点正是这种模式支持读与读,读与写的并发(WAL 模式下)。那么,我们就来具体分析一下,在移动客户端这个场景下,哪种模式可能更适用。

多线程多句柄 vs 单线程单句柄

并发的需求

要回答这个问题,首先我们要问,在移动客户端下,数据库的读与读,读与写的并发的需求有多强

还是拿数据说话。在 iPhone 6s 上对微信读书 app 的主要场景进行了数据库并发性的统计。由于不同 app 不尽相同,不同机器性能也不一样,因此下面的统计数据只能做参考意义。

在上面的结果中,对于n读0写n读1写的情况,多线程模式能起到优化作用。这两种情况分别占了 2% 跟 7%。我们进一步分析发现,出现这两种情况的场景,大多数是在网络请求回来时处理数据。

分析:移动客户端 app 的运行,都是用户操作驱动的。用户在界面上进行一系列操作,从主线程发起请求,app 响应请求,处理数据,显示结果,一般来说这一系列动作是一条线串起来执行的,没有并发的需求。移动客户端出现的最多的并发情况,在于发起网络请求,和网络请求回来后的数据处理。然而,在网络请求回来的情况下,数据库执行最多的操作是存储返回的数据。对于这种情况,我们都知道,通过事务把多个写操作包在一起能极大地提升性能。下面,我们分析一下两种方案在这种情况下的表现:

  1. 多线程多句柄:如果把写操作放在一个事务中,在事务结束前,其他线程其他句柄的操作都会被卡住,返回 Busy Retry,造成空等待。
  2. 单线程单句柄:自动地把多个写操作包在一个事务中,由于所有操作都放到一个队列,其他的读写操作可以穿插其中,不会被卡住。

当然,如果你对返回的数据的存储有原子性要求,即要求在存储期间不能有其他任何的读写操作,那么其他读写操作是一定会被卡住的,两种方案都是无法解决的。

Cache 的管理

我们都知道,cache 能很好地提升性能。如果采用单线程的方案,cache 的实现就比较简单了。由于所有操作都是在一个队列上排队操作,cache 的维护与查询也在一个队列上排队进行即可,cache 与 db 数据的一致性可以得到保证。

而如果在多线程多句柄方案上做 cache,可能会有以下两个难点:

  1. 保证 cache 与 db 数据的一到性;
  2. 由于 cache 也是在多线程访问的,因此也需要加锁,也有可能引进竞争锁的性能问题;

结语

通过上面的分析,我个人更偏向于使用单线程单句柄的模式。然而,世事无绝对,还是要具体情况具体分析。