如何作好SQLite 使用质量检测,让事故消灭在摇篮里

本文由云+社区发表算法

SQLite 在移动端开发中普遍使用,其使用质量直接影响到产品的体验。sql

常见的 SQLite 质量监控通常都是依赖上线后反馈的机制,好比耗时监控或者用户反馈。这种方式问题是:数据库

  • 过后发现,负面影响已经发生。
  • 关注的只是没这么差。eg. 监控阈值为 500ms ,那么一条可优化为 20ms 而平均耗时只有 490ms 的 sql 就被忽略了。

可否在上线前就进行SQLite使用质量的监控?因而咱们尝试开发了一个工具: SQLiteLint 。虽然名带 “lint ” ,但并非代码的静态检查,而是在 APP 运行时对 sql 语句、执行序列、表信息等进行分析检测。而和 “lint” 有点相似的是:在开发阶段就介入,并运用一些最佳实践的规则来检测,从而发现潜在的、可疑的 SQLite 使用问题。api

本文会介绍 SQLiteLint 的思路,也算是 SQLite 使用经验的分享,但愿对你们有所帮助。微信

简述

SQLiteLint 在 APP 运行时进行检测,并且大部分检测算法与数据量无关即不依赖线上的数据状态。只要你触发了某条 sql 语句的执行,SQLiteLint 就会帮助你 review 这条语句是否写得有问题。而这在开发、测试或者灰度阶段就能够进行。框架

检测流程十分简单:ide

img

\1. 收集 APP 运行时的 sql 执行信息 包括执行语句、建立的表信息等。其中表相关信息能够经过 pragma 命令获得。对于执行语句,有两种状况: a)DB 框架提供了回调接口。好比微信使用的是 WCDB ,很容易就能够经过MMDataBase.setSQLiteTrace 注册回调拿到这些信息。 b) 若使用 Android 默认的 DB 框架,SQLiteLint 提供了一种无侵入的获取到执行的sql语句及耗时等信息的方式。经过hook的技巧,向 SQLite3 C 层的 api sqlite3_profile 方法注册回调,也能拿到分析所需的信息,从而无需开发者额外的打点统计代码。工具

\2. 预处理 包括生成对应的 sql 语法树,生成不带实参的 sql ,判断是否 select* 语句等,为后面的分析作准备。预处理和后面的算法调度都在一个单独的处理线程。性能

\3. 调度具体检测算法执行 checker 就是各类检测算法,也支持扩展。而且检测算法都是以 C++ 实现,方便支持多平台。而调度的时机包括:最近未分析 sql 语句调度,抽样调度,初始化调度,每条 sql 语句调度。测试

\4. 发布问题 上报问题或者弹框提示。

能够看到重点在第 3 步,下面具体讨论下 SQLiteLint 目前所关注的质量问题检测。

检测问题简介

1、检测索引使用问题

索引的使用问题是数据库最多见的问题,也是最直接影响性能的问题。SQLiteLint 的分析主要基于 SQLite3 的 "explain query plan" ,即 sql 的查询计划。先简单说下查询计划的最多见的几个关键字:


SCAN TABLE: 全表扫描,遍历数据表查找结果集,复杂度 O(n) SEARCH TABLE: 利用索引查找,通常除了 without rowid 表或覆盖索引等,会对索引树先一次 Binary Search 找到 rowid ,而后根据获得 rowid 去数据表作一次 Binary Search 获得目标结果集,复杂度为 O(logn) USE TEMP B-TREE: 对结果集临时建树排序,额外须要空间和时间。好比有 Order By 关键字,就有可能出现这样查询计划


经过分析查询计划,SQLiteLint 目前主要检查如下几个索引问题:

1. 未建索引致使的全表扫描(对应查询计划的 SCAN TABLE... )

虽然创建索引是最基本优化技巧,但实际开发中,不少同窗由于意识不够或者需求太紧急,而疏漏了创建合适的索引,SQLiteLint 帮助提醒这种疏漏。问题虽小,解决也简单,但最广泛存在。 这里也顺带讨论下通常不适合创建索引的状况:写多读少以及表行数很小。但对于客户端而言,写多读少的表应该不常见。而表行数很小的状况,建索引是有可能致使查询更慢的(由于索引的载入须要的时间可能大过全表扫描了),可是这个差异是微乎其微的。因此这里认为通常状况下,客户端的查询仍是尽可能使用索引优化,若是肯定预估表数量很小或者写多读少,也能够将这个表加到不检测的白名单。

