一步一步了解WCDB

你好,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一样,有一定的人力成本;需要考虑到老版本数据库适配问题

参考

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

推荐阅读更多精彩内容