【Dev Club 分享】微信 iOS SQLite 源码优化实践

时间:2022-05-05
本文章向大家介绍【Dev Club 分享】微信 iOS SQLite 源码优化实践,主要内容包括引言、1. 多线程并发优化、2. I/O 性能优化、3. 其他优化、4. 结语、问答环节、基本概念、基础应用、原理机制和需要注意的事项等,并结合实例形式分析了其使用技巧,希望通过本文能帮助到大家理解应用这部分内容。

Dev Club 是一个交流移动开发技术,结交朋友,扩展人脉的社群,成员都是经过审核的移动开发工程师。每周都会举行嘉宾分享,话题讨论等活动。

本期,我们邀请了腾讯 WXG iOS 开发工程师——张三华,为大家分享《微信 iOS SQLite 源码优化实践》

分享内容简介:

SQLite是微信iOS选用的数据库,随着微信iOS客户端业务的增长,在重度用户的场景下,性能瓶颈逐渐显现。靠单纯地修改SQLite的参数配置,已经不能彻底解决问题,因此我们尝试从源码开始做深入的优化。

内容大体框架:

  1. SQLite对于多线程的处理和不足及微信的优化
  2. SQLite在I/O上可压榨的性能
  3. 其他细节优化

下面是本期分享内容整理


Hello,大家好,我是张三华,目前在微信主要负责 iOS 的基础优化工作。第一次进行这种形式的群分享,可能准备的不是太充分。若有任何疑问,欢迎在分享结束后提问。

下面开始我们今天的分享。

引言

SQLite 是我们在移动端常用的数据库,微信也是基于它封装了一层 ObjC 接口。我们知道,微信里消息的收发是很频繁的,尤其是对于重度用户,这对于数据库的多线程并发和 I/O 是很大的挑战。

通常对这部分做优化,有两种方式:

  • 一是修改 SQLite 的参数,如 Cache Size 等
  • 二是改业务层调用,如主线程操作 dispatch 到子线程。

然而,前者有明显的瓶颈,后者则是个 endless 的工作。我们希望能一劳永逸地解决同类问题。这就是我们本次所要分享的优化。

1. 多线程并发优化

1.1 SQLite 多句柄方案

我们先讲 SQLite 所提供的多线程并发方案。它对这方面的支持做的很不错,在使用上,只需

  1. 开启句柄多线程支持的配置 PRAGMA SQLITE_THREADSAFE=2
  2. 确保同一个句柄同一时间只有一个线程在操作
  3. (可选)开启 WAL 模式 PRAGMA journal_mode=WAL

此时写操作会先 append 到 wal 文件末尾,而不是直接覆盖旧数据。而读操作开始时,会记下当前的 WAL 文件状态,并且只访问在此之前的数据。这就确保了多线程读与读、读与写之间可以并发地进行。

1.2 Busy Retry 方案

而写与写之间仍会互相阻塞。SQLite 提供了 Busy Retry 的方案,即发生阻塞时,会触发 Busy Handler,此时可以让线程休眠一段时间后,重新尝试操作。重试一定次数依然失败后,则返回 SQLITE_BUSY 错误码。

下面这段代码是 SQLite 默认的 Busy Handler

1.3 Busy Retry 方案的不足

上面介绍了 SQLite 多线程并发方案,接下来我们把焦点放在 Busy Retry 这个方案的不足上。

Busy Retry 的方案虽然基本能解决问题,但对性能的压榨做的不够极致。在 Retry 过程中,休眠时间的长短和重试次数,是决定性能和操作成功率的关键。

然而,它们的最优值,因不同操作不同场景而不同。若休眠时间太短或重试次数太多,会空耗 CPU 的资源;若休眠时间过长,会造成等待的时间太长;若重试次数太少,则会降低操作的成功率。如下图

可以看到

  • CPU空转那段,线程一操作还没结束,这里空耗了 CPU 的资源
  • 线程闲置那段,线程一已经结束,而线程二仍在等待,空耗了时间

对于这个的优化,简单的方法可以是修改休眠时间,尽最大限度缩短以上两段空耗的资源。

