【腾讯Bugly干货分享】移动客户端中高效使用SQLite

本文来自于腾讯bugly开发者社区,非经做者赞成,请勿转载,原文地址:http://dev.qq.com/topic/57b57...html

做者:赵丰git

导语

iOS 程序能从网络获取数据。少许的 KV 类型数据能够直接写文件保存在 Disk 上,App 内部经过读写接口获取数据。稍微复杂一点的数据类型,也能够将数据格式化成 JSON 或 XML 方便保存,这些通用类型的增删查改方法也很容易获取和使用。这些解决方案在数据量在数百这一量级有着不错的表现,但对于大数据应用的支持则在稳定性、性能、可扩展性方面都有所欠缺。在更大一个量级上,移动客户端须要用到更专业的桌面数据库 SQLite。sql

这篇文章主要从 SQLite 数据库的使用入手,介绍如何合理、高效、便捷的将这个桌面数据库和 App 全面结合。避免 App 开发过程当中可能遇到的坑,也提供一些在开发过程当中经过大量实践和数据对比后总结出的一些参数设置。整篇文章将以一个个具体的技术点做为讲解单元,从 SQLite 数据库生命周期起始讲解到其终结。但愿不管是从微观仍是从宏观都能给工程师以帮助。数据库

1、SQLite 初始化

在写提纲的时候发现,原来 SQLite 初始化居然是技术点一点也很多。编程

1. 设置合理的 page_sizecache_size

网上有不少的文章提到了,在内存容许的状况下增长 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 最优值选取的三个因素:网络

  1. table_size

  2. 存储的数据类型

  3. 增删查改比例

咱们简单的分析一下看看为何这三个变量会共同做用于 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 的增长对于数据库查询的优化明显优于插入操做的优化。从0五、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...

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. 除了数据库结构发生变化时能够用上述的方法升级。当发现老版本的逻辑引起了数据错误,也能够用相似的方法从新计算正确结果,刷新数据库。

2、如何写出高效的 SQL 语句

这个部分将以 App 开发中常常面对的场景做为样例进行对比分析。

1. 分类建索引(covering index & explain query)

或许不少开发都知道,当用某列或某些列做为查询条件时,给这些列增长索引是能大大提高查询速度的。

但真的如此的简单吗?

要回答这个问题,咱们须要借助 SQLite 提供的 explain query 工具。

顾名思义,它是用来向开发人员解释在数据库内部一条查询语句是如何进行的。在 SQLite 数据库内部,一条查询语句可能的执行方式是多种多样的。它有可能会扫描整张数据表,也可能会扫描主键子表、索引子表,或者是这些方式的组合。具体的关于 SQLite 查询的方式能够参看官方文档 Query Planning

https://www.sqlite.org/queryp...

简单的说,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倍。

3、一些可能有用的辅助模块

1. 利用 Lambda 表达式简化从 UI 线程异步调用数据库接口

好的 App 架构,必定会为数据库单独安排一个线程。在多线程环境下,UI 线程发起了数据库接口请求后,必定要保证接口是异步返回数据才能保证整个UI操做的流畅性。可是异步接口开发最大的麻烦在于调用在A处,还要实现一个 B 方法来处理异步返回的结果。这里推荐使用 C++11的 lambda 表达式加模板函数 base::Bind 来实现像 JavaScript 语言同样,可以将异步回调方法做为输入参数传递给执行方,待执行完成操做后进行异步回调。用异步化接口编程,大大下降开发难度和实现量,并带来了流畅的界面体验。
C++要实现将回调函数做为输入参数传递给函数执行者,并在执行者完成预约逻辑得到返回结果时调用回调函数传递回结果,有两个难点须要克服。

  1. 如何将函数变成一个局部变量(C++11 lambda 表达式)

  2. 如何将一个函数匿名化(C++11 auto decltype 联合推导 lambda 表达式的类型)

2. 加密数据库

有些时候,出于某种考虑,咱们须要加密数据库。SQLite 数据库加密对性能的损耗按照官方文档的评测大约在3%的 CPU 时间。实现加密一种方案是购买 SQLite 的加密版本,大约是3000刀。还有一种就是本身实现数据库的加密模块。网上有不少介绍如何实现 SQLite 免费版中空实现的加密方法。

最后,但愿本文能对你们有所帮助。

相关文章
相关标签/搜索