> For the complete documentation index, see [llms.txt](https://philm.gitbook.io/philm-ios-wiki/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://philm.gitbook.io/philm-ios-wiki/mei-zhou-yue-du/wei-xin-ios-sqlite-yuan-ma-you-hua-shi-jian.md).

# 微信iOS SQLite源码优化实践

\# 微信iOS SQLite源码优化实践

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

![](https://1795314173-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-LAdZ7gFz4IllRQpWbzu%2F-LAdZ8MxWrHaExfP4jSI%2F-LAdZOmybejueo539aOq%2Fsqlite_busy_handler.jpg?generation=1524337069914875\&alt=media)

\### 1.3 Busy Retry 方案的不足

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

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

然而，它们的最优值，因不同操作不同场景而不同。若休眠时间太短或重试次数太多，会空耗 CPU 的资源；若休眠时间过长，会造成等待的时间太长；若重试次数太少，则会降低操作的成功率。如下图![](https://1795314173-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-LAdZ7gFz4IllRQpWbzu%2F-LAdZ8MxWrHaExfP4jSI%2F-LAdZOocwCnXqGH_V-1R%2Fsqlite_multiThread.jpg?generation=1524337069318265\&alt=media)

可以看到

\* CPU空转那段，线程一操作还没结束，这里空耗了 CPU 的资源

\* 线程闲置那段，线程一已经结束，而线程二仍在等待，空耗了时间

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

我们通过 A/B Test 对不同休眠时间进行了实验，得到了如下的结果![](https://1795314173-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-LAdZ7gFz4IllRQpWbzu%2F-LAdZ8MxWrHaExfP4jSI%2F-LAdZOq6pXDEqWz0B5DL%2Fsqlite_success_rate.jpg?generation=1524337069883628\&alt=media)可以看到，倘若休眠时间与重试成功率的关系，按照绿色的曲线进行分布，那么 p 点的值也不失为该方案的一个次优解。然而不同业务和操作的需求，还是有很大的不同的。

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

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

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

![](https://1795314173-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-LAdZ7gFz4IllRQpWbzu%2F-LAdZ8MxWrHaExfP4jSI%2F-LAdZOrRxzRKgYAOiaQO%2Fsqlite_concurrency.png?generation=1524337071710240\&alt=media)

\* \*\*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 唤醒对应的线程重试。![](https://1795314173-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-LAdZ7gFz4IllRQpWbzu%2F-LAdZ8MxWrHaExfP4jSI%2F-LAdZOzw3qL89lOrRjTn%2Fsqlite_multiThread2.jpg?generation=1524337072249753\&alt=media)

新的方案可以在 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![](https://1795314173-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-LAdZ7gFz4IllRQpWbzu%2F-LAdZ8MxWrHaExfP4jSI%2F-LAdZP1YQku_MWjOhuv5%2Fsqlite_commit_changes.jpg?generation=1524337071927784\&alt=media)> 开启 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 的前后被锁保护了起来。

![](https://1795314173-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-LAdZ7gFz4IllRQpWbzu%2F-LAdZ8MxWrHaExfP4jSI%2F-LAdZP8uhuhmHvohrQXG%2Fsqlite_malloc.jpg?generation=1524337083125506\&alt=media)

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

因此，如果不需要内存统计的特性，可以通过 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 源码后再编译的吗？

\> 这个最开始有提到了

\> 开启句柄多线程支持的配置 PRAGMA SQLITE\_THREADSAFE=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 ：请问下，句柄开几个比较合适？读写分离开来对性能是否会有提升呢？

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