作者:qiuwen,来自微信客户端团队
一、简介
WCDB 是微信团队开源的一款基于 SQLite 的终端数据库。自 2017 年 6 月开源以来,它在业界得到了广泛认可并被大量应用,迄今已经推出了十多个版本。在这个过程中,WCDB 一直保持良好的向后兼容性,不断完善原有接口的细节并添加新功能。
二、挑战
然而,作为国内乃至全球范围内使用数据库最频繁的 App,微信内部涉及上百种不同业务的数据库,存储的消息条数可达百万乃至千万级别。这种庞大的数据量和日益丰富的应用场景,给 WCDB 带来了不断更新的需求和挑战,原有的代码框架逐渐难以应对。
三、重大升级
因此,自 2019 年起,我们决定放弃接口的向后兼容性,全力打造一个更加强大的新版 WCDB。经过多次迭代,WCDB 的接口层和核心逻辑层已经得到了全面改进,同时也积累了许多新功能。如今,我们将该迎来重大升级的新版本WCDB进行开源,主要变化及更新包括:
- • 更丰富的开发语言支持:新增支持了C++,完整支持了Java和Kotlin语言的ORM,覆盖更多终端平台;
- • 更强大的SQL表达能力:对 Winq 进行了重写、强化等;
- • 更安全的数据存储能力:全新的数据备份方案、修复方案等;
- • 更灵活的数据扩展能力:数据迁移、数据压缩等;
- • 更细致的性能优化能力:FTS5 优化、可中断事务等。
变化一:更丰富的开发语言支持
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,这样就完整覆盖了现在终端开发的主流语言。
代码框架
图1:接口层代码结构
在这种代码架构下,不同语言的 WCDB 可以按需集成到同个项目中,有利于节省代码和减少包大小,还可以避免不同语言接口逻辑的冲突,甚至使用不同语言的接口来使用同一个DB都不用担心有任何逻辑冲突。
ORM 实现示例
在支持各个语言的过程中,要解决的关键问题是为每个语言分别设计 ORM(Object–relational mapping) 机制。有了 ORM,才能使用原生语言的对象来读写数据库。以 C++ 为例,下面是个简单的对象:
class Sample {
public:
int id;
std::string content;
WCDB_CPP_ORM_DECLARATION(Sample)
};
在 WCDB 中可以直接使用这个 C++对象 来读写数据库,而且还可以用原生语言来写表达式:
// INSERT INTO myTable(id, content) VALUES(1, 'text')
database.insertObjects<Sample>(Sample(1, "text"), myTable);
// SELECT id, content FROM myTable WHERE id > 0
auto objects = database.getAllObjects<Sample>(myTable, WCDB_FIELD(Sample::id) > 0);
上面的用到的WCDB_FIELD(Sample::id)
,它既可以表示表中 id
这个字段,用来组成各种条件表达式,也可以用来访问Sample
的实例中的id
这个成员变量,进而可以实现将一个C++对象序列化写到数据库,或者从数据库中反序列化读出来,就像里面包含了id
这个成员变量的Getter
和Setter
。
这里读者可能会好奇,C++ 作为静态语言,是没法在运行时获取到类的成员变量的数据类型和读写接口这些元数据的,那WCDB_FIELD(Sample::id)
又如何生效呢?这恰是 C++ ORM 设计的难点。早期比较成熟的 C++ ORM 方案是用了预编译的方法,将这些元数据通过代码生成的方式 hardcode 到代码中。
后来随着 C++ 模版类型推导能力逐渐完善之后,有些方案则是尝试将这些元数据的内容全部记录到变量的类型中,当要使用这些内容时,则使用模版推导能力从对象的类型中推导出来需要的信息,非常巧妙。但这样的弊端就是变量的类型变得十分复杂,而且这种方案都是以模版库的方式实现,很难迭代,也会带来代码膨胀问题。以比较出名的 sqlite_orm 为例,用它来创建上面示例中Sample
对应的表,DB 对象的类型就会变得非常复杂,模版膨胀问题可见一斑:
图2:sqlite_orm 示例
用类成员指针实现 C++ ORM
C++ 虽然无法在运行时获取到类的元数据,但是在编译期是很容易获取到的,那就是 C++98 之前就有的类成员指针。类成员指针并不指向一个具体的内存位置,它指向的是一个类的特定成员,它的值跟这个成员在类的内存布局中的位置相关。类成员指针既可以用来读写类的成员变量,其类型中也包含了这个成员变量的数据类型和其所在类的类型,下面是个示例:
// 指向 id 成员变量的指针 memberPointer 中包含了 Sample 和 int 两个类型
int Sample::* memberPointer = &Sample::id;
Sample obj;
// 用类成员指针 写 成员变量
obj.*memberPointer = 1;
// 用类成员指针 读 成员变量
int id = obj.*memberPointer;
类成员指针所拥有的这些信息都是 ORM 需要的,我们可以使用类成员的指针来实现 ORM。因为类成员指针的类型是非常多样的,接收类成员指针的函数就必须写成模版,不同成员指针的组合使用的场景也就更加容易带来不同的模版实例化。为了避免代码膨胀问题,我们先使用下面的这个方法将类成员指针的类型去掉:
template<typename T, typename O>
void* castMemberPointer(T O::*memberPointer) {
union {
T O::*memberp;
void* voidp;
};
memberp = memberPointer;
return voidp;
}
无类型的类成员指针的值虽然不是全局唯一的,但是在一个指定类的范围内是唯一的,它可以作为关联数据表列名和成员变量的元信息的 key:
图3:类成员指针作为key
有了这个映射关系之后,可以用类成员指针来获取到列名,进而我们就可以用类成员指针来表示数据表中的列。
接下来还需要获取成员变量的数据类型。类型只是编译期的信息,在运行时是不存在的,我们需要将类型转换成数值,才能在运行时使用。如果是要将任意类型都转换成数值,这是做不到的,C++ 的数据类型可以有无数种。实际上,存储在数据库中的数据类型只有整型、浮点型、文本、二进制和空值这五种类型,我们只需要将这五种类型对应到数值。因为类成员指针上已经有成员变量的数据类型,我们可以将这个类型提取出来,然后使用 C++ 模版的 SFINAE
机制,将支持写入数据库的类型映射到这些数值上,就完成了类型到数值的转换:
图4:数据类型转换
最后还需要生成成员变量的读写方法。因为类成员指针可以直接读写成员变量,一个直接的想法是使用类成员指针来构造读写方法,然后将读写方法的函数指针保存起来。但是这两个函数指针的类型里面是包含成员变量的数据类型的,如果保持这个类型,还是会在存储的各个环节引入模版,所以要去掉这个类型,只保存一个无类型的指针。要读写的时候,如果直接调用无类型的函数指针,虽然能跳转到正确的代码地址,但是编译器不知道出入参的类型,会导致传参出错,所以调用的时候我们还需要想办法恢复函数指针的参数类型。
图5:读写指针类型转换
恢复读写函数指针的类型需要读写对象的类型和成员变量的类型,其中对象本身在读写时肯定是要用到的,那么它的类型可以从上层调用逻辑中通过模版传递过来;但是成员变量的类型就无法传递了,也无法实时获取,我们已有的存储信息中,只有数据库的数据类型的枚举值。这个枚举值只能描述对应的成员变量的数据类型的类别,不能精准还原原来的数据类型。我们的做法是为每个类别的类型指定一个标准类型,比如整型的标准类型是long long
,浮点型的标准类型是double
,这个标准类型能够不丢失精度地存储这个类别里面所有类型的所有值。这样我们将标准类型作为Getter
和Setter
的出入参,在 Getter
和 Setter
内部实现中,再负责将标准类型的数据转换成具体的类型。
图6:标准类型转换
变化二:更强大的 SQL 表达能力
上面提到的 CRUD 操作都是用的便捷接口,可以覆盖大部分的 DB 使用场景,但是少部分复杂的DB操作还是要拼写 SQL,其实上面写的一些条件表达式其实也算是在拼写 SQL 的一部分。WCDB 1.0 提供了Winq(WCDB Integrated Query,WCDB集成查询)来方便数据库开发者拼写 SQL 语句。1.0 版本的 Winq 使用 C++ 语言抽象和实现了 SQLite 的 SQL 语法规则,使得开发者可以告别字符串拼接的胶水代码。通过和接口层的 ORM 结合,使得即便是很复杂的查询,也可以通过一行代码完成,并借助 IDE 的代码提示和编译检查的特性,大大提升了开发效率。比如一个SQLite_sequence
表的查询语句,使用 Winq 来编写可以是这样:
WCDB::StatementSelect().select({WCDB::ColumnResult(WCTSequence.seq)})
.from("sqlite_sequence")
.where(WCTSequence.seq > 1000)
.orderBy({WCTSequence.seq.order(WCTOrderedAscending)})
.limit(10)
.offset(100)
可以看到,Winq 将 SQL 语句中的Token
抽象成C++
类,将不同的 Token 的连接能力抽象成了C++类的接口,并通过链式调用的方式,让Winq拼接出来的SQL语句读起来跟实际的SQL语句接近,可读性好。但随着在微信中的应用推广,这一版的Winq还有下面几个明显的问题:
-
- 每次接口调用之后,是立即 append 对应内容到 SQL 字符串。这就要求接口的调用顺序必须严格符合 SQL 的语法顺序,而且调用之后无法再修改原内容。这样不符合链式调用的使用直觉,比较容易犯错。
-
- Winq 创建的语句没有独立保存它内部各个Token的配置状态,只保存一个 SQL 字符串。这样当内部逻辑接收到业务逻辑调用的 Winq 语句时,它面对的只是SQL 字符串,很难对 Winq 语句做一些语法分析或者修改 Winq 语句,限制了 WCDB 的功能扩展。
-
- Java、Kotlin、Swift这些不能使用 C++ 的语言上也需要使用 Winq。
-
- 不支持表达全部的 SQL 语句,一些少用的复杂语句就只能手写 SQL 字符串了。
-
- 一些接收 Token 的接口在使用的时候还不够简洁,比如
.select()
中接收 ORM 的 Property 时需要先构造WCDB::ColumnResult
,再显式转成数组传入。
- 一些接收 Token 的接口在使用的时候还不够简洁,比如
存储 SQL 中各个Token的状态
为了解决这些问题,我们完整重写了 Winq,推出了新版 Winq。新版 Winq 在分为 接口层 和 核心层 两层,这两层的对象一一对应。核心层作为基层,提供 SQL 语句中各个 Token 的状态存储,并提供将当前 Token 转成对应 SQL 字符串的能力,还可以校验当前配置的 Token 状态是否符合 SQL 的语法规则,防止输出错误的 SQL。接口层对象则是持有对应核心层的对象,提供对核心层对象的高可读性编辑接口,并且提供核心层对象所转成 SQL 字符串的缓存的统一管理逻辑,避免多次获取 SQL 字符串时重复拼接字符串。如下图所示:
图7:Winq 2.0
将 Winq 桥接到其他语言
有了上面的设计,已经可以满足 C++ 和 ObjC 两种语言的 SQL 拼写能力,但 Java、Kotlin、Swift这三种语言同样需要 Winq,是不是可以照着 C++ 的样子同样实现一份呢?答案是否定的。一方面是因为工作量大,对齐也很麻烦。另一方面,也是最重要的原因,就是如果各个语言都照着实现一遍,那拼写 Winq 形成的语句的内存结构是很难传到 WCDB 核心逻辑层的,只能传一个字符串过来,这样就让 Winq 的作用大打折扣了。本次 Winq 重写的一个重要目的是为了独立保存它内部各个 Token 的配置状态,这样就很容易对 Winq 语句做一些语法分析或者修改 Winq 语句,这些能力如何发挥作用读者在后续的章节将会看到。
为了在 WCDB 在核心逻辑层能够面对统一的 Winq 语句内存结构,也即是统一的核心层的对象,我们采用桥接的方式把 Winq 中每个 Token 对象及其接口都桥接到了 Java、Kotlin、Swift这三种语言(其实 Kotlin 直接调用了Java 的实现),这样每次拼写 Winq 语句的时候,其实都是操作 Winq 的核心层对象,这样就能在核心逻辑层产生一样的内存结构。以 StatementSelect 对象为例,整体结构如下:
图8:StatementSelect桥接示例
使用新版 Winq,上面的查询语句可以写成下面这样,传参可以得到简化,调换链式调用的执行顺序也可以输出正确的 SQL 语句,更容易使用,符合直觉:
WCDB::StatementSelect().select(WCTSequence.seq)
.from("sqlite_sequence")
.offset(100)
.limit(10)
.order(WCTSequence.seq)
.where(WCTSequence.seq > 1000)
特别是 SQL 语句根据不同的条件有不同的组装结果这种复杂场景,使用字符串拼接压力就会更大一些,需要处理好上下的衔接,而使用 Winq 就没有这些麻烦,Winq 在上层只是接口的调用,底下会自动处理好 SQL 的衔接,灵活多了,下面是 Java 业务场景中的一个示例:
图9:winq按条件组装示例
Winq 支持全部 SQL 语法
这次新版的 Winq 在各个语言完全封装了 SQLite 支持的全部 26 种 SQL 语句,以及这些语句中涉及到的全部 23 种 Token,这样开发者就可以在 WCDB 支持的五种语言中使用原生语法来拼写任意的 SQL 语句,可以完全告别拼写 SQL 字符串带来的无输入提示、容易出错等问题。而且 Winq 中的字符串参数会全部加引号处理,从而能够完全避免 SQL 注入问题,提高安全性。
图10:winq全部文件
下面是一个 Java 中使用新版 Winq 来组装复杂 SQL 的例子:
图11:winq复杂SQL示例
可以看到,即便是复杂 SQL 语句,Winq 语句的书写顺序也跟 SQL 基本一致,了解 SQL 的人,也可以无门槛读懂 Winq,而且也不会带来多少代码膨胀。
变化三:更安全的数据存储能力
前面两节让大家对如何使用 WCDB 有了个整体感受,这部分的设计目标是让大家能够更便捷得存储数据,而如何更安全地存储数据,是数据库设计更重要的目标,这一直是我们不断思考的问题,也是我们需要扩展强化 SQLite 的最初动机。因为聊天记录作为用户在微信上产生的最重要数字信息,只存储在用户的终端设备上。如果出现数据库损坏,聊天记录将会永久性丢失,这是绝大部分用户无法接受的。为了提高数据安全性,新版 WCDB 有了下面两个新设计。
1、新数据备份和修复方案
WCDB 1.0 中我们推出了一种数据库备份和修复方案,这里有详细介绍,它的整体逻辑是这样的:
SQLite 数据库是以页为单位的双层的 BTree 的结构,上层是 SQLite 的 master 表,下层是每个用户定义的表,其叶子页就是真正的数据所在的地方。当数据库损坏发生在某一中间节点时,它下面的所有支路的数据都将因为找不到而丢失。我们可以备份下层表的表名到根结点页码的映射,那么可以解决最严重的问题,即上层表损坏。当下层表损坏时,也只会丢失单个表。
WCDB 1.0 的备份和修复方案解决了当时数据库损坏后数据就全部丢失的燃眉之急,平均修复率有 70~80%。但是数据库损坏通常发生在磁盘损坏的时候,一般都是一大片数据坏了,所以经常修回来也依然是一片狼藉。所以新版 WCDB 就干脆一点,除了备份 master 表,还增加备份普通表的表名到它叶子页页号和crc校验值的映射,这样就能一步到位,修复的时候根据页号就可以直接找到普通表的数据,校验 crc 值没变,就可以确认数据没有损坏或者变更,从而可以将未损坏的数据完整恢复到新数据库。
图12:master表与用户表
这个方案有两个挑战,性能和时效性。
性能问题
旧方案只需要下层BTree的根结点页码,这个只需要遍历 master 表就可以了,master 表很小,甚至大部分时候它是在内存里的,所以很快。但是要获取每个表对应的叶子结点,几乎是需要遍历整个数据库,这个耗时是很高的。除了 IO,大部分耗时是在申请内存消耗的。这类问题是解决的比较多了,基本立刻能想到用 mmap 来解决。但是又会进一步遇到三个问题:
-
- 分段式、按需加载的 mmap。因为单个数据库文件可能会比较大,单次将它 map 到虚拟内存,可能会因为虚拟地址空间不够,导致失败。所以这里按需按内存的页大小为单位,根据每次申请的页,在其前后 map 共 1MB 的内存。这样一方面小的虚拟内存块,因为地址空间不足 map 失败的可能性会降低很多,另一方面,map 1MB 一般会远比设备的默认内存页的大很多,这样可以减少 map 的次数。
-
- LRU Cache + 引用计数。map 的虚拟内存虽然可以在内存不足的时候换页出去,但是它会挤占虚拟地址空间。64 位机的虚拟地址空间很多,但是单个进程的可用空间不多。如果 WCDB 这边消耗太多,会导致其他地方地址空间不足,申请内存失败从而 crash。所以这里加了一重 LRU,限制使用地址空间上限。同时对 mmap 的内存引用计数,由最后持有者来释放,以保证 map 内存指针可用。后续 map 如果直接命中已经 map 的内存,就不需要再次 mmap 了。
-
- 事务备份。让备份操作在 SQLite 的读事务备份中进行。在读备份进行过程中,数据库不能进行 checkpoint,写入操作只会 append 到 WAL,换句话说,数据库文件本身不会发生改变。通常 IO 操作的时候,为了避免文件上数据被修改,通常会将获取到的数据拷贝到一块新申请的内存,再进行操作。但是这里保证了数据不会被修改的,那么搭配上 LRU 和引用计数,就可以直接从 mmap 的虚拟内存里解析数据,不用申请内存、拷贝数据。
图13:MMap内存LRU Cache
这个方案实质提高的性能其实就两个,一个是 mmap 从虚拟内存读数据,一个是不需要申请内存。上面三个优化都是为了让这两者可行而做。同时这个备份数据属于读操作,可以在子线程进行,而且不影响其他读写操作。
这个备份方案上线之后,iPhone设备上 500M 的DB平均每次备份耗时是 3秒,在绝大部分场景可以接受了,但是少数用户会有达到 1G 以上的DB,这些用户的数据备份操作还是很容易引起手机发烫问题,特别是在一些直播或者是视频通话等高性能消耗的场景,如果恰好又执行数据备份,那微信的使用体验就会受到明显影响。
这个版本的备份逻辑算是一个整量备份的方式,还是需要把数据库的所有内容都读一遍,而且要给所有叶子页算 crc 校验值,IO 量和计算量还是比较高。为了解决这两个问题,我们再次对备份逻辑做了更深度的优化,引入了增量备份能力。
WCDB的数据库是开启了WAL模式的,而且使用自己设计的异步 Checkpoint 模式,每次有新内容写入数据库的时候,是先将新内容 append 到 WAL 文件的末尾,然后在异步 Checkpoint 的时候,再将 WAL 文件中的内容,在回写到主 DB。在这个过程中,每个有更新的页,都会读到内存,在这里可以用非常低的成本,获取到这些页的页号,和叶子页的 crc 校验值。先把这些信息都存到一个增量备份文件中。等到备份触发的时候,再把这些新内容更新整合到主备份文件,看起来就达成了增量备份叶子页的效果。整体流程如下:
图14:增量备份整体流程
但这里我们只能知道有更新的页号,不知道这些页是属于哪个表。因为 SQLite 的文件内容中,只会保存BTree父节点到子节点的关系,并没有保存子节点到父节点的关系,所以我们要知道那些更新的页属于哪个表,只能遍历这些表的根页和中间页来获取。不过这个遍历不用读取叶子页。而根据统计,根页和中间页只占了所有页的1%-2% ,所以 IO 量和计算量比之前大幅减少了。同时遍历的时候,优先遍历上次备份时有更新的表,只要找到所有有更新的页,就可以停止遍历了,一些不常更新的表就很少遍历到,也能在一定程度上优化性能。
这个方案上线之后,500M 的DB备份平均耗时只需 63ms,即便是大到 10G 的DB,备份耗时也不到 0.9秒,可以说已经充分满足任意场景的数据备份需求。
时效性问题
旧方案备份的下层树的根结点页码只会在建表删表时改变,而新方案的叶子结点页码在任意一次写操作都有可能变更。而且现在增量备份时,数据库的新内容要经过 Checkpoint 和备份两次异步操作,先传到增量备份文件之后才能传递到主备份文件中,而且是以增量的形式传递,是很容易出错的。这些操作都可能因为用户杀掉微信或者设备断电,而中途断掉,没有原子性保证。备份过期或备份版本上下衔接失败,可能会导致数据错乱或者修复率很低。所以这里引入了数据库的 savepoint 的概念。
Savepoint由 WAL 文件的 salt 值和 nbackfill 值的组合来表示。Salt 值保存在 WAL 文件的文件头,每次 WAL 文件完整回写到主 DB 文件之后,都会修改这个 salt 值来重置 WAL 文件的内容;而 nbackfill 则表示 WAL 文件中还没回写到主 DB 文件的内容的偏移。每次 checkpoint 之后,这两个值的组合都会更新,而且恰好具有单调性,可以作为数据库内容的版本号,保存到增量备份文件和主备份文件中。
图15:Savepoint
这样就可以在每次更新备份文件的时候,都要对齐彼此的 Savepoint 值,如果 Savepoint 对不上,就说明增量备份的过程中,有部分信息传递丢失了,这时候就执行一次全量备份来重置所有savepoint。现网实践下来,版本不对齐的发生概率只有千分之一,所以少数情况下执行的全量备份不会给整体性能带来多少影响。
新方案的性能数据
新方案在微信中上线之后,数据库损坏时的数据修复率提升到了 99% 以上,备份内容大小约为数据库大小的千分之一。这个方案可以极大得降低磁盘损坏给用户带来的数据损失。
2、防止外部逻辑写坏数据库
使用备份和修复来保护数据属于比较被动的方法,数据出错了才补救,修复率无法做到100%,还是不够安全。在磁盘损坏这种低概率发生的场景,备份和修复方法还能应对,但是在外部逻辑出现Bug导致大规模写坏数据库的场景,就难以应对了。我们需要一种更主动的方法来防止数据库被写坏,防患于未然。
外部逻辑写坏数据库的情况会有两种,一种是误用了数据库的路径或者误删了数据库,这个很难出现,要保护也是通过hook系统调用的方式来做,无法集成在WCDB内部;另一种是误用了数据库的文件句柄,这种相对常见,微信就遇到了好几次这种问题,要重点处理。
要防止文件句柄被误用时写坏数据库,一个简单的想法是尽量打开数据库文件时都是只读打开,这样外部逻辑就无法用这个句柄来更改数据库了。对于大部分数据库组件来讲,要实现这点,还是挺复杂。打开句柄时要能够判断下这个操作会不会修改数据库,只读打开之后还要遇到更改数据库的操作时,又要重新打开数据库文件句柄。
而 WCDB 的 WAL 模式是采用独立线程异步执行 checkpoint 的,在这种配置下,业务逻辑即便是要写入数据到数据库,也不需要修改到主 DB 文件,只需要修改 WAL 文件,只有到 checkpoint 时才需要修改主 DB 文件。所以 WCDB 可以在业务逻辑读写数据库时全部只读打开主 DB 文件,只有在 checkpoint 时才可写打开主DB文件。这样就能最大限度地减少主 DB 文件的可写句柄的存活时间,防止外部逻辑误写。
图16:只读打开DB文件
变化四:更灵活的数据扩展能力
随着用户数据的积累和功能的复杂化,早期数据库表设计会越来越难以满足需求,微信在迭代的过程中也遇到了很多这类问题:
- • 早期功能开发的时候为了方便,将多种没有直接关联的表格存放到同一个数据库中存储。因为 SQLite 不支持并行写入,这样也就限制了不同表格的并行更新性能,在数据积累多了和调用频繁了之后,容易造成性能瓶颈;同时,因为数据库是会损坏的,读写越频繁越容易损坏,把数据都放到一个数据库会大幅增加数据损坏和丢失的风险。
- • 很多业务的表一般都会有一两个字段用来存储 XML、Json、PB 之类的序列化数据,这些数据容易随着业务发展,新增属性越来越多,数据越来越长,数据库也越来越大,我们的业务场景里面有些xml长度都达到了 10k 以上。这不仅不会导致空间占用问题,还会影响读写性能,而且这个问题发现的时候都是在已经积累了相当量数据之后的,存量数据已经难以处理了。
- • 随着功能的扩展,不仅需要在 XML 或者 Json 字段中加内容,还可能需要对原有的数据表添加新列,如果在旧表没有添加新字段之前就对新字段进行读写,就会出现读写错误。
针对这两类场景,WCDB 给出了业界首创的解决方法,分别是数据迁移能力、数据压缩能力和自动添加新列能力。
1、数据迁移能力
iOS微信早期在业务逻辑层面做过两次数据迁移,一次是收消息操作指令数据迁移,因为数据量较小,可以阻塞式一步迁移到位之后再使用迁移后的数据;另一次是联系人数据迁移,因为数据量较大,需要采用非阻塞式迁移的方案。非阻塞式迁移过程中,数据可能处于三种状态,未迁移状态只有旧表,迁移完成后只有新表,而在迁移中则两张表都有,开发者需要对所有业务涉及的代码都做这三种状态的区分,并且在迁移中合并旧表和新表的数据。这部分代码并不难,但是冗长、而且和业务耦合严重,比较难开发和维护,更尴尬的是,很难找到一个适合的删除兼容代码的时间,兼容代码可能需要一直存在,很影响后续的迭代。
为了解决这个问题,WCDB 就提出了一个概念,由 WCDB 来解决兼容问题,让开发者可以 以迁移已经完成为假定前提 进行开发。同时因为是框架层代码,天然就是 code once, run everywhere,所以开发也不需要花费时间在灰度上。
WCDB 的数据迁移方案是这样的,当数据库操作的请求过来时,会先对其使用的数据库句柄进行迁移配置,如果是跨 db 的迁移,会把另一个 db attach 到当前句柄,以实现跨 db 的 SQL。然后检测旧表是否存在,如果不存在则说明迁移已经完成,直接执行 SQL。如果存在则创建一个 temp view,用作后续的兼容。然后 WCDB 会预处理数据库的操作请求,再进行真正的执行。这个预处理是类似于 hook 的逻辑,WCDB 会拦截开发者需要执行的 SQL,然后进行一些修改和处理,以给开发者提供一个迁移已经完成的假象。这里主要针对增删查改中的操作进行处理。
图17:迁移流程
SQL 语句预处理方法
接下来把常用的 CRUD 语句的预处理原理做个介绍,首先看 SELECT 语句。假定对于新旧两个表,oldTable 和 newTable。由于这里开发者是假定迁移已经完成的,因此他进行的操作只会是新表的查询。那么 WCDB 会预处理,将操作中的新表名替换为 unionView。这个 unionView 就是在迁移配置中创建的,它所对应的内容就是两个表合并的结果。这样开发者只查询新表,WCDB 就会将新旧表的合并后的结果返回给他。
图18:预处理SELECT
对于 UPDATE、DELETE 操作,WCDB 也让对新表的操作,同时对新旧表都生效。不过这里稍有变化。由于 SQLite 一次只能 update 或者 delete 一个表的数据,因此这里的做法是,update 新表,然后将 sql 中的表名改为旧表,再 update 一次,并通过 事务 确保这个操作的原子性
图19:预处理UPDATE
对于INSERT插入操作,则情况复杂一些了,有下面这些情况需要考虑:
-
- SQLite 有一个隐藏的字段 行号 rowid,一般而言开发者不会手动进行插入,但是它可以是自增长的,如果在新旧表中 rowid 的不同,就可能导致行为不符合迁移完成的情况。
-
- 约束,SQLite 建表的时候可以使用一些比如唯一约束、主键约束,那么插入的时候就可能发生:在新表插入成功,但是实际这个数据在旧表有相同主键之类的问题。
-
- 冗余,当数据插入到新表时,旧表可能已经存在相同的数据了。如果不删掉旧表的数据,那就会出现冗余,导致新的问题。比如 update 和 delete 的实际数量不一致,或者 select 的结果出现冗余。
为了解决上面这些问题,首先数据需要先在旧表插入一次,这里解决了约束的问题。如果因为和旧数据存在冲突,这里就会失败并且退出了。然后保存在旧表产生的 rowid,并将旧表的数据,连同 rowid 一起插入到新表。由于 rowid 是从旧表产生的,因此它总是按照旧表的方式自增。然后用 rowid 将刚才在旧表插入的数据删掉,同时也解决了数据冗余的问题。最后进行提交。同时在性能上,由于这里都是在一个 savepoint 之内进行的,提交时对于旧表的插入和删除相互抵消,最终只有新表的插入操作写入到文件中,与原来期望的一样,都是只有一次插入操作,所以性能上也几乎没有影响。
图20:预处理INSERT
使用新版 Winq 进行预处理
通过预处理 SQL,将开发者执行的 SQL 替换为了兼容新旧表的 SQL 来解决了这个问题,达到了给外层迁移完成的假象的效果。不过这个方案其实是知易行难的,这几句 SQL 并不难,最大难点是修改 SQL。SQL 是具有一定复杂度的。在上述方案中,首先是区分 SQL 属于 SELECT、INSERT、UPDATE、DELETE 的那种。对于稍有复杂度的 SQL,并不能通过字符串匹配或者正则等简单的方式来识别。一般的做法是和 SQLite 一样的逻辑,开发一个 SQL 解析器将他们解析成虚拟机的操作码,然后修改,再还原为修改后的 SQL。但是这种方式不仅复杂度很高,而且性能也不能保证。
WCDB 执行的所有 SQL 都是使用 Winq 来表达的,而新版 Winq 保存了 SQL 所有语法的结构化数据,我们很容易就可以对 Winq 语句做语法分析,精准修改其中的各个部分,以达到修改所执行的 SQL 语句的效果。所以说这个让开发者和用户都无感知的数据库迁移方案,市面上是只有 WCDB 做了,也只有 WCDB 做得到。
图21:Winq语法分析
执行真正的数据迁移
有了预处理之后,开发者可以使用新表进行开发,剩下就是执行真正数据迁移。当开发者不太关心数据迁移的节奏时,可以直接使用 WCDB 自动迁移能力,WCDB 会每隔 2 秒花 10 毫秒执行一次数据迁移,直到数据迁移完整。如果要加快迁移速度,WCDB 也提供了手动执行迁移的接口。
数据迁移期间的读写性能
图22:迁移数据库性能
性能上,基本上无感。不管是否使用迁移,性能都不会有大的差别。Insert 的时候因为同一个 Savepoint 内,插入和删除抵消,提交后只会有一个插入。而 update/delete/select 的操作,由于方案中数据不冗余的设计,因此他们在迁移前中后操作的数据量都是一致的,因此没有性能损耗。同时,在迁移完成后,数据库就退回了无迁移原来的逻辑,行为上就真正是一样了,因此也不存在删除遗留代码的问题。
更泛化的迁移能力
上面的介绍中新表和旧表的表配置一样,而且都是有 rowid 的表,其实 WCDB 的迁移能力扩展了以下更泛化的能力:
- • 支持新表和旧表的配置不一样,只要求新表的字段是旧表字段的子集,不要求两者都有相同的约束和索引(当然新旧表的约束不能在数据上有冲突),不要求有新旧表都有rowid或者都没有 rowid,不要求都有主键。
- • 支持新旧数据库的加密配置不一样,这样可以实现将未加密的数据库加密,或者把加密的数据库重加密。没有迁移能力,更改数据库的加密方式都需要重写一次数据库,是个非常重的操作。
- • 支持给迁移的表配置一个 SQL 表达式来筛选迁移部分数据,使用这个特性可以实现把一张表的数据拆分到多张表的效果,或者是清理冗余数据。
这些能力限于篇幅关系,就不展开介绍了,有兴趣的开发者可以自己尝试一下不同的可能性。
2、数据压缩能力
要解决数据库中 XML、Json、PB等序列化数据过长的问题,一个直接的方法是把这些数据都压缩一下再写入数据库。一般来讲,开发者要做数据压缩,首先是是要选择一个合适的压缩算法,然后需要在数据读写的各个环节引入加解压逻辑,要对压缩的数据做好标记,然后要想办法处理存量数据,要做极致性能优化的话还要想办法缓存加解压过程的各种内存状态。这些事情处理起来都是不小的工作量,而 WCDB 提出的数据压缩能力可以帮助开发者一步到位解决这些麻烦,只需要一个简单的无侵入配置就好。
在压缩算法方面,肯定是要选择无损压缩算法。早期的无损压缩算法主要分为哈夫曼编码和算术编码两大类。哈夫曼编码相信大家都非常熟悉,它通过将高概率出现的字符编码为更短的码点来实现压缩。这类算法的优势在于编码速度快,但只有当各个字符的出现概率都是 2 的负整数次幂时,哈夫曼编码的压缩率才能达到香农极限,其他情况下都无法达到,因此压缩率较低。
与之相对的是算术编码,它根据整个字符串出现的概率,将整个字符串转换为一个介于 0 到 1 之间的小数。由于这个小数能精确表示字符串的出现概率,因此算术编码的压缩率能够逼近香农极限。然而,由于编解码过程涉及大量乘除法,其性能相较于哈夫曼编码较差。
ANS+FSE编码是 2014 年发布的一种新算法,它将整个字符串编码成一个大于 1 的整数,这个整数与字符串的出现概率精确相关,因此这个算法的压缩率也能够逼近香农极限。同时,由于其计算过程仅涉及加法、移位和掩码计算,性能上更接近哈夫曼编码,因此它目前被认为是压缩率和性能综合最优的算法。这个算法的最佳实现便是众所周知的 Zstd。
然而,Zstd 的普通压缩模式仅能解决单个 XML 或 Json 内部的冗余度。由于不同的 XML 或 Json 具有相似的标签,不断存储这些标签也会产生很多冗余。为了解决这个问题,Zstd 的字典压缩模式可以有效消除不同数据之间的相似部分,显著提高压缩率并提升性能。因此,Zstd 字典压缩模式被认为是当前压缩序列化数据的最优解。预计在未来,也不太可能出现能明显提升压缩率的压缩算法。因此,WCDB 主要采用 Zstd 字典压缩算法来进行数据压缩。
确定了压缩算法之后,我们看下数据压缩的整体框架:
图23:数据压缩整体流程
外部逻辑写入的新数据的时候,在 WCDB 的内部会把数据压缩了之后,再写入文件;读取数据的时候,对于已经压缩的数据,WCDB 也是解压后再给到外部。同时,WCDB 也会在子线程处理存量数据,把未压缩的数据读取出来,压缩后再更新回去。这样外部只需要配置数据库的哪个表的哪个字段需要压缩,CRUD的时候,都可以假定数据都是没压缩的来操作数据,不需要关注数据压缩的实现细节和内部状态,整个加解压过程可以做到外部无感知和无侵入。这样数据压缩就可以很方便在不同业务场景和不同平台扩展。
外部逻辑 CRUD 的时候,为了隐藏数据加解压的细节,需要在WCDB的内部,对要执行的 SQL 做一些处理和转换。首先,如果一个表有字段配置了压缩字段的话,底下就会给这个表的压缩字段逐个添加一个对应的,存储压缩状态的字段,状态字段存储了是否压缩的状态,以及压缩所用的算法,然后还要预处理 SQL,把SQL 中对压缩字段的读写,转换成对压缩字段和压缩状态字段的合并读写,这样就能把加解压逻辑引入进来。
图24:数据压缩CRUD兼容方法
这里预处理的原理跟上一章数据迁移中的类似,也是INSERT、UPDATE、SELECT 和 DELETE 这些语句的预处理是都不相同的,接下来逐个介绍。
SQL语句预处理方法
首先是看INSERT语句,如果语句比较简单,它写入的未压缩数据,是很容易在 WCDB 的内部截取到的,那就将这个数据压缩了之后,再连同压缩状态值一起写入。
图25:压缩预处理INSERT1
这里WCDB_CT_content
这个字段,就是content
字段的压缩状态字段,它加了个前缀。业务实践中绝大部分插入语句都是这种简单形式,都可以按照这个方法处理,性能影响上只增加了压缩数据的消耗。当然,偶尔也有一些复杂的insert语句,需要更复杂的处理。比如这个带有冲突更新操作的 INSERT 语句,或者一些插入的值是从一个 SELECT 语句中读取出来的 INSERT 语句。这些情况很难判断它要写入的数据的具体值,也就无法直接对它进行压缩。如果逐个 case 单独处理就太复杂了,WCDB 采取一个统一的方法来处理这些复杂 INSERT 语句:
图26:压缩预处理INSERT2
先让 INSERT 语句直接执行,从而可以获取到新插入的数据的 rowid
,然后根据这个rowid
,把新插入的未压缩内容读出来,压缩了,再更新到表中。这样执行的语句虽然多了,但是因为都在一个事务内,数据都还在内存中,所以并不会增加 IO 量,对性能的影响不会太明显。
对于 UPDATE 语句,也是类似的。如果语句结构简单,更新写入的未压缩数据也是能获取到的,就跟 INSERT 语句一样,就将这个数据压缩了之后,再连同压缩状态值一起更新进去。
图27:压缩预处理Update1
绝大部分 UPDATE 语句都可以按照这种方式来处理,当然也有一些复杂情况需要另外讨论。比如下面这个 UPDATE 语句中更新的值,它是从一个 SELECT 语句中读取出来的,这样就无法简单获取到要更新的具体值,这种少见的场景,也是用统一方法来处理:
图28:压缩预处理Update2
先读取出符合 UPDATE 条件的行的 rowid
,然后用 rowid
逐行更新,再把更新的数据读出来,压缩完再写进去。这样执行语句看上去多了很多,但是因为都在一个事务内,每条更新的数据都还在内存中,所以也不会增加 IO 量,对性能的影响也是有限。
对于 SELECT 语句或者 DELETE 语句,则相对简单很多。只需要定义一个解压函数,它接收压缩字段和压缩状态字段来做解压,比如下面示例中的decompress
函数,然后再把 SELECT/DELETE 语句中用到压缩字段的地方,全部替换成解压函数,这样就能把数据解压之后再使用:
图29:压缩预处理SELECT
压缩存量数据
兼容了 CRUD 之后,WCDB 只需要把存量数据慢慢压缩完就可以了。处理存量数据的大概过程,是先整行得读出一批需要压缩的数据,对它们进行压缩,并缓存在内存中,然后把这些行全部删除之后,再逐行把压缩过的数据重新插入进去。这里之所以采用删掉再重新插入的方式,是为了触发sqlite重新排版这些行的存储位置,让存储布局更加紧致。如果是压缩后直接更新回原来的位置,那行与行之间的间隔还是会比较松散,压缩出来的空间也无法得到充分利用。但这样也就要求,整批数据必须要完整得在一个事务中处理才行,不能在中途提交,否则就会有数据丢失了。为了能够更好得重新排版存储空间,这里每批处理 100 行数据,整个事务的耗时可能会达到 100毫秒 级别,容易卡住UI线程的写入逻辑,造成 UI 卡顿。好在 WCDB 有完整的 SQLite 锁监控机制,可以很方便监控到是否有外部逻辑被当前线程的操作阻塞,这样就可以每次执行一小段操作就检测一下,如果有外部逻辑阻塞了,就可以先回滚事务,下次再重做。这样也就能够避免给外部逻辑带来性能影响。
图30:异步压缩流程
性能表现
WCDB 的数据压缩能力现在已经应用来压缩微信中的公众号消息,XML 字段的压缩率高达 89%,相当高了。
在读写性能方面,如果是顺序读写,性能大概会降低 3%~4%,主要是数据加解压带来性能消耗。因为是顺序读写,IO 量减少带来的性能增量不明显。如果是随机读写,性能就有提升了,随机写的性能有轻微提升,这个主要是随机写在 WAL 模式下,都是在 WAL 文件末尾 append,做不到真正的随机,所以性能提升还是不明显。而随机读就能做到真正随机 IO 了,所以IO量减少带来的性能增量非常明显,随机读性能提升达到 30%以上,还是很可观的。
所以说,数据压缩在读写性能方面也是一个正向的优化效果,再加上可以大幅减少数据占用空间,数据压缩是值得推广到各个业务场景的,配置简单,无侵入,而且各方面有利无害。
更多扩展压缩能力
为了应对微信中多样的需求场景,以及复杂的数据环境,我们还给数据压缩扩展很多能力:
- • 支持多种压缩方式,其中就包括 Zstd 的默认压缩方式、单字典压缩方式和多字典压缩方式,多字典压缩可以根据表中某个字段的值来采用不同的压缩字典,主要用于多种异构XML/JSON/PB等存储到同一个字段的场景。
- • 支持压缩多字段,一个正在压缩的表随时可以再添加新的压缩字段,满足扩展性的需求。
- • 支持数据压缩和数据迁移同时独立进行,开发者可以给一个正在迁移的表同时配置上数据压缩,这样数据在迁移时会压缩之后再写入新表,压缩和迁移可以各自独立开始,独立结束,互不干扰。
这些能力的实现细节因为篇幅的关系就不展开介绍了,读者如果有兴趣,可以自己尝试一下。
3、自动补全新列能力
业务逻辑在开发迭代的过程中可能会给原有的表格添加新列,SQLite 是支持给已有的表格添加新列的,WCDB 也会在调用 createTable 的时候自动添加 ORM 类中新配置的列,但是在我们实践过程中这类错误还是很常见。一个原因是可能是开发同学的疏漏,必须要在使用表格之前先主动调用添加新列的逻辑,依赖开发同学的自觉,在多人协作开发时更容易疏漏;另一个原因也可能是确实找不到合适的时机添加新列,比如很多个表对应统一个 ORM 类的场景。如果要对这些表添加一个新列,是找不到一个统一的处理时机的,因为重度用户可能有几千个这样的表,如果一起处理的话,会很耗时,容易造成卡顿;如果每次读写这些表时都判断一下是否需要添加新列,又会明显降低性能。
一个表格的所有列都是在其对应的 ORM 类中配置的。在理想的情况下,开发者在 ORM 类中配置了新列之后,就应该让这个配置可以视为立即生效,开发者无需关心添加新列的时机。为了达到这个效果,WCDB 添加了自动补全新列的能力,其核心的思想是这样,当读写数据库的时候如果报错有未识别的列,则立即检查读写的表格对应的 ORM 类是否有新配置的列跟这个未识别的列同名,如果存在的话,就将新配置的列添加到这个表格,再重试出错的逻辑。采用这种出错再检查的方式,可以将检查新列的逻辑的调用时机降低到最少,又能全面处理新列没及时添加数据库时造成的问题。
图31:自动补全新列流程
自动补全新列的能力在性能影响和解决问题完整程度上看都比较理想,但实现起来也比较有难度。主要要解决两个问题,一个是如何在执行出错时获取到这个表格对应的 ORM 类,一个是如何避免将错误的列添加到表格中。
对于第一个问题,因为要使用 ORM 类配置的列时,都是从这个类的内部信息中去获取这个列配置的列名,这样才能用列名构造一个 Column
对象用于 Winq 中组装语句,比如上文例子中用到的 WCTSequence.seq
就是调用WCTSequence
这个类的方法来获取 seq 属性配置的列名来构造一个 Column
对象。所以我们可以在使用这种途径构造Column
时,将整个 ORM 类的数据库配置信息一并传入,并保存在Column
中,这样就可以在 Winq 语句中获取到其中所用到的列所在的 ORM 类的全部配置信息。因为 ORM 信息是保存在堆上的全局量,所以这个改动实际上只是多传递和保存一个指针,并不会给 Winq 的使用带来性能影响。
实现了这些之后还不够,我们实际需要知道的是 Winq 语句中涉及到的表格对应的 ORM 信息,而不是列的。这里我们采用了舍弃部分场景的方法,只处理读写单个表格的场景,缺失的列在 Winq 语句中对应两个不同的 ORM 类也放弃处理,在一个 SQL 语句中操作多个表格或者使用多个 ORM 类的情况在实际应用中还是极少见。
对于第二个问题,主要存在下面两种情况需要解决:
-
- 防止 SQLite 误报未识别列。比如
SELECT city FROM China WHERE city MATCH '广东: 广州'
会报错no such column: 广东
,但实际并不存在这一列,只是 fts 的搜索语法误把冒号前面这部分识别为列名。这种情况可以通过提取报错信息中的列名去匹配 Winq 语句中的列名来解决。
- 防止 SQLite 误报未识别列。比如
-
- 防止开发者用错 ORM 类时把这个类配置的列都误添加进来。开发者在编写 Winq 语句时,即便是有输入提示,编写错误的情况还是无法完全避免。这种情况可以通过检测匹配的 ORM 类中配置的列必须有一半已经添加到这个表格来解决。极端情况下,即便误添加一些列,只要这些列不实际写入数据,也不会占用存储空间和影响读写性能。
变化五:更极致的性能优化能力
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,单个数据页一般可以存多条消息,逐条消息写入容易导致同一个数据页被读写多次。为了减少磁盘写入量,只能将所有的数据更新操作放到一个事务中执行,这样又会造成主线程阻塞的问题。
图32:收消息写入示例
为了解决大事务会阻塞主线程的问题,我们在 WCDB 中开发了一种可中断事务。可中断事务把一个流程很长的事务过程看成一个循环逻辑,每次循环执行一次短时间的DB操作,比如写入一条新消息。操作之后根据外部传入的参数判断当前事务是否可以结束,如果可以结束的话,就直接Commit Transaction
,将事务修改内容写入磁盘。如果事务还不可以结束,再判断主线程是否因为当前事务阻塞,没有的话就回调外部逻辑,继续执行后面的循环,直到外部逻辑处理完毕。如果检测到主线程因为当前事务阻塞,则会立即 Commit Transaction
,先将部分修改内容写入磁盘,并唤醒主线程执行DB操作。等到主线程的DB操作执行完成之后,在重新开一个新事务,让外部可以继续执行之前中断的逻辑。可中断事务的整体逻辑如下图所示:
图33:可中断事务
可中断事务让一系列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%。
图34: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 优化和可中断事务等新特性,使开发者在特定场景下可以实现更极致的性能优化。