我们通过 A/B Test 对不同休眠时间进行了实验,得到了如下的结果

可以看到,倘若休眠时间与重试成功率的关系,按照绿色的曲线进行分布,那么 p 点的值也不失为该方案的一个次优解。然而不同业务和操作的需求,还是有很大的不同的。

既然 SQLite 的方案不行,我们就要开始往深层探索新的可能性了。

1.4 SQLite 中控制并发相关的原理

SQLite是一个适配不同平台的数据库,不仅支持多线程并发,还支持多进程并发。它的核心逻辑可以分为两部分:

  • Core 层。包括了接口层、编译器和虚拟机。通过接口传入 SQL 语句,由编译器编译SQL生成虚拟机的操作码 opcode。而虚拟机是基于生成的操作码,控制 Backend 的行为。
  • Backend 层。由 B-Tree、Pager、OS 三部分组成,实现了数据库的存取数据的主要逻辑。

在架构最底端的 OS 层是对不同操作系统的系统调用的抽象层。它实现了一个 VFS(Virtual File System),将 OS 层的接口在编译时映射到对应操作系统的系统调用。锁的实现也是在这里进行的。

SQLite 通过两个锁来控制并发。第一个锁对应 DB 文件,通过5种状态进行管理;第二个锁对应WAL文件,通过修改一个 16-bit 的 unsigned short int 的每一个 bit 进行管理。尽管锁的逻辑有一些复杂,但此处并不需关心。这两种锁最终都落在 OS 层的 sqlite3OsLock、sqlite3OsUnlock 和 sqlite3OsShmLock 上具体实现。

它们在锁的实现比较类似。以 lock 操作在 iOS 上的实现为例:

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

而 SQLite 选择 Busy Retry 的方案的原因也正是在此

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

1.5 新的方案

搞清楚了 SQLite 并发的实现,我们就是可以开始改造了。

我们知道,iOS app 是单进程的,并没有多进程并发的需求,这和 SQLite 的设计初衷是不相同的。这就给我们的优化提供了理论上的基础。在 iOS 这一特定场景下,我们可以舍弃兼容性,提高并发性。

新的方案修改为,当 OS 层进行 lock 操作时:

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

当 OS 层的 unlock 操作结束后:

  1. 取出 Queue 头部的状态量,并比较状态是否能够跳转。若能够跳转,则通过 pthread_cond_signal_thread_np 唤醒对应的线程重试。

新的方案可以在 DB 空闲时的第一时间,通知到其他正在等待的线程,最大程度地降低了空等待的时间,且准确无误。

此外,由于 Queue 的存在,当主线程被其他线程阻塞时,可以将主线程的操作“插队”到 Queue 的头部。当其他线程发起唤醒通知时,主线程可以有更高的优先级,从而降低用户可感知的卡顿

2. I/O 性能优化

上面介绍了多线程并发的优化,接下来将介绍 I/O 方面的优化。

2.1 mmap

提到 I/O 效率的提升,最容易想到的就是 mmap了,它可以减少数据从 kernel 层到 user 层的数据拷贝,从而提高效率。

SQLite 不仅支持 mmap,而且推荐使用,在大多数平台是在一定程度上默认打开的。然而早期的 iOS 版本的存在一些 bug,SQLite 在编译层就关闭了在 iOS 上对 mmap 的支持,并且后知后觉地在16年1月才重新打开。所以如果使用的 SQLite 版本较低,还需注释掉相关代码后,重新编译生成后,才可以享受上 mmap 的性能。

下图就是 SQLite 注释掉相关代码的 commit

开启 mmap 后,SQLite 性能将有所提升,但这还不够。因为它只会对 DB 文件进行了 mmap,而 WAL 文件享受不到这个优化。原因如下:

