DEV Club 分享笔记(微信iOS SQLite源码优化实践)

<strong>首先,要感谢 DEV Club 能给我一个这么好的平台,在那里学到大牛们分享的技术.这次感谢的是张三华,张哥的这么好的分享.从中学到很多,在这做个摘抄笔记.</strong>
   <strong>下面开始我们今天的分享。SQLite是我们在移动端常用的数据库,微信也是基于它封装了一层ObjC接口。我们知道,微信里消息的收发是很频繁的,尤其是对于重度用户,这对于数据库的多线程并发和I/O是很大的挑战。通常对这部分做优化,有两种方式,一是修改SQLite的参数,如Cache Size等,二是改业务层调用,如主线程操作dispatch到子线程。然而,前者有明显的瓶颈,后者则是个endless的工作。我们希望能一劳永逸地解决同类问题。这就是我们本次所要分享的优化。</strong>

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

  1. 开启句柄多线程支持的配置 PRAGMA SQLITE_THREADSAFE=2

  2. 确保同一个句柄同一时间只有一个线程在操作

  3. (可选)开启WAL模式PRAGMA journal_mode=WAL

    此时写操作会先append到wal文件末尾,而不是直接覆盖旧数据。而读操作开始时,会记下当前的WAL文件状态,并且只访问在此之前的数据。这就确保了多线程读与读、读与写之间可以并发地进行. 而写与写之间仍会互相阻塞。SQLite提供了Busy Retry的方案,即发生阻塞时,会触发Busy Handler,此时可以让线程休眠一段时间后,重新尝试操作。重试一定次数依然失败后,则返回SQLITE_BUSY错误码。

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

SQLite 默认的 Busy Handler.jpeg

   上面介绍了SQLite多线程并发方案,接下来我们把焦点放在Busy Retry这个方案的不足上. Busy Retry的方案虽然基本能解决问题,但对性能的压榨做的不够极致。在Retry过程中,休眠时间的长短和重试次数,是决定性能和操作成功率的关键。 然而,它们的最优值,因不同操作不同场景而不同。若休眠时间太短或重试次数太多,会空耗CPU的资源;若休眠时间过长,会造成等待的时间太长;若重试次数太少,则会降低操作的成功率。如下图

等待.重复.jpeg

可以看到

  • CPU空转那段,线程一操作还没结束,这里空耗了CPU的资源
  • 线程闲置那段,线程一已经结束,而线程二仍在等待,空耗了时间
    对于这个的优化,简单的方法可以是修改休眠时间,尽最大限度缩短以上两段空耗的资源。
    我们通过A/B Test对不同休眠时间进行了实验,得到了如下的结果
A:B Test.jpeg

   可以看到,倘若休眠时间与重试成功率的关系,按照绿色的曲线进行分布,那么p点的值也不失为该方案的一个次优解。然而不同业务和操作的需求,还是有很大的不同的。
既然SQLite的方案不行,我们就要开始往深层探索新的可能性了。

(1).下面将介绍SQLite中控制并发相关的原理

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

SQLite 核心逻辑.jpeg
  • 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的通知机制。当一个进程的数据库操作结束时,无法通过锁来第一时间通知到其他进程进行重试。因此只能退而求其次,通过多次休眠来进行尝试。

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

我们知道,iOS app是单进程的,并没有多进程并发的需求,这和SQLite的设计初衷是不相同的。这就给我们的优化提供了理论上的基础。在iOS这一特定场景下,我们可以舍弃兼容性,提高并发性。
   新的方案修改为,当OS层进行lock操作时:

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

    当OS层的unlock操作结束后:

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

优化后的等待和重复.jpeg

  新的方案可以在DB空闲时的第一时间,通知到其他正在等待的线程,最大程度地降低了空等待的时间,且准确无误。此外,由于Queue的存在,当主线程被其他线程阻塞时,可以将主线程的操作“插队”到Queue的头部。当其他线程发起唤醒通知时,主线程可以有更高的优先级,从而降低用户可感知的卡顿.

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

