开源项目CuteSqlite开发笔记(五):SQLite查询优化器概述

在开发CuteSqlite图形客户端的时候,需要用到SQL的语法解释,来对SQL语句进行优化。找了很多的SQL语法解释器,都不是十分满意,只有翻开Sqlite的源码,看看SQLite对SQL语句的解释过程,上一篇文章翻译了官方介绍VDBE字节码引擎,本文翻译了官方SQLite查询优化器的文章。

官方文章:https://www.sqlite.org/optoverview.html

开源项目CuteSqlite网址:GitHub - shinehanx/CuteSqlite

目录

 1.介绍

 2. WHERE子句分析

2.1.索引术语使用示例

3. BETWEEN优化

 4.或优化

 5. LIKE优化

6.跳过扫描优化

 7.加入

7.1.联接中表的顺序

7.2.使用SQLITE_STAT表手动控制查询计划

7.3.使用CROSS JOIN手动控制查询计划

8.在多个索引之间选择

8.1.使用一元-"+”取消WHERE子句条件

 8.2.范围查询

 9.覆盖索引

10. ORDER BY优化

10.1.通过索引部分排序

 11.子查询展平

 12.子查询协同例程

12.1.使用协同例程将工作延迟到排序之后

13.最小/最大优化

 14.自动索引

 14.1.散列连接

15.下推优化

16.外联接强度降低优化

17. OUTER JOIN优化

18.常数传播优化


 1.介绍

本文档概述了SQLite的查询规划器和优化器的工作原理。

给定一条SQL语句,可能有几十种、几百种甚至几千种方法来实现该语句,这取决于语句本身和底层数据库模式的复杂性。查询规划器的任务是选择最小化磁盘I/O和CPU开销的算法。

索引教程文档中提供了其他背景信息。

在3.8.0(2013-08-26)版本中,SQLite查询规划器被重新实现为下一代查询规划器或“NGQP”。本文档中描述的所有特性、技术和算法都适用于3.8.0之前的遗留查询规划器和NGQP。有关NGQP与传统查询规划器的不同之处的更多信息,请参见NGQP的详细描述。

 2. WHERE子句分析

查询中的WHERE子句被分解为“terms”,其中每个terms通过AND运算符与其他terms分隔开。如果WHERE子句由OR运算符分隔的约束组成,则整个子句被视为应用OR子句优化的单个“项”。

分析WHERE子句的所有项,以查看是否可以使用索引来满足它们。要被索引使用,术语通常必须是以下形式之一:


  column = expression
  column IS expression
  column > expression
  column >= expression
  column < expression
  column <= expression
  expression = column
  expression > column
  expression >= column
  expression < column
  expression <= column
  column IN (expression-list)
  column IN (subquery)
  column IS NULL
  column LIKE pattern
  column GLOB pattern

如果使用如下语句创建索引:

CREATE INDEX idx_ex1 ON ex1(a,b,c,d,e,...,y,z);

然后,如果索引的初始列(列a、b等)出现在WHERE子句项中,则可以使用索引。索引的初始列必须与=或IN或IS运算符一起使用。所使用的最右边的列可以使用不等式。对于所使用的索引的最右侧列,最多可以有两个不等式,它们必须将列的允许值夹在两个极值之间。