开启 WAL 模式后,写入的数据会先 append 到 WAL 文件的末尾。待文件增长到一定长度后,SQLite 会进行 checkpoint。这个长度默认为1000个页大小,在 iOS 上约为3.9MB。 而在多句柄下,对 WAL 文件的操作是并行的。一旦某个句柄将 WAL 文件缩短了,而没有一个通知机制让其他句柄进行更新 mmap 的内容。此时其他句柄若使用 mmap 操作已被缩短的内容,就会造成 crash。而普通的 I/O 接口,则只会返回错误,不会造成 crash。因此,SQLite 没有实现对 WAL 文件的 mmap。 显然 SQLite 的设计是针对容量较小的设备,尤其是在十几年前的那个年代,这样的设备并不在少数。而随着硬盘价格日益降低,对于像 iPhone 这样的设备,几 MB 的空间已经不再是需要斤斤计较的了。 另一方面,文件重新增长,对于文件系统来说,这就意味着需要消耗时间重新寻找合适的文件块。

权衡两者,我们可以改为

  1. 数据库关闭并 checkpoint 成功时,不再 truncate 或删除 WAL 文件,只修改 WAL 的文件头的 Magic Number。下次数据库打开时, SQLite 会识别到 WAL 文件不可用,重新从头开始写入。
  2. 为 WAL 添加 mmap 的支持 有了上面两个优化,整体性能就会提升不少了。

这里我没有贴具体代码需要改哪些地方,一方面是因为改动点较零散,另一方面是代码上的改动并不难。这个优化的工作量主要是在 SQLite 原理和优化点的挖掘上了,大家可以根据优化方案去尝试。

3. 其他优化

不过我们还有一些简单易行且效果还不错的小优化,希望可以成为大家打开 SQLite 黑盒的一个契机。

3.1 禁用文件锁

如我们在多线程优化时所说,对于 iOS app 并没有多进程的需求。因此我们可以直接注释掉 os_unix.c 中所有文件锁相关的操作。也许你会很奇怪,虽然没有文件锁的需求,但这个操作耗时也很短,是否有必要特意优化呢?其实并不全然。耗时多少是比出来。

SQLite 中有 cache 机制。被加载进内存的 page,使用完毕后不会立刻释放。而是在一定范围内通过 LRU 的算法更新 page cache。这就意味着,如果 cache 设置得当,大部分读操作不会读取新的 page。然而因为文件锁的存在,本来只需在内存层面进行的读操作,不得不进行至少一次 I/O 操作。而我们知道,I/O 操作是远远慢于内存操作的。

3.2 禁用内存统计锁

SQLite 会对申请的内存进行统计,而这些统计的数据都是放到同一个全局变量里进行计算的。这就意味着统计前后,都是需要加线程锁,防止出现多线程问题的。

以下 SQLite 内存申请的函数可以看到,当内存统计打开时,会跑代码的第二个 if,malloc 的前后被锁保护了起来。

其实这里内存申请的量不大,并不是非常耗时的操作,但却很频繁。多线程并发时,各线程很容易互相阻塞。因为耗时很短,所以被阻塞的时间也很短暂。似乎不会有太大问题。但频繁地阻塞却意味着线程不断地切换,这是个很影响性能的操作,尤其对于单核设备。

因此,如果不需要内存统计的特性,可以通过 sqlite3_config(SQLITE_CONFIG_MEMSTATUS, 0)进行关闭。这个修改虽然不需要改动源码,但如果不查看源码,恐怕是比较难发现的。

4. 结语

总的来说,移动客户端数据库虽然不如后台数据库那么复杂,但也存在着不少可挖掘的技术点。

这次也只尝试了对 SQLite 原有的方案进行优化,而市面上还有许多优秀的数据库,如 LevelDB、RocksDB、Realm 等,它们采用了和 SQLite 不同的实现原理。后续我们将借鉴它们的优化经验,尝试更深入的优化。

以上就是我今天的分享,谢谢大家。

问答环节

Q1 :前一阵微信提示我微信数据文件发现有损坏,这个是什么原因呢?

这个是数据库损坏,SQLite 是以B树结构存储的,如果某一个节点发生损坏,可能导致无法读取数据。损坏的原因多种多样,如断电、文件系统错误、硬盘损坏等。据我所知很多产品都出现了类似问题。 你看到的那个是微信的损坏监测和修复逻辑,我们做了自研的工具进行修复。这块我们后续也会分享 db 损坏的监测、保护、修复方案的