解决这类问题,固然是创建对应的索引。

2. 索引未生效致使的全表扫描(对应查询计划的 SCAN TABLE... )

有些状况即使创建了索引,但依然可能不生效,而这种状况有时候是能够经过优化 sql 语句去用上索引的。举个例子:

img

以上看到,即使已创建了索引,但实际没有使用索引来查询。 如对于这个 case ,能够把 like 变成不等式的比较:

img

这里看到已是使用索引来 SEARCH TABLE ,避免了全表扫描。但值得注意的是并非全部 like 的状况均可以这样优化,如 like '%lo' 或 like '%lo%' ,不等式就作不到了。

再看个位操做致使索引不生效的例子:

img

位操做是最多见的致使索引不生效的语句之一。但有些时候也是有些技巧的利用上索引的,假如这个 case 里 flag 的业务取值只有 0x1,0x2,0x4,0x8 ,那么这条语句就能够经过穷举值的方式等效:

img

以上看到,把位操做转成 in 穷举就能利用索引了。

解决这类索引未生效致使的全表扫描 的问题,须要结合实际业务好好优化sql语句,甚至使用一些比较trick的技巧。也有可能没办法优化,这时须要添加到白名单。

3. 没必要要的临时建树排序(对应查询计划的 USE TEMP B-TREE... )。

好比sql语句中 order by 、distinct 、group by 等就有可能引发对结果集临时额外建树排序,固然不少状况都是能够经过创建恰当的索引去优化的。举个例子:

img

以上看到,即使id和mark都分别创建了索引,即使只须要一行结果,依然会引发从新建树排序( USE TEMP B-TREE FOR ORDER BY )。固然这个case很是简单,不过若是对 SQLite 的索引不熟悉或者开发时松懈了,确实很容易发生这样的问题。一样这个问题也很容易优化:

img

这样就避免了从新建树排序,这对于数据量大的表查询,优化效果是立竿见影的好。

解决这类问题,通常就是创建合适的索引。

4. 不足够的索引组合

这个主要指已经创建了索引,但索引组合的列并无覆盖足够 where 子句的条件式中的列。SQLiteLint 检测出这种问题,建议先关注该 sql 语句是否有性能问题,再决定是否创建一个更长的索引。举个例子:

img

以上看到,确实是利用了索引 genderIndex 来查询,但看到where子句里还有一个 mark=60 的条件,因此还有一次遍历判断操做才能获得最终须要的结果集。尤为对于这个 case,gender 也就是性别,那么最多 3 种状况,这个时候单独的 gender 索引的优化效果的已经不明显了。而一样,优化也是很容易的:

img

解决这类问题,通常就是创建一个更大的组合索引。

5. 怎么下降误报

如今看到 SQLiteLint 主要根据查询计划的某些关键字去发现这些问题,但SQLite支持的查询语法是很是复杂的,而对应的查询计划也是无穷变化的。因此对查询计划自动且正确的分析,不是一件容易的事。SQLiteLint 很大的功夫也在这件事情上

因此对查询计划自动且正确的分析,不是一件容易的事。SQLiteLint 很大的功夫也在这件事情上。SQLiteLint 这里主要对输出的查询计划从新构建了一棵有必定的特色的分析树,并结合sql语句的语法树,依据必定的算法及规则进行分析检测。建分析树的过程会使用到每条查询计划前面如 "0|1|0" 的数字,这里不具体展开了。 举个例子:是否是全部带有 "SCAN TABLE" 前缀的查询计划,都认为是须要优化的呢?明显不是。具体看个 case :

img

这是一个联表查询,在 SQLite 的实现里通常就是嵌套循环。在这个语句中里, t3.id 列建了索引,而且在第二层循环中用上了,但第一层循环的 SCAN TABLE是没法优化的。好比尝试给t4的id列也创建索引:

img

能够看出,依然没法避免 SCAN TABLE 。对于这种 SCAN TABLE 没法优化的状况,SQLiteLint 不该该误报。前面提到,会对查询计划组织成树的结构。好比对于这个 case ,最后构建的查询计划分析树为:

img

