背景
sqlite作为客户端常用的数据库,已经成为移动端开发不可避免的一项技能,但是在使用的过程中却常常会出现性能的问题。介于99u之前遇到了一些坑与sqlite的索引有关,借此机会深挖一下sqlite中索引的相关问题与坑,希望对后续的开发能有借鉴意义。
关于索引的数据结构
索引是为了快速搜索数据而在原始数据之外,维护的满足特定查找算法的数据结构。简单点说索引是一种数据结构,他指向了原始数据。例如我们的数据库有col1和col2两列,如果我们想要快速查找col2满足某个条件的数据,在没有索引的情况下我们只能遍历整个表。如果为col2建立了索引,那么就可以通过索引快速找到满足条件的col2那行数据所对应的位置。
索引的实现
二叉树
二叉树是一种经典的数据结构,但是并不适合数据库索引,原因是二叉树每一个节点的度只有2,树的深度较高,在存储时,一般一个节点比较需要一次io,那么树的深度较高,查询一个数据所需要的io次数也就也高,查找时间也就越长。
- 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值;
- 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
- 它的左、右子树也分别为二叉排序树。
B树
根据上面的二叉树,如果要提高查询速度,那么就要降低树的深度。要降低树的深度,很自然的方法就是采用多叉树,再结合平衡二叉树的思想,我们可以构建一个平衡多叉树结构,然后就可以在上面构建平衡多路查找算法,提高大数据量下的搜索效率。其中一种典型的就是B树。
B树最大的特点就是每一个节点的度可以大于2.这样在设计数据库的时候如果将一个节点的大小设置为一个页(计算机管理存储器的逻辑块),那么每个节点只需要一次io就可以完全载入。
- 每条数据表示为[key, data]
- 每个非叶子节点有(n-1)条数据和n个指针组成
- 所有的叶节点具有相同的深度,等于树高
- 指针指向节点的key大于左边的记录小于右边的记录
B+树是对B树的扩展,特点是非叶子节点不存储data,只存储key。如果每一个集诶单的大小固定(如sqlite中是4k),那么可以进一步提高内部节点的度,降低树的深度
具体的数据结构可参考文末的参考文献1.
Sqlite中数据存储方式
- 表(table)和索引(Index)都是带顺序访问指针的B+树
- table对应的B+树中,key是rowid,data是这一行其他列数据(sqlite为每一行分配了一个rowid)
- index对应的B+树中,key是需要索引的列,data是rowid
- 对某一行或某几行添加PRIMARY KEY或UNIQUE约束,那么数据库会自动为这些列创建索引
- 指定某一列为INTEGER PRIMARY KEY,那么这一列和rowid被指定为同一列。即可以通过rowid来获取,也可以通过列名来获取
测试一、table本身通过的B+树,即rowid
CREATE TABLE Test (
Column1 integer NOT NULL DEFAULT(''),
Column2 integer NOT NULL
);
通过sqlite3_analyzer工具查看数据库的硬盘存储信息如下
*** Table TEST ****************************************************************
Percentage of total database...................... 0.0%
Number of entries................................. 20
Bytes of storage consumed......................... 4096
Bytes of payload.................................. 360 8.8%
Bytes of metadata................................. 88 2.1%
B-tree depth...................................... 1
Average payload per entry......................... 18.00
Average unused bytes per entry.................... 182.40
Average metadata per entry........................ 4.40
Maximum payload per entry......................... 19
Entries that use overflow......................... 0 0.0%
Primary pages used................................ 1
Overflow pages used............................... 0
Total pages used.................................. 1
Unused bytes on primary pages..................... 3648 89.1%
Unused bytes on overflow pages.................... 0
Unused bytes on all pages......................... 3648 89.1%
官方解释:
Most tables in a typical SQLite database schema are rowid tables.
Rowid tables are distinguished by the fact that they all have a unique, non-NULL, signed 64-bit integer rowid that is used as the access key for the data in the underlying B-tree storage engine.
测试二、INTEGER PRIMARY KEY列和rowid被指定为同一列
CREATE TABLE Test (
Column1 integer PRIMARY KEY,
Column2 integer NOT NULL
);
通过sqlite3_analyzer工具查看数据库的硬盘存储信息如下,依然只有一个B树
*** Table TEST ****************************************************************
Percentage of total database...................... 0.0%
Number of entries................................. 0
Bytes of storage consumed......................... 4096
Bytes of payload.................................. 0 0.0%
Bytes of metadata................................. 8 0.20%
B-tree depth...................................... 1
Average payload per entry......................... 0.0
Average unused bytes per entry.................... 0.0
Average metadata per entry........................ 0.0
Maximum payload per entry......................... 0
Entries that use overflow......................... 0
Primary pages used................................ 1
Overflow pages used............................... 0
Total pages used.................................. 1
Unused bytes on primary pages..................... 4088 99.80%
Unused bytes on overflow pages.................... 0
Unused bytes on all pages......................... 4088 99.80%
如果不是integer类型的主键
CREATE TABLE Test (
Column1 varchar PRIMARY KEY NOT NULL,
Column2 integer NOT NULL
);
那么就会有两个B树
*** Table TEST w/o any indices ************************************************
Percentage of total database...................... 0.0%
Number of entries................................. 0
Bytes of storage consumed......................... 4096
Bytes of payload.................................. 0 0.0%
Bytes of metadata................................. 8 0.20%
B-tree depth...................................... 1
Average payload per entry......................... 0.0
Average unused bytes per entry.................... 0.0
Average metadata per entry........................ 0.0
Maximum payload per entry......................... 0
Entries that use overflow......................... 0
Primary pages used................................ 1
Overflow pages used............................... 0
Total pages used.................................. 1
Unused bytes on primary pages..................... 4088 99.80%
Unused bytes on overflow pages.................... 0
Unused bytes on all pages......................... 4088 99.80%
*** Index SQLITE_AUTOINDEX_TEST_1 of table TEST *******************************
Percentage of total database...................... 0.0%
Number of entries................................. 0
Bytes of storage consumed......................... 4096
Bytes of payload.................................. 0 0.0%
Bytes of metadata................................. 8 0.20%
B-tree depth...................................... 1
Average payload per entry......................... 0.0
Average unused bytes per entry.................... 0.0
Average metadata per entry........................ 0.0
Maximum payload per entry......................... 0
Entries that use overflow......................... 0
Primary pages used................................ 1
Overflow pages used............................... 0
Total pages used.................................. 1
Unused bytes on primary pages..................... 4088 99.80%
Unused bytes on overflow pages.................... 0
Unused bytes on all pages......................... 4088 99.80%
官方解释:
The PRIMARY KEY of a rowid table (if there is one) is usually not the true primary key for the table, in the sense that it is not the unique key used by the underlying B-tree storage engine. The exception to this rule is when the rowid table declares an INTEGER PRIMARY KEY. In the exception, the INTEGER PRIMARY KEY becomes an alias for the rowid.
当满足上面情况的时候,如下语句其实是等价的
select * from Test where rowid = 1;
select * from Test where Column1 = 1;
测试三、UNIQUE约束其实也是索引
CREATE TABLE Test (
Column1 integer NOT NULL UNIQUE,
Column2 integer NOT NULL
);
可以看到此时的磁盘存储也是有两个B树
*** Table TEST w/o any indices ************************************************
Percentage of total database...................... 0.0%
Number of entries................................. 0
Bytes of storage consumed......................... 4096
Bytes of payload.................................. 0 0.0%
Bytes of metadata................................. 8 0.20%
B-tree depth...................................... 1
Average payload per entry......................... 0.0
Average unused bytes per entry.................... 0.0
Average metadata per entry........................ 0.0
Maximum payload per entry......................... 0
Entries that use overflow......................... 0
Primary pages used................................ 1
Overflow pages used............................... 0
Total pages used.................................. 1
Unused bytes on primary pages..................... 4088 99.80%
Unused bytes on overflow pages.................... 0
Unused bytes on all pages......................... 4088 99.80%
*** Index SQLITE_AUTOINDEX_TEST_1 of table TEST *******************************
Percentage of total database...................... 0.0%
Number of entries................................. 0
Bytes of storage consumed......................... 4096
Bytes of payload.................................. 0 0.0%
Bytes of metadata................................. 8 0.20%
B-tree depth...................................... 1
Average payload per entry......................... 0.0
Average unused bytes per entry.................... 0.0
Average metadata per entry........................ 0.0
Maximum payload per entry......................... 0
Entries that use overflow......................... 0
Primary pages used................................ 1
Overflow pages used............................... 0
Total pages used.................................. 1
Unused bytes on primary pages..................... 4088 99.80%
Unused bytes on overflow pages.................... 0
Unused bytes on all pages......................... 4088 99.80%
关于索引的注意事项
在sqlite中有一个命令叫做explain query plan,可以查看sqlite是如何执行查找操作的。不用索引时,使用的是“SCAN”这个词,即全表扫描。使用索引时,使用的是“SEARCH”这个词。
索引生效规则
很多时候创建了索引,却发现没有生效。对于WHERE子句中出现的列要想索引生效,会有一些限制,这就和前导列有关。所谓前导列,就是在创建复合索引语句的第一列或者连续的多列。比如通过:CREATE INDEX comp_ind ON table1(x, y, z)创建索引,那么x,xy,xyz都是前导列,而yz,y,z这样的就不是。
- 在WHERE子句中,前导列必须使用等于或者IN或IS操作,最右边的列可以使用不等式,这样索引才可以完全生效。
- 不需要组合索引中的所有列都出现在WHERE子句中,但是必须保证使用的索引列之间没有间隙
- 如果一个索引列出现在了不等式的右边,那边他也不会被使用
CREATE TABLE Test (
a integer UNIQUE NOT NULL,
b integer NOT NULL,
c char(128),
d char(128),
e char(128),
f char(128),
g char(128),
h char(128)
);
CREATE INDEX NewIndex0 ON Test (a, b, c, d);
explain query plan select * from Test WHERE a=5 AND b IN (1,2,3) AND c IS NULL AND d='hello'
此时索引生效
SEARCH TABLE Test USING INDEX idx1 (a=? AND b=? AND c=? AND d=?)
索引部分失效
explain query plan select * from Test WHERE a=5 AND b IN (1,2,3) AND c>12 AND d='hello'
此时因为列c使用了不等号,因此右侧的索引列d失效
SEARCH TABLE Test USING INDEX idx1 (a=? AND b=? AND c>?)
或者如下
explain query plan select * from Test WHERE a=5 AND b IN (1,2,3) AND d='hello'
因为中间跳过了列c,所以此时只有a和b生效
0,0,0,SEARCH TABLE Test USING INDEX idx1 (a=? AND b=?)
索引完全失效
explain query plan select * from Test WHERE b IN (1,2,3) AND c NOT NULL AND d='hello'
因此直接从b开始索引,因此组合索引无法生效
SCAN TABLE Test
BETWEEN语句的优化
CREATE TABLE Test (
a integer NOT NULL,
b integer NOT NULL,
c char(128),
d char(128)
);
CREATE INDEX idx2 ON Test (d);
CREATE INDEX idx ON Test (a);
between语句正常来讲是不会使用索引的,但是系统会自动优化为> and <从句来执行
explain query plan select * from Test WHERE a between 0 and 10
0,0,0,SEARCH TABLE Test USING INDEX idx (a>? AND a<?)
LIKE语句与写法有关
直接写不会使用索引
explain query plan select * from Test WHERE d like 'hell%'
0,0,0,SCAN TABLE Test
但是可以转换下用比较的写法
explain query plan select * from Test WHERE d > 'hell' and d < 'hellp'
0,0,0,SEARCH TABLE Test USING INDEX idx2 (d>? AND d<?)
or语句的优化
or语句需要分两种情况
如果每个or语句都是同一个索引列的话,那么该or语句会被转换为in表达式
column = expr1 OR column = expr2 OR column = expr3 OR ...
column IN (expr1,expr2,expr3,...)
如
explain query plan select * from Test WHERE a = 1 or a = 2 or a = 3;
explain query plan select * from Test WHERE a in (1,2,3)
0,0,0,SEARCH TABLE Test USING INDEX idx (a=?)
如果or从句中的每一项都不一样,那么sqlite会针对每一个子项分别判断是否使用索引,然后再对各项的结果做一个union集合。但是or优化并不是一定的,sqlite的查询计划生成器会综合估算cpu和ios的耗时等综合因素,如果or选项过多,或者某些or选项的区分度并不是特别大,那么sqlite也可能使用不同的查询算法,或者直接扫描表。
思考(待续)
对于枚举类型的字段,使用索引好还是不使用索引好呢。
以message表(80万条数据)中的isRead为例,在isRead有索引的情况下,在mac上执行select * from Message where isRead = 1时间为
5.870 5.656 5.928
删除索引后的执行时间
5.378 5.475 5.509
从粗略的测试结果来看,使用扫描全表似乎要比使用索引快些