在昨日举行的 GMTC 2017 全球移动技术大会上,来自微信的 Android 高级工程师何俊伟做了微信 SQLite 数据库损坏恢复实践的分享,并在大会上宣布跨平台移动数据库框架 WCDB 开源。 今天,WCDB(WeChat Database)通过了公司的最终审核,作为腾讯微信的一个开源组件分享给大家。
从 WCDB 初建,到不断摸索、优化,再到整理代码、文档,最终看着她在 GitHub 上静静等待着“Make Public”被按下,心情犹如看着女儿出嫁的父亲。趁此机会,正好回顾一下 WCDB 这个“微信的数据库”的成长,分享我们的心路历程,也希望以此让大家更了解 WCDB。
最早期的微信,各个平台除了“使用 SQLite”这个共识,基本各自为政。
Android 平台由于 SDK 提供的支持尚可,而且使用 NDK 开发不便,自然选择系统 API 接口进行开发。
iOS 情况则有不同。系统提供的 CoreData 学习成本很高、性能一般,并不那么好用。因此,在再三考量之下,我们决定自行封装一套接口,命名为 WCDB,WeChat Database。
WCDB 最初的封装与 FMDB 类似,都是直接暴露字符串接口,让业务开发自己拼接字符串,取出数据后赋值给对应的 Object。在线程管理上,则是通过线程锁,使所有线程的访问串行执行,以保证线程安全。
然而,这种方式过于简单粗暴,以至于我们自己使用起来都觉得甚是烦心。
翻开业务和 WCDB 的粘合层,一个几十行的函数,绝大部分都是拼接 SQL、处理 SQLite 返回的空数据和错误码之类的“裹脚布”代码。而且这种代码四处分布,字里行间都写着"Copy & Paste"。
SQL 基于字符串,命令行爱好者甚喜之。但对于基于现代 IDE 的移动开发者,却是一大痛。字符串得不到任何编译器的检查,业务开发往往心中一团热火,奋笔疾书下几百行代码,满心欢喜点下 Run 后才发现:出错了!
静心下来逐步看 log、断点后才发现,噢,SELECT 敲成 SLEECT 了。改正,再等待编译完成,此时已过去十几分钟,心中的热火早被浇灭,还谈何效率?
随着微信业务的发展,安全问题也逐渐突显。客户端数据库虽然不像服务端数据库那么容易被坏人盯上,但在微信这么大的体量下,防贼之心绝不可无。
SQL 注入通常是利用 SQL 字符串拼接的特点,用一些特殊符号提前截断 SQL,达到执行其他 SQL 的目的。试想这么一段代码:
这段封装很简单,就是将消息内容插入到数据库中。假设对方发来这么一条消息:');DELETE FROM message;--
,那么这条 SQL 就会被截断成三部分:
他会在插入一条消息后,将表内的所有消息删除。倘若微信内存在这样的漏洞,后果将不堪设想。
其实反注入并不难,通过绑定参数或替换单引号为双单引号即可解决。但要在业务开发的过程时时刻刻警惕这样的风险,并不现实,毕竟人总会犯错的。
随着微信内收发消息量的不断增长,串行的视线使得当多个线程同时并发时,就造成了相互阻塞。
与此同时,一些微信内也产生了一些新的需求:聊天记录备份。
聊天记录备份是会不断地读取手机上的聊天记录,并传输到 PC/Mac 微信上。换句话说,就是在单线程下会不断地阻塞数据库。这就会直接影响到用户收发和查看聊天记录。
难道用户备份数据的时候,就不能使用微信了吗?显然不现实。
于是,我们就让 WCDB 完成了一次进化。
WCDB 内置了一个句柄池,会根据不同线程的访问,动态地分发管理 SQLite 句柄,从而达到读与读、读与写并发的效果。根据 SQLite 的实现,其写与写操作依然是串行的,但在一个操作进行时,另一个操作是通过 休眠 - 重试 的方式进行的,因此在性能上不够极致。
而 WCDB 通过优化源码,使得写操作结束时,能第一时间唤醒另一个线程进行操作,进一步压榨了性能。
关于这个优化的细节,可以参考我们之前的一篇分享 --- [微信 iOS SQLite 源码优化实践] 。
WCDB 通过封装宏,让业务代码在类内定义字段和类型。WCDB 通过宏保存这些信息,在之后的增删改查中使用。这成为 WCDB 的 ORM 雏形。
然后我们收紧了接口,只提供最基础的增删改查接口,不支持自定义 SQL。同时,我们利用 C++ 模版特性,将 SQL 的拼装隐藏在函数调用内,并内建 SQL 反注入。这成为了 WINQ(WCDB 语言集成查询)的雏形。
通过这两个手段,我们暂且解决了上述问题,但这个封装简单粗暴,缺乏打磨,使得使用上并不方便。
“哥们,朋友圈需要查最近十条消息,加个接口呗?”
“哥们,联系人需要分组查询,加个接口呗?”
“哥们,加个接口呗?”
“哥们...”
算了,不用哥们了,这个需求是我自己的,我自己加吧。。。
痛定思痛,乘着开源这股风,我决定将 WCDB 的易用性优化到极致。这,便是 WINQ,WCDB Integrated Query(WCDB 语言集成查询)。
WINQ 基于 SQL 的语法规则实现,即便是很复杂的 SQL 语句都逃不出这个规则。
同时,简化后的宏也更清晰易懂。
关于 WINQ 的用法,可以参考之前的文章 --- [微信移动端数据库组件 WCDB 系列(一)-iOS 基础篇], 其实现原理我也会在之后进行分享。
当 iOS 在架构上发力的时候,Android 却遇到了别的问题。
在 Android 2.x 时代,由于系统不完善,很多手机用户选择通过 Root 和刷机来定制自己的手机。Root 了之后,用户和恶意程序可以随意读取任意APP 的数据,为了数据安全方面的考虑,Android 决定引入加密数据库 SQLCipher。
SQLCipher 使用 AES-256 进行全数据库加密,包括文件头以及 Journal/WAL,这能满足微信的需要。
当时业务逻辑已经成型,幸好 SQLCipher 提供了与系统一样的接口,我们很快完成了迁移。
往 SQLCipher 的迁移使得我们离开了 Android 不断升级的大环境,SQLCipher Android 框架至今一直使用 Android 2.x 的实现,很多 4.x 才引入的新特性微信无法受益,包括微信非常需要的连接池多线程并发。有没办法加密与新特性兼而有之呢?为了这个目标,Android 也开始自立门户,第一个目标是将 SQLCipher 和最新 Android 框架结合起来。
我们将 SQLCipher 与 Android 源码结合在一起稍作改动,同时加上设置加密的接口,同时获得了 Android SQLite 最新特性以及 SQLCipher带来的固定 SQLite 版本与加密的优势。
Android 4.x 框架内建了连接池,实现上与 iOS WCDB 类似,只是实现在 Java 层,线程唤醒机制也使用 Java 的同步手段实现,可以媲美WCDB iOS 连接池了。
在 Android SDK 中,SQLite 是会不断升级的,实际上使用哪个版本的 SQLite 取决于 APP 运行在哪个版本的系统上,这是对开发者来说相当不友好,因为同样的 SQL 语句会有不同的性能表现。如果业务需要使用 SQLite 的新特性,比如我们的我们的全文搜索,就更加需要确定版本的 SQLite来保证新特性在所有手机上都可用。
WCDB 由于内建了自己的 SQLite 实现(准确来说是 SQLCipher),所以 SQLite 版本是确定的,这规避了很多开发上的问题。
Android 框架查询数据库使用的是 Cursor 接口,调用 SQLiteDatabase.query(...)
会返回一个Cursor
对象,之后就可以使用 Cursor遍历结果集了。Android SDK SQLite Cursor 的实现是分配一个固定 2MB 大小的缓冲区,称作 Cursor Window,用于存放查询结果集。
查询时,先分配 Cursor Window,然后执行 SQL 获取结果集填充之,直到 Cursor Window 放满或者遍历完结果集,之后将 Cursor 返回给调用者。假如 Cursor 遍历到缓冲区以外的行,Cursor 会丢弃之前缓冲区的所有内容,重新查询,跳过前面的行,重新选定一个开始位置填充Cursor Window 直到缓冲区再次填满或遍历完结果集。
这样的实现能保证大部分情况正常工作,在很多情况下却不是最优实现。微信对 DB 操作最多的场景是获取 Cursor 直接遍历获取数据后关闭,获取到的数据,一般是生成对应的实体对象(通过 ORM 或者自行从 Cursor 转换)后放到 List
或 Map
等容器里返回,或用于显示,或用于其他逻辑。
在这种场景下,先将数据保存到 Cursor Window 后再取出,中间要经历两次内存拷贝和转换(SQLite → CursorWindow → Java),这是完全没有必要的。另外,由于 Cursor Window 是定长的,对于较小的结果集,需要无故分配 2MB 内存,对于大结果集,如果 2MB 不足以放下,遍历到途中还会引发 Cursor 重查询,这个消耗就相当大了。
Cursor Window,其实也是在 JNI 层通过 SQLite 库的 Statement 填充的,Statement 这里可以理解为一个轻量但只能往前遍历,没有缓存的 Cursor。这个不就跟我们的场景一致吗?何不直接使用底层的 Statement 呢?我们对 Statement 做了简单的封装,暴露了 Cursor 接口, SQLiteDirectCursor
就诞生了,它直接操作底层 SQLite 获取数据,只能执行往前迭代的操作,但这完全满足需要。
这样,在大部分不需要将 Cursor 传递出去的场景,能很好的解决 Cursor 的额外消耗,特别是结果集大于 2MB 的场合。
随着时间推移,微信的聊天记录越来越多,很快聊天记录的查询就成为了一个性能瓶颈,这个问题在 Android 平台上尤为严重。由于前期各自为政,iOS 和 Android 在数据表设计上并不一致,Android 将所有聊天记录保存在一个 message
表上,导致表非常的大,行数达到百万数量级,对表的索引效率非常低,进入会话非常的慢。而 iOS 则将每个会话的消息分别存放在不同的表,因此存在非常大量的表,但每个表的行数都不多,进入会话时速度较快。iOS 虽然没有进入会话速度上的问题,但每次打开数据库初始化都很慢,造成微信启动时卡顿,如何优化无从入手。
为了解决会话索引效率低的问题,Android 想到 iOS 的方案,大家一起探讨后,Android 也开始试验分表。分表试验得出了意想不到的结果:分表确实能解决索引慢问题,但 表个数增加会严重拖慢初始化速度。至此我们发现,无论是分表还是不分表,都不是完美的方案,为了保证其他业务的开发进度,两个平台都不做表结构的改动,而是互相配合排查各自的问题: Android 的索引效率低和 iOS 的初始化卡顿。
Android 方面最后通过增加 I/O 监控的方法,找到了消息索引的瓶颈:使用字符串作索引,占用空间太大,需要遍历的节点过多,从而造成大量 I/O。解决方法为使用整型代替字符串作为索引,具体解决方案可参考之前的一篇分享 --- [微信 ANDROID 客户端 - 会话速度提升 70% 的背后]。
iOS 也不简单,一度认为无法优化的初始化流程也找到了突破口。在打点和 Profile 相继无果后,我们决定直接接入 SQLite 的源代码,进行更细致的优化。后续发现,SQLite 在初始化的时候,会将 sqlite_master
表中的元信息加载进一个 Hash 表中,而这个表的默认容量是 1KB,对于大小为 32 字节的节点,只需超过 32 个表,就会将其填满。超载的 Hash 表会退化成线性表,并通过比较字符串的方式将元素插入到正确的位置。于是,每新增一个表,都会产生大量的字符串比较的操作,拖慢效率。因此,在调整 Hash 表的容量之后,卡顿问题迎刃而解。
通过这次优化的经历,我们发现 Android 和 iOS 一些问题是共通的,研究和优化成果可以互通有无。自此之后,Android 和 iOS 在数据库方面的合作开始变得紧密。
好景不长,正值 2016 年春节抢红包高峰期,Android 与 iOS 同时收到告警: 反馈聊天记录丢失的用户数异常上涨。
这是数据库损坏引起的,在此之前,我们已经有过应对方案:dump 恢复,虽然修复率不高,但确实解决了部分问题,一直没有太过关注。但此次,猜测是大家抢红包热情高涨,导致消息收发数(谢谢老板.gif)暴涨,手机经常掉电关机,存储空间也被大量占用,这些恰好就是推高数据库损坏率的诱因。
由于有过成功的合作案例,这次在问题初始就订立目标: Android 与 iOS 各自研发对自己平台更有效的方案,但从立项之初就考虑使用跨平台方案,使最终成果可以共享。于是,Android 方面负责研发高效的备份恢复方案,iOS 方面则研究成功率更高的直接恢复手段。经过不懈努力,备份恢复与 Repair Kit 相继面世,并且符合跨平台标准,可以共享成果。想了解更多关于恢复方面的技术细节,可以看之前的两篇分享 --- [微信移动端数据库组件 WCDB 系列(二) — 数据库修复三板斧] 和 [微信 SQLite 数据库修复实践] 。
至此,Android 和 iOS 的数据库有了跨平台组件的想法和实践经验,思考问题更多从方案通用性的方向考量。为了更好地共享成果,Android 与 iOS 数据库组件 WCDB 经过重构后脱离各自的业务逻辑,变成一个独立的,专注的,可推广的组件,在公司内部供其他产品接入。
最终,WCDB 成为一个开源组件跟大家见面。
开源只是故事的开始,我们仍会持续对 WCDB 做改进,包括更易用的接口、更好的性能、更高的可靠性。这些改进最终也会原封不动地在内微信使用。
扫码关注 WCDB,你们的 Star 是我们最大的动力!
https://github.com/Tencent/wcdb
今日荐文
点击下方图片即可阅读