转发自2016-08-18张三华腾讯Bugly
前言
随着微信 iOS 客户端业务的增长,在数据库上遇到的性能瓶颈也逐渐凸显。在微信的卡顿监控系统上,数据库相关的卡顿不断上升。而在用户侧也逐渐能感知到这种卡顿,尤其是有大量群聊、联系人和消息收发的重度用户。
我们在对 SQLite 进行优化的过程中发现,靠单纯地修改 SQLite 的参数配置,已经不能彻底解决问题。因此从6.3.16版本开始,我们合入了 SQLite 的源码,并开始进行源码层的优化。
本文将分享在 SQLite 源码上进行的多线程并发、I/O 性能优化等,并介绍优化相关的 SQLite 原理。
多线程并发优化
1. 背景
由于历史原因,旧版本的微信一直使用单句柄的方案,即所有线程共有一个 SQLite Handle,并用线程锁避免多线程问题。当多线程并发时,各线程的数据库操作同步顺序进行,这就导致后来的线程会被阻塞较长的时间。
2. SQLite 的多句柄方案及 Busy Retry 方案
SQLite 实际是支持多线程(几乎)无锁地并发操作。只需
开启配置PRAGMA SQLITE_THREADSAFE=2
确保同一个句柄同一时间只有一个线程在操作
倘若再开启 SQLite 的 WAL 模式(Write-Ahead-Log),多线程的并发性将得到进一步的提升。
此时写操作会先 append 到 wal 文件末尾,而不是直接覆盖旧数据。而读操作开始时,会记下当前的 WAL 文件状态,并且只访问在此之前的数据。这就确保了多线程读与读、读与写之间可以并发地进行。
然而,阻塞的情况并非不会发生。
当多线程写操作并发时,后来者还是必须在源码层等待之前的写操作完成后才能继续。
SQLite 提供了 Busy Retry 的方案,即发生阻塞时,会触发 Busy Handler,此时可以让线程休眠一段时间后,重新尝试操作。重试一定次数依然失败后,则返回SQLITE_BUSY错误码。
3. SQLite Busy Retry 方案的不足
Busy Retry 的方案虽然基本能解决问题,但对性能的压榨做的不够极致。在 Retry 过程中,休眠时间的长短和重试次数,是决定性能和操作成功率的关键。
然而,它们的最优值,因不同操作不同场景而不同。若休眠时间太短或重试次数太多,会空耗 CPU 的资源;若休眠时间过长,会造成等待的时间太长;若重试次数太少,则会降低操作的成功率。
我们通过 A/B Test 对不同的休眠时间进行了测试,得到了如下的结果:
可以看到,倘若休眠时间与重试成功率的关系,按照绿色的曲线进行分布,那么 p 点的值也不失为该方案的一个次优解。然而事总不遂人愿,我们需要一个更好的方案。
4. SQLite 中的线程锁及进程锁
作为有着十几年发展历史、且被广泛认可的数据库,SQLite 的任何方案选择都是有其原因的。在完全理解由来之前,切忌盲目自信、直接上手修改。因此,首先要了解 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 上的实现为例:
通过pthread_mutex_lock进行线程锁,防止其他线程介入。然后比较状态量,若当前状态不可跳转,则返回SQLITE_BUSY
通过fcntl进行文件锁,防止其他进程介入。若锁失败,则返回SQLITE_BUSY
而 SQLite 选择 Busy Retry 的方案的原因也正是在此---文件锁没有线程锁类似 pthread_cond_signal 的通知机制。当一个进程的数据库操作结束时,无法通过锁来第一时间通知到其他进程进行重试。因此只能退而求其次,通过多次休眠来进行尝试。
5. 新的方案
通过上面的各种分析、准备,终于可以动手开始修改了。
我们知道,iOS app 是单进程的,并没有多进程并发的需求,这和 SQLite 的设计初衷是不相同的。这就给我们的优化提供了理论上的基础。在 iOS 这一特定场景下,我们可以舍弃兼容性,提高并发性。
新的方案修改为,当 OS 层进行 lock 操作时:
通过pthread_mutex_lock进行线程锁,防止其他线程介入。然后比较状态量,若当前状态不可跳转,则将当前期望跳转的状态,插入到一个 FIFO 的 Queue 尾部。最后,线程通过pthread_cond_wait进入 休眠状态,等待其他线程的唤醒。
忽略文件锁
当 OS 层的 unlock 操作结束后:
取出 Queue 头部的状态量,并比较状态是否能够跳转。若能够跳转,则通过pthread_cond_signal_thread_np唤醒对应的线程重试。
pthread_cond_signal_thread_np是 Apple 在 pthread 库中新增的接口,与pthread_cond_signal类似,它能唤醒一个等待条件锁的线程。不同的是,pthread_cond_signal_thread_np可以指定一个特定的线程进行唤醒。
新的方案可以在 DB 空闲时的第一时间,通知到其他正在等待的线程,最大程度地降低了空等待的时间,且准确无误。此外,由于 Queue 的存在,当主线程被其他线程阻塞时,可以将主线程的操作“插队”到 Queue 的头部。当其他线程发起唤醒通知时,主线程可以有更高的优先级,从而降低用户可感知的卡顿。
该方案上线后,卡顿检测系统检测到
等待线程锁的造成的卡顿下降超过90%
SQLITE_BUSY 的发生次数下降超过95%
I/O 性能优化
保留 WAL 文件大小
如上文多线程优化时提到,开启 WAL 模式后,写入的数据会先 append 到 WAL 文件的末尾。待文件增长到一定长度后,SQLite 会进行 checkpoint。这个长度默认为1000个页大小,在 iOS 上约为3.9MB。
同样的,在数据库关闭时,SQLite 也会进行 checkpoint。不同的是,checkpoint 成功之后,会将 WAL 文件长度删除或 truncate 到0。下次打开数据库,并写入数据时,WAL 文件需要重新增长。而对于文件系统来说,这就意味着需要消耗时间重新寻找合适的文件块。
显然 SQLite 的设计是针对容量较小的设备,尤其是在十几年前的那个年代,这样的设备并不在少数。而随着硬盘价格日益降低,对于像 iPhone 这样的设备,几 MB 的空间已经不再是需要斤斤计较的了。
因此我们可以修改为:
数据库关闭并 checkpoint 成功时,不再 truncate 或删除 WAL 文件只修改 WAL 的文件头的 Magic Number。下次数据库打开时,SQLite 会识别到 WAL 文件不可用,重新从头开始写入。
保留 WAL 文件大小后,每个数据库都会有这约3.9MB的额外空间占用。如果数据库较多,这些空间还是不可忽略的。因此,微信中目前只对读写频繁且检测到卡顿的数据库开启,如聊天记录数据库。
mmap 优化
mmap 对 I/O 性能的提升无需赘言,尤其是对于读操作。SQLite 也在 OS 层封装了 mmap 的接口,可以无缝地切换 mmap 和普通的I/O接口。只需配置PRAGMA mmap_size=XXX即可开启 mmap。
There are advantages and disadvantages to using memory-mapped I/O. Advantages include:
然而,你在 iOS 上这样配置恐怕不会有任何效果。因为早期的 iOS 版本的存在一些 bug,SQLite 在编译层就关闭了在 iOS 上对 mmap 的支持,并且后知后觉地在16年1月才重新打开。所以如果使用的 SQLite 版本较低,还需注释掉相关代码后,重新编译生成后,才可以享受上 mmap 的性能。
开启 mmap 后,SQLite 性能将有所提升,但这还不够。因为它只会对 DB 文件进行了 mmap,而 WAL 文件享受不到这个优化。
WAL 文件长度是可能变短的,而在多句柄下,对 WAL 文件的操作是并行的。一旦某个句柄将 WAL 文件缩短了,而没有一个通知机制让其他句柄进行更新 mmap 的内容。此时其他句柄若使用 mmap 操作已被缩短的内容,就会造成 crash。而普通的 I/O 接口,则只会返回错误,不会造成 crash。因此,SQLite 没有实现对 WAL 文件的 mmap。
还记得我们上一个优化吗?没错,我们保留了 WAL 文件的大小。因此它在这个场景下是不会缩短的,那么不能 mmap 的条件就被打破了。实现上,只需在 WAL 文件打开时,用unixMapfile将其映射到内存中,SQLite 的 OS 层即会自动识别,将普通的 I/O 接口切换到 mmap 上。
其他优化
禁用文件锁
如我们在多线程优化时所说,对于 iOS app 并没有多进程的需求。因此我们可以直接注释掉os_unix.c中所有文件锁相关的操作。也许你会很奇怪,虽然没有文件锁的需求,但这个操作耗时也很短,是否有必要特意优化呢?其实并不全然。耗时多少是比出来。
SQLite 中有 cache 机制。被加载进内存的 page,使用完毕后不会立刻释放。而是在一定范围内通过 LRU 的算法更新 page cache。这就意味着,如果 cache 设置得当,大部分读操作不会读取新的 page。然而因为文件锁的存在,本来只需在内存层面进行的读操作,不得不进行至少一次 I/O 操作。而我们知道,I/O 操作是远远慢于内存操作的。
禁用内存统计锁
SQLite 会对申请的内存进行统计,而这些统计的数据都是放到同一个全局变量里进行计算的。这就意味着统计前后,都是需要加线程锁,防止出现多线程问题的。
内存申请虽然不是非常耗时的操作,但却很频繁。多线程并发时,各线程很容易互相阻塞。
阻塞虽然也很短暂,但频繁地切换线程,却是个很影响性能的操作,尤其是单核设备。
因此,如果不需要内存统计的特性,可以通过sqlite3_config(SQLITE_CONFIG_MEMSTATUS, 0)进行关闭。这个修改虽然不需要改动源码,但如果不查看源码,恐怕是比较难发现的。
优化上线后,卡顿监控系统监测到
DB 写操作造成的卡顿下降超过80%
DB 读操作造成的卡顿下降超过85%
结语
移动客户端数据库虽然不如后台数据库那么复杂,但也存在着不少可挖掘的技术点。本次尝试了仅对 SQLite 原有的方案进行优化,而市面上还有许多优秀的数据库,如 LevelDB、RocksDB、Realm 等,它们采用了和 SQLite 不同的实现原理。后续我们将借鉴它们的优化经验,尝试更深入的优化。
come from
网友评论