多应用+插件架构,代码干净,二开方便,首家独创一键云编译技术,文档视频完善,免费商用码云13.8K 广告
[TOC] ## 进程与线程并发 如果我们在项目中有使用 SQLite,那么下面这个SQLiteDatabaseLockedException就是经常会出现的一个问题。 ``` android.database.sqlite.SQLiteDatabaseLockedException: database is locked at android.database.sqlite.SQLiteDatabase.dbopen at android.database.sqlite.SQLiteDatabase.openDatabase at android.database.sqlite.SQLiteDatabase.openDatabaseSQLiteDatabaseLockedException ``` 归根到底是因为并发导致,而 SQLite 的并发有两个维度,一个是多进程并发,一个是多线程并发。下面我们分别来讲一下它们的关键点。 ### 多进程并发 多进程并发SQLite 默认是**支持多进程并发操作**的,它通过文件锁来控制多进程的并发。SQLite 锁的粒度并没有非常细,它针对的是整个 DB 文件。 简单来说,多进程可以同时获取 SHARED 锁来读取数据,但是只有一个进程可以获取 EXCLUSIVE 锁来写数据库。 ### 多线程并发 SQLite 支持多线程并发模式,需要开启下面的配置,当然系统 SQLite 会默认开启多线程Multi-thread 模式。 ``` PRAGMA SQLITE\_THREADSAFE = 2 ``` 跟多进程的锁机制一样,为了实现简单,SQLite 锁的粒度都是数据库文件级别,并没有实现表级甚至行级的锁。还有需要说明的是,同一个句柄同一时间只有一个线程在操作,这个时候我们需要打开连接池 Connection Pool。 跟多进程类似,多线程可以同时读取数据库数据,但是写数据库依然是互斥的。SQLite 提供了 Busy Retry 的方案,即发生阻塞时会触发 Busy Handler,此时可以让线程休眠一段时间后,重新尝试操作,你可以参考《微信 iOS SQLite 源码优化实践》这篇文章。为了进一步提高并发性能,我们还可以打开WAL(Write-Ahead Logging)模式。WAL 模式会将修改的数据单独写到一个 WAL 文件中,同时也会引入了 WAL 日志文件锁。通过 WAL 模式读和写可以完全地并发执行,不会互相阻塞。 为了进一步提高并发性能,我们还可以打开WAL(Write-Ahead Logging)模式。WAL 模式会将修改的数据单独写到一个 WAL 文件中,同时也会引入了 WAL 日志文件锁。通过 WAL 模式读和写可以完全地并发执行,不会互相阻塞。 ``` PRAGMA schema.journal_mode = WAL ``` 但是需要注意的是,写之间是仍然不能并发。如果出现多个写并发的情况,依然有可能会出现 SQLiteDatabaseLockedException。这个时候我们可以让应用中捕获这个异常,然后等待一段时间再重试。 ``` } catch (SQLiteDatabaseLockedException e) { if (sqliteLockedExceptionTimes < (tryTimes - 1)) { try { Thread.sleep(100); } catch (InterruptedException e1) { } } sqliteLockedExceptionTimes++; } ``` 总的来说通过连接池与 WAL 模式,我们可以很大程度上增加 SQLite 的读写并发,大大减少由于并发导致的等待耗时,建议大家在应用中可以尝试开启。 ## 组件优化 ### mmap接口 使用语句“PRAGMA mmap\_size=268435456;”开启内存映射。 在读取和写入过程中,每次都将用户空间的数据和内核空间的数据拷贝一次,能否直接将文件读取到用户空间?SQLite3提供了mmap方式的IO。 理论上mmap方式能减少内核和用户空间的IO,但在iOS系统中,这个从我这里测试效果看,影响并不大。 ### synchronous 关闭文件强制同步 总体数据占用量大,但是可重建恢复的数据库,可以关闭synchronous以提高性能。 * PRAGMA synchronous = FULL; (2)  * PRAGMA synchronous = NORMAL; (1)  * PRAGMA synchronous = OFF; (0) #### 参数含义 当synchronous设置为FULL (2), SQLite数据库引擎在紧急时刻会暂停以确定数据已经写入磁盘。这使系统崩溃或电源出问题时能确保数据库在重起后不会损坏。FULL synchronous很安全但很慢。 当synchronous设置为NORMAL, SQLite数据库引擎在大部分紧急时刻会暂停,但不像FULL模式下那么频繁。 NORMAL模式下有很小的几率(但不是不存在)发生电源故障导致数据库损坏的情况。但实际上,在这种情况 下很可能你的硬盘已经不能使用,或者发生了其他的不可恢复的硬件错误。 设置为synchronous OFF (0)时,SQLite在传递数据给系统以后直接继续而不暂停。若运行SQLite的应用程序崩溃, 数据不会损伤,但在系统崩溃或写入数据时意外断电的情况下数据库可能会损坏。另一方面,在synchronous OFF时 一些操作可能会快50倍甚至更多。在SQLite 2中,缺省值为NORMAL.而在3中修改为FULL。 ### WAL模式 WAL模式会在共享内存中根据数据顺序建立索引,每个读操作都会记录一下最新的数据更改索引,读操作只会读取此索引之前的数据,而写操作可以继续在WAL中追加数据,并发性能有一定提升。 ##### 缺点 当每个事务数据量比较大时,接近或超过1000页的数据量时,会导致WAL内容频繁同步至实际数据库文件,导致性能下降。 #### 测试总结 * 异步IO似乎并不能提高多少性能,官方已经deprecate它了,推荐使用WAL模式。 * 大量小记录写入(不合并为事务)时,一般模式即使关闭文件sync,还没有WAL全sync模式快。 * 操作频繁,单条记录数据量小的,建议使用WAL模式。 * 操作少,单条记录数据量大,建议使用一般数据库,不要使用WAL模式。 ### 页大小与缓存大小 在 I/O 文件系统中,我讲过数据库就像一个小文件系统一样,事实上它内部也有页和缓存的概念。 对于 SQLite 的 DB 文件来说,页(page)是最小的存储单位,如下图所示每个表对应的数据在整个 DB 文件中都是通过一个一个的页存储,属于同一个表不同的页以 B 树(B-tree)的方式组织索引,每一个表都是一棵 B 树。 ![](https://img.kancloud.cn/27/89/278931243cdf9ffba2fac2750040d961_567x384.png) 跟文件系统的页缓存(Page Cache)一样,SQLite 会将读过的页缓存起来,用来加快下一次读取速度。页大小默认是 1024Byte,缓存大小默认是 1000 页。 ``` PRAGMA page_size = 1024 PRAGMA cache_size = 1000 ``` 每个页永远只存放一个表或者一组索引的数据,即不可能同一个页存放多个表或索引的数据,表在整个 DB 文件的第一个页就是这棵 B 树的根页。继续以上图为例,如果想查询 rowID 为 N+2 的数据,我们首先要从 sqlite\_master 查找出 table 的 root page 的位置,然后读取 root page、page4 这两个页,所以一共会需要 3 次 I/O。 ![](https://img.kancloud.cn/3d/ea/3deaa8fe2b9523666da18ad4e065d512_696x468.png) 从上表可以看到,增大 page size 并不能不断地提升性能,在拐点以后可能还会有副作用。我们可以通过 PRAGMA 改变默认 page size 的大小,也可以再创建 DB 文件的时候进行设置。但是需要注意如果存在老的数据,需要调用 vacuum 对数据表对应的节点重新计算分配大小。 在微信的内部测试中,如果使用 4KB 的 page size 性能提升可以在 5%~10%。但是考虑到历史数据的迁移成本,最终还是使用 1024Byte。所以这里建议大家在新建数据库的时候,就提前选择 4KB 作为默认的 page size 以获得更好的性能。 ### Cursor 实现优化 wcdb方案 Android 框架查询数据库使用的是 Cursor 接口,调用 SQLiteDatabase.query(...) 会返回一个Cursor 对象,之后就可以使用 Cursor 遍历结果集了。Android SDK SQLite Cursor 的实现是分配一个固定 2MB 大小的缓冲区,称作 Cursor Window,用于存放查询结果集。 查询时,先分配Cursor Window,然后执行 SQL 获取结果集填充之,直到 Cursor Window 放满或者遍历完结果集,之后将 Cursor 返回给调用者。 假如 Cursor 遍历到缓冲区以外的行,Cursor 会丢弃之前缓冲区的所有内容,重新查询,跳过前面的行,重新选定一个开始位置填充 Cursor Window 直到缓冲区再次填满或遍历完结果集。 这样的实现能保证大部分情况正常工作,在很多情况下却不是最优实现。微信对 DB 操作最多的场景是获取 Cursor 直接遍历获取数据后关闭,获取到的数据,一般是生成对应的实体对象(通过 ORM 或者自行从 Cursor 转换)后放到 List 或 Map 等容器里返回,或用于显示,或用于其他逻辑。 在这种场景下,先将数据保存到 Cursor Window 后再取出,中间要经历两次内存拷贝和转换(SQLite → CursorWindow → Java),这是完全没有必要的。另外,由于 Cursor Window 是定长的,对于较小的结果集,需要无故分配 2MB 内存,对于大结果集,如果 2MB 不足以放下,遍历到途中还会引发 Cursor 重查询,这个消耗就相当大了。 Cursor Window,其实也是在 JNI 层通过 SQLite 库的 Statement 填充的,Statement 这里可以理解为一个轻量但只能往前遍历,没有缓存的 Cursor。这个不就跟我们的场景一致吗?何不直接使用底层的 Statement 呢?我们对 Statement 做了简单的封装,暴露了 Cursor 接口, SQLiteDirectCursor 就诞生了,它直接操作底层 SQLite 获取数据,只能执行往前迭代的操作,但这完全满足需要。 ## 读写优化 ### 索引优化 正确使用索引在大部分的场景可以大大降低查询速度,微信的数据库优化也是通过索引开始。下面是索引使用非常简单的一个例子,我们先从索引表找到数据对应的 rowid,然后再从原数据表直接通过 rowid 查询结果。 ![](https://img.kancloud.cn/04/4f/044f30f296c61e7baa398fbbf11e4332_672x141.png) 建立索引是有代价的,需要一直维护索引表的更新。比如对于一个很小的表来说就没必要建索引;如果一个表经常是执行插入更新操作,那么也需要节制的建立索引。总的来说有几个原则: * 建立正确的索引。这里不仅需要确保索引在查询中真正生效,我们还希望可以选择最高效的索引。如果一个表建立太多的索引,那么在查询的时候 SQLite 可能不会选择最好的来执行。 * 单列索引、多列索引与复合索引的选择。索引要综合数据表中不同的查询与排序语句一起考虑,如果查询结果集过大,还是希望可以通过复合索引直接在索引表返回查询结果。 * 索引字段的选择。整型类型索引效率会远高于字符串索引,而对于主键 SQLite 会默认帮我们建立索引,所以主键尽量不要用复杂字段。 **总的来说索引优化是 SQLite 优化中最简单同时也是最有效的,但是它并不是简单的建一个索引就可以了,有的时候我们需要进一步调整查询语句甚至是表的结构,这样才能达到最好的效果。** ### 其他优化 * 慎用“select\*”,需要使用多少列,就选取多少列。 * 正确地使用事务。 * 预编译与参数绑定,缓存被编译后的 SQL 语句。对于 blob 或超大的 Text 列,可能会超出一个页的大小,导致出现超大页。建议将这些列单独拆表,或者放到表字段的后面。 * 定期整理或者清理无用或可删除的数据,例如朋友圈数据库会删除比较久远的数据,如果用户访问到这部分数据,重新从网络拉取即可。 * 分库 ## 检测 ### 检测索引使用问题 索引的使用问题是数据库最常见的问题,也是最直接影响性能的问题。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 语句去用上索引的。举个例子: ![](https://img.kancloud.cn/18/8a/188a147725c7bcd91ce0911b1ec4980a_1080x268.png) 以上看到,即便已建立了索引,但实际没有使用索引来查询。 如对于这个 case ,可以把 like 变成不等式的比较: ![](https://img.kancloud.cn/1e/c7/1ec7510621f5f8c140d6a97ced222d30_1080x72.png) 这里看到已经是使用索引来 SEARCH TABLE ,避免了全表扫描。但值得注意的是并不是所有 like 的情况都可以这样优化,如 like '%lo' 或 like '%lo%' ,不等式就做不到了。 再看个位操作导致索引不生效的例子: ![](https://img.kancloud.cn/08/cc/08cca8fc6db477b26152b7d9cd215b0b_1080x219.png) 位操作是最常见的导致索引不生效的语句之一。但有些时候也是有些技巧的利用上索引的,假如这个 case 里 flag 的业务取值只有 0x1,0x2,0x4,0x8 ,那么这条语句就可以通过穷举值的方式等效: ![](https://img.kancloud.cn/a2/5d/a25d86b181b69fa5dd6089c0410f05e2_1080x100.png) 以上看到,把位操作转成 in 穷举就能利用索引了。 解决这类索引未生效导致的全表扫描 的问题,需要结合实际业务好好优化sql语句,甚至使用一些比较trick的技巧。也有可能没办法优化,这时需要添加到白名单。 #### 3\. 不必要的临时建树排序(对应查询计划的 USE TEMP B-TREE... )。 比如sql语句中 order by 、distinct 、group by 等就有可能引起对结果集临时额外建树排序,当然很多情况都是可以通过建立恰当的索引去优化的。举个例子: ![](https://img.kancloud.cn/af/3a/af3ab8444237280bd4d85d262ecd9833_1080x238.png) 以上看到,即便id和mark都分别建立了索引,即便只需要一行结果,依然会引起重新建树排序( USE TEMP B-TREE FOR ORDER BY )。当然这个case非常简单,不过如果对 SQLite 的索引不熟悉或者开发时松懈了,确实很容易发生这样的问题。同样这个问题也很容易优化: ![](https://img.kancloud.cn/d2/45/d245ef8eec30f0358548cecd0ecff900_1080x94.png) 这样就避免了重新建树排序,这对于数据量大的表查询,优化效果是立竿见影的好。 解决这类问题,一般就是建立合适的索引。 #### 4\. 不足够的索引组合 这个主要指已经建立了索引,但索引组合的列并没有覆盖足够 where 子句的条件式中的列。SQLiteLint 检测出这种问题,建议先关注该 sql 语句是否有性能问题,再决定是否建立一个更长的索引。举个例子: ![](https://img.kancloud.cn/02/a8/02a8d1208a21f119af4760ba412bfff5_1080x269.png) 以上看到,确实是利用了索引 genderIndex 来查询,但看到where子句里还有一个 mark=60 的条件,所以还有一次遍历判断操作才能得到最终需要的结果集。尤其对于这个 case,gender 也就是性别,那么最多 3种情况,这个时候单独的 gender 索引的优化效果的已经不明显了。而同样,优化也是很容易的: ![](https://img.kancloud.cn/42/02/42024f0268f81fb609100e89cf815082_1080x112.png) 解决这类问题,一般就是建立一个更大的组合索引。 #### 5\. 怎么降低误报 现在看到 SQLiteLint 主要根据查询计划的某些关键字去发现这些问题,但SQLite支持的查询语法是非常复杂的,而对应的查询计划也是无穷变化的。所以对查询计划自动且正确的分析,不是一件容易的事。SQLiteLint 很大的功夫也在这件事情上 所以对查询计划自动且正确的分析,不是一件容易的事。SQLiteLint 很大的功夫也在这件事情上。SQLiteLint 这里主要对输出的查询计划重新构建了一棵有一定的特点的分析树,并结合sql语句的语法树,依据一定的算法及规则进行分析检测。建分析树的过程会使用到每条查询计划前面如 "0|1|0" 的数字,这里不具体展开了。 举个例子:是不是所有带有 "SCAN TABLE" 前缀的查询计划,都认为是需要优化的呢?明显不是。具体看个 case : ![](https://img.kancloud.cn/84/0e/840e7bb47b9816f883b234b4b9e54ed7_1080x182.png) 这是一个联表查询,在 SQLite 的实现里一般就是嵌套循环。在这个语句中里, t3.id 列建了索引,并且在第二层循环中用上了,但第一层循环的 SCAN TABLE是无法优化的。比如尝试给t4的id列也建立索引: ![](https://img.kancloud.cn/95/09/95095a4a815b45254eb4b0b3fd18990e_1080x161.png) 可以看出,依然无法避免 SCAN TABLE 。对于这种 SCAN TABLE 无法优化的情况,SQLiteLint 不应该误报。前面提到,会对查询计划组织成树的结构。比如对于这个 case ,最后构建的查询计划分析树为: ![](https://img.kancloud.cn/c2/02/c20298ed96e6e395da1275a53a61b7e9_540x305.png) 分析树,有个主要的特点:叶子节点有兄弟节点的是联表查询,其循环顺序对应从左往右,而无兄弟节点是单表查询。而最后的分析会落地到叶子节点的分析。遍历叶子节点时,有一条规则(不完整描述)是: *叶子节点有兄弟节点的,且是最左节点即第一层循环,且 where 子句中不含有相关常量条件表达式时,SCAN TABLE 不认为是质量问题。* 这里有两个条件必须同时满足,SCAN TABLE 才不报问题:第一层循环 & 无相关常量表达式。第一层循环前面已经描述,这里再解释下后面一个条件。 ![](https://img.kancloud.cn/95/09/95095a4a815b45254eb4b0b3fd18990e_1080x161.png) 由上看到,当select子句中出现常量条件表达式 “t4.id=666” , 若 t3.id,t4.id 都建了索引,是可以优化成没有 SCAN TABLE 。 ![](https://img.kancloud.cn/ce/f4/cef4b433af27ef810e11465c24dd5087_1080x122.png) 而把 t4.id 的索引删除后,又出现了 SCAN TABLE 。而这种 SCAN TABLE 的情况,不满足规则里的的第二个条件,SQLiteLint 就会报出可以使用索引优化了。 这里介绍了一个较简单语句的查询计划的分析,当然还有更复杂的语句,还有子查询、组合等等,这里不展开讨论了。巨大的复杂性,无疑对准确率有很大的挑战,需要对分析规则不断地迭代完善。当前 SQLiteLint 的分析算法依然不足够严谨,还有很大的优化空间。 这里还有另一个思路去应对准确性的问题:对所有上报的问题,结合耗时、是否主线程、问题等级等信息,进行优先级排序。这个“曲线救国”来降低误报的策略也适用本文介绍的所有检测问题。 ### 检测冗余索引问题 SQLiteLint 会在应用启动后对所有的表检测一次是否存在冗余索引,并建议保留最大那个索引组合。 先定义什么是冗余索引:如对于某个表,如果索引组合 index1,index2 是另一个索引组合 index3 的前缀,那么一般情况下 index3 可以替代掉 index1 和 index2 的作用,所以 index1,index2 就冗余了。而多余的索引就会有多余的插入消耗和空间消耗,一般就建议只保留索引 index3 。 看个例子: ![](https://img.kancloud.cn/c1/7b/c17b8fea4457e04baaa817521118d7bc_990x276.png) 以上看到,如果已经有一个 length 和 type 的组合索引,就已经满足了单 length 列条件式的查询,没必要再为 length 再建一个索引。 ### 检测 select \* 问题 SQLiteLint这里通过扫描 sql 语法树,若发现 select \* 子句,就会报问题,建议尽量避免使用 select \* ,而是按需 select 对应的列。 select \* 是SQLite最常用的语句之一,也非常方便,为什么还认为是问题的呢?这里有必要辩驳一下: 1. 对于 select \* ,SQLite 底层依然存在一步把 \* 展开成表的全部列。 2. select \* 也减少了可以使用覆盖索引的机会。覆盖索引指索引包含的列已经覆盖了 select 所需要的列,而使用上覆盖索引就可以减少一次数据表的查询。 3. 对于 Android 平台而言,select \* 就会投射所有的列,那么每行结果占据的内存就会相对更大,那么 CursorWindow(缓冲区)的容纳条数就变少,那么 SQLiteQuery.fillWindow 的次数就可能变多,这也有一定的性能影响。 基于以上原因,出于 SQLiteLint 目标最佳实践的原则,这里依然报问题。 ### 检测 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 可以保证了历史自增的唯一性,但对于客户端应用有多少这样的场景呢? ### 检测建议使用 prepared statement SQLiteLint 会以抽样的时机去检测这个问题,比如每 50 条执行语句,分析一次执行序列,如果发现连续执行次数超过一定阈值的相同的(当然实参可以不同)而未使用 prepared statement 的 sql 语句,就报问题,建议使用 prepared statement 优化。 如阈值是 3 ,那么连续执行下面的语句,就会报问题: ![](https://img.kancloud.cn/e9/ec/e9ecee6d65720177dab33565029b6dfa_722x132.png) 使用 prepared statement 优化的好处有两个: 1. 对于相同(实参不同)的 sql 语句多次执行,会有性能提升 2. 如果参数是不可信或不可控输入,还防止了注入问题 ### 检测建议使用 without rowid 特性 SQLiteLint 会在应用启动后检测一次所有表的创建语句,发现未使用 without rowid 技巧且根据表信息判断适合使用 without rowid 优化的表,就报问题,建议使用 without rowid 优化。 这是 SQLiteLint 的另一个思路,就是发现是否可以应用上一些 SQLite 的高级特性。 without rowid 在某些情况下可以同时带来空间以及时间上将近一半的优化。简单说下原理,如: ![](https://img.kancloud.cn/0c/73/0c737497395e59087450b33572f3d709_1020x104.png) 对于这个含有 rowid 的表( rowid 是自动生成的),这时这里涉及到两次查询,一次在 name 的索引树上找到对应的 rowid ,一次是用这个 rowid 在数据树上查询到 mark 列。 而使用 without rowid 来建表: ![](https://img.kancloud.cn/79/ba/79ba428580fe7f426b6bfa9f5c2b2f01_1080x57.png) 数据树构建是以 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 的检测。 ## 异常修复 ### 官方的Dump恢复方案 Google 一下SQLite DB恢复,不难搜到使用`.dump`命令恢复DB的方法。`.dump`命令的作用是将 整个数据库的内容输出为很多 SQL 语句,只要对空 DB 执行这些语句就能得到一个一样的 DB。 `.dump`命令原理很简单:每个SQLite DB都有一个`sqlite_master`表,里面保存着全部table 和index的信息(table本身的信息,不包括里面的数据哦),遍历它就可以得到所有表的名称和`CREATE TABLE ...`的SQL语句,输出`CREATE TABLE`语句,接着使用`SELECT * FROM ...`通过表名遍历整个表,每读出一行就输出一个`INSERT`语句,遍历完后就把整个DB dump出来了。 这样的操作,和普通查表是一样的,遇到损坏一样会返回`SQLITE_CORRUPT`,我们忽略掉损坏错误, 继续遍历下个表,最终可以把所有没损坏的表以及**损坏了的表的前半部分**读取出来。将dump 出来的SQL语句逐行执行,最终可以得到一个等效的新DB。由于直接跑在SQLite上层,所以天然 就支持加密SQLCipher,不需要额外处理。 ![](https://img.kancloud.cn/8d/2b/8d2b2982ba19e5f431c25f7ef40aa60c_640x276.png) 这个方案不需要任何准备,只有坏DB的用户要花好几分钟跑恢复,大部分用户是不感知的。 数据量大小,主要影响恢复需要的临时空间:先要保存dump 出来的SQL的空间,这个 大概一倍DB大小,还要另外一倍 DB大小来新建 DB恢复。至于我们最关心的成功率呢?上线后,**成功率约为30%**。这个成功率的定义是至少恢复了一条记录,也就是说一大半用户 一条都恢复不成功! 研究一下就发现,恢复失败的用户,原因都是`sqlite_master`表读不出来,特别是第一页损坏, 会导致后续所有内容无法读出,那就完全不能恢复了。恢复率这么低的尴尬状况维持了好久, 其他方案才渐渐露出水面。 ### 备份恢复方案 定期备份`sqlite_master`表 ### 删除wal文件 ## 全文搜索 ## 参考资料 [微信iOS SQLite源码优化实践](https://mp.weixin.qq.com/s/8FjDqPtXWWqOInsiV79Chg) [Matrix SQLiteLint -- SQLite 使用质量检测](https://mp.weixin.qq.com/s/laUgOmAcMiZIOfM2sWrQgw) [微信移动端数据库组件WCDB系列(二) — 数据库修复三板斧](https://mp.weixin.qq.com/s/Ln7kNOn3zx589ACmn5ESQA) [WCDB 的 WAL 模式和异步 Checkpoint](https://cloud.tencent.com/developer/article/1031030) [Sqlite3并发读写注意事项](https://blog.csdn.net/hqfok/article/details/75020563) [SQLite 数据库 WAL 工作模式原理简介](https://www.jianshu.com/p/b47986e7e734)