你好,WCDB
WCDB是一个高效、完整、易用的移动数据库框架,基于SQLCipher,支持iOS, macOS和Android。
1 基本特性
易用,WCDB支持一句代码即可将数据取出并组合为object。
WINQ(WCDB语言集成查询):通过WINQ,开发者无须为了拼接SQL的字符串而写一大坨胶水代码。
ORM(Object Relational Mapping):WCDB支持灵活、易用的ORM。开发者可以很便捷地定义表、索引、约束,并进行增删改查操作。
[database getObjectsOfClass:WCTSampleConvenient.class
fromTable:tableName
where:WCTSampleConvenient.intValue>=10
limit:20];
高效,WCDB通过框架层和sqlcipher源码优化,使其更高效的表现。
多线程高并发:WCDB支持多线程读与读、读与写并发执行,写与写串行执行。
-
批量写操作性能测试:
更多关于WCDB的性能数据,请参考benchmark。 完整,WCDB覆盖了数据库相关各种场景的所需功能。
加密:WCDB提供基于SQLCipher的数据库加密。
损坏修复:WCDB内建了Repair Kit用于修复损坏的数据库。
反注入:WCDB内建了对SQL注入的保护。
2 数据库修复方案
通过收集到的大量案例和日志,分析出实际上移动端数据库损坏的真正原因其实就3个:
- 空间不足
- 设备断电
- 文件 sync 失败
我们需要针对这些原因一一进行优化
2.1 优化空间占用
- 业务文件先申请后使用,如果某个文件没有申请就使用了,会被自动扫描出来并删除;
- 每个业务文件都要申明有效期,是一天、一个星期、一个月还是永久存储;
- 过期文件会被自动清理。
对于微信之外的空间占用,例如相册、视频、其他App的空间占用,微信本身是做不了什么事情的,我们可以提示用户进行空间清理
2.2 优化文件 sync
2.2.1 synchronous = FULL
设置SQLite的文件同步机制为全同步,亦即要求每个事物的写操作是真的flush到文件里去。
2.2.2 fullfsync = 1
通过与苹果工程师的交流,我们发现在 iOS 平台下还有 fullfsync 这个选项,可以严格保证写入顺序跟提交顺序一致。设备开发商为了测评数据好看,往往会对提交的数据进行重排,再统一写入,亦即写入顺序跟App提交的顺序不一致。在某些情况下,例如断电,就可能导致写入文件不一致的情况,导致文件损坏。
2.3 SQLite 修复逻辑优化
官方修复算法是这样一个流程:从 master 表中读出一个个表的信息,根据根节点地址和创表语句来 select 出表里的数据,能 select 多少是多少,然后插入到一个新 DB 中。要注意的是 master 表他本身也是一个 B+树 形式的普通表,DB 第0页就是他的根节点。那么只要 master 表某个节点损坏,这个节点下面记录的表就都恢复不了。更坏的情况是 DB 第0页损坏,那么整个 master 表都读不出来,就导致整个DB都恢复失败。这就是官方修复算法成功率这么低的原因,太依赖 master 表了。
2.3.1 解析B-tree恢复方案(RepairKit)
正常情况下,SQLite 引擎打开DB后首次使用,需要先遍历sqlite_master,并将里面保存的SQL语句再解析一遍, 保存在内存中供后续编译SQL语句时使用。假如sqlite_master损坏了无法解析,“Dump恢复”这种走正常SQLite 流程的方法,自然会卡在第一步了。为了让sqlite_master受损的DB也能打开,需要想办法绕过SQLite引擎的逻辑。 由于SQLite引擎初始化逻辑比较复杂,为了避免副作用,没有采用hack的方式复用其逻辑,而是决定仿造一个只可以 读取数据的最小化系统
sqlite_master信息量比较小,而且只有改变了表结构的时候(例如执行了CREATE TABLE、ALTER TABLE等语句)才会改变,因此对它进行备份成本是非常低的,一般手机典型只需要几毫秒到数十毫秒即可完成,一致性也容易保证, 只需要执行了上述语句的时候重新备份一次即可。有了备份,我们的逻辑可以在读取DB自带的sqlite_master失败的时候 使用备份的信息来代替。
DB初始化的问题除了文件头和sqlite_master完整性外,还有加密。SQLCipher加密数据库,对应的恢复逻辑还需要加上 解密逻辑。按照SQLCipher的实现,加密DB 是按page 进行包括头部的完整加密,所用的密钥是根据用户输入的原始密码和 创建DB 时随机生成的 salt 运算后得出的。可以猜想得到,如果保存salt错了,将没有办法得出之前加密用的密钥, 导致所有page都无法读出了。由于salt 是创建DB时随机生成,后续不再修改,将它纳入到备份的范围内即可
到此,初始化必须的数据就保证了,可以仿造读取逻辑了。我们常规使用的读取DB的方法(包括dump方式恢复), 都是通过执行SQL语句实现的,这牵涉到SQLite系统最复杂的子系统——SQL执行引擎。我们的恢复任务只需要遍历B-tree所有节点, 读出数据即可完成,不需要复杂的查询逻辑,因此最复杂的SQL引擎可以省略。同时,因为我们的系统是只读的, 写入恢复数据到新 DB 只要直接调用 SQLite 接口即可,因而可以省略同样比较复杂的B-tree平衡、Journal和同步等逻辑。 最后恢复用的最小系统只需要:
VFS读取部分的接口(Open/Read/Close),或者直接用stdio的fopen/fread、Posix的open/read也可以
SQLCipher的解密逻辑
B-tree解析逻辑
即可实现
B-tree解析好处是准备成本较低,不需要经常更新备份,对大部分表比较少的应用备份开销也小到几乎可以忽略, 成功恢复后能还原损坏时最新的数据,不受备份时限影响。 坏处是,和Dump一样,如果损坏到表的中间部分,比如非叶子节点,将导致后续数据无法读出。
使用 Repair Kit 可以直接从损坏的数据库里尽量读出未损坏的数据,不需要事先准备, 但是先备份 Master 信息可以大大增加恢复成功率。 如果有意使用 Repair Kit 恢复数据库, 建议备份 Master 信息
2.3.2 备份方案
主要的方案有:
拷贝: 不能再直白的方式。由于SQLite DB本身是文件(主DB + journal 或 WAL), 直接把文件复制就能达到备份的目的。
Dump: 上一个恢复方案用到的命令的本来目的。在DB完好的时候执行.dump, 把 DB所有内容输出为 SQL语句,达到备份目的,恢复的时候执行SQL即可。
Backup API: SQLite自身提供的一套备份机制,按 Page 为单位复制到新 DB, 支持热备份。
对以上方案做简单测试后,备份方案也就基本定下了。测试用的DB大小约 50MB, 数据条目数大约为 10万条:
微信在Dump + gzip方案上再加以优化,由于格式化SQL语句输出耗时较长,因此使用了自定义 的二进制格式承载Dump输出。第二耗时的压缩操作则放到别的线程同时进行,在双核以上的环境 基本可以做到无额外时间消耗。由于数据保密需要,二进制Dump数据也做了加密处理。 采用自定义二进制格式还有一个好处是,恢复的时候不需要重复的编译SQL语句,编译一次就可以 插入整个表的数据了,恢复性能也有一定提升。优化后的方案比原始的Dump + 压缩, 每秒备份行数提升了 150%,每秒恢复行数也提升了 40%。
2.3.3 不同方案的组合
由于解析B-tree恢复原理和备份恢复不同,失败场景也有差别,可以两种手段混合使用覆盖更多损坏场景。 微信的数据库中,有部分数据是临时或者可从服务端拉取的,这部分数据可以选择不修复,有些数据是不可恢复或者 恢复成本高的,就需要修复了。
如果修复过程一路都是成功的,那无疑使用B-tree解析修复效果要好于备份恢复。备份恢复由于存在 时效性,总有部分最新的记录会丢掉,解析修复由于直接基于损坏DB来操作,不存在时效性问题。 假如损坏部分位于不需要修复的部分,解析修复有可能不发生任何错误而完成。
若修复过程遇到错误,则很可能是需要修复的B-tree损坏了,这会导致需要修复的表发生部分或全部缺失。 这个时候再使用备份修复,能挽救一些缺失的部分。
最早的Dump修复,场景已经基本被B-tree解析修复覆盖了,若B-tree修复不成功,Dump恢复也很有可能不会成功。 即便如此,假如上面的所有尝试都失败,最后还是会尝试Dump恢复。
注:了解到iOS端恢复方式只提供Repair Kit, 且所有的备份和恢复操作都需要开发人员自己调用相应的接口
3 SQLite源文件优化
3.1 优化并发效率
3.1.1 SQLite 多句柄方案
我们先讲 SQLite 所提供的多线程并发方案。它对这方面的支持做的很不错,在使用上,只需
- 1.开启句柄多线程支持的配置 PRAGMA SQLITE_THREADSAFE=2
- 2.确保同一个句柄同一时间只有一个线程在操作
- 3.(可选)开启 WAL 模式 PRAGMA journal_mode=WAL
此时写操作会先 append 到 wal 文件末尾,而不是直接覆盖旧数据。而读操作开始时,会记下当前的 WAL 文件状态,并且只访问在此之前的数据。这就确保了多线程读与读、读与写之间可以并发地进行。
3.1.2 Busy Retry 方案
而写与写之间仍会互相阻塞。SQLite 提供了 Busy Retry 的方案,即发生阻塞时,会触发 Busy Handler,此时可以让线程休眠一段时间后,重新尝试操作。重试一定次数依然失败后,则返回 SQLITE_BUSY 错误码。
下面这段代码是 SQLite 默认的 Busy Handler
3.1.3 Busy Retry 方案的不足
上面介绍了 SQLite 多线程并发方案,接下来我们把焦点放在 Busy Retry 这个方案的不足上。
Busy Retry 的方案虽然基本能解决问题,但对性能的压榨做的不够极致。在 Retry 过程中,休眠时间的长短和重试次数,是决定性能和操作成功率的关键。
然而,它们的最优值,因不同操作不同场景而不同。若休眠时间太短或重试次数太多,会空耗 CPU 的资源;若休眠时间过长,会造成等待的时间太长;若重试次数太少,则会降低操作的成功率。如下图
可以看到
- CPU空转那段,线程一操作还没结束,这里空耗了 CPU 的资源
- 线程闲置那段,线程一已经结束,而线程二仍在等待,空耗了时间
3.1.3 开始改造
当 OS 层进行 lock 操作时:
- 1.通过 pthread_mutex_lock 进行线程锁,防止其他线程介入。然后比较状态量,若当前状态不可跳转,则将当前期望跳转的状态,插入到一个 FIFO 的 Queue 尾部。最后,线程通过 pthread_cond_wait 进入 休眠状态,等待其他线程的唤醒。
- 2.忽略文件锁
当 OS 层的 unlock 操作结束后:
取出 Queue 头部的状态量,并比较状态是否能够跳转。若能够跳转,则通过 pthread_cond_signal_thread_np 唤醒对应的线程重试。
新的方案可以在 DB 空闲时的第一时间,通知到其他正在等待的线程,最大程度地降低了空等待的时间,且准确无误。
此外,由于 Queue 的存在,当主线程被其他线程阻塞时,可以将主线程的操作“插队”到 Queue 的头部。当其他线程发起唤醒通知时,主线程可以有更高的优先级,从而降低用户可感知的卡顿
3.2 I/O 性能优化
提到 I/O 效率的提升,最容易想到的就是 mmap了,它可以减少数据从 kernel 层到 user 层的数据拷贝,从而提高效率。
SQLite 不仅支持 mmap,而且推荐使用,在大多数平台是在一定程度上默认打开的。然而早期的 iOS 版本的存在一些 bug,SQLite 在编译层就关闭了在 iOS 上对 mmap 的支持,并且后知后觉地在16年1月才重新打开。所以如果使用的 SQLite 版本较低,还需注释掉相关代码后,重新编译生成后,才可以享受上 mmap 的性能。
主要修改了
- 1.数据库关闭并 checkpoint 成功时,不再 truncate 或删除 WAL 文件,只修改 WAL 的文件头的 Magic Number。下次数据库打开时, SQLite 会识别到 WAL 文件不可用,重新从头开始写入。
- 2.为 WAL 添加 mmap 的支持
3.3 其他优化
禁用文件锁
如我们在多线程优化时所说,对于 iOS app 并没有多进程的需求。因此我们可以直接注释掉 os_unix.c 中所有文件锁相关的操作。也许你会很奇怪,虽然没有文件锁的需求,但这个操作耗时也很短,是否有必要特意优化呢?其实并不全然。耗时多少是比出来。
SQLite 中有 cache 机制。被加载进内存的 page,使用完毕后不会立刻释放。而是在一定范围内通过 LRU 的算法更新 page cache。这就意味着,如果 cache 设置得当,大部分读操作不会读取新的 page。然而因为文件锁的存在,本来只需在内存层面进行的读操作,不得不进行至少一次 I/O 操作。而我们知道,I/O 操作是远远慢于内存操作的。
禁用内存统计锁
SQLite 会对申请的内存进行统计,而这些统计的数据都是放到同一个全局变量里进行计算的。这就意味着统计前后,都是需要加线程锁,防止出现多线程问题的。
以下 SQLite 内存申请的函数可以看到,当内存统计打开时,会跑代码的第二个 if,malloc 的前后被锁保护了起来。
其实这里内存申请的量不大,并不是非常耗时的操作,但却很频繁。多线程并发时,各线程很容易互相阻塞。因为耗时很短,所以被阻塞的时间也很短暂。似乎不会有太大问题。但频繁地阻塞却意味着线程不断地切换,这是个很影响性能的操作,尤其对于单核设备。
因此,如果不需要内存统计的特性,可以通过 sqlite3_config(SQLITE_CONFIG_MEMSTATUS, 0)进行关闭。这个修改虽然不需要改动源码,但如果不查看源码,恐怕是比较难发现的。
多线程并发优化使得卡顿率从4.08%降至0.19,I/O 优化使得读卡顿从1.50%降至0.20%,写卡顿从1.18%降至0.21%
4 从FMDB迁移到WCDB好处
4.1 语法上
由于 FMDB 和 WCDB 都基于 SQLite ,因此两者在数据库的文件格式上一致。用 FMDB 创建、操作的数据库,可以直接通过 WCDB 打开、使用。因此开发者无需做额外的数据迁移
WCDB通过WINQ抽象SQLite语法规则,使得开发者可以告别字符串拼接的胶水代码。通过和接口层的ORM结合,使得即便是很复杂的查询,也可以通过一行代码完成,更少的代码量通常意味着更快的开发效率和更少的错误。并借助IDE的代码提示和编译检查的特性,大大提升了开发效率。同时还内建了反注入的保护
OC语法式的ORM建模
查询操作
插入操作
as重定向
链式调用
多表查询
类字段绑定
4.2 方便的数据库升级
WCDB 将数据库升级和 ORM 结合起来,对于需要增删改的字段,只需直接在 ORM 层面修改,并再次调用 createTableAndIndexesOfName:withClass: 接口即可自动升级
4.3 安全的多线程操作
WCDB 与 FMDB 都支持多线程操作。
在 FMDB 内,当开发者需要进行多线程操作时,需要使用另外一个类 FMDatabasePool来进行操作。
而 WCDB 基础的 CRUD 接口都支持多线程,因此开发者不需要额外关心线程安全的问题。同样的, WCDB 多线程使用的代码量也比 FMDB 少得多
4.4 更多
- WCDB 写操作优于 FMDB 28%、批量写操作优于 FMDB 180%; WCDB 的初始化速度有 107% 的性能优势
- WCDB内建了对SQL注入的保护
- WCDB 基于 SQLCipher 提供了加密功能
- WCDB 内提供统计的接口注册获取数据库操作的 SQL 、性能、错误等,开发者可以将这些信息打印到日志或上报到后台,以调试或统计
- WCDB 提供了数据库修复工具,以应对数据库损坏无法使用的极端情况。
5.迁移方案
5.1 逐步迁移
使用WCDB新建一个数据库,和老数据库共存,一步一步迁移过来
优点: 可以一步一步慢慢迁移过去,一个表一个表迁移;能保存原始数据
缺点: 周期长,需要考虑老版本数据库迁移时;-适配问题
5.2 暴力迁移
删除原应用所有数据,让用户重新登录,使用WCDB建完所有表和操作
优点: 快、准、狠
缺点: 太狠了,对用户有一定影响,同时一个版本把所有数据库操作全部替换有一定的人力成本
5.3 直接框架迁移(推荐)
直接将以前FMDB的写法全部转换为WCDB的写法,一步到位
优点: 能保存原始数据
缺点: 跟方法2一样,有一定的人力成本;需要考虑到老版本数据库适配问题