流浪的加菲
2024-04-09 14:36 deepin
微信作为国内乃至全球范围内使用数据库最频繁的 App,微信内部涉及上百种不同业务的数据库,这种复杂度和数据量给数据库带来的挑战是巨大的,微信开源了WCDB,希望大家都能从中学到一些东西。
Reply Like 1 View the author
微信作为国内乃至全球范围内使用数据库最频繁的 App,微信内部涉及上百种不同业务的数据库,这种复杂度和数据量给数据库带来的挑战是巨大的,微信开源了WCDB,希望大家都能从中学到一些东西。
WCNB
是不是意味着原生微信,也会越来越好用?
第一次看到这么详实的文章,转给大家看看,很多提出问题和如何解决问题的方法因为太细了,就没有完全转载,感兴趣的可以去看原文。
一、简介
WCDB 是微信团队开源的一款基于 SQLite 的终端数据库。自 2017 年 6 月开源以来,它在业界得到了广泛认可并被大量应用,迄今已经推出了十多个版本。在这个过程中,WCDB 一直保持良好的向后兼容性,不断完善原有接口的细节并添加新功能。
二、挑战
作为国内乃至全球范围内使用数据库最频繁的 App,微信内部涉及上百种不同业务的数据库,存储的消息条数可达百万乃至千万级别。这种庞大的数据量和日益丰富的应用场景,给 WCDB 带来了不断更新的需求和挑战,原有的代码框架逐渐难以应对。
三、重大升级
自 2019 年起,微信团队决定放弃接口的向后兼容性,全力打造一个更加强大的新版 WCDB。经过多次迭代,WCDB 的接口层和核心逻辑层已经得到了全面改进,同时也积累了许多新功能。如今,微信团队已经重大升级的新版WCDB进行了开源,主要变化及更新包括:
变化一:更丰富的开发语言支持
WCDB 1.0 版本支持 Objective-C、Swift、Java 三种开发语言,但是三种语言的 WCDB 除了共用同一个版本的 SQLite 和共用同一套备份修复逻辑,其他代码都是独立开发的。随着 WCDB 不断迭代,WCDB 的很多新能力都是在 ObjC 版本上开发完成和上线验证,Swift和Java版本基本处于停止迭代的状态,他们之间的差异也越来越大。在理想的状态下,不同语言版本的 WCDB 应该拥有同样的能力,但是如果把 ObjC 版本的新逻辑重新在 Swift 和 Java上实现一遍,不仅工作量大,还容易出错,需要再次上线验证,不太现实。
幸运的是,ObjC 版本的 WCDB 的核心逻辑都是用 C++ 实现的,ObjC 只是用来实现接口层的逻辑。很多支持多种开发语言的库都是使用 C++ 语言来实现核心逻辑,其他语言只是用来实现接口层,比如很热门的客户端 NoSQL 数据库组件realmDB就是如此。WCDB 也可以按照这个思路来设计,这样 ObjC 版本的 WCDB 只需小幅调整,将核心逻辑完全改用 C++ 来实现,Swift 和 Java 通过桥接方法来接入 C++ 核心逻辑。此外,为了充分支持微信各端不同场景的数据库开发需求,WCDB还扩展支持了C++ 和 Kotlin,这样就完整覆盖了现在终端开发的主流语言。
接口层代码结构如下:
在这种代码架构下,不同语言的 WCDB 可以按需集成到同个项目中,有利于节省代码和减少包大小,还可以避免不同语言接口逻辑的冲突,甚至使用不同语言的接口来使用同一个DB都不用担心有任何逻辑冲突。
变化二:更强大的 SQL 表达能力
WCDB 1.0 提供了Winq(WCDB Integrated Query,WCDB集成查询)来方便数据库开发者拼写 SQL 语句。1.0 版本的 Winq 使用 C++ 语言抽象和实现了 SQLite 的 SQL 语法规则,使得开发者可以告别字符串拼接的胶水代码。通过和接口层的 ORM 结合,使得即便是很复杂的查询,也可以通过一行代码完成,并借助 IDE 的代码提示和编译检查的特性,大大提升了开发效率。比如一个
SQLite_sequence
表的查询语句,使用 Winq 来编写可以是这样:可以看到,Winq 将 SQL 语句中的
Token
抽象成C++
类,将不同的 Token 的连接能力抽象成了C++类的接口,并通过链式调用的方式,让Winq拼接出来的SQL语句读起来跟实际的SQL语句接近,可读性好。但随着在微信中的应用推广,这一版的Winq还有下面几个明显的问题:.select()
中接收 ORM 的 Property 时需要先构造WCDB::ColumnResult
,再显式转成数组传入。为了解决这些问题,微信团队采取了诸多解决措施,具体可以 查看原文
变化三:更安全的数据存储能力
前面两节让大家对如何使用 WCDB 有了个整体感受,这部分的设计目标是让大家能够更便捷得存储数据,而如何更安全地存储数据,是数据库设计更重要的目标,这一直是微信团队不断思考的问题,也是其需要扩展强化 SQLite 的最初动机。因为聊天记录作为用户在微信上产生的最重要数字信息,只存储在用户的终端设备上。如果出现数据库损坏,聊天记录将会永久性丢失,这是绝大部分用户无法接受的。为了提高数据安全性,新版 WCDB 有了下面两个新设计:
1、新数据备份和修复方案
WCDB 1.0 中我们推出了一种数据库备份和修复方案,这里有详细介绍,它的整体逻辑是这样的:
SQLite 数据库是以页为单位的双层的 BTree 的结构,上层是 SQLite 的 master 表,下层是每个用户定义的表,其叶子页就是真正的数据所在的地方。当数据库损坏发生在某一中间节点时,它下面的所有支路的数据都将因为找不到而丢失。我们可以备份下层表的表名到根结点页码的映射,那么可以解决最严重的问题,即上层表损坏。当下层表损坏时,也只会丢失单个表。
WCDB 1.0 的备份和修复方案解决了当时数据库损坏后数据就全部丢失的燃眉之急,平均修复率有 70~80%。但是数据库损坏通常发生在磁盘损坏的时候,一般都是一大片数据坏了,所以经常修回来也依然是一片狼藉。所以新版 WCDB 就干脆一点,除了备份 master 表,还增加备份普通表的表名到它叶子页页号和crc校验值的映射,这样就能一步到位,修复的时候根据页号就可以直接找到普通表的数据,校验 crc 值没变,就可以确认数据没有损坏或者变更,从而可以将未损坏的数据完整恢复到新数据库。
2、防止外部逻辑写坏数据库
使用备份和修复来保护数据属于比较被动的方法,数据出错了才补救,修复率无法做到100%,还是不够安全。在磁盘损坏这种低概率发生的场景,备份和修复方法还能应对,但是在外部逻辑出现Bug导致大规模写坏数据库的场景,就难以应对了。我们需要一种更主动的方法来防止数据库被写坏,防患于未然。
外部逻辑写坏数据库的情况会有两种,一种是误用了数据库的路径或者误删了数据库,这个很难出现,要保护也是通过hook系统调用的方式来做,无法集成在WCDB内部;另一种是误用了数据库的文件句柄,这种相对常见,微信就遇到了好几次这种问题,要重点处理。
要防止文件句柄被误用时写坏数据库,一个简单的想法是尽量打开数据库文件时都是只读打开,这样外部逻辑就无法用这个句柄来更改数据库了。对于大部分数据库组件来讲,要实现这点,还是挺复杂。打开句柄时要能够判断下这个操作会不会修改数据库,只读打开之后还要遇到更改数据库的操作时,又要重新打开数据库文件句柄。
而 WCDB 的 WAL 模式是采用独立线程异步执行 checkpoint 的,在这种配置下,业务逻辑即便是要写入数据到数据库,也不需要修改到主 DB 文件,只需要修改 WAL 文件,只有到 checkpoint 时才需要修改主 DB 文件。所以 WCDB 可以在业务逻辑读写数据库时全部只读打开主 DB 文件,只有在 checkpoint 时才可写打开主DB文件。这样就能最大限度地减少主 DB 文件的可写句柄的存活时间,防止外部逻辑误写。
变化四:更灵活的数据扩展能力
随着用户数据的积累和功能的复杂化,早期数据库表设计会越来越难以满足需求,微信在迭代的过程中也遇到了很多这类问题:
针对这两类场景,WCDB 给出了业界首创的解决方法,分别是数据迁移能力、数据压缩能力和自动添加新列能力。
1、数据迁移能力
iOS微信早期在业务逻辑层面做过两次数据迁移,一次是收消息操作指令数据迁移,因为数据量较小,可以阻塞式一步迁移到位之后再使用迁移后的数据;另一次是联系人数据迁移,因为数据量较大,需要采用非阻塞式迁移的方案。非阻塞式迁移过程中,数据可能处于三种状态,未迁移状态只有旧表,迁移完成后只有新表,而在迁移中则两张表都有,开发者需要对所有业务涉及的代码都做这三种状态的区分,并且在迁移中合并旧表和新表的数据。这部分代码并不难,但是冗长、而且和业务耦合严重,比较难开发和维护,更尴尬的是,很难找到一个适合的删除兼容代码的时间,兼容代码可能需要一直存在,很影响后续的迭代。
为了解决这个问题,WCDB 就提出了一个概念,由 WCDB 来解决兼容问题,让开发者可以 以迁移已经完成为假定前提 进行开发。同时因为是框架层代码,天然就是 code once, run everywhere,所以开发也不需要花费时间在灰度上。
WCDB 的数据迁移方案是这样的,当数据库操作的请求过来时,会先对其使用的数据库句柄进行迁移配置,如果是跨 db 的迁移,会把另一个 db attach 到当前句柄,以实现跨 db 的 SQL。然后检测旧表是否存在,如果不存在则说明迁移已经完成,直接执行 SQL。如果存在则创建一个 temp view,用作后续的兼容。然后 WCDB 会预处理数据库的操作请求,再进行真正的执行。这个预处理是类似于 hook 的逻辑,WCDB 会拦截开发者需要执行的 SQL,然后进行一些修改和处理,以给开发者提供一个迁移已经完成的假象。这里主要针对增删查改中的操作进行处理。迁移流程如下:
2、数据压缩能力
要解决数据库中 XML、Json、PB等序列化数据过长的问题,一个直接的方法是把这些数据都压缩一下再写入数据库。一般来讲,开发者要做数据压缩,首先是是要选择一个合适的压缩算法,然后需要在数据读写的各个环节引入加解压逻辑,要对压缩的数据做好标记,然后要想办法处理存量数据,要做极致性能优化的话还要想办法缓存加解压过程的各种内存状态。这些事情处理起来都是不小的工作量,而 WCDB 提出的数据压缩能力可以帮助开发者一步到位解决这些麻烦,只需要一个简单的无侵入配置就好。
在压缩算法方面,肯定是要选择无损压缩算法。早期的无损压缩算法主要分为哈夫曼编码和算术编码两大类。哈夫曼编码相信大家都非常熟悉,它通过将高概率出现的字符编码为更短的码点来实现压缩。这类算法的优势在于编码速度快,但只有当各个字符的出现概率都是 2 的负整数次幂时,哈夫曼编码的压缩率才能达到香农极限,其他情况下都无法达到,因此压缩率较低。
与之相对的是算术编码,它根据整个字符串出现的概率,将整个字符串转换为一个介于 0 到 1 之间的小数。由于这个小数能精确表示字符串的出现概率,因此算术编码的压缩率能够逼近香农极限。然而,由于编解码过程涉及大量乘除法,其性能相较于哈夫曼编码较差。
ANS+FSE编码是 2014 年发布的一种新算法,它将整个字符串编码成一个大于 1 的整数,这个整数与字符串的出现概率精确相关,因此这个算法的压缩率也能够逼近香农极限。同时,由于其计算过程仅涉及加法、移位和掩码计算,性能上更接近哈夫曼编码,因此它目前被认为是压缩率和性能综合最优的算法。这个算法的最佳实现便是众所周知的 Zstd。
然而,Zstd 的普通压缩模式仅能解决单个 XML 或 Json 内部的冗余度。由于不同的 XML 或 Json 具有相似的标签,不断存储这些标签也会产生很多冗余。为了解决这个问题,Zstd 的字典压缩模式可以有效消除不同数据之间的相似部分,显著提高压缩率并提升性能。因此,Zstd 字典压缩模式被认为是当前压缩序列化数据的最优解。预计在未来,也不太可能出现能明显提升压缩率的压缩算法。因此,WCDB 主要采用 Zstd 字典压缩算法来进行数据压缩。
确定了压缩算法之后,我们看下数据压缩的整体框架和流程:
外部逻辑写入的新数据的时候,在 WCDB 的内部会把数据压缩了之后,再写入文件;读取数据的时候,对于已经压缩的数据,WCDB 也是解压后再给到外部。同时,WCDB 也会在子线程处理存量数据,把未压缩的数据读取出来,压缩后再更新回去。这样外部只需要配置数据库的哪个表的哪个字段需要压缩,CRUD的时候,都可以假定数据都是没压缩的来操作数据,不需要关注数据压缩的实现细节和内部状态,整个加解压过程可以做到外部无感知和无侵入。这样数据压缩就可以很方便在不同业务场景和不同平台扩展。
外部逻辑 CRUD 的时候,为了隐藏数据加解压的细节,需要在WCDB的内部,对要执行的 SQL 做一些处理和转换。首先,如果一个表有字段配置了压缩字段的话,底下就会给这个表的压缩字段逐个添加一个对应的,存储压缩状态的字段,状态字段存储了是否压缩的状态,以及压缩所用的算法,然后还要预处理 SQL,把SQL 中对压缩字段的读写,转换成对压缩字段和压缩状态字段的合并读写,这样就能把加解压逻辑引入进来。
数据压缩CRUD兼容方法:
3、自动补全新列能力
业务逻辑在开发迭代的过程中可能会给原有的表格添加新列,SQLite 是支持给已有的表格添加新列的,WCDB 也会在调用 createTable 的时候自动添加 ORM 类中新配置的列,但是在我们实践过程中这类错误还是很常见。一个原因是可能是开发同学的疏漏,必须要在使用表格之前先主动调用添加新列的逻辑,依赖开发同学的自觉,在多人协作开发时更容易疏漏;另一个原因也可能是确实找不到合适的时机添加新列,比如很多个表对应统一个 ORM 类的场景。如果要对这些表添加一个新列,是找不到一个统一的处理时机的,因为重度用户可能有几千个这样的表,如果一起处理的话,会很耗时,容易造成卡顿;如果每次读写这些表时都判断一下是否需要添加新列,又会明显降低性能。
一个表格的所有列都是在其对应的 ORM 类中配置的。在理想的情况下,开发者在 ORM 类中配置了新列之后,就应该让这个配置可以视为立即生效,开发者无需关心添加新列的时机。为了达到这个效果,WCDB 添加了自动补全新列的能力,其核心的思想是这样,当读写数据库的时候如果报错有未识别的列,则立即检查读写的表格对应的 ORM 类是否有新配置的列跟这个未识别的列同名,如果存在的话,就将新配置的列添加到这个表格,再重试出错的逻辑。采用这种出错再检查的方式,可以将检查新列的逻辑的调用时机降低到最少,又能全面处理新列没及时添加数据库时造成的问题。
自动补全新列流程:
自动补全新列的能力在性能影响和解决问题完整程度上看都比较理想,但实现起来也比较有难度。主要要解决两个问题,一个是如何在执行出错时获取到这个表格对应的 ORM 类,一个是如何避免将错误的列添加到表格中。
对于第一个问题,因为要使用 ORM 类配置的列时,都是从这个类的内部信息中去获取这个列配置的列名,这样才能用列名构造一个
Column
对象用于 Winq 中组装语句,比如上文例子中用到的WCTSequence.seq
就是调用WCTSequence
这个类的方法来获取 seq 属性配置的列名来构造一个Column
对象。所以我们可以在使用这种途径构造Column
时,将整个 ORM 类的数据库配置信息一并传入,并保存在Column
中,这样就可以在 Winq 语句中获取到其中所用到的列所在的 ORM 类的全部配置信息。因为 ORM 信息是保存在堆上的全局量,所以这个改动实际上只是多传递和保存一个指针,并不会给 Winq 的使用带来性能影响。实现了这些之后还不够,我们实际需要知道的是 Winq 语句中涉及到的表格对应的 ORM 信息,而不是列的。这里我们采用了舍弃部分场景的方法,只处理读写单个表格的场景,缺失的列在 Winq 语句中对应两个不同的 ORM 类也放弃处理,在一个 SQL 语句中操作多个表格或者使用多个 ORM 类的情况在实际应用中还是极少见。
对于第二个问题,主要存在下面两种情况需要解决:
SELECT city FROM China WHERE city MATCH '广东: 广州'
会报错no such column: 广东
,但实际并不存在这一列,只是 fts 的搜索语法误把冒号前面这部分识别为列名。这种情况可以通过提取报错信息中的列名去匹配 Winq 语句中的列名来解决。变化五:更极致的性能优化能力
1、FTS5 优化
iOS微信在 2020 年到 2021年期间,将联系人搜索、聊天记录搜索、收藏搜索这三个主要的本地搜索逻辑全部改用 SQLite 的 FTS5 组件来实现,WCDB 也借此机会完善了 FTS5 支持,优化了 FTS5 的读写性能,重新设计了 FTS5 分词器,并丰富了分词器的能力,还支持了拼音搜索,具体见《iOS微信全文搜索技术优化》:https://mp.weixin.qq.com/s/Ph0jykLr5CMF-xFgoJw5UQ 。
2、可中断事务
在需要对数据库进行大量数据更新的场景,我们的开发习惯一般是将这些更新操作统一到子线程处理,这样可以避免阻塞主线程,影响用户体验。在微信中这种场景有收消息、清理朋友圈数据、清理视频号数据等,收消息可能会一次性收取几百上千条消息,朋友圈和视频号的数据拉下来之后会存储在数据库中,但是不需要永久存储,就需要定期清理过期数据。
对于这类场景,如果只是将数据更新操作放到子线程执行,是不能完整解决问题的。因为 SQLite 的同个DB不支持并行写入,如果子线程的数据更新操作耗时太久,而主线程又有数据写入操作,比如用户在收消息的同时还会发消息,这样也会造成主线程阻塞。以前的做法是,将子线程的数据更新操作拆成一个个耗时很小的独立操作分别执行,比如收消息是逐条写入数据库,这样可以避免主线程阻塞问题,但是又会导致磁盘 IO 量大和增加子线程耗时的问题。因为SQLite读写数据库时以一个数据页为单位的,一个数据页的大小在 WCDB 中是 4kb,单个数据页一般可以存多条消息,逐条消息写入容易导致同一个数据页被读写多次。为了减少磁盘写入量,只能将所有的数据更新操作放到一个事务中执行,这样又会造成主线程阻塞的问题。
收消息写入示例:
为了解决大事务会阻塞主线程的问题,我们在 WCDB 中开发了一种可中断事务。可中断事务把一个流程很长的事务过程看成一个循环逻辑,每次循环执行一次短时间的DB操作,比如写入一条新消息。操作之后根据外部传入的参数判断当前事务是否可以结束,如果可以结束的话,就直接
Commit Transaction
,将事务修改内容写入磁盘。如果事务还不可以结束,再判断主线程是否因为当前事务阻塞,没有的话就回调外部逻辑,继续执行后面的循环,直到外部逻辑处理完毕。如果检测到主线程因为当前事务阻塞,则会立即Commit Transaction
,先将部分修改内容写入磁盘,并唤醒主线程执行DB操作。等到主线程的DB操作执行完成之后,在重新开一个新事务,让外部可以继续执行之前中断的逻辑。可中断事务的整体逻辑如下图所示:可中断事务让一系列DB操作尽量保持在一个事务中执行,同时能够及时响应主线程的阻塞事件,避免了主线程的卡顿问题。因为事务可能会被分成多次提交,所以事务整体的原子性是不保证的,这个需要使用者注意,必要的时候需要有额外的机制来保证事务的原子性。
3、WAL 文件头更新优化
WAL 文件的文件头保存着 WAL 文件的版本号、页大小、salt 值、校验值等关键信息,每次写入 WAL 文件的第一页数据的时候,都需要一起更新文件头的内容(只有这个时机更新 WAL 文件的头部)。SQLite 的早期版本(WCDB 1.0.8版本之前用的 SQLite 版本)在写入 WAL 文件头时,只是将内容写到磁盘缓存,没有调用 fsync。SQLite 后来发现如果磁盘缓存是随机写入到磁盘,那可能存在 WAL 文件头以外的内容已经写入到磁盘但是文件头还没更新的情况,会导致数据库损坏(具体见https://sqlite.org/src/info/ff5be73dee)。所以现在的 SQLite 版本写入 WAL 文件头之后会调用 fsync 将磁盘缓存写到磁盘上,这会导致写入 WAL 文件第一个 frame 的耗时从 5ms 左右提升到 100ms,容易造成卡顿,这个曾经是 iOS 微信的数据库卡顿的头号共性问题。
为了解决这个问题,WCDB 修改 SQLite 源码,对 WAL 文件头的更新做了个优化。在 WCDB 的配置下,写入 WAL 文件的第一页有两个时机,一个是新建数据库后首次写入数据,另一个是将 WAL 文件中的内容完全 Checkpoint 完的时候。对于第一个时机,没法做优化,对于第二个时机,则可以将 WAL 文件头内容的更新操作提前到 Checkpoint 时执行。
具体逻辑是这样,Checkpoint 结束后,如果此时没有其他线程在读写 WAL 文件,则加锁防止其他线程写 WAL 文件,sync 重写 WAL 文件的文件头,再释放锁。在写入 WAL 文件的第一个 frame,如果发现 WAL 文件没创建或者文件头没有重写时,才尝试 sync 重写文件头。因为 Checkpoint 都是子线程执行的,而且读写 WAL 文件的时机不是很多,所以这个优化可以把绝大部分 WAL 文件头的更新操作放到子线程执行,避免造成 UI 卡顿。优化上线之后,iOS 微信的卡顿次数降低了5%~10%。
wal 文件头更新优化:
至此,关于新版本WCDB的主要变化及更新介绍完毕。
四、开源地址
新版 WCDB 已在 Github 开源:https://github.com/Tencent/wcdb,欢迎各位Star!
五、总结
在接口层面,新版 WCDB 全面支持了 C++、Java、Kotlin、Swift 和 ObjC 这五种主要的终端开发语言,覆盖了 Android、iOS、Windows 、macOS 和 Linux 这五大终端平台。同时,我们还对 Winq 进行了重写和强化,使开发者能够在各种语言中使用原生语法编写任意 SQL。
在功能层面,新版 WCDB 推出了全新的数据备份和修复方案,大幅提升了数据修复率,同时将数据备份的性能消耗降至可忽略不计。此外,我们还重点推出了数据迁移和数据压缩这两个新功能,让开发者仅通过简单的配置,就能高效处理复杂业务中的数据过度聚集和数据过度膨胀这两大难题。新版 WCDB 还推出了 FTS5 优化和可中断事务等新特性,使开发者在特定场景下可以实现更极致的性能优化。
本文转载自【微信客户端技术团队】微信公众号
原文地址:五年沉淀,微信全平台终端数据库WCDB迎来重大升级!