提到I/O效率的提升,最容易想到的就是mmap了,它可以减少数据从kernel层到user层的数据拷贝,从而提高效率。SQLite不仅支持mmap,而且推荐使用,在大多数平台是在一定程度上默认打开的。 然而早期的iOS版本的存在一些bug,SQLite在编译层就关闭了在iOS上对mmap的支持,并且后知后觉地在16年1月才重新打开。所以如果使用的SQLite版本较低,还需注释掉相关代码后,重新编译生成后,才可以享受上mmap的性能。下图就是SQLite注释掉相关代码的commit

mmap 相关代码.jpeg

   开启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原理和优化点的挖掘上了,大家可以根据优化方案去尝试。不过我们还有一些简单易行且效果还不错的小优化,希望可以成为大家打开SQLite黑盒的一个契机。

第一个是禁用文件锁。如我们在多线程优化时所说,对于iOS app并没有多进程的需求。因此我们可以直接注释掉os_unix.c中所有文件锁相关的操作。也许你会很奇怪,虽然没有文件锁的需求,但这个操作耗时也很短,是否有必要特意优化呢?其实并不全然。耗时多少是比出来。 SQLite中有cache机制。被加载进内存的page,使用完毕后不会立刻释放。而是在一定范围内通过LRU的算法更新page cache。这就意味着,如果cache设置得当,大部分读操作不会读取新的page。然而因为文件锁的存在,本来只需在内存层面进行的读操作,不得不进行至少一次I/O操作。而我们知道,I/O操作是远远慢于内存操作的。

第二个是禁用内存统计锁。SQLite会对申请的内存进行统计,而这些统计的数据都是放到同一个全局变量里进行计算的。这就意味着统计前后,都是需要加线程锁,防止出现多线程问题的。以下SQLite内存申请的函数可以看到,当内存统计打开时,会跑代码的第二个if,malloc的前后被锁保护了起来。

SQLite 内存申请函数.jpeg

   其实这里内存申请的量不大,并不是非常耗时的操作,但却很频繁。多线程并发时,各线程很容易互相阻塞。因为耗时很短,所以被阻塞的时间也很短暂。似乎不会有太大问题。但频繁地阻塞却意味着线程不断地切换,这是个很影响性能的操作,尤其对于单核设备。因此,如果不需要内存统计的特性,可以通过sqlite3_config(SQLITE_CONFIG_MEMSTATUS, 0)进行关闭。这个修改虽然不需要改动源码,但如果不查看源码,恐怕是比较难发现的。

以上就是我今天的分享,总的来说,移动客户端数据库虽然不如后台数据库那么复杂,但也存在着不少可挖掘的技术点。这次也只尝试了对SQLite原有的方案进行优化,而市面上还有许多优秀的数据库,如LevelDB、RocksDB、Realm等,它们采用了和SQLite不同的实现原理。后续我们将借鉴它们的优化经验,尝试更深入的优化。

三,问答互动

1: 请问微信在全文索引上有实践吗?有没有自己做本地的搜索索引
答:SQLite是支持有全文索引的支持的,我们要做的是提供一个好的,支持中文的分词器。

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

3:请问有没有对能耗的监测和优化经验?
答:检测相关的我们有卡顿监控系统,可以到我们的公众号WeMobileDev上了解@talisk-斗鱼TV

4:iOS客户端用操作数据库需要每次先open,执行完了再close,每次都这样,还是app只需要开关一次比较好呢?
答:常用的db没有必要经常开关,db占用的内存并不高,可以权衡一下

5:微信对于本地空间不足会有一个强提醒,这是出于什么考虑?不同机型有不同的策略吗?
答:空间不足是个硬伤,所谓巧妇难为无米之炊。如16GB的iPhone,其实很影响正常使用了。不同机型会做细化.

6:微信对于数据库升级有没有特别优化的地方?或者说不同版本的跳版本升级
答:不知道这个问题值得是SQLite的升级还是表结构的升级。前者的话,暂时没看到SQLite新版本有比较大的特性值得我们跟进。后者可以用alter table在封装层支持升级,性能损坏不大.

7:微信 sqllite数据库用的内存数据库吗?那和文件数据库导入导出怎么控制的?
答:没有使用内存数据库.

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容