索引的每一列都不一定要出现在WHERE子句项中才能使用该索引。但是,所使用的索引列中不能有间隙。因此,对于上面的示例索引,如果不存在约束列c的WHERE子句项,则约束列a和b的项可以与索引一起使用,但约束列d到z的项不能与索引一起使用。类似地,如果索引列位于仅受不等式约束的列的右侧,则通常不会使用索引列(用于索引目的)。(See下面针对异常的跳过扫描优化。

在表达式上的索引的情况下,每当在前面的文本中使用单词“列”时,可以替换为“索引表达式”(意味着出现在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而非AND连接的WHERE子句条件约束可以两种不同的方式行程。如果一个术语由多个子术语组成,这些子术语包含一个公共列名并由OR分隔,如下所示:


  column = expr1 OR column = expr2 OR column = expr3 OR ...

然后,该术语重写如下:


  column IN (expr1,expr2,expr3,...)

重写后的项可能会继续使用IN操作符的常规规则来约束索引。注意,列必须是每个OR连接子项中的同一列,尽管该列可以出现在=运算符的左侧或右侧。

当且仅当前面描述的OR到IN运算符的转换不起作用时,尝试第二个OR子句优化。假设OR子句由多个子项组成,如下所示:


  expr1 OR expr2 OR expr3

单个子项可以是单个比较表达式,如a=5或x>y,或者它们可以是LIKE或BETWEEN表达式,或者子项可以是与连接的子子项的括号列表。每个子项都被当作整个WHERE子句来分析,以查看子项本身是否可索引。如果OR子句的每个子项都是可单独索引的,则OR子句可以被编码为使用单独的索引来评估OR子句的每个项。考虑SQLite如何为每个OR子句项使用单独的索引的一种方法是想象WHERE子句重写如下:


  rowid IN (SELECT rowid FROM table WHERE expr1
            UNION SELECT rowid FROM table WHERE expr2
            UNION SELECT rowid FROM table WHERE expr3)

上面重写的表达式是概念性的;包含OR的WHERE子句实际上并不是这样重写的。OR子句的实际实现使用了一种更有效的机制,甚至适用于WITHOUT ROWID表或“rowid”不可访问的表。然而,实现的本质是由上面的语句捕获的:单独的索引用于从每个OR子句项中查找候选结果行,最终结果是这些行的并集。

请注意,在大多数情况下,SQLite将只为查询的FROM子句中的每个表使用一个索引。这里描述的第二个OR子句优化是该规则的例外。对于OR子句,不同的索引可以用于OR子句中的每个子项。

对于任何给定的查询,这里描述的OR子句优化可以使用的事实并不保证它会被使用。SQLite使用一个基于成本的查询计划器,它估计各种竞争查询计划的CPU和磁盘I/O成本,并选择它认为最快的计划。如果WHERE子句中有许多OR项,或者单个OR子句子项上的某些索引不是很有选择性,那么SQLite可能会决定使用不同的查询算法,甚至是全表扫描。应用程序开发人员可以在语句上使用EXPLAIN QUERY前缀来获得所选查询策略的高级概述。

 5. LIKE优化

使用LIKE或GLOB运算符的SELECT子句术语有时可以与索引一起使用来进行范围搜索,几乎就像LIKE或GLOB是BETWEEN运算符的替代品一样。这种优化有许多条件:

  1. LIKE或GLOB的右侧必须是一个字符串文字或绑定到一个开始不以小写字符开头的字符串文字的参数。
  2. 不能通过在左侧使用数值(而不是字符串或blob)来使LIKE或GLOB运算符为true。这意味着:
    1. LIKE或GLOB运算符的左侧是具有TEXT亲和性的索引列的名称,或者
    2. 右侧模式参数不以负号(“-”)或数字开始开始。

    这个限制来自于数字不按字典顺序排序的事实。例如:9<10但'9'>'10'。
  3. 用于实现LIKE和GLOB的内置函数不能使用sqlite3_parallel_function()API重载。
  4. 对于GLOB运算符,必须使用内置的BINARY排序序列对列进行索引。
  5. 对于LIKE运算符,如果启用了case_sensitive_like模式,则必须使用BINARY排序序列对列进行索引,或者如果禁用了case_sensitive_like模式,则必须使用内置的NOCASE排序序列对列进行索引。
  6. 如果使用ESCAPE选项,则ESCAPE字符必须是ASCII或UTF-8中的单字节字符。

LIKE操作符有两种模式可以通过杂注设置。LIKE比较的默认模式是对latin 1字符的大小写差异不敏感。因此,默认情况下,以下表达式为true:

'a' LIKE 'A'

如果按如下方式启用了case_sensitive_like杂注:

PRAGMA case_sensitive_like=ON;

然后LIKE运算符会注意大小写,上面的例子会计算为false。请注意,大小写不敏感性仅适用于latin 1字符-基本上是ASCII的低127字节代码中的英语字母的大小写。国际字符集在SQLite中是区分大小写的,除非提供了一个应用程序定义的排序序列和like()SQL函数来考虑非ASCII字符。如果提供了应用程序定义的排序序列和/或like()SQL函数,则这里描述的LIKE优化将永远不会被采用。

LIKE运算符默认情况下不区分大小写,因为这是SQL标准所要求的。您可以在编译时使用编译器的SQLITE_CASE_SENSITIVE_LIKE命令行选项更改默认行为。

如果使用内置的BINARY排序序列对操作符左侧命名的列进行索引并且启用了case_sensitive_like,则可能会发生LIKE优化。或者如果使用内置的NOCASE排序序列对列进行索引并且禁用了case_sensitive_like模式,则可能会发生优化。这是LIKE操作符将被优化的仅有的两种组合。

GLOB运算符始终区分大小写。GLOB操作符左侧的列必须始终使用内置的BINARY排序序列,否则将不会尝试使用索引优化该操作符。

只有当GLOB或LIKE操作符的右侧是字符串或绑定到字符串字面量的参数时,才会尝试LIKE优化。字符串文字不能开始与一个字符串;如果右手边开始与一个字符串,那么这种优化是不尝试。如果右边是一个绑定到字符串的参数,那么只有当包含表达式的预准备语句是用sqlite3_tagre_v2()或sqlite3_tagre16_v2()编译的时,才会尝试此优化。如果右侧是一个参数,并且语句是使用sqlite3_prepare()或sqlite3_prepare 16()准备的,则不会尝试LIKE优化。

假设LIKE或GLOB运算符右侧的非字符串的初始序列是x。我们使用单个字符来表示这个非前缀,但读者应该理解前缀可以由多个字符组成。设y是与/x/长度相同但比x大的最小字符串。例如,如果x是 'hello' ,那么y就是 'hellp' 。LIKE和GLOB优化包括添加两个虚拟术语,如下所示:


  column >= x AND column < y

在大多数情况下,即使使用虚拟项来约束索引,仍会针对每个输入行测试原始的LIKE或GLOB运算符。这是因为我们不知道x前缀右边的字符可能会施加什么额外的约束。但是,如果x的右侧只有一个全局变量,则原始的LIKE或GLOB测试将被禁用。换句话说,如果模式是这样的:


  column LIKE x%
  column GLOB x*

那么当虚拟项约束索引时,原始的LIKE或GLOB测试被禁用,因为在这种情况下,我们知道索引选择的所有行都将通过LIKE或GLOB测试。

请注意,当LIKE或GLOB运算符的右侧是一个参数,并且语句是使用sqlite3_stepre_v2()或sqlite3_stepre16_v2()准备的时,如果绑定到右侧参数的绑定自上次运行以来发生了更改,则在每次运行的第一次sqlite3_step()调用时,语句会自动重新解析和重新编译。这种重新解析和重新编译本质上与模式更改后发生的操作相同。重新编译是必要的,这样查询计划器就可以检查绑定到LIKE或GLOB运算符右侧的新值,并确定是否使用上述优化。

6.跳过扫描优化

一般的规则是,只有在索引的最左边的列上有EXE子句约束时,索引才有用。然而,在某些情况下,SQLite能够使用索引,即使索引的前几列从WHERE子句中省略,但后面的列包括在内。

考虑如下表格:

CREATE TABLE people(
  name TEXT PRIMARY KEY,
  role TEXT NOT NULL,
  height INT NOT NULL, -- in cm
  CHECK( role IN ('student','teacher') )
);
CREATE INDEX people_idx1 ON people(role, height);

人员表为大型组织中的每个人员提供一个条目。每个人都是“学生”或“教师”,由“角色”字段确定。该表还记录了每个人的身高厘米。角色和身高都有索引。请注意,索引最左边的列选择性不强-它只包含两个可能的值。

现在考虑一个查询,查找组织中身高180 cm或更高的每个人的姓名:

SELECT name FROM people WHERE height>=180;

因为索引的最左边的列没有出现在查询的WHERE子句中,所以人们很容易得出这样的结论:索引在这里不可用。但是,SQLite可以使用索引。从概念上讲,SQLite使用索引,就好像查询更像下面这样:

SELECT name FROM people
 WHERE 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”值存储在一个内部变量中,这里我们称之为“$role”。然后SQLite运行一个查询:“SELECT name FROM people WHERE role=$role AND height>=180”。这个查询在索引的最左边的列上有一个相等约束,因此可以使用索引来解析这个查询。一旦查询完成,SQLite就使用“people_idx1”索引来定位“role”列的下一个值,使用逻辑上类似于“SELECT role FROM people WHERE role>$role LIMIT 1”的代码。这个新的“role”值会覆盖$role变量,并且重复该过程,直到检查完“role”的所有可能值。

我们称这种索引使用为“skip-scan”,因为数据库引擎基本上是对索引进行全扫描,但它通过偶尔向前跳到下一个候选值来优化扫描(使其小于“全”)。

如果SQLite知道前一个或多个列包含许多重复值,它可能会对索引使用跳过扫描。如果在索引的最左边的列中有太少的重复,那么简单地前进到下一个值,从而进行全表扫描,比在索引上进行二分搜索以定位下一个左列值要快。

SQLite知道索引最左边的列中有许多重复的唯一方法是在数据库上运行ANALYZE命令。如果没有ANALYZE的结果,SQLite必须猜测表中数据的“形状”,默认的猜测是索引最左边的列中的每个值平均有10个重复。只有当重复数达到18或更多时,跳过扫描才变得有利可图(它只比全表扫描快)。因此,跳过扫描永远不会在尚未分析的数据库上使用。

 7.加入

内部连接的ON和USING子句被转换为WHERE子句的附加项,然后进行上文第2.0段中描述的WHERE子句分析。因此,对于SQLite,使用较新的SQL92连接语法比使用较旧的SQL89逗号连接语法在计算上没有优势。它们最终都在内部连接上完成了完全相同的事情。

对于外部联接,情况更加复杂。以下两个查询是不等价的:

SELECT * FROM tab1 LEFT JOIN tab2 ON tab1.x=tab2.y;
SELECT * FROM tab1 LEFT JOIN tab2 WHERE tab1.x=tab2.y;

对于内部联接,上面的两个查询是相同的。但是,特殊处理适用于OUTER联接的ON和USING子句:具体而言,如果联接的右表位于空行上,则ON或USING子句中的约束不适用,但约束适用于WHERE子句。实际效果是,将LEFT JOIN的ON或USING子句表达式放在WHERE子句中,可以有效地将查询转换为普通的INNER JOIN -尽管内部连接运行得更慢。

7.1.联接中表的顺序

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 n2
 WHERE n1.name = 'alice'
   AND n2.name = 'bob'
   AND e.orig = n1.id
   AND 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.id
      return n1.*, n2.*, e.*
    end
  end
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.*
    end
  end
end

在两种实现选项中,使用相同的索引来加速每个循环。这两个查询计划的唯一区别是循环的嵌套顺序。

那么,哪种查询计划更好呢?事实证明,答案取决于在节点和边表中找到的数据类型。

假设Alice节点的数量为M,Bob节点的数量为N。考虑两种情况。在第一种情况下,M和N都是2,但每个节点上有数千条边。在这种情况下,选择1是可取的。使用选项1,内部循环检查一对节点之间是否存在边,如果找到则输出结果。因为每个节点只有2个alice和bob节点,所以内部循环只需运行4次,查询速度非常快。选择2在这里需要更长的时间。选项2的外循环只执行两次,但由于每个alice节点都有大量的边离开,中间的循环必须重复执行数千次。会慢很多。所以在第一种情况下,我们倾向于使用选项1。

现在考虑M和N都是3500的情况。爱丽丝节点是丰富的。这一次,假设这些节点中的每一个仅由一条或两条边连接。选择2是首选。使用选项2,外部循环仍然必须运行3500次,但中间循环只为每个外部循环运行一次或两次,内部循环将只为每个中间循环运行一次。因此,内部循环的总迭代次数约为7000次。另一方面,选项1必须运行其外部循环和中间循环各3500次,导致中间循环的1200万次迭代。因此,在第二种情况下,选项2比选项1快近2000倍。

因此,您可以看到,根据表中数据的结构,查询计划1或查询计划2可能更好。SQLite默认选择哪个计划?从3.6.18版本开始,如果不运行ANALYZE,SQLite将选择选项2。如果运行ANALYZE命令是为了收集统计信息,则如果统计信息表明替代方案可能运行得更快,则可能会做出不同的选择。

7.2.使用SQLITE_STAT表手动控制查询计划

SQLite为高级程序员提供了对优化器选择的查询计划进行控制的能力。一种方法是在sqlite_stat1、sqlite_stat3和/或sqlite_stat4表中伪造ANALYZE结果。大多数情况下不建议这样做。

7.3.使用CROSS JOIN手动控制查询计划

程序员可以通过使用CROSS JOIN操作符而不仅仅是JOIN、INNER JOIN、NATURAL JOIN或“,”join来强制SQLite对join使用特定的循环嵌套顺序。虽然CROSS JOIN在理论上是可交换的,但SQLite选择在CROSS JOIN中从不重新排序表。因此,CROSS JOIN的左表将始终处于相对于右表的外部循环中。

在下面的查询中,优化器可以自由地以它认为合适的任何方式重新排序FROM子句的表:

SELECT *
  FROM node AS n1,
       edge AS e,
       node AS n2
 WHERE n1.name = 'alice'
   AND n2.name = 'bob'
   AND e.orig = n1.id
   AND e.dest = n2.id;

在同一查询的以下逻辑等效公式中,用“CROSS JOIN”代替“,“意味着表的顺序必须是N1,E,N2。

SELECT *
  FROM node AS n1 CROSS JOIN
       edge AS e CROSS JOIN
       node AS n2
 WHERE n1.name = 'alice'
   AND n2.name = 'bob'
   AND e.orig = n1.id
   AND e.dest = n2.id;

在后一个查询中,查询计划必须是选项2。请注意,您必须使用关键字“CROSS”来禁用表重新排序优化; INNER JOIN、NATURAL JOIN、JOIN和其他类似的组合就像逗号连接一样工作,因为优化器可以自由地重新排序表。(在外部联接上也禁用表重新排序,但这是因为外部联接不是关联或可交换的。在OUTER JOIN中重新排序表会更改结果。

请参阅“The化石NGQP Upgrade Case Study”,了解使用CROSS JOIN手动控制连接嵌套顺序的另一个真实示例。查询计划器检查表在同一文档的后面部分提供了关于手动控制查询计划器的进一步指导。

8.在多个索引之间选择

查询的FROM子句中的每个表最多可以使用一个索引(除非OR子句优化起作用),SQLite努力在每个表上使用至少一个索引。有时,两个或多个索引可能是用于单个表的候选索引。举例来说:

CREATE TABLE ex2(x,y,z);
CREATE INDEX ex2i1 ON ex2(x);
CREATE INDEX ex2i2 ON ex2(y);
SELECT z FROM ex2 WHERE x=5 AND y=6;

对于上面的SELECT语句,优化器可以使用ex2i1索引查找ex2中包含x=5的行,然后根据y=6项测试每行。或者它可以使用ex2i2索引来查找ex2中包含y=6的行,然后针对x=5项测试这些行中的每一行。

当面临两个或多个索引的选择时,SQLite尝试估计使用每个选项执行查询所需的总工作量。然后,它选择给出最少估计功的选项。

为了帮助优化器更准确地估计使用各种索引所涉及的工作,用户可以选择运行ANALYZE命令。ANALYZE命令扫描数据库的所有索引,其中可能有两个或多个索引之间的选择,并收集有关这些索引的选择性的统计信息。通过此扫描收集的统计信息存储在特殊的数据库表中,名称显示名称都以“sqlite_stat”开始。这些表的内容不会随着数据库的变化而更新,因此在进行重大更改后,谨慎的做法是重新分析。ANALYZE命令的结果仅可用于在ANALYZE命令完成后打开的数据库连接。

各种sqlite_statN表包含有关各种索引的选择性的信息。例如,sqlite_stat1表可能表明,列x上的相等约束将搜索空间平均减少到10行,而列y上的相等约束将搜索空间平均减少到3行。在这种情况下,SQLite更倾向于使用索引ex2i2,因为该索引更具选择性。

8.1.使用一元-"+”取消WHERE子句条件

通过在列名前添加一个一元+运算符,可以手动取消WHERE子句的项与索引一起使用的资格。一元+是一个空操作,不会在预处理语句中生成任何字节码。但是,一元+操作符将阻止该项约束索引。所以,在上面的例子中,如果查询被重写为:

SELECT z FROM ex2 WHERE +x=5 AND y=6;

x列上的+运算符将防止该项约束索引。这将强制使用ex2i2索引。

请注意,一元+运算符也会从表达式中删除类型关联,在某些情况下,这可能会导致表达式含义的细微变化。在上面的示例中,如果列x具有TEXT亲和性,则比较“x=5”将作为文本进行。+运算符删除关联。因此,比较“+x=5”将x列中的文本与数值5进行比较,并且始终为false。

 8.2.范围查询

考虑一个稍微不同的场景:

CREATE TABLE ex2(x,y,z);
CREATE INDEX ex2i1 ON ex2(x);
CREATE INDEX ex2i2 ON ex2(y);
SELECT z FROM ex2 WHERE x BETWEEN 1 AND 100 AND y BETWEEN 1 AND 100;

进一步假设列x包含分布在0和1,000,000之间的值,列y包含分布在0和1,000之间的值。在这种情况下,列x上的范围约束应该将搜索空间减少10,000倍,而列y上的范围约束应该仅将搜索空间减少10倍。因此,应首选ex2i1指数。

SQLite将进行此确定,但仅当它已使用SQLITE_ENABLE_STAT3或SQLITE_ENABLE_STAT4编译时。SQLITE_ENABLE_STAT3和SQLITE_ENABLE_STAT4选项使ANALYZE命令收集sqlite_stat3或sqlite_stat4表中列内容的直方图,并使用此直方图更好地猜测用于上述范围约束的最佳查询。STAT 3和STAT 4之间的主要区别在于,STAT 3只记录索引最左边列的直方图数据,而STAT 4记录索引所有列的直方图数据。对于单列索引,STAT3和STAT4的工作原理相同。

只有当约束的右侧是一个简单的编译时常量或参数而不是表达式时,直方图数据才有用。

直方图数据的另一个限制是它只适用于索引上最左边的列。考虑以下情况:

CREATE TABLE ex3(w,x,y,z);
CREATE INDEX ex3i1 ON ex2(w, x);
CREATE INDEX ex3i2 ON ex2(w, y);
SELECT z FROM ex3 WHERE w=5 AND x BETWEEN 1 AND 100 AND y BETWEEN 1 AND 100;

这里的不等式是在列x和y上,它们不是最左边的索引列。因此,从索引的最左边列收集的直方图数据在帮助在列x和y的范围约束之间进行选择方面是无用的。

 9.覆盖索引

当对一行进行索引查找时,通常的过程是对索引进行二分搜索以找到索引条目,然后从索引中提取rowid,并使用该rowid对原始表进行二分搜索。因此,典型的索引查找涉及两个二进制搜索。但是,如果所有要从表中获取的列都已经在索引中可用,SQLite将使用索引中包含的值,并且永远不会查找原始表行。这样可以为每一行节省一次二分搜索,并且可以使许多查询的运行速度提高两倍。

当索引包含查询所需的所有数据,并且不需要查询原始表时,我们称该索引为“覆盖索引”。

10. ORDER BY优化

SQLite尝试在可能的情况下使用索引来满足查询的ORDER BY子句。当面临使用索引来满足WHERE子句约束或满足ORDER BY子句的选择时,SQLite会进行与上述相同的成本分析,并选择它认为会产生最快答案的索引。

SQLite还将尝试使用索引来帮助满足GROUP BY子句和DISTINCT关键字。如果联接的嵌套循环可以被布置为使得对于GROUP BY或对于DISTINCT等效的行是连续的,则GROUP BY或DISTINCT逻辑可以简单地通过将当前行与前一行进行比较来确定当前行是否是同一组的一部分或者当前行是否是不同的。这可能比将每一行与所有先前行进行比较的替代方法快得多。

10.1.通过索引部分排序

如果一个查询包含一个带有多个术语的ORDERBY子句,SQLite可能会使用索引来使行按照ORDERBY中术语的某些前缀的顺序出现,但ORDERBY中后面的术语不满足。在这种情况下,SQLite执行块排序。假设ORDERBY子句有四个术语,查询的自然顺序导致行按前两个术语的顺序出现。当查询引擎输出每一行并将其输入排序器时,将当前行中对应于ORDERBY的前两个项的输出与前一行进行比较。如果它们发生了变化,则当前排序结束并输出,然后开始新的排序。这导致排序稍微快一些。事件更大的优点是,需要保存在内存中的行更少,减少了内存需求,并且在核心查询运行完成之前,输出就可以开始出现。

 11.子查询展平

当子查询出现在SELECT的FROM子句中时,最简单的行为是将子查询计算到一个瞬态表中,然后对瞬态表运行外部SELECT。这样的计划可能不是最佳的,因为临时表将没有任何索引,外部查询(可能是连接)将被迫对临时表进行全表扫描。

为了克服这个问题,SQLite尝试在SELECT的FROM子句中扁平化子查询。这涉及到将子查询的FROM子句插入到外部查询的FROM子句中,并重写外部查询中引用子查询结果集的表达式。举例来说:

SELECT t1.a, t2.b FROM t2, (SELECT x+y AS a FROM t1 WHERE z<100) WHERE a>5

将使用查询扁平化重写为:

SELECT t1.x+t1.y AS a, t2.b FROM t2, t1 WHERE z<100 AND a>5

要实现查询扁平化,必须满足一长串条件。某些约束用斜体文本标记为过时。这些额外的约束保留在文档中,以保留其他约束的编号。

不经意的读者不应该理解所有这些规则。本节的一个关键要点是,确定查询扁平化是安全还是不安全的规则是微妙而复杂的。多年来,由于过于积极的查询扁平化而导致了多个错误。另一方面,如果查询扁平化更保守,则复杂查询和/或涉及视图的查询的性能往往会受到影响。

  1. (反对。不再尝试对聚合子查询进行查询扁平化。)
  2. (反对。不再尝试对聚合子查询进行查询扁平化。)
  3. 如果子查询是LEFT JOIN的右操作数,
    1. 子查询可以不是联接,并且
    2. 子查询的FROM子句不能包含虚拟表,并且
    3. 外部查询可以不是聚合。
  4. 子查询不是DISTINCT。
  5. (纳入约束4)
  6. (反对。不再尝试对聚合子查询进行查询扁平化。)
  7. 子查询有一个FROM子句。
  8. 子查询不使用LIMIT或外部查询不是联接。
  9. 子查询不使用LIMIT,或者外部查询不使用聚合。
  10. (2005年放宽限制)
  11. 子查询和外部查询不都具有ORDER BY子句。
  12. (纳入约束3)
  13. 子查询和外部查询并不都使用LIMIT。
  14. 子查询不使用OFFSET。
  15. 如果外部查询是复合选择的一部分,则子查询可能没有LIMIT子句。
  16. 如果外部查询是聚合,则子查询可能不包含ORDER BY。
  17. 如果子查询是复合SELECT,则
    1. 所有复合运算符必须是UNION ALL,并且
    2. 子查询复合词中的任何词都不能是聚合词或DISTINCT词,
    3. 子查询中的每个术语都必须有一个FROM子句,并且
    4. 外部查询可以不是聚合、DISTINCT查询或联接。

    父查询和子查询可以包含WHERE子句。除第(11)、(12)及(13)条另有规定外,该等规则亦可载有ORDER BY、LIMIT及OFFSET条款。
  18. 如果子查询是一个复合选择,那么父查询的ORDERBY子句的所有项都必须是对子查询的列的简单引用。
  19. 如果子查询使用LIMIT,则外部查询可能没有WHERE子句。
  20. 如果子查询是一个复合选择,那么它不能使用ORDERBY子句。
  21. 如果子查询使用LIMIT,则外部查询可能不是DISTINCT。
  22. 子查询不能是递归CTE。
  23. (已纳入约束17 d。
  24. (反对。不再尝试对聚合子查询进行查询扁平化。)

当使用视图时,查询扁平化是一个重要的优化,因为视图的每次使用都被转换为一个子查询。

 12.子查询协同例程

在SQLite 3.7.15(2012-12-12)之前,FROM子句中的子查询将被扁平化为外部查询,或者子查询将在外部查询开始之前运行完成,来自子查询的结果集将存储在瞬态表中,然后瞬态表将用于外部查询。较新版本的SQLite有第三种选择,即使用协同例程实现子查询。

协同例程与子例程类似,因为它与调用者在同一线程中运行,并最终将控制返回给调用者。不同之处在于,协同例程还可以在完成之前返回,然后在下次调用它时从停止的地方继续。

当子查询被实现为协同例程时,生成字节码来实现子查询,就好像它是一个独立的查询一样,除了不是将结果行返回给应用程序之外,协同例程在计算每行之后将控制权返回给调用者。然后调用者可以使用这一个计算行作为其计算的一部分,然后在准备好下一行时再次调用协同例程。

协同例程比将子查询的完整结果集存储在瞬态表中更好,因为协同例程使用更少的内存。对于协同例程,只需要记住结果的一行,而对于瞬态表,必须存储结果的所有行。此外,因为协同例程不需要在外部查询开始工作之前运行完成,所以输出的第一行可以更快地出现,并且如果整个查询在完成之前被放弃,则总体上完成的工作较少。

另一方面,如果子查询的结果必须被多次扫描(例如,因为它只是连接中的一个表),那么最好使用瞬态表来记住子查询的整个结果,以避免多次计算子查询。

12.1.使用协同例程将工作延迟到排序之后

从SQLite版本3.21.0(2017-10-24)开始,查询规划器总是倾向于使用一个co-routine来实现包含ORDER BY子句的FROM子句子查询,并且当外部查询的结果集是“复杂的”时,这些子查询不是连接的一部分。此功能允许应用程序将昂贵的计算从排序器之前转移到排序器之后,这可以导致更快的操作。例如,考虑以下查询:

SELECT expensive_function(a) FROM tab ORDER BY date DESC LIMIT 5;

这个查询的目标是计算表中最近五个条目的某个值。在上面的查询中,“expensive_function()”是在排序之前调用的,因此会在表的每一行上调用,即使是由于LIMIT子句而最终被忽略的行。可以使用一个co-routine来解决这个问题:

SELECT expensive_function(a) FROM (
  SELECT a FROM tab ORDER BY date DESC LIMIT 5
);

在修改后的查询中,由协同例程实现的子查询计算“a”的五个最近值。这五个值从协同例程向上传递到外部查询,在外部查询中,只对应用程序关心的特定行调用“expensive_function()”。

SQLite未来版本中的查询规划器可能会变得足够智能,可以在两个方向上自动进行上述转换。也就是说,SQLite的未来版本可能会将第一种形式的查询转换为第二种形式,或者将以第二种方式编写的查询转换为第一种形式。从SQLite版本3.22.0(2018-01-22)开始,如果外部查询在其结果集中没有使用任何用户定义的函数或子查询,则查询规划器将扁平化子查询。但是,对于上面显示的示例,SQLite按照编写的方式实现每个查询。

13.最小/最大优化

对于包含单个MIN()或MAX()聚合函数(其参数是索引的最左侧列)的索引,可以通过执行单个索引查找而不是扫描整个表来满足。示例如下:

SELECT MIN(x) FROM table;
SELECT MAX(x)+1 FROM table;

 14.自动索引

当没有索引可用于帮助查询的评估时,SQLite可能会创建一个自动索引,该索引仅持续单个SQL语句的持续时间。由于构造自动索引的成本是O(NlogN)(其中N是表中的条目数),而进行全表扫描的成本仅为O(N),因此只有当SQLite预期在SQL语句期间查找将运行超过logN次时才会创建自动索引。举个例子:

CREATE TABLE t1(a,b);
CREATE TABLE t2(c,d);
-- Insert many rows into both t1 and t2
SELECT * FROM t1, t2 WHERE a=c;

在上面的查询中,如果t1和t2都有大约N行,那么没有任何索引,查询将需要O(N*N)时间。另一方面,在表t2上创建索引需要O(NlogN)时间,使用该索引评估查询需要额外的O(NlogN)时间。在没有ANALYZE信息的情况下,SQLite猜测N是100万,因此它认为构建自动索引将是更便宜的方法。

自动索引也可以用于子查询:

CREATE TABLE t1(a,b);
CREATE TABLE t2(c,d);
-- Insert many rows into both t1 and t2
SELECT a, (SELECT d FROM t2 WHERE c=b) FROM t1;

在本例中,t2表用于子查询中,以转换t1.b列的值。如果每个表包含N行,SQLite预计子查询将运行N次,因此它会认为首先在t2上构建一个自动的临时索引,然后使用该索引来满足子查询的N个实例会更快。

可以在运行时使用automatic_index杂注禁用自动索引功能。默认情况下,自动索引是打开的,但可以使用SQLITE_DEFAULT_AUTOMATIC_INDEX编译时选项将其更改为关闭。通过使用SQLITE_OMIT_AUTOMATIC_INDEX编译时选项进行编译,可以完全禁用创建自动索引的功能。

在SQLite 3.8.0(2013-08-26)及更高版本中,每次准备使用自动索引的语句时,都会向错误日志发送SQLITE_AUTOINDEX消息。应用程序开发人员可以并且应该使用这些警告来确定模式中是否需要新的持久索引。

不要将自动索引与内部索引(具有类似“sqlite_autoindex_table_N”的名称)混淆,有时创建内部索引是为了实现PRIMARY KEY约束或UNIQUE约束。这里描述的自动索引仅在单个查询期间存在,从不持久化到磁盘,并且仅对单个数据库连接可见。内部索引是PRIMARY KEY和UNIQUE约束实现的一部分,是持久的,持久存储到磁盘,并且对所有数据库连接可见。术语“autoindex”出现在内部索引的名称中是出于遗留原因,并不表示内部索引和自动索引是相关的。

 14.1.散列连接

自动索引与散列连接差不多。唯一的区别是使用B树而不是哈希表。如果你愿意说为自动索引构造的临时B树实际上只是一个花哨的哈希表,那么使用自动索引的查询就是一个哈希连接。

SQLite在这种情况下构建了一个临时索引而不是哈希表,因为它已经有了一个健壮且高性能的B树实现,而哈希表则需要添加。添加一个单独的哈希表实现来处理这种情况将增加库的大小(它是为在低内存嵌入式设备上使用而设计的),从而获得最小的性能增益。SQLite可能有一天会通过哈希表实现来增强,但现在看来,在客户端/服务器数据库引擎可能使用哈希连接的情况下,继续使用自动索引似乎更好。

15.下推优化

如果一个子查询不能被扁平化到外部查询中,仍然可以通过将WHERE子句项从外部查询“下推”到子查询中来提高性能。举个例子:

CREATE TABLE t1(a INT, b INT);
CREATE TABLE t2(x INT, y INT);
CREATE VIEW v1(a,b) AS SELECT DISTINCT a, b FROM t1;

SELECT x, y, b
  FROM t2 JOIN v1 ON (x=a)
 WHERE b BETWEEN 10 AND 20;

视图v1不能展平,因为它是DISTINCT。它必须作为子查询运行,结果存储在一个临时表中,然后在t2和临时表之间执行连接。下推优化将“B BETWEEN 10 AND 20”项下推到视图中。这使得临时表更小,如果t1.b上有索引,则有助于子查询运行得更快。结果评估如下:

SELECT x, y, b
  FROM t2
  JOIN (SELECT DISTINCT a, b FROM t1 WHERE b BETWEEN 10 AND 20)
 WHERE b BETWEEN 10 AND 20;

下推优化不能总是使用。例如,如果子查询包含LIMIT,则从外部查询向下推WHERE子句的任何部分都可能更改内部查询的结果。还有其他限制,在实现此优化的pushDownWhereTerms()例程的源代码中的注释中进行了解释。

16.外联接强度降低优化

外部联接(左联接、右联接或全联接)有时可以被简化。LEFT或RIGHT JOIN可以转换为普通(INNER)JOIN,或者FULL JOIN可以转换为LEFT或RIGHT JOIN。如果WHERE子句中的某些项保证简化后的结果相同,则会发生这种情况。例如,如果为了使WHERE子句为真,LEFT JOIN右侧表中的任何列必须为非NULL,则LEFT JOIN将降级为普通JOIN。

确定联接是否可以简化的定理证明器是不完善的。它有时会返回假阴性。换句话说,它有时无法证明降低外联接的强度是安全的,而事实上它是安全的。例如,证明器不知道datetime()SQL函数在第一个参数为NULL时将始终返回NULL,因此它不会识别以下查询中的LEFT JOIN可以被强度降低:

SELECT urls.url
  FROM urls
  LEFT JOIN
    (SELECT *
      FROM (SELECT url_id AS uid, max(retrieval_time) AS rtime
              FROM lookups GROUP BY 1 ORDER BY 1)
      WHERE uid IN (358341,358341,358341)
    ) recent
    ON u.source_seed_id = recent.xyz OR u.url_id = recent.xyz
 WHERE
     DATETIME(recent.rtime) > DATETIME('now', '-5 days');

将来对证明器的增强可能使它能够识别某些内置函数的NULL输入总是导致NULL答案。然而,并不是所有的内置函数都具有该属性(例如coalesce()),当然,证明器永远无法推理应用程序定义的SQL函数。

17. OUTER JOIN优化

有时可以从查询中完全省略LEFT或RIGHT JOIN而不更改结果。如果以下所有情况都为真,则可能发生这种情况:

  1. 查询不是聚合
  2. 要么查询是DISTINCT,要么OUTER JOIN上的ON或USING子句约束联接,使其仅匹配单行
  3. LEFT JOIN的右侧表或RIGHT JOIN的左侧表不能在查询中的USING或ON子句之外的任何地方使用。

当OUTER JOIN在视图内部使用时,OUTER JOIN消除经常出现,然后视图以这样的方式使用,即LEFT JOIN的右侧表或RIGHT JOIN的左侧表上的任何列都不被引用。

下面是一个简单的例子,省略了一个左连接:

CREATE TABLE t1(ipk INTEGER PRIMARY KEY, v1);
CREATE TABLE t2(ipk INTEGER PRIMARY KEY, v2);
CREATE TABLE t3(ipk INTEGER PRIMARY KEY, v3);

SELECT v1, v3 FROM t1 
  LEFT JOIN t2 ON (t1.ipk=t2.ipk)
  LEFT JOIN t3 ON (t1.ipk=t3.ipk)

t2表在上面的查询中完全没有使用,因此查询规划器能够实现查询,就像它是这样写的:

SELECT v1, v3 FROM t1 
  LEFT JOIN t3 ON (t1.ipk=t3.ipk)

在撰写本文时,只删除了LEFT JOIN。这种优化还没有推广到使用RIGHT JOIN,因为RIGHT JOIN是SQLite的一个相对较新的添加。这种不对称性可能会在未来的版本中得到纠正。

18.常数传播优化

当一个WHERE子句包含两个或多个由AND运算符连接的等式约束,使得各种约束的所有亲和性都相同时,SQLite可以使用等式的传递属性来构造新的“虚拟”约束,这些约束可以用于简化表达式和/或提高性能。这被称为“恒定传播优化”。

例如,考虑以下模式和查询:

CREATE TABLE t1(a INTEGER PRIMARY KEY, b INT, c INT);
SELECT * FROM t1 WHERE a=b AND b=5;

SQLite查看“a=B”和“B=5”约束,并推断如果这两个约束为真,那么“a=5”也必须为真。这意味着可以使用整数主键的值5快速查找所需的行。

上一篇: 开源项目CuteSqlite开发笔记(四):SQLite字节码引擎(SQLite的Explain详解)

下一篇:开源项目CuteSqlite开发笔记(六):SQLite通过索引优化查询

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值