分析树,有个主要的特色:叶子节点有兄弟节点的是联表查询,其循环顺序对应从左往右,而无兄弟节点是单表查询。而最后的分析会落地到叶子节点的分析。遍历叶子节点时,有一条规则(不完整描述)是:

叶子节点有兄弟节点的,且是最左节点即第一层循环,且 where 子句中不含有相关常量条件表达式时,SCAN TABLE 不认为是质量问题。

这里有两个条件必须同时知足,SCAN TABLE 才不报问题:第一层循环 & 无相关常量表达式。第一层循环前面已经描述,这里再解释下后面一个条件。

img

由上看到,当select子句中出现常量条件表达式 “t4.id=666” , 若 t3.id,t4.id 都建了索引,是能够优化成没有 SCAN TABLE 。

img

而把 t4.id 的索引删除后,又出现了 SCAN TABLE 。而这种 SCAN TABLE 的状况,不知足规则里的的第二个条件,SQLiteLint 就会报出可使用索引优化了。

这里介绍了一个较简单语句的查询计划的分析,固然还有更复杂的语句,还有子查询、组合等等,这里不展开讨论了。巨大的复杂性,无疑对准确率有很大的挑战,须要对分析规则不断地迭代完善。当前 SQLiteLint 的分析算法依然不足够严谨,还有很大的优化空间。 这里还有另外一个思路去应对准确性的问题:对全部上报的问题,结合耗时、是否主线程、问题等级等信息,进行优先级排序。这个“曲线救国”来下降误报的策略也适用本文介绍的全部检测问题。

2、检测冗余索引问题

SQLiteLint 会在应用启动后对全部的表检测一次是否存在冗余索引,并建议保留最大那个索引组合。

先定义什么是冗余索引:如对于某个表,若是索引组合 index1,index2 是另外一个索引组合 index3 的前缀,那么通常状况下 index3 能够替代掉 index1 和 index2 的做用,因此 index1,index2 就冗余了。而多余的索引就会有多余的插入消耗和空间消耗,通常就建议只保留索引 index3 。 看个例子:

img

以上看到,若是已经有一个 length 和 type 的组合索引,就已经知足了单 length 列条件式的查询,不必再为 length 再建一个索引。

3、检测 select * 问题

SQLiteLint这里经过扫描 sql 语法树,若发现 select * 子句,就会报问题,建议尽可能避免使用 select * ,而是按需 select 对应的列。

select * 是SQLite最经常使用的语句之一,也很是方便,为何还认为是问题的呢?这里有必要辩驳一下:

  1. 对于 select * ,SQLite 底层依然存在一步把 * 展开成表的所有列。
  2. select * 也减小了可使用覆盖索引的机会。覆盖索引指索引包含的列已经覆盖了 select 所须要的列,而使用上覆盖索引就能够减小一次数据表的查询。
  3. 对于 Android 平台而言,select * 就会投射全部的列,那么每行结果占据的内存就会相对更大,那么 CursorWindow(缓冲区)的容纳条数就变少,那么 SQLiteQuery.fillWindow 的次数就可能变多,这也有必定的性能影响。

基于以上缘由,出于 SQLiteLint 目标最佳实践的原则,这里依然报问题。

4、检测 Autoincrement 问题

SQLiteLint 在应用启动后会检测一次全部表的建立语句,发现 AUTOINCREMENT 关键字,就会报问题,建议避免使用 Autoincrement 。

这里看下为何要检测这个问题,下面引用 SQLite 的官方文档:

The AUTOINCREMENT keyword imposes extra CPU, memory, disk space, and disk I/O overhead and should be avoided if not strictly needed. It is usually not needed.

能够看出 Auto Increment 确实不是个好东西。 ps. 我这里补充说明一下 strictly needed 是什么是意思,也就是为何它没必要要。一般 AUTOINCREMENT 用于修饰 INTEGER PRIMARY KEY 列,后简称IPK 列。而 IPK 列等同于 rowid 别名,自己也具备自增属性,但会复用删除的 rowid 号。好比当前有 4 行,最大的rowid是 4,这时把第 4 行删掉,再插入一行,新插入行的 rowid 取值是比当前最大的 rowid 加 1,也就 3+1=4 ,因此复用了 rowid 号 4 。而若是加以 AUTOINCREMENT 修饰就是阻止了复用,在这个状况,rowid 号是 5 。也就是说,AUTOINCREMENT 能够保证了历史自增的惟一性,但对于客户端应用有多少这样的场景呢?