Q2 :请问 sqlite 有时候会出 signal 11的错误,可能是什么原因导致的

signal 11 就是 SQLITE_CORRUPT,上面提到的数据库损坏的其中一种。另一种是26 SQLITE_NOTADB

Q3 :请问微信在全文索引上有实践吗?有没有自己做本地的搜索索引

SQLite 是支持有全文索引的支持的,我们要做的是提供一个好的,支持中文的分词器。

Q4 :请问微信在 db 文件修复上有什么心得呢?

看来大家对 db 文件损坏很关注啊。SQLite 提供了 PRAGMA integrity_check 的工具检测损坏 和 DUMP 工具导出损坏 db。但从实践来看,效果并不理想。我们采用了按 BTree 结构遍历修复的方式,以后有机会可以分享给大家

Q5 :目前有没有已有的优化过的 sqlite 框架可供使用呢?

iOS上SQLite 的框架似乎只有 FMDB 和 CoreData,坦白说两个都不是很好。我们是自己封装的 WCDB 框架。

Q6 :微信的 orm 是怎么搞的

通过封装和规范来处理 ORM

Q7 :请问下多句柄怎么开启,是修改 sqlite 源码后再编译的吗?

这个最开始有提到了

  1. 开启句柄多线程支持的配置 PRAGMA SQLITE_THREADSAFE=2
  2. 确保同一个句柄同一时间只有一个线程在操作

Q8 :微信是怎么分析它的锁竞争的?

最重要的是读懂源码。辅助手段可以有 SQLite 官方的 Technical/Design Document 和 Instrument 工具

Q9 :请问有没有对能耗的监测和优化经验?

检测相关的我们有卡顿监控系统,可以到我们的公众号 WeMobileDev 上了解

Q10 :请问 sqlite 优化后有性能对比数据吗,差别有多大?

性能数据我以我们的卡顿系统为准,多线程并发优化使得卡顿率从4.08%降至0.19,I/O 优化使得读卡顿从1.50%降至0.20%,写卡顿从1.18%降至0.21%

Q11 :iOS 客户端用操作数据库需要每次先 open,执行完了再 close,每次都这样,还是 app 只需要开关一次比较好呢?

常用的 db 没有必要经常开关,db 占用的内存并不高,可以权衡一下

Q12 :微信对于本地空间不足会有一个强提醒,这是出于什么考虑?不同机型有不同的策略吗?

空间不足是个硬伤,所谓巧妇难为无米之炊。如16GB 的 iPhone,其实很影响正常使用了。不同机型会做细化

Q13 :请问 sqlite 多线程机制,大概能应付多大量级的数据库操作(基本无卡顿),微信有这方面的测试体验吗,然后是使用了底层代码修改多线程机制后,有大概的提升量级吗?

优化的效果我们是以卡顿系统检测到的为准的。能否减少用户感知到的卡顿,优化用户体验才是重点,而不在于能承受多大的量级

Q14 :微信对于数据库升级有没有特别优化的地方?或者说不同版本的跳版本升级

不知道这个问题指的是 SQLite 的升级还是表结构的升级。前者的话,暂时没看到 SQLite 新版本有比较大的特性值得我们跟进。后者可以用 alter table 在封装层支持升级,性能损耗不大

Q15 :请问微信的 SQLite 有没有开启加密?如果有,性能是否有提升空间?

iOS 版本目前没有开启加密

Q16 :微信 sqllite 数据库用的内存数据库吗?那和文件数据库导入导出怎么控制的?

没有使用内存数据库

Q17 :可以问一下,目前做 iOS 版,没有针对 android 版么?

这次分享的大部分内容,对Android也是通用的,触类旁通即可。

Q18 :请问下,句柄开几个比较合适?读写分离开来对性能是否会有提升呢?

我们是按需生成新句柄的,并设了上限,若超过上限会有报警。如果同一时间并发量太大的话,其实更多要考虑业务层是否适用得当。至于业务层的使用,若能做细化那自然是更好


本文系腾讯Bugly独家内容,转载请在文章开头显眼处注明作者和出处“腾讯Bugly(http://bugly.qq.com)”