移动客户端中高效使用 SQLite
转发自 2016-08-18赵丰腾讯Bugly
导语
iOS 程序能从网络获取数据。少量的 KV 类型数据可以直接写文件保存在 Disk 上,App 内部通过读写接口获取数据。稍微复杂一点的数据类型,也可以将数据格式化成 JSON 或 XML 方便保存,这些通用类型的增删查改方法也很容易获取和使用。这些解决方案在数据量在数百这一量级有着不错的表现,但对于大数据应用的支持则在稳定性、性能、可扩展性方面都有所欠缺。在更大一个量级上,移动客户端需要用到更专业的桌面数据库 SQLite。
这篇文章主要从 SQLite 数据库的使用入手,介绍如何合理、高效、便捷的将这个桌面数据库和 App 全面结合。避免 App 开发过程中可能遇到的坑,也提供一些在开发过程中通过大量实践和数据对比后总结出的一些参数设置。整篇文章将以一个个具体的技术点作为讲解单元,从 SQLite 数据库生命周期起始讲解到其终结。希望无论是从微观还是从宏观都能给工程师以帮助。
一、SQLite 初始化
在写提纲的时候发现,原来 SQLite 初始化竟然是技术点一点也不少。
1. 设置合理的page_size和cache_size
PRAGMA schema.page_size = bytes;
PRAGMA schema.cache_size = pages;
网上有很多的文章提到了,在内存允许的情况下增加 page_size 和 cache_size 能够获得更快的查询速度。但过大的 page_size 也会造成 B-Tree 查询退化到二分查找、CPU 占用增加以及 OS 级 cache 命中率的下降的问题。
通过反复比较测试不同组合的 page_size、cache_size、table_size、存储的数据类型以及各种可能的增删查改比例,我们发现后三者都是引起 page_size 和 cache_size 性能波动的因素。也就是说对于不同的数据库并不存在普遍适用的 page_size 和 cache_size 能一劳永逸的帮我们解决问题。
并且在对比测试中我们发现 page_size 的选取往往会出现一个拐点。拐点以前随着 page_size 增加各种性能指标都会持续改善。但一旦过了拐点,性能将没有明显的改变,各个指标将围绕拐点时的数据值小范围波动。
那么如何选取合适的 page_size 和 cache_size 呢?
上一点我们已经提到了可能影响到 page_size 和 cache_size 最优值选取的三个因素:
table_size
存储的数据类型
增删查改比例
我们简单的分析一下看看为什么这三个变量会共同作用于 page_size 和 cache_size。
SQLite 数据库把其所存储的数据以 page 为最小单位进行存储。cache_size 的含义为当进行查询操作时,用多少个 page 来缓存查询结果,加快后续查询相同索引时方便从缓存中寻找结果的速度。
了解了两者的含义,我们可以发现。SQLite 存储等长的 int int64 BOOL 等数据时,page 可以优化对齐地址存储更多的数据。而在存储变长的 varchar blob 等数据时,一则 page 因为数据变长的影响无法提前计算存储地址,二则变长的数据往往会造成 page 空洞,空间利用率也有下降。
下表是设置不同的 page_size 和 cache_size 时,数据库操作中最耗时的增查改三种操作分别与不同数据类型,表列数不同的表之间共同作用的一组测试数据。
其中各列数据含义如下,时间单位为毫秒
从上表我们看到,放大 page_size 和 cache_size 并不能不断的获得性能的提升,在拐点以后提升带来的优化不明显甚至是副作用了。这一点甚至体现到了数据库大小这方面。从 G 列可以看到,page_size 的增加对于数据库查询的优化明显优于插入操作的优化。从05、06行可以发现,增加 cache_size 对于数据库性能提升并不明显。从 J 列可以看到,当插入操作的数据量比较小的时候,反而是小的 page_size 和 cache_size 更有优势。但 App DB 耗时更多的体现在大量数据增删查改时的性能,所以选取合适的、稍微大点的 page_size 是合理的。
所以通过表格分析以后,我们倾向于选择 DB 线程总耗时以及线程内部耗时最多的三个方法,作为衡量 page_size 优劣的参考标准。
page_size 有两种设置方法。一是在创建 DB 的时候进行设置。二是在初始化时设置新的 page_size 后,需要调用vacuum对数据表对应的节点重新计算分配大小。这里可参考 pragma_page_size 官方文档
https://www.sqlite.org/pragma.html#pragma_page_size
2. 通过 timer 控制数据库事务定时提交
Transaction 是任何一个数据库中最核心的功能,但其对 Server 端和客户端的意义却不尽相同。对 Server 而言,一个 Transaction 是主备容灾分片的最小单位(当然还有其他意义)。对客户端而言,一个 Transaction 能够大大的提升其内部的增删查改操作的速度。SQLite 官方文档以及工程实测的数据都显示,事务的引入能提升性能两个数量级以上。
实现方案其实非常简单。程序初始化完毕以后,启动一个事务,并创建一个 repeated 的 Timer
在 Timer 的回调函数 RenewTransaction 中,提交事务,并新启动一个事务
这样就能实现自动化的事务管理,将优化的实现黑盒化。逻辑使用方能将更多精力集中在逻辑实现方面,不用关心性能优化、数据丢失方面的问题。
从手动事务管理到自动事务管理会引发一个问题:
当两份数据必须拥有相同的生命周期,同时写入 DB、同时从 DB 删除、同时被修改时,通过时间作为提交事务的唯一标准,就有可能引发两份数据的操作进入了不同的事务。而第二个事务如果不能正确的提交,就会造成数据丢失或错误。
解决这个问题,可以利用 SQLite 的事务嵌套功能,设计一组开启事务和关闭提交事务的接口,供逻辑使用者按照其需求调用事务的开始、提交和关闭。让内层事务保证两(多)份数据的完整性。
3. 缓存被编译后的 SQL 语句
和其他很多编程语言一样,数据库使用的 SQL 语句也需要经过编译后才能被执行使用。SQL 语句的编译结果如果能够被缓存下来,第二次及以后再被使用时就能直接利用缓存结果,大大减少整个操作的执行时间。与此同理的还有 Java 数学库优化,通过把极其复杂的 Java 数学库实现翻译成 byte code,在调用处直接执行机器码,能大大优化 Java 数学库的执行速度和 C++ 持平甚至优于其。而对 SQLite 而言,一次 compile 的时间根据语句复杂程度从几毫秒到十几毫秒不等,对于批量操作性能优化是极其明显的。
其实在上面的第2点中,已经是用一个专门的类将编译结果保存下来。每次根据文件名称和行号为索引,获得对应位置的 SQL 语句编译结果。为了便于大家理解,我在注释中也将 SQLIite 内部最底层的方法写出来供大家参考和对比性能数据。
4. 数据库完整性校验
移动客户端中的数据库运行环境要远复杂于桌面平台和服务器。掉电、后台被挂起、进程被 kill、磁盘空间不足等原因都有可能造成数据库的损坏。SQLite 提供了检查数据库完整性的命令
PRAGMA integrity_check
该 SQL 语句的执行结果如果不为 OK ,则意味着数据库损坏。程序可以通过 ROLLBACK 到一个稍老的版本等方法来解决数据库损坏带来的不稳定性。
5. 数据库升级逻辑
代码管理可以用 git、svn,数据库如果要做升级逻辑相对来说会复杂很多。好在我们可以利用 SQLite,在内部用一张 meta 表专门用于记录数据库的当前版本号、最低兼容版本号等信息。用好了这张表,我们就可以对数据库是否需要升级、升级的路径进行规范。
我们代入一个简单银行客户的例子来说明如何进行数据库的升级。
a.V1 版本对数据库的要求非常简单,保存客户的账号、姓、名、出生日期、年龄、信用这6列。以及对应的增删查改,对应的SQL语句如下
并且在 meta 表中保存当前数据库的版本号为1,向前兼容的版本为1,代码如下
b.V2 版本时需要在数据库中增加客户在银行中的存款和欠款两列。首先我们需要从 meta 表中读取用户的数据库版本号。增加了两列后创建 table 和增删查改的 SQL 语句都要做出适当的修改。代码如下
很显然 V2 版本的 SQL 语句很多都和 V1 是不兼容的。V1 的数据使用 V2 的 SQL 进行操作会引发异常产生。所以在 SQLite 封装层,我们需要根据当前数据库版本分别进行处理。V1 版本的数据库需要通过 ALTER 操作增加两列后使用。记得升级完毕后要更新数据库的版本。代码如下
c.V3 版本发现出生日期与年龄两个字段有重复,冗余的数据会带来数据库体积的增加。希望 V3 数据库能够只保留出生日期字段。我们依然从 meta 读取数据库版本号信息。不过这次需要注意的是直到 SQLite 3.9.10 版本并没有删掉一列的操作。不过这并不影响新版本创建的 TABLE 会去掉这一列,而老版本的DB也可以和新的 SQL 语句一起配合工作不会引发异常。代码如下
注意 last_compatible_version 这里可以填2也可以填3,主要根据业务逻辑合理选择
d.除了数据库结构发生变化时可以用上述的方法升级。当发现老版本的逻辑引发了数据错误,也可以用类似的方法重新计算正确结果,刷新数据库。
二、如何写出高效的 SQL 语句
这个部分将以 App 开发中经常面对的场景作为样例进行对比分析。
1. 分类建索引(covering index & explain query)
或许很多开发都知道,当用某列或某些列作为查询条件时,给这些列增加索引是能大大提升查询速度的。
但真的如此的简单吗?
要回答这个问题,我们需要借助 SQLite 提供的 explain query 工具。
顾名思义,它是用来向开发人员解释在数据库内部一条查询语句是如何进行的。在 SQLite 数据库内部,一条查询语句可能的执行方式是多种多样的。它有可能会扫描整张数据表,也可能会扫描主键子表、索引子表,或者是这些方式的组合。具体的关于 SQLite 查询的方式可以参看官方文档 Query Planning
https://www.sqlite.org/queryplanner.html#searching
简单的说,SQLite 对主键会按照平衡多叉树理论对其建树,使其搜索速度降低到 Log(N)。
针对某列建立索引,就是将这列以及主键所有数据取出。以索引列为主键按照升序,原表主键为第二列,重新创建一张新的表。需要特别注意的是,针对多列建立索引的内部实现方案是,索引第一列作为主键按照升序,第一列排序完毕后索引第二列按照升序,以此类推,最后以原表主键作为最后一列。这样就能保证每一行的数据都不完全相同,这种多列建索引的方式也叫 COVERING INDEX。所以对多列进行索引,只有第一列的搜索速度理论上能到 Log(N)。
更重要的是,SQLite 这种建索引的方式确实可以带来搜索性能的提升,但对于数据库初始化的性能有着非常大的负面影响。这里先点到为止,下文会专门论述如何进行优化。这里以 SQLite 官方的一个例子来说明,在逻辑上 SQLite 是如何建立索引的。
实际上 SQLite 建立索引的方式并不是下列图看起来的聚集索引,而是采用了非聚集索引。因为非聚集索引的性能并不比聚集索引低,但空间开销却会小很多。SQLite 官方图片只是示意,请一定注意
一列行号外加三列数据 fruit state price
当我们用CREATE INDEX Idx1 ON fruitsforsale(fruit)为 fruit 列创建索引后,SQLite 在内部会创建一张新的索引表,并以 fruit 为主键。如上图所示
而当我们继续用CREATE INDEX Idx3 ON FruitsForSale(fruit, state)创建了 COVERING IDNEX 时,SQLite 在内部并不会为所有列单独创建索引表。而是以第一列作为主键,其他列升序,行号最后来创建一张表。如上图所示
我们接下来要做的就是利用 explain query 来分析不同的索引方式对于查询方式的影响,以及性能对比。
不加索引的时候,查询将会扫描整个数据表
针对 WHERE CLAUSE 中的列加了索引以后的情况。SQLite 在进行搜索的时候会先根据索引表i1找到对应的行,再根据 rowid 去原表中获取 b 列对应的数据。可能有些工程师已经发现了,这里可以优化啊,没必要找到一行数据后还要去原表找一次。刚才不是说了嘛,对多列建索引的时候,是把这些列的数据都放入一个新的表。那我们试试看。
果然,同样的搜索语句,不同的建索引的方式,SQLite 的查询方式也是不同的。这次 SQLite 选择了索引 i2 而非索引 i1,因为 a、b 列数据都在同一张表中,减少了一次根据行号去原表查询数据的操作。
看到这里不知道大家有没有产生这样的一个疑问,如果我们用 COVERING INDEX i2 的非第一列去搜索是不是并没有索引的效果?
WTF,果然,看起来我们为 b 列创建了索引 i2,但用 EXPLAIN QUERY PLAN 一分析发现 SQLite 内部依然是扫描整张数据表。这点也和上面分析的对 COVERING INDEX 建索引表的理论一致,不过情况依然没这么简单,我们看看下面三个搜索
WTF,搜索的时候用 AND 和 OR 的效果是不一样的。其实多想想 COVERING INDEX 的实现原理也就想通了。对于没有建索引的列进行搜索那不就是扫描整张数据表。所以如果 App 对于两列或以上有搜索需求时,就需要了解一个概念“前导列”。所谓前导列,就是在创建 COVERING INDEX 语句的第一列或者连续的多列。比如通过:CREATE INDEX covering_idx ON table1(a, b, c)创建索引,那么 a, ab, abc 都是前导列,而 bc,b,c 这样的就不是。在 WHERE CLAUSE 中,前导列必须使用等于或者 in 操作,最右边的列可以使用不等式,这样索引才可以完全生效。如果确实要用到等于类的操作,需要像上面最后一个例子一样为右边的、不等于类操作的列单独建索引。
很多时候,我们对于搜索结果有排序的要求。如果对于排序列没有建索引,可以想象 SQLite 内部会对结果进行一次排序。实际上如果对没有建索引,SQLite 会建一棵临时 B Tree 来进行排序。
所以我们建索引的时候别忘了对 ORDER BY 的列进行索引
讲了这么多关于 SQLite 建索引,其实也不过官方文档的万一。但是了解了 SQLite 建索引的理论和实际方案,掌握了通过 EXPLAIN QUERY PLAN 去分析自己的每一条 WHERE CLAUSE和ORDER BY。我们就可以分析出性能到底还有没有可以优化的空间。尽量减少扫描数据表的次数、尽量扫描索引表而非原始表,做好与数据库体积的平衡。让好的索引加快你程序的运行。
2. 先建原始数据表,再创建索引 - insert first then index
是的,当我第一眼看见这个结论时,我甚至觉得这是搞笑的。当我去翻阅 SQLite 官方文档时,并没有对此相关的说明文档。看着 StackOverflow 上面华丽丽的 insert first then index VS insert and index together 的对比数据,当我真的将建索引挪到了数据初始化插入后,奇迹就这样发生了。XCode Instrument 统计的十万条数据的插入CPU耗时,降低了20%(StackOverflow 那篇介绍文章做的对比测试下降还要更多达30%)。
究其原因,索引表在 SQLite 内部是以 B-Tree 的形式进行组织的,一个树节点一般对应一个 page。我们可以看到数据库要写入、读取、查询索引表其实都需要用到公共的一个操作是搜索找到对应的树节点。从外存读取索引表的一个节点到内存,再在内存判断这个节点是否有对应的 key(或者判断节点是否需要合并或分裂)。而统计研究表明,外存中获取下一个节点的耗时比内存中各项操作的耗时多好几个数量级。也就是说,对索引表的各项操作,增删查改的耗时取决于外存获取节点的时间(SQLite 用 B-Tree 而非 STL 中采用的 RB-Tree 或平衡二叉树,正是为了尽可能降低树的高度,减少外存读取次数)。一边插入原始表的数据,一边插入索引表数据,有可能造成索引表节点被频繁换到外存又从外存读取。而同一时间只进行建索引的操作,OS 缓存节点的量将增加,命中率提高以后速度自然得到了一定的提升。
SQLite 的索引采用了 B-Tree,树上的一个 Node 一般占用一个 page_size。
B-Tree 的搜索节点复杂度如上。我们可以看到公式中的 m 就是 B-Tree 的阶数也就是节点中最大可存放关键字数+1。也就是说,m 是和 page_size 成正比和复杂度成反比和树的高度成反比和读取外存次数成反比和耗时成反比。所以 page_size 越大确实可以减少 SQLite 含有查询类的操作。但无限制的增加 page_size 会使得节点内数据过多,节点内数据查询退化成线性二分查询,复杂度反而有些许上升。
所以在这里还是想强调一下,page_size 的选择没有普适标准,一定要根据性能工具的实际分析结果来确定
3. SELECT then INSERT VS INSERT OR REPLACE INTO
有过 SQLite 开发经验的工程师都知道,INSERT 插入数据时如果主键已经存在是会引发异常的。而这时往往逻辑会要求用新的数据代替数据库已存在的老数据。曾经老版本的 SQLite 只能通过先 SELECT 查询插入数据主键对应的行是否存在,不存在才能 INSERT,否则只能调用 UPDATE。而3.x版本起,SQLite 引入了 INSERT OR REPLACE INTO,用一行 SQL 语句就把原来的三行 SQL 封装替代了。
不过需要注意的是,SQLite 在实现 INSERT OR REPLACE INTO 时,实现的方案也是先查询主键对应行是否存在,如果存在则删除这一行,最后插入这行的数据。从其实现过程来看,当数据存在时原来只需要刷新这一行,现在则是删掉老的插入新的,理论速度上会变慢。这种写法仅仅是对数据库封装开发提供了便利,对性能还是有些许影响的。不过对于数据量比较少不足1000行的情况,用这种方法对性能的损耗还是细微的,且这样写确实方便了很多。但对于更多的数据,插入的时候还是推荐虽然写起来很麻烦,但是性能更好的,先 SELECT 再选择 INSERT OR UPDATE 的方法。
4. Full Text Search(FTS)
INTEGER 类的数据能够很方便的建索引,但对于 VARCHAR 类的数据,如果不建索引则只能使用 LIKE 去进行字符串匹配。如果 App 对于字符串搜索有要求,那么基本上 LIKE 是满足不了要求的。
FTS 是 SQLite 为加快字符串搜索而创建的虚拟表。FTS 不仅能通过分词大大加快英文类字符串的搜索,对于中文字符串 FTS 配合 ICU 也能对中文等其他语言进行分词、分字处理,加快这些语言的搜索速度。下面这个是 SQLite 官方文档对两者搜索速度的一个对比。
上面创建 FTS 虚拟表的方式只能对英文搜索起作用,对其他语言的支持是通过 ICU 模块支持来实现的。所以工程是需要编译创建 ICU 的静态库,编译 SQLite 时需要指定链接ICU库。
其实无论创建数据表的时候是否创建了行号(rowid)列,SQLite 都会为每个数据表创建行号列。想想上面的 fruitsforsale,当数据表没有任何列建了索引的时候,行号就是数据表的唯一索引。FTS 表略微不同的是,它的行号叫 docid,并且是可以用 SQL 语句访问的。我们一般会用字符串在原始表中的行号作为这里的 docid。
如果你仔细看搜索语句你会发现和官方文档不太一样的是,对于 MATCH 的结果我们会再用 LIKE 过滤一次。
在回答这个问题前,我们需要知道 SQLite 默认对英文是按单词(空格为分隔符)进行分词,对中文则是按照字进行拆分。当中文是按字进行拆分时,SQLite 会对关键字也按字进行拆分后进行搜索。这会带来一个 bug,当关键字是叠词时,比如“天天”,除了可以把正确的如“天天向上”搜索出来,还能把“今天天气不错,挺风和日丽的”给搜索出来。就是因为关键词“天天”也被按字拆分了。如果我们把 SQLite 内英文搜索设置成按字母拆分,一样会产生相同的问题。所以我们需要把结果再 LIKE 一次,因为在一个小范围内 LIKE 且不用加%通配符,这里的速度也是很快的。
如果希望对英文也按字母拆分,使得输入关键字 “cent”,就能匹配上 “Tencent” 也非常简单。只需要找到,SQLite 实现的 icuOpen 方法。
其实只需要改变读取 ICU 的方式,就能支持英文按字母拆分了。
4. 不固定个数的元素集合不要分表
在设计数据库时,我们会把一个对象的属性分成不同的列按行存储。如果属性是个数量不定的数组,切忌不要把这个数组属性放到一个新表里面。上面我们提到过数据操作最耗时的其实是访问外存上面的数据。当数据量很大时,多张表的外存访问是非常慢的。这里的做法是讲数组数据用 JSON 序列化后,已 VARCHAR 或者 BLOB 的形式存成一列,和其他的数据放在同一个数据表当中。
5. 用 protobuf 作为数据库的输入输出参数
先说结论,这样做是数据库 Model 跨 iOS、Android 平台的解决方案。两个平台用同一份 proto 文件分别生成各自的实现文件。需要跨平台时将数据序列化后,以传递内存的方式通过 JNI 接口将数据传递给对方平台。对方平台有相应的方式进行反序列化。JNI 封装层的工作也大大降低了。这样做还有个好处是,后台返回 protobuf 的结果,网络只需要拷贝在内存一份数据(实际上如果 UI、DB 是不同的线程,有可能会需要两份)就能让数据库进行使用,减少了不必要的内存开销。
6. 千万不要编译使用 SQLite 多线程实现
标题已经胜过千言万语了。多线程版的 SQLite 可是对每行操作加锁的,性能是比较差的,同样的操作耗时是单线程版本的2倍。
三、一些可能有用的辅助模块
1. 利用 Lambda 表达式简化从UI线程异步调用数据库接口
好的 App 架构,一定会为数据库单独安排一个线程。在多线程环境下,UI 线程发起了数据库接口请求后,一定要保证接口是异步返回数据才能保证整个 UI 操作的流畅性。但是异步接口开发最大的麻烦在于调用在 A 处,还要实现一个 B 方法来处理异步返回的结果。这里推荐使用 C++ 11的 lambda 表达式加模板函数 base::Bind 来实现像 JavaScript 语言一样,能够将异步回调方法作为输入参数传递给执行方,待执行完成操作后进行异步回调。用异步化接口编程,大大降低开发难度和实现量,并带来了流畅的界面体验。
C++ 要实现将回调函数作为输入参数传递给函数执行者,并在执行者完成预定逻辑获得返回结果时调用回调函数传递回结果,有两个难点需要克服。
如何将函数变成一个局部变量(C++11 lambda 表达式)
如何将一个函数匿名化(C++11 auto decltype 联合推导 lambda 表达式的类型)
2. 加密数据库
有些时候,出于某种考虑,我们需要加密数据库。SQLite 数据库加密对性能的损耗按照官方文档的评测大约在3%的 CPU 时间。实现加密一种方案是购买 SQLite 的加密版本,大约是3000刀。还有一种就是自己实现数据库的加密模块。网上有很多介绍如何实现 SQLite 免费版中空实现的加密方法。