文章目录
- 1. 引言
- 2. WHERE子句分析
- 2.1. 索引项使用示例
- 3. BETWEEN优化
- 4. OR优化
- 4.1. 将OR连接的约束转换为IN运算符
- 4.2. 分别评估OR约束并取结果的并集
- 5. LIKE优化
- 6. 跳跃扫描优化
- 7. 连接
- 7.1. 手动控制连接顺序
- 7.1.1. 使用 SQLITE_STAT 表手动控制查询计划
1. 引言
给定一个SQL语句,可能有数十种、数百种,甚至数千种方法来实现该语句,这取决于语句本身的复杂性和底层数据库模式的复杂性。查询计划的任务是选择最小化磁盘I/O和CPU开销的算法。
2. WHERE子句分析
在分析之前,进行以下转换,将所有的连接约束转移到WHERE子句中:
- 所有的NATURAL连接被转换为带有USING子句的连接。
- 所有的USING子句(包括上一步创建的)被转换为等效的ON子句。
- 所有的ON子句(包括上一步创建的)作为新的连接词(AND连接的项)添加到WHERE子句中。
SQLite不区分出现在WHERE子句中的连接约束和内连接的ON子句中的约束,因为这种区别不会影响结果。然而,外连接的ON子句约束和WHERE子句约束是有区别的。因此,当SQLite将一个外连接的ON子句约束移动到WHERE子句时,它会在抽象语法树(AST)中添加特殊的标签,以指示约束来自外连接,并且来自哪个外连接。在纯SQL文本中无法添加这些标签。因此,SQL输入必须在外连接上使用ON子句。但在内部AST中,所有的约束都是WHERE子句的一部分,因为将所有的内容放在一个地方可以简化处理。
在所有的约束都转移到WHERE子句之后,WHERE子句被分解为连接词(以下称为"项")。换句话说,WHERE子句被分解为由AND运算符分隔的片段。如果WHERE子句由OR运算符(析取式)分隔的约束组成,则整个子句被视为一个"项",对其应用OR子句优化。
分析WHERE子句的所有项,看看它们是否可以使用索引来满足。要被索引使用,项通常必须是以下形式之一:
column = expressioncolumn IS expressioncolumn > expressioncolumn >= expressioncolumn < expressioncolumn <= expressionexpression = columnexpression IS columnexpression > columnexpression >= columnexpression < columnexpression <= columncolumn IN (expression-list)column IN (subquery)column IS NULLcolumn LIKE patterncolumn GLOB pattern
如果使用像这样的语句创建索引:
CREATE INDEX idx_ex1 ON ex1(a,b,c,d,e,...,y,z);
那么如果索引的初始列(a、b等列)出现在WHERE子句项中,那么可能会使用该索引。索引的初始列必须使用=或IN或IS操作符。使用的最右边的列可以使用不等式。对于使用的索引的最右边的列,可以有多达两个不等式,这些不等式必须将列的允许值夹在两个极端之间。
并非索引的每一列都必须出现在WHERE子句项中,才能使用该索引。然而,使用的索引列之间不能有间隙。因此,对于上面的示例索引,如果没有WHERE子句项约束列c,那么约束列a和b的项可以使用索引,但不能使用约束列d到z的项。同样,索引列通常不会被使用(用于索引目的),如果它们在仅受不等式约束的列的右边。(请参阅下面的跳过扫描优化以获取例外。)
在表达式上的索引的情况下,无论上述文本中何时使用“列”这个词,都可以替换为“索引表达式”(意思是出现在CREATE INDEX语句中的表达式的副本),一切都会按照相同的方式运行。
2.1. 索引项使用示例
对于上面的索引和类似这样的WHERE子句:
... WHERE a=5 AND b IN (1,2,3) AND c IS NULL AND d='hello'
索引的前四列a、b、c和d将是可用的,因为这四列形成了索引的前缀,并且都被等式约束。
对于上面的索引和类似这样的WHERE子句:
... WHERE a=5 AND b IN (1,2,3) AND c>12 AND d='hello'
只有索引的a、b和c列将是可用的。d列将不可用,因为它出现在c的右边,而c只受不等式约束。
对于上面的索引和类似这样的WHERE子句:
... WHERE a=5 AND b IN (1,2,3) AND d='hello'
只有索引的a和b列将是可用的。d列将不可用,因为列c没有受到约束,索引可用的列集合中不能有空隙。
对于上面的索引和类似这样的WHERE子句:
... WHERE b IN (1,2,3) AND c NOT NULL AND d='hello'
索引完全不可用,因为索引的最左边的列(列"a")没有受到约束。假设没有其他索引,上述查询将导致全表扫描。
对于上面的索引和类似这样的WHERE子句:
... WHERE a=5 OR b IN (1,2,3) OR c NOT NULL OR d='hello'
索引不可用,因为WHERE子句的项由OR而不是AND连接。这个查询将导致全表扫描。然而,如果添加了三个额外的索引,这些索引包含了列b、c和d作为它们的最左边的列,那么可能会应用OR子句优化。
3. BETWEEN优化
如果WHERE子句的一个项是以下形式:
expr1 BETWEEN expr2 AND expr3
那么将添加两个"虚拟"项,如下所示:
expr1 >= expr2 AND expr1 <= expr3
虚拟项仅用于分析,不会生成任何字节码。如果两个虚拟项最终都被用作索引的约束,那么原始的BETWEEN项将被省略,对输入行不进行相应的测试。因此,如果BETWEEN项最终被用作索引约束,那么对该项的测试永远不会被执行。另一方面,虚拟项本身永远不会导致对输入行的测试。因此,如果BETWEEN项没有被用作索引约束,而必须用来测试输入行,那么expr1表达式只会被评估一次。
4. OR优化
由OR而不是AND连接的WHERE子句约束可以用两种不同的方式处理。
4.1. 将OR连接的约束转换为IN运算符
如果一个项由多个包含公共列名的子项组成,并由OR分隔,如下所示:
column = expr1 OR column = expr2 OR column = expr3 OR ...
那么该项将被重写为以下形式:
column IN (expr1,expr2,expr3,...)
然后重写的项可能会按照IN运算符的正常规则约束索引。请注意,列必须在每个OR连接的子项中都是相同的列,尽管列可以出现在=运算符的左边或右边。
4.2. 分别评估OR约束并取结果的并集
如果且仅当先前描述的将OR转换为IN运算符的方法不起作用时,将尝试第二个OR子句优化。假设OR子句由多个子项组成,如下所示:
expr1 OR expr2 OR expr3
单个子项可以是单个比较表达式,如a=5或x>y,也可以是LIKE或BETWEEN表达式,或者子项可以是带括号的由AND连接的子子项列表。每个子项都被分析,就像它本身是整个WHERE子句一样,以查看该子项是否可以单独进行索引。如果OR子句的每个子项都可以单独进行索引,那么可能会对OR子句进行编码,以便对OR子句的每个项使用单独的索引。可以这样想象SQLite如何为每个OR子句项使用单独的索引:想象一下,WHERE子句被重写为以下形式:
rowid IN (SELECT rowid FROM table WHERE expr1UNION SELECT rowid FROM table WHERE expr2UNION SELECT rowid FROM table WHERE expr3)
上面的重写表达式是概念性的;包含OR的WHERE子句实际上并没有以这种方式重写。实际上,OR子句的实现使用了一种更高效的机制,即使对于WITHOUT ROWID表或"rowid"不可访问的表也可以工作。然而,通过上述语句可以捕捉到实现的本质:对于每个OR子句项,使用单独的索引查找候选结果行,最终结果是这些行的并集。
请注意,在大多数情况下,SQLite只会为查询的FROM子句中的每个表使用一个索引。这里描述的第二个OR子句优化是该规则的例外。对于OR子句,每个子项可能使用不同的索引。
对于任何给定的查询,这里描述的OR子句优化可以使用的事实并不保证它会被使用。SQLite使用基于成本的查询计划器,估计各种竞争查询计划的CPU和磁盘I/O成本,并选择它认为最快的计划。如果WHERE子句中有许多OR项,或者如果某些OR子句子项上的索引不是很有选择性,那么SQLite可能会决定使用不同的查询算法,甚至全表扫描。应用程序开发人员可以在语句前面使用EXPLAIN QUERY PLAN前缀,以获取所选查询策略的高级概述。
5. LIKE优化
使用LIKE或GLOB运算符的WHERE子句项有时可以与索引一起使用,以进行范围搜索,就像LIKE或GLOB是BETWEEN运算符的替代品一样。这种优化有许多条件:
- LIKE或GLOB的右侧必须是一个字符串字面量,或者一个绑定到不以通配符字符开头的字符串字面量的参数。
- 不能通过在LIKE或GLOB运算符的左侧有一个数值(而不是字符串或blob)使LIKE或GLOB运算符为真。这意味着:
- LIKE或GLOB运算符的左侧是具有TEXT亲和性的索引列的名称,或者
- 右侧的模式参数不以负号(“-”)或数字开头。
这个约束源于数字不按字典顺序排序的事实。例如:9<10但’9’>‘10’。
- 不能使用sqlite3_create_function() API来重载实现LIKE和GLOB的内置函数。
- 对于GLOB运算符,列必须使用内置的BINARY排序序列进行索引。
- 对于LIKE运算符,如果启用了case_sensitive_like模式,则列必须使用BINARY排序序列进行索引,如果禁用了case_sensitive_like模式,则列必须使用内置的NOCASE排序序列进行索引。
- 如果使用了ESCAPE选项,则ESCAPE字符必须是ASCII,或者在UTF-8中是单字节字符。
LIKE运算符有两种模式,可以通过pragma设置。默认模式是让LIKE比较对于拉丁1字符的大小写不敏感。因此,默认情况下,以下表达式为真:
'a' LIKE 'A'
如果启用了case_sensitive_like pragma,如下所示:
PRAGMA case_sensitive_like=ON;
那么LIKE运算符将注意大小写,上面的示例将被评估为假。注意,大小写不敏感只适用于拉丁1字符 - 基本上是ASCII的低127字节代码中的英文大写和小写字母。SQLite中的国际字符集是大小写敏感的,除非提供了考虑非ASCII字符的应用程序定义的排序序列和like() SQL函数。如果提供了应用程序定义的排序序列和/或like() SQL函数,那么这里描述的LIKE优化将永远不会被采用。
LIKE运算符默认是大小写不敏感的,因为这是SQL标准要求的。您可以在编译时使用SQLITE_CASE_SENSITIVE_LIKE命令行选项更改默认行为。
LIKE优化可能会发生,如果运算符左边的列名使用内置的BINARY排序序列进行索引,并且case_sensitive_like已经打开。或者优化可能会发生,如果列使用内置的NOCASE排序序列进行索引,并且case_sensitive_like模式关闭。这些是LIKE运算符将被优化的唯一两种组合。
GLOB运算符总是区分大小写。GLOB运算符左边的列必须始终使用内置的BINARY排序序列,否则不会尝试使用索引优化该运算符。
只有当GLOB或LIKE运算符的右侧是字面字符串或绑定到字面字符串的参数时,才会尝试LIKE优化。字符串字面量不能以通配符开头;如果右侧以通配符字符开头,则不会尝试此优化。如果右侧是绑定到字符串的参数,那么只有在包含表达式的预编译语句使用sqlite3_prepare_v2()或sqlite3_prepare16_v2()编译时,才会尝试此优化。如果右侧是参数,并且语句使用sqlite3_prepare()或sqlite3_prepare16()准备,则不会尝试LIKE优化。
假设LIKE或GLOB运算符的右侧的非通配符字符的初始序列是x。我们使用单个字符来表示这个非通配符前缀,但读者应该理解,前缀可以由多于1个字符组成。让y是与/x/长度相同但比x大的最小字符串。例如,如果x是’hello’,那么y将是’hellp’。然后,LIKE或GLOB优化将运算符重写为以下形式:
column >= x AND column < y
然后,优化器将尝试使用索引来满足这两个新的虚拟约束。如果成功,那么将使用索引来满足LIKE或GLOB运算符。如果失败,那么将回退到全表扫描。在任何情况下,都将使用LIKE或GLOB运算符对所有候选行进行测试,以确保它们符合LIKE或GLOB模式。这是因为索引只能用于确定前缀匹配。索引不能用于处理LIKE或GLOB模式中可能出现的通配符。
6. 跳跃扫描优化
一般规则是,索引只有在 WHERE 子句约束了索引的最左侧列时才有用。然而,在某些情况下,即使索引的前几列被 WHERE 子句省略,但后面的列被包含,SQLite 也能够使用索引。
考虑如下表:
CREATE TABLE people(name TEXT PRIMARY KEY,role TEXT NOT NULL,height INT NOT NULL, -- in cmCHECK( role IN ('student','teacher') )
);
CREATE INDEX people_idx1 ON people(role, height);
people 表包含了一个大型组织中的每个人的条目。每个人要么是 “student”,要么是 “teacher”,由 “role” 字段确定。该表还记录了每个人的身高(以厘米为单位)。角色和身高都被索引。注意,索引的最左侧列的选择性不高 - 它只包含两个可能的值。
现在考虑一个查询,找出组织中身高为180cm或更高的每个人的名字:
SELECT name FROM people WHERE height>=180;
因为索引的最左侧列没有出现在查询的 WHERE 子句中,人们可能会得出索引在这里无法使用的结论。然而,SQLite 能够使用索引。从概念上讲,SQLite 使用索引就像查询更像下面的形式:
SELECT name FROM peopleWHERE role IN (SELECT DISTINCT role FROM people)AND height>=180;
或者这样:
SELECT name FROM people WHERE role='teacher' AND height>=180
UNION ALL
SELECT name FROM people WHERE role='student' AND height>=180;
上面显示的替代查询公式只是概念性的。SQLite 并没有真正改变查询。实际的查询计划是这样的:SQLite 定位到 “role” 的第一个可能值,它可以通过将 “people_idx1” 索引倒回到开始并读取第一条记录来做到这一点。SQLite 将这个第一个 “role” 值存储在一个我们在这里称为 “ r o l e " 的内部变量中。然后 S Q L i t e 运行一个类似于 " S E L E C T n a m e F R O M p e o p l e W H E R E r o l e = role" 的内部变量中。然后 SQLite 运行一个类似于 "SELECT name FROM people WHERE role= role"的内部变量中。然后SQLite运行一个类似于"SELECTnameFROMpeopleWHERErole=role AND height>=180” 的查询。这个查询在索引的最左侧列上有一个相等约束,所以索引可以用来解决这个查询。一旦这个查询完成,SQLite 然后使用 “people_idx1” 索引来定位 “role” 列的下一个值,使用的代码在逻辑上类似于 “SELECT role FROM people WHERE role>$role LIMIT 1”。这个新的 “role” 值覆盖了 $role 变量,这个过程重复,直到检查了所有可能的 “role” 值。
我们称这种索引使用方式为 “跳跃扫描”,因为数据库引擎基本上是在对索引进行全扫描,但是通过偶尔跳跃到下一个候选值来优化扫描(使其少于 “全”)。
SQLite 可能会在索引上使用跳跃扫描,如果它知道第一列或更多列包含许多重复值。如果在索引的最左侧列中有太少的重复项,那么简单地向前走到下一个值,从而进行全表扫描,会比在索引上进行二分搜索来定位下一个左列值更快。
SQLite 只有在对数据库运行 ANALYZE 命令后才能知道索引的最左侧列中有许多重复项。没有 ANALYZE 的结果,SQLite 不得不猜测表中数据的 “形状”,默认猜测是在索引的最左侧列中每个值有平均10个重复项。当重复项的数量大约为18个或更多时,跳跃扫描才变得有利可图(它只是比全表扫描更快)。因此,在没有分析过的数据库上,永远不会使用跳跃扫描。
7. 连接
SQLite 通过嵌套循环实现连接。在连接的嵌套循环的默认顺序中,FROM 子句中最左侧的表形成外循环,最右侧的表形成内循环。然而,如果这样做有助于选择更好的索引,SQLite 将以不同的顺序嵌套循环。
内连接可以自由重新排序。然而,外连接既不可交换也不可关联,因此不会被重新排序。如果优化器认为这样做是有利的,那么外连接左侧和右侧的内连接可能会被重新排序,但外连接总是按照它们出现的顺序进行评估。
SQLite 对 CROSS JOIN 运算符进行特殊处理。从理论上讲,CROSS JOIN 运算符是可交换的。然而,SQLite 选择永远不重新排序 CROSS JOIN 中的表。这提供了一种通过编程方式强制 SQLite 选择特定循环嵌套顺序的机制。
在选择连接中的表顺序时,SQLite 使用了一种高效的多项式时间图算法,该算法在下一代查询规划器文档中有描述。正因为如此,SQLite 能够在微秒级别规划具有50个或60个连接的查询。
连接重新排序是自动的,通常工作得很好,程序员不必考虑它,特别是如果已经使用 ANALYZE 收集了有关可用索引的统计信息,尽管偶尔需要程序员的一些提示。例如,考虑以下模式:
CREATE TABLE node(id INTEGER PRIMARY KEY,name TEXT
);
CREATE INDEX node_idx ON node(name);
CREATE TABLE edge(orig INTEGER REFERENCES node,dest INTEGER REFERENCES node,PRIMARY KEY(orig, dest)
);
CREATE INDEX edge_idx ON edge(dest,orig);
上述模式定义了一个有向图,可以在每个节点处存储一个名称。现在考虑针对此模式的查询:
SELECT *FROM edge AS e,node AS n1,node AS n2WHERE n1.name = 'alice'AND n2.name = 'bob'AND e.orig = n1.idAND e.dest = n2.id;
这个查询要求的是从标有 “alice” 的节点到标有 “bob” 的节点的所有边的信息。SQLite 查询优化器基本上有两种选择来实现这个查询。(实际上有六种不同的选择,但我们在这里只考虑其中的两种。)下面是演示这两种选择的伪代码。
选项 1:
foreach n1 where n1.name='alice' do:foreach n2 where n2.name='bob' do:foreach e where e.orig=n1.id and e.dest=n2.idreturn n1.*, n2.*, e.*endend
end
选项 2:
foreach n1 where n1.name='alice' do:foreach e where e.orig=n1.id do:foreach n2 where n2.id=e.dest and n2.name='bob' do:return n1.*, n2.*, e.*endend
end
这两个实现选项中,每个循环都使用相同的索引来加速。这两个查询计划的唯一区别是循环的嵌套顺序。
那么哪个查询计划更好呢?结果取决于节点表和边表中的数据类型。
假设有 M 个 alice 节点,N 个 bob 节点。考虑两种情况。在第一种情况中,M 和 N 都为2,但每个节点有数千条边。在这种情况下,选项1更优。对于选项1,内循环检查一对节点之间是否存在边,并在找到时输出结果。因为只有2个 alice 和 bob 节点,所以内循环只需要运行四次,查询就非常快了。选项2在这里会花费更长的时间。选项2的外循环只执行两次,但因为每个 alice 节点都有大量的边,所以中间循环必须迭代数千次。它会慢得多。所以在第一种情况下,我们更倾向于使用选项1。
现在考虑 M 和 N 都为3500的情况。Alice 节点非常多。这次假设这些节点中的每一个只通过一两条边连接。现在选项2更优。对于选项2,外循环仍然需要运行3500次,但是每个外循环中,中间循环只运行一次或两次,内循环只有在每个中间循环中才会运行一次,如果有的话。所以内循环的总迭代次数大约是7000次。另一方面,选项1必须分别运行其外循环和中间循环3500次,导致中间循环的迭代次数达到1200万次。因此,在第二种情况下,选项2比选项1快近2000倍。
所以你可以看到,根据表中数据的结构,查询计划1或查询计划2可能更好。SQLite 默认选择哪个计划呢?在3.6.18版本中,如果没有运行 ANALYZE,SQLite 将选择选项2。如果运行了 ANALYZE 命令以收集统计信息,如果统计信息表明另一种选择可能运行得更快,可能会做出不同的选择。
7.1. 手动控制连接顺序
SQLite 几乎总是自动选择最佳的连接顺序。很少有开发者需要干预来给查询规划器提供关于最佳连接顺序的提示。最好的策略是使用 PRAGMA optimize 确保查询规划器可以访问数据库中数据形状的最新统计信息。
本节描述了开发者如何控制 SQLite 中的连接顺序,以解决可能出现的任何性能问题。然而,除非作为最后的手段,否则不推荐使用这些技术。
如果你遇到一个情况,即使在运行 PRAGMA optimize 后,SQLite 仍然选择了次优的连接顺序,请在 SQLite 社区论坛上报告你的情况,以便 SQLite 的维护者可以对查询规划器进行新的改进,使得不需要手动干预。
7.1.1. 使用 SQLITE_STAT 表手动控制查询计划
SQLite 提供了一种能力,让高级程序员可以控制优化器选择的查询计划。一种方法是在 sqlite_stat1 表中篡改 ANALYZE 的结果。