5、检测建议使用 prepared statement

SQLiteLint 会以抽样的时机去检测这个问题,好比每 50 条执行语句,分析一次执行序列,若是发现连续执行次数超过必定阈值的相同的(固然实参能够不一样)而未使用 prepared statement 的 sql 语句,就报问题,建议使用 prepared statement 优化。 如阈值是 3 ,那么连续执行下面的语句,就会报问题:

img

使用 prepared statement 优化的好处有两个:

  1. 对于相同(实参不一样)的 sql 语句屡次执行,会有性能提高
  2. 若是参数是不可信或不可控输入,还防止了注入问题

6、检测建议使用 without rowid 特性

SQLiteLint 会在应用启动后检测一次全部表的建立语句,发现未使用 without rowid 技巧且根据表信息判断适合使用 without rowid 优化的表,就报问题,建议使用 without rowid 优化。 这是 SQLiteLint 的另外一个思路,就是发现是否能够应用上一些 SQLite 的高级特性。

without rowid 在某些状况下能够同时带来空间以及时间上将近一半的优化。简单说下原理,如:

img

对于这个含有 rowid 的表( rowid 是自动生成的),这时这里涉及到两次查询,一次在 name 的索引树上找到对应的 rowid ,一次是用这个 rowid 在数据树上查询到 mark 列。 而使用 without rowid 来建表:

img

数据树构建是以 name 为 key ,mark 为 data 的,而且是以普通 B-tree 的方式存储。这样对于刚刚一样的查询,就须要只有一次数据树的查询就获得了 mark 列,因此算法复杂度上已经省了一个 O(logn)。另外又少维护了一个 name 的索引树,插入消耗和空间上也有了节省。

固然 withou rowid 不是到处适用的,否则确定是默认属性了。SQLiteLint 判断若是同时知足如下两个条件,就建议使用 without rowid :

  1. 表含有 non-integer or composite (multi-column) PRIMARY KEY
  2. 表每行数据大小不大,一个比较好的标准是行数据大小小于二十分之一的page size 。ps.默认 page size SQLite 版本3.12.0之后(对应 Android O 以上)是 4096 bytes ,之前是 1024 。而因为行数据大小业务相关,为了下降误报,SQLiteLint 使用更严格的断定标准:表不含有 BLOB 列且不含有非 PRIMARY KEY TEXT 列。

简单说下缘由: 对于1,假如没有 PRIMARY KEY ,没法使用 without rowid 特性;假若有 INTEGER PRIMARY KEY ,前面也说过,这时也已经等同于 rowid 。 对于 2,小于 20 分之一 pagesize 是官方给出的建议。 这里说下我理解的缘由。page 是 SQLite 通常的读写单位(实际上磁盘的读写 block 更关键,而磁盘的消耗更多在定位上,更多的page就有可能须要更多的定位)。without rowid 的表是以普通 B-Tree 存储的,而这时数据也存储在全部树结点上,那么假如数据比较大,一个 page 存储的结点变少,那么查找的过程就须要读更多的 page ,从而查找的消耗更大。固然这是相对 rowid 表 B*-Tree 的存储来讲的,由于这时数据都在叶子结点,搜索路径上的结点只有 KEY ,那么一个page能存的结点就多了不少,查找磁盘消耗变小。这里注意的是,不要以纯内存的算法复杂度去考量这个问题。以上是推论不必定正确,欢迎指教。

引伸一下,这也就是为何 SQLite 的索引树以 B-Tree 组织,而 rowid 表树以 B*-Tree 组织,由于索引树每一个结点的存主要是索引列和 rowid ,每每没这么大,相对 B*-Tree 优点就在于不用一直查找到叶子结点就能结束查找。与 without rowid 一样的限制,不建议用大 String 做为索引列,这固然也能够加入到 SQLiteLint 的检测。

小结

这里介绍了一个在开发、测试或者灰度阶段进行 SQLite 使用质量检测的工具,这个思路的好处是:

  • 上线前发现问题
  • 关注最佳实践

本文的较大篇幅实际上是对 SQLite 最佳实践的讨论,由于 SQLiteLint 的思路就是对最佳实践的自动化检测。固然检查能够覆盖更广的范围,准确性也是挑战,这里还有很大的空间。

此文已由做者受权腾讯云+社区发布

相关文章
相关标签/搜索