小巧。快速。可靠。
三选二。

SQLite 的虚拟数据库引擎

过时文档警告: 本文档描述了 SQLite 版本 2.8.0 中使用的虚拟机。SQLite 版本 3.0 和 3.1 中的虚拟机在概念上类似,但现在是基于寄存器的,而不是基于堆栈的,每个操作码有五个操作数而不是三个,并且与下面显示的操作码集不同。有关当前的 VDBE 操作码集以及 VDBE 工作原理的简要概述,请参阅 虚拟机指令 文档。保留本文档作为历史参考。

如果您想了解 SQLite 库的内部工作原理,则需要从深入了解虚拟数据库引擎或 VDBE 开始。VDBE 出现在处理流的正中间(请参阅 体系结构图),因此它似乎与库的大部分部分相关。即使是那些没有直接与 VDBE 交互的代码部分,通常也是起辅助作用的。VDBE 确实是 SQLite 的核心。

本文简要介绍了 VDBE 的工作原理,特别是各种 VDBE 指令(在 此处 文档化)如何协同工作以对数据库执行有用的操作。该教程的风格是从简单任务开始,逐步解决更复杂的问题。在过程中,我们将访问 SQLite 库中的大多数子模块。完成本教程后,您应该对 SQLite 的工作原理有相当好的了解,并且可以开始学习实际的源代码。

预备知识

VDBE 实现了一个虚拟计算机,它在其虚拟机语言中运行程序。每个程序的目标都是查询或更改数据库。为此,VDBE 实现的机器语言专门设计用于搜索、读取和修改数据库。

VDBE 语言中的每个指令都包含一个操作码和三个操作数,分别标记为 P1、P2 和 P3。操作数 P1 是一个任意整数。P2 是一个非负整数。P3 是指向数据结构或以零结尾字符串的指针,可能为空。只有少数 VDBE 指令使用所有三个操作数。许多指令仅使用一个或两个操作数。相当数量的指令根本不使用操作数,而是从执行堆栈中获取数据并将其结果存储在执行堆栈中。每个指令的具体操作以及它使用的操作数在单独的 操作码描述 文档中进行了描述。

VDBE 程序从指令 0 开始执行,并继续执行后续指令,直到它 (1) 遇到致命错误,(2) 执行 Halt 指令,或 (3) 将程序计数器移至程序的最后一个指令之后。VDBE 完成执行后,所有打开的数据库游标都将关闭,所有内存都将释放,并且堆栈中的所有内容都将弹出。因此,无需担心内存泄漏或未分配的资源。

如果您做过任何汇编语言编程,或者以前使用过任何类型的抽象机器,那么所有这些细节都应该很熟悉。因此,让我们直接进入并开始查看一些代码。

将记录插入数据库

我们从一个可以使用只有几条指令的 VDBE 程序解决的问题开始。假设我们有一个使用以下方式创建的 SQL 表

CREATE TABLE examp(one text, two int);

换句话说,我们有一个名为“examp”的数据库表,它包含两个名为“one”和“two”的数据列。现在假设我们要将一条记录插入此表。如下所示

INSERT INTO examp VALUES('Hello, World!',99);

我们可以使用 sqlite 命令行实用程序查看 SQLite 用于实现此 INSERT 的 VDBE 程序。首先在一个新的空数据库上启动 sqlite,然后创建该表。接下来,通过输入“.explain”命令,将 sqlite 的输出格式更改为一种旨在与 VDBE 程序转储一起使用的格式。最后,输入上面显示的 [INSERT] 语句,但在 [INSERT] 之前加上特殊关键字 [EXPLAIN]。[EXPLAIN] 关键字将导致 sqlite 打印 VDBE 程序,而不是执行它。我们有

sqlite test_database_1
sqlite> CREATE TABLE examp(one text, two int);
sqlite> .explain
sqlite> EXPLAIN INSERT INTO examp VALUES('Hello, World!',99);
addr  opcode        p1     p2     p3                                      
----  ------------  -----  -----  -----------------------------------
0     Transaction   0      0                                         
1     VerifyCookie  0      81                                        
2     Transaction   1      0                                         
3     Integer       0      0                                         
4     OpenWrite     0      3      examp                              
5     NewRecno      0      0                                         
6     String        0      0      Hello, World!                      
7     Integer       99     0      99                                 
8     MakeRecord    2      0                                         
9     PutIntKey     0      1                                         
10    Close         0      0                                         
11    Commit        0      0                                         
12    Halt          0      0

如您在上面看到的,我们的简单插入语句是在 12 条指令中实现的。前 3 条和最后 2 条指令是标准的序言和尾声,因此实际工作是在中间的 7 条指令中完成的。没有跳转,因此程序从上到下执行一次。现在让我们详细查看每条指令。

0     Transaction   0      0                                         
1     VerifyCookie  0      81                                        
2     Transaction   1      0

指令 Transaction 开始一个事务。当遇到 Commit 或 Rollback 操作码时,事务结束。P1 是启动事务的数据库文件的索引。索引 0 是主数据库文件。当启动事务时,将在数据库文件上获取一个写锁。在事务进行期间,任何其他进程都无法读取或写入该文件。启动事务还会创建一个回滚日志。必须在对数据库进行任何更改之前启动事务。

指令 VerifyCookie 检查 cookie 0(数据库模式版本)以确保它等于 P2(上次读取数据库模式时获得的值)。P1 是数据库编号(主数据库为 0)。这样做是为了确保数据库模式没有被另一个线程更改,在这种情况下,必须重新读取它。

第二个 Transaction 指令开始一个事务,并为数据库 1(用于临时表的数据库)启动一个回滚日志。

3     Integer       0      0                                    
4     OpenWrite     0      3      examp

指令 Integer 将整数 P1 值(0)压入堆栈。这里 0 是在接下来的 OpenWrite 指令中使用的数据库编号。如果 P3 不为空,则它是一个字符串形式的相同整数。之后,堆栈如下所示

(integer) 0

指令 OpenWrite 在表“examp”上打开一个新的读写游标,其句柄为 P1(本例中为 0),根页为 P2(在本数据库文件中为 3)。游标句柄可以是任何非负整数。但是 VDBE 在一个数组中分配游标,该数组的大小比最大的游标大 1。因此,为了节省内存,最好使用从 0 开始并连续向上工作的句柄。这里 P3(“examp”)是正在打开的表的名称,但它未使用,仅用于使代码更易读。此指令从堆栈顶部弹出了要使用的数据库编号(0,主数据库),因此之后堆栈再次为空。

5     NewRecno      0      0

指令 NewRecno 为游标 P1 指向的表创建一个新的整型记录编号。该记录编号是当前未用作表中键的编号。新记录编号被压入堆栈。之后,堆栈如下所示

(integer) new record key
6     String        0      0      Hello, World!

指令 String 将其 P3 操作数压入堆栈。之后,堆栈如下所示

(string) "Hello, World!"
(integer) new record key
7     Integer       99     0      99

指令 Integer 将其 P1 操作数 (99) 压入堆栈。之后,堆栈如下所示

(integer) 99
(string) "Hello, World!"
(integer) new record key
8     MakeRecord    2      0

指令 MakeRecord 从堆栈顶部弹出 P1 个元素(本例中为 2),并将它们转换为用于在数据库文件中存储记录的二进制格式。(有关详细信息,请参阅 文件格式 说明。)MakeRecord 指令生成的新记录被压回到堆栈中。之后,堆栈如下所示

(record) "Hello, World!", 99
(integer) new record key
9     PutIntKey     0      1

指令 PutIntKey 使用堆栈顶部的 2 个条目将一个条目写入由光标 P1 指向的表中。如果条目不存在,则会创建一个新条目,否则会覆盖现有条目的数据。记录数据是堆栈顶部的条目,键是下一个条目。此指令将堆栈弹出两次。因为操作数 P2 为 1,所以行变更计数将递增,并且行 ID 将存储起来,以便稍后由 sqlite_last_insert_rowid() 函数返回。如果 P2 为 0,则行变更计数不会修改。此指令是实际进行插入操作的地方。

10    Close         0      0

指令 Close 关闭先前打开的作为 P1 (0,唯一打开的光标) 的光标。如果 P1 当前未打开,则此指令将不会执行任何操作。

11    Commit        0      0

指令 Commit 会使自上次事务以来对数据库进行的所有修改生效。在开始另一个事务之前,不允许进行其他修改。Commit 指令会删除日志文件并释放对数据库的写锁定。如果仍有光标打开,则会继续保持读锁定。

12    Halt          0      0

指令 Halt 会导致 VDBE 引擎立即退出。所有打开的光标、列表、排序等都会自动关闭。P1 是 sqlite_exec() 返回的结果代码。对于正常停止,这应该是 SQLITE_OK (0)。对于错误,它可以是其他值。操作数 P2 仅在发生错误时使用。每个程序的末尾都包含一个隐含的 "Halt 0 0 0" 指令,VDBE 在准备运行程序时会附加该指令。

跟踪 VDBE 程序执行

如果 SQLite 库在没有 NDEBUG 预处理器宏的情况下编译,则 PRAGMA vdbe_trace 会使 VDBE 跟踪程序的执行。虽然此功能最初是为了测试和调试而设计的,但它在了解 VDBE 的工作原理方面也很有用。使用 "PRAGMA vdbe_trace=ON;" 打开跟踪,并使用 "PRAGMA vdbe_trace=OFF" 关闭跟踪。例如:

sqlite> PRAGMA vdbe_trace=ON;
   0 Halt            0    0
sqlite> INSERT INTO examp VALUES('Hello, World!',99);
   0 Transaction     0    0
   1 VerifyCookie    0   81
   2 Transaction     1    0
   3 Integer         0    0
Stack: i:0
   4 OpenWrite       0    3 examp
   5 NewRecno        0    0
Stack: i:2
   6 String          0    0 Hello, World!
Stack: t[Hello,.World!] i:2
   7 Integer        99    0 99
Stack: si:99 t[Hello,.World!] i:2
   8 MakeRecord      2    0
Stack: s[...Hello,.World!.99] i:2
   9 PutIntKey       0    1
  10 Close           0    0
  11 Commit          0    0
  12 Halt            0    0

在跟踪模式开启的情况下,VDBE 会在执行指令之前打印每个指令。执行指令后,会显示堆栈中顶部的一些条目。如果堆栈为空,则不会显示堆栈信息。

在堆栈显示中,大多数条目都使用前缀来显示,该前缀表示该堆栈条目的数据类型。整数以 "i" 开头。浮点值以 "r" 开头。("r" 代表 "实数"。)字符串以 "s", "t", "e" 或 "z" 开头。字符串前缀之间的区别是由它们在内存中的分配方式造成的。z: 字符串存储在从 malloc() 获得的内存中。t: 字符串是静态分配的。e: 字符串是临时的。所有其他字符串都有 s: 前缀。这对您(观察者)来说没有任何区别,但对 VDBE 来说至关重要,因为 z: 字符串在弹出时需要传递给 free() 以避免内存泄漏。请注意,只会显示字符串值的前 10 个字符,并且二进制值(例如 MakeRecord 指令的结果)被视为字符串。VDBE 堆栈上可以存储的唯一其他数据类型是 NULL,它不带前缀显示,只是 "NULL"。如果整数以整数和字符串两种形式放置在堆栈上,则其前缀为 "si".

简单查询

至此,您应该了解 VDBE 如何写入数据库的基本原理。现在,让我们看看它如何执行查询。我们将使用以下简单的 SELECT 语句作为示例

SELECT * FROM examp;

为该 SQL 语句生成的 VDBE 程序如下所示

sqlite> EXPLAIN SELECT * FROM examp;
addr  opcode        p1     p2     p3                                 
----  ------------  -----  -----  -----------------------------------
0     ColumnName    0      0      one                                
1     ColumnName    1      0      two                                
2     Integer       0      0                                         
3     OpenRead      0      3      examp                              
4     VerifyCookie  0      81                                        
5     Rewind        0      10                                        
6     Column        0      0                                         
7     Column        0      1                                         
8     Callback      2      0                                         
9     Next          0      6                                         
10    Close         0      0                                         
11    Halt          0      0

在我们开始研究这个问题之前,让我们简要回顾一下 SQLite 中查询的工作原理,这样我们就知道我们要完成什么。对于查询结果中的每一行,SQLite 会调用一个具有以下原型的回调函数

int Callback(void *pUserData, int nColumn, char *azData[], char *azColumnName[]);

SQLite 库为 VDBE 提供了指向回调函数的指针和 pUserData 指针。(回调和用户数据最初都作为参数传递给 sqlite_exec() API 函数。)VDBE 的任务是为 nColumnazData[]azColumnName[] 生成值。nColumn 是结果中的列数,当然。azColumnName[] 是一个字符串数组,每个字符串都是结果列之一的名称。azData[] 是一个包含实际数据的字符串数组。

0     ColumnName    0      0      one                                
1     ColumnName    1      0      two

查询的 VDBE 程序中的前两个指令与为 azColumn 设置值有关。ColumnName 指令告诉 VDBE 在 azColumnName[] 数组的每个元素中填写哪些值。每个查询都将以每个结果列一个 ColumnName 指令开头,并且在查询的后面会为每个指令匹配一个 Column 指令。

2     Integer       0      0                                         
3     OpenRead      0      3      examp                              
4     VerifyCookie  0      81

指令 2 和 3 在要查询的数据库表上打开一个读光标。这与 INSERT 示例中的 OpenWrite 指令的工作原理相同,只是这次是为读取而不是写入而打开的光标。指令 4 就像 INSERT 示例中一样验证数据库模式。

5     Rewind        0      10

指令 Rewind 初始化一个循环,该循环遍历 "examp" 表。它将光标 P1 倒回其表中的第一个条目。Column 和 Next 指令需要这样做,因为它们使用光标遍历表。如果表为空,则跳转到 P2 (10),即循环后面的指令。如果表不为空,则贯穿到下一条指令(地址 6),即循环体的开头。

6     Column        0      0                                         
7     Column        0      1                                         
8     Callback      2      0

指令 6 到 8 构成循环体,该循环体将对数据库文件中的每个记录执行一次。地址 6 和 7 处的 Column 指令分别从 P1-th 光标中获取 P2-th 列并将其压入堆栈。在此示例中,第一个 Column 指令将 "one" 列的值压入堆栈,第二个 Column 指令将 "two" 列的值压入堆栈。地址 8 处的 Callback 指令调用 callback() 函数。Callback 的 P1 操作数将成为 nColumn 的值。Callback 指令从堆栈中弹出 P1 个值,并使用它们来填充 azData[] 数组。

9     Next          0      6

地址 9 处的指令实现循环的分支部分。它与地址 5 处的 Rewind 一起构成循环逻辑。这是一个关键概念,您应该密切关注。Next 指令将光标 P1 前进到下一条记录。如果光标前进成功,则立即跳转到 P2 (6,循环体的开头)。如果光标位于末尾,则贯穿到下一条指令,该指令结束循环。

10    Close         0      0                                         
11    Halt          0      0

程序末尾的 Close 指令关闭指向 "examp" 表的光标。这里实际上没有必要调用 Close,因为所有光标都会在程序停止时由 VDBE 自动关闭。但我们需要一个指令供 Rewind 跳转到,所以我们不妨让该指令做一些有用的事情。Halt 指令结束 VDBE 程序。

请注意,此 SELECT 查询的程序不包含 INSERT 示例中使用的 Transaction 和 Commit 指令。因为 SELECT 是一个不会更改数据库的读取操作,所以它不需要事务。

一个稍微复杂的查询

前面示例的关键点是使用 Callback 指令调用回调函数,以及使用 Next 指令实现对数据库文件所有记录的循环。本示例试图通过演示一个稍微复杂一点的查询来加深对这些概念的理解,该查询涉及更多输出列,其中一些是计算值,并且 WHERE 子句限制了哪些记录实际上能够到达回调函数。考虑以下查询

SELECT one, two, one || two AS 'both'
FROM examp
WHERE one LIKE 'H%'

此查询可能有点牵强,但它确实可以说明我们的要点。结果将有三列,名称为 "one"、"two" 和 "both"。前两列是表中两列的直接副本,第三个结果列是由表的第 1 列和第 2 列连接而成的字符串。最后,WHERE 子句指出我们只选择 "one" 列以 "H" 开头的行的结果。以下是此查询的 VDBE 程序的样子

addr  opcode        p1     p2     p3                                      
----  ------------  -----  -----  -----------------------------------
0     ColumnName    0      0      one
1     ColumnName    1      0      two
2     ColumnName    2      0      both
3     Integer       0      0
4     OpenRead      0      3      examp
5     VerifyCookie  0      81
6     Rewind        0      18
7     String        0      0      H%                                      
8     Column        0      0
9     Function      2      0      ptr(0x7f1ac0)
10    IfNot         1      17
11    Column        0      0
12    Column        0      1
13    Column        0      0
14    Column        0      1
15    Concat        2      0
16    Callback      3      0
17    Next         0      7
18    Close         0      0
19    Halt         0      0

除了 WHERE 子句之外,本示例程序的结构与之前的示例非常相似,只是多了一列。现在有 3 列,而不是之前的 2 列,并且有 3 个 ColumnName 指令。使用 OpenRead 指令打开游标,就像之前的示例一样。地址 6 的 Rewind 指令和地址 17 的 Next 指令构成对表中所有记录的循环。结尾处的 Close 指令用于在 Rewind 指令完成时提供一个跳转目标。所有这些都与第一个查询演示相同。

本示例中的 Callback 指令必须为三个结果列生成数据,而不是两个,但在其他方面与第一个查询相同。当调用 Callback 指令时,结果的最左侧列应该位于堆栈的最低处,最右侧的结果列应该位于堆栈的顶部。我们可以看到堆栈在地址 11 到 15 处以这种方式设置。地址 11 和 12 的 Column 指令将第一个结果列的值推入堆栈。地址 13 和 14 的两个 Column 指令拉入计算第三个结果列所需的数值,地址 15 的 Concat 指令将它们连接成堆栈上的单个条目。

当前示例中唯一真正新颖的是 WHERE 子句,该子句由地址 7 到 10 处的指令实现。地址 7 和 8 处的指令将表中 "one" 列的值和文字字符串 "H%" 推入堆栈。地址 9 的 Function 指令从堆栈中弹出这两个值,并将 LIKE() 函数的结果压回堆栈。地址 10 的 IfNot 指令弹出堆栈顶部的值,如果顶部值是 false(与文字字符串 "H%" 不匹配),则立即向前跳转到 Next 指令。执行此跳转实际上跳过了回调,这是 WHERE 子句的全部意义所在。如果比较结果为 true,则不会执行跳转,控制权将继续传递到下面的 Callback 指令。

请注意 LIKE 运算符是如何实现的。它是 SQLite 中的自定义函数,因此其函数定义的地址在 P3 中指定。操作数 P1 是它从堆栈中获取的函数参数数量。在本例中,LIKE() 函数接受 2 个参数。参数按相反顺序(从右到左)从堆栈中取出,因此要匹配的模式是堆栈顶部的元素,而下一个元素是要比较的数据。返回值将被推入堆栈。

SELECT 程序模板

前两个查询示例说明了一种模板,每个 SELECT 程序都将遵循该模板。基本上,我们有

  1. 为回调初始化 azColumnName[] 数组。
  2. 打开指向要查询表的游标。
  3. 对于表中的每个记录,执行以下操作
    1. 如果 WHERE 子句计算结果为 FALSE,则跳过以下步骤,并继续执行下一条记录。
    2. 计算结果当前行的所有列。
    3. 调用结果当前行的回调函数。
  4. 关闭游标。

当我们考虑额外的复杂情况,例如连接、复合选择、使用索引来加速搜索、排序以及带有和不带有 GROUP BY 和 HAVING 子句的聚合函数时,此模板将被大幅扩展。但是,相同的基本思想将继续适用。

UPDATE 和 DELETE 语句

UPDATE 和 DELETE 语句使用与 SELECT 语句模板非常相似的模板进行编码。当然,主要区别在于最终操作是修改数据库,而不是调用回调函数。由于它会修改数据库,因此它也会使用事务。让我们从查看 DELETE 语句开始

DELETE FROM examp WHERE two<50;

此 DELETE 语句将删除 "examp" 表中 "two" 列小于 50 的所有记录。生成执行此操作的代码如下

addr  opcode        p1     p2     p3                                      
----  ------------  -----  -----  -----------------------------------
0     Transaction   1      0
1     Transaction   0      0
2     VerifyCookie  0      178
3     Integer       0      0
4     OpenRead      0      3      examp
5     Rewind        0      12
6     Column        0      1
7     Integer       50     0      50
8     Ge            1      11
9     Recno         0      0
10    ListWrite     0      0
11    Next         0      6
12    Close         0      0
13    ListRewind    0      0
14    Integer       0      0
15    OpenWrite     0      3
16    ListRead      0      20
17    NotExists     0      19
18    Delete        0      1
19    Goto         0      16
20    ListReset     0      0
21    Close         0      0
22    Commit        0      0
23    Halt         0      0

以下是程序必须执行的操作。首先,它必须定位 "examp" 表中要删除的所有记录。这使用与上面的 SELECT 示例中使用的循环非常相似的循环来完成。一旦找到所有记录,我们就可以逐一返回并删除它们。请注意,我们不能在找到每条记录后立即删除它。我们必须先找到所有记录,然后返回删除它们。这是因为 SQLite 数据库后端可能会在删除操作后更改扫描顺序。如果扫描顺序在扫描过程中发生变化,则某些记录可能会被访问多次,而其他记录则可能根本不被访问。

因此,DELETE 的实现实际上是在两个循环中进行的。第一个循环(指令 5 到 11)定位要删除的记录并将它们的键保存到一个临时列表中,第二个循环(指令 16 到 19)使用键列表逐一删除记录。

0     Transaction   1      0
1     Transaction   0      0
2     VerifyCookie  0      178
3     Integer       0      0
4     OpenRead      0      3      examp

指令 0 到 4 与 INSERT 示例中的相同。它们为主数据库和临时数据库启动事务,验证主数据库的数据库架构,并在 "examp" 表上打开一个读取游标。请注意,游标是为读取打开的,而不是为写入打开的。在此程序阶段,我们只打算扫描表,而不是更改它。我们将在后面的指令 15 处重新打开同一个表以进行写入。

5     Rewind        0      12

与 SELECT 示例一样,Rewind 指令将游标倒回到表的开头,使其准备好用于循环体。

6     Column        0      1
7     Integer       50     0      50
8     Ge            1      11

WHERE 子句由指令 6 到 8 实现。WHERE 子句的工作是如果 WHERE 条件为 false 则跳过 ListWrite。为此,它如果 "two" 列(由 Column 指令提取)大于或等于 50,则向前跳转到 Next 指令。

与之前一样,Column 指令使用游标 P1 并将数据记录中的列 P2(1,"two" 列)推入堆栈。Integer 指令将值 50 推入堆栈顶部。在这两个指令之后,堆栈看起来像

(integer) 50
(record) "two" 列的当前记录

Ge 运算符比较堆栈顶部的两个元素,弹出它们,然后根据比较结果进行分支。如果第二个元素 >= 顶部元素,则跳转到地址 P2(循环末尾的 Next 指令)。由于 P1 为 true,如果任一操作数为 NULL(因此结果为 NULL),则执行跳转。如果我们不跳转,只需前进到下一条指令。

9     Recno         0      0
10    ListWrite     0      0

Recno 指令将一个整数推入堆栈,该整数是游标 P1 指向的表中当前条目的键的前 4 个字节。ListWrite 指令将堆栈顶部的整数写入临时存储列表并将顶部元素弹出。这是此循环的重要工作,将要删除的记录的键存储起来,以便我们可以在第二个循环中删除它们。在执行此 ListWrite 指令之后,堆栈将再次为空。

11    Next         0      6
12    Close         0      0

Next 指令递增游标以指向游标 P0 指向的表中的下一个元素,如果成功则分支到 P2(6,循环体的开头)。Close 指令关闭游标 P1。它不会影响临时存储列表,因为它与游标 P1 无关;相反,它是一个全局工作列表(可以使用 ListPush 保存)。

13    ListRewind    0      0

ListRewind 指令将临时存储列表倒回到开头。这将使其准备好用于第二个循环。

14    Integer       0      0
15    OpenWrite     0      3

与 INSERT 示例一样,我们将数据库编号 P1(0,主数据库)推入堆栈,并使用 OpenWrite 在表 P2(基页 3,"examp")上打开游标 P1 以进行修改。

16    ListRead      0      20
17    NotExists     0      19
18    Delete        0      1
19    Goto         0      16

此循环执行实际的删除操作。它与 UPDATE 示例中的循环组织方式不同。ListRead 指令在该循环中扮演了 Next 在 INSERT 循环中的角色,但由于它在失败时跳转到 P2,而 Next 在成功时跳转,因此我们将它放在循环的开头而不是结尾。这意味着我们必须在循环的末尾放置一个 Goto 来跳转回开头处的循环测试。因此,此循环的形式为 C 语言中的 while(){...} 循环,而 INSERT 示例中的循环形式为 do{...}while() 循环。Delete 指令在该循环中扮演了回调函数在前面示例中的角色。

The ListRead 指令从临时存储列表中读取一个元素并将其压入堆栈。如果成功,它将继续执行下一条指令。如果失败,因为列表为空,它将分支到 P2,即循环后的指令。之后,堆栈看起来像这样

(integer) 当前记录的键

请注意 ListRead 和 Next 指令之间的相似之处。两种操作都遵循以下规则

将下一个“事物”压入堆栈,并继续执行,或者跳转到 P2,具体取决于是否有下一个“事物”要压入。

Next 和 ListRead 之间的区别在于它们对“事物”的定义。Next 指令的“事物”是数据库文件中的记录。ListRead 的“事物”是列表中的整数键。另一个区别是,如果没有下一个“事物”,是跳转还是继续执行。在本例中,Next 继续执行,而 ListRead 跳转。稍后,我们将看到其他使用相同原则运行的循环指令(NextIdx 和 SortNext)。

The NotExists 指令弹出堆栈顶部的元素,并将其用作整数键。如果表 P1 中不存在具有该键的记录,则跳转到 P2。如果存在记录,则继续执行下一条指令。在本例中,P2 将我们带到循环末尾的 Goto,该 Goto 跳转回开头的 ListRead。这本来可以编写为让 P2 为 16,即循环开始处的 ListRead,但生成此代码的 SQLite 解析器没有进行这种优化。

The Delete 指令执行该循环的工作;它从堆栈中弹出整数键(由前面的 ListRead 放入堆栈中),并删除游标 P1 中具有该键的记录。因为 P2 为真,所以行更改计数器将增加。

The Goto 指令跳转回循环的开头。这是循环的结束。

20    ListReset     0      0
21    Close         0      0
22    Commit        0      0
23    Halt         0      0

此指令块清理 VDBE 程序。其中的三个指令实际上并不需要,但它们是由 SQLite 解析器从其代码模板生成的,这些模板旨在处理更复杂的情况。

The ListReset 指令清空临时存储列表。此列表会在 VDBE 程序终止时自动清空,因此在本例中是不必要的。Close 指令关闭游标 P1。同样,这也是在 VDBE 引擎完成运行此程序时执行的。Commit 成功结束当前事务,并导致该事务中发生的所有更改保存到数据库中。最后的 Halt 也是不必要的,因为它在准备运行时被添加到每个 VDBE 程序中。

UPDATE 语句的工作方式与 DELETE 语句非常相似,只是它们不会删除记录,而是用新记录替换它们。考虑以下示例

UPDATE examp SET one= '(' || one || ')' WHERE two < 50;

此语句不会删除“two”列小于 50 的记录,而是将“one”列放在括号中。实现此语句的 VDBE 程序如下

addr  opcode        p1     p2     p3                                      
----  ------------  -----  -----  -----------------------------------
0     Transaction   1      0                                         
1     Transaction   0      0                                         
2     VerifyCookie  0      178                                            
3     Integer       0      0                                         
4     OpenRead      0      3      examp                              
5     Rewind       0      12                                        
6     Column        0      1                                         
7     Integer       50     0      50                                 
8     Ge            1      11                                        
9     Recno         0      0                                         
10    ListWrite     0      0                                         
11    Next         0      6                                              
12    Close         0      0                                         
13    Integer       0      0                                         
14    OpenWrite     0      3                                              
15    ListRewind    0      0                                         
16    ListRead      0      28                                             
17    Dup           0      0                                         
18    NotExists     0      16                                             
19    String        0      0      (                                  
20    Column        0      0                                         
21    Concat        2      0                                         
22    String        0      0      )                                  
23    Concat        2      0                                         
24    Column        0      1                                         
25    MakeRecord    2      0                                         
26    PutIntKey     0      1                                         
27    Goto         0      16                                             
28    ListReset     0      0                                         
29    Close         0      0                                         
30    Commit        0      0                                         
31    Halt         0      0

此程序与 DELETE 程序基本相同,只是第二个循环的主体被用于更新记录而不是删除记录的一系列指令(在地址 17 到 26 处)替换了。大多数此指令序列你应该已经熟悉,但有一些细微的调整,因此我们将简要回顾一下。另请注意,第二个循环之前和之后的某些指令的顺序发生了变化。这只是 SQLite 解析器选择使用不同的模板输出代码的方式。

当我们进入第二个循环的内部(在指令 17 处)时,堆栈包含一个整数,它是我们要修改的记录的键。我们需要使用此键两次:一次用于获取记录的旧值,第二次用于写回修改后的记录。因此,第一条指令是 Dup,它在堆栈顶部创建键的副本。Dup 指令会复制堆栈的任何元素,而不仅仅是顶部元素。你使用 P1 操作数指定要复制的元素。当 P1 为 0 时,堆栈顶部的元素被复制。当 P1 为 1 时,堆栈中下一个元素被复制。依此类推。

在复制键之后,下一条指令 NotExists 弹出堆栈一次,并使用弹出的值作为键来检查数据库文件中记录是否存在。如果不存在具有此键的记录,它将跳转回 ListRead 获取另一个键。

指令 19 到 25 创建一个新的数据库记录,该记录将用于替换现有记录。这与我们在 INSERT 描述中看到的代码类型相同,不再赘述。在指令 25 执行后,堆栈看起来像这样

(record) 新的数据记录
(integer) 键

PutIntKey 指令(也在关于 INSERT 的讨论中进行了描述)将一个条目写入数据库文件,其数据为堆栈顶部,其键为堆栈中的下一个元素,然后弹出堆栈两次。PutIntKey 指令会用具有相同键的现有记录的数据覆盖它,这是我们在这里想要的。覆盖在 INSERT 中不是问题,因为在 INSERT 中,键是由 NewRecno 指令生成的,该指令保证提供以前从未使用过的键。

CREATE 和 DROP

使用 CREATE 或 DROP 创建或删除表或索引实际上与从特殊的“sqlite_master”表中执行 INSERT 或 DELETE 相同,至少从 VDBE 的角度来看是这样的。sqlite_master 表是一个特殊的表,会自动为每个 SQLite 数据库创建。它看起来像这样

CREATE TABLE sqlite_master (
  type      TEXT,    -- either "table" or "index"
  name      TEXT,    -- name of this table or index
  tbl_name  TEXT,    -- for indices: name of associated table
  sql       TEXT     -- SQL text of the original CREATE statement
)

每个表(除了“sqlite_master”表本身)和每个 SQLite 数据库中的命名索引在 sqlite_master 表中都有一个条目。你可以使用 SELECT 语句查询该表,就像查询任何其他表一样。但是,你不能使用 UPDATE、INSERT 或 DELETE 直接更改该表。对 sqlite_master 的更改必须使用 CREATE 和 DROP 命令,因为 SQLite 在添加或删除表和索引时还需要更新一些内部数据结构。

但从 VDBE 的角度来看,CREATE 几乎就像 INSERT 一样工作,而 DROP 则像 DELETE 一样工作。当 SQLite 库打开到现有数据库时,它所做的第一件事就是执行 SELECT 以读取“sqlite_master”表所有条目的“sql”列。 “sql”列包含最初生成索引或表的 CREATE 语句的完整 SQL 文本。该文本被反馈到 SQLite 解析器中,并用于重建描述索引或表的内部数据结构。

使用索引加速搜索

在上面的示例查询中,查询的表的每一行都必须从磁盘加载并检查,即使最终只有很小一部分行出现在结果中。对于大型表,这可能需要很长时间。为了加快速度,SQLite 可以使用索引。

SQLite 文件将一个键与一些数据关联。对于 SQLite 表,数据库文件被设置成使得键是一个整数,而数据是表中一行的信息。SQLite 中的索引反转了这种安排。索引键是(一部分)被存储的信息,而索引数据是一个整数。为了访问具有特定内容的表行,我们首先在索引表中查找该内容以找到它的整数索引,然后使用该整数在表中查找完整记录。

请注意,SQLite 使用 B 树,这是一种排序的数据结构,因此在 SELECT 语句的 WHERE 子句包含对等式或不等式的测试时,可以 使用索引。

SELECT * FROM examp WHERE two==50;
SELECT * FROM examp WHERE two<50;
SELECT * FROM examp WHERE two IN (50, 100);

如果存在将“examp”表的“two”列映射到整数的索引,那么 SQLite 将使用该索引找到“examp”表中所有“two”列值为 50 的行的整数键,或者所有值小于 50 的行的整数键,等等。但以下查询无法使用索引

SELECT * FROM examp WHERE two%50 == 10;
SELECT * FROM examp WHERE two&127 == 3;

请注意,SQLite 解析器不会总是生成代码来使用索引,即使这样做是可能的。以下查询目前不会使用索引

SELECT * FROM examp WHERE two+10 == 50;
SELECT * FROM examp WHERE two==50 OR two==100;

为了更好地理解索引的工作原理,让我们首先了解如何创建索引。让我们继续在“examp”表的“two”列上添加一个索引。我们有

CREATE INDEX examp_idx1 ON examp(two);

上述语句生成的 VDBE 代码如下

addr  opcode        p1     p2     p3                                      
----  ------------  -----  -----  -----------------------------------
0     Transaction   1      0                                         
1     Transaction   0      0                                         
2     VerifyCookie  0      178                                            
3     Integer       0      0                                         
4     OpenWrite     0      2                                         
5     NewRecno      0      0                                         
6     String        0      0      index                              
7     String        0      0      examp_idx1                         
8     String        0      0      examp                              
9     CreateIndex   0      0      ptr(0x791380)                      
10    Dup          0      0                                         
11    Integer       0      0                                         
12    OpenWrite     1      0                                         
13    String        0      0      CREATE INDEX examp_idx1 ON examp(tw
14    MakeRecord    5      0                                         
15    PutIntKey     0      0                                         
16    Integer       0      0                                         
17    OpenRead      2      3      examp                              
18    Rewind        2      24                                             
19    Recno         2      0                                         
20    Column        2      1                                         
21    MakeIdxKey    1      0      n                                  
22    IdxPut        1      0      索引的列不是唯一的     
23    Next          2      19                                             
24    Close         2      0                                         
25    Close         1      0                                         
26    Integer       333    0                                         
27    SetCookie     0      0                                         
28    Close         0      0                                         
29    Commit        0      0                                         
30    Halt          0      0

请记住,每个表(除了 sqlite_master)和每个命名索引在 sqlite_master 表中都有一个条目。由于我们正在创建一个新索引,因此我们必须在 sqlite_master 中添加一个新条目。这由指令 3 到 15 处理。向 sqlite_master 添加条目就像任何其他 INSERT 语句一样,所以我们在这里不再赘述。在这个例子中,我们想要重点关注用有效数据填充新索引,这发生在指令 16 到 23 上。

16    Integer       0      0                                         
17    OpenRead      2      3      examp

首先发生的事情是我们打开要索引的表以供读取。为了为表构建索引,我们必须知道该表中有什么。索引已经使用游标 0 由指令 3 和 4 打开以供写入。

18    Rewind        2      24                                             
19    Recno         2      0                                         
20    Column        2      1                                         
21    MakeIdxKey    1      0      n                                  
22    IdxPut        1      0      索引的列不是唯一的     
23    Next          2      19

指令 18 到 23 实现了一个循环,遍历要索引的表的每一行。对于每一行表,我们首先使用指令 19 中的 Recno 提取该行的整数键,然后使用指令 20 中的 Column 获取“two”列的值。 MakeIdxKey 指令 21 将来自“two”列的数据(位于堆栈顶部)转换为有效的索引键。对于单个列上的索引,这基本上是一个无操作。但是,如果 MakeIdxKey 的 P1 操作数大于 1,那么多个条目将从堆栈中弹出并转换为单个索引键。IdxPut 指令 22 是真正创建索引条目的指令。IdxPut 从堆栈中弹出两个元素。堆栈顶部的元素用作键,从索引表中获取一个条目。然后将位于堆栈中第二位的整数添加到该索引的整数集,并将新记录写回数据库文件。请注意,如果“two”列的值相同,则同一个索引条目可以存储多个整数。

现在让我们看看如何使用这个索引。考虑以下查询

SELECT * FROM examp WHERE two==50;

SQLite 生成以下 VDBE 代码来处理此查询

addr  opcode        p1     p2     p3                                      
----  ------------  -----  -----  -----------------------------------
0     ColumnName    0      0      one                                
1     ColumnName    1      0      two                                
2     Integer       0      0                                         
3     OpenRead      0      3      examp                              
4     VerifyCookie  0      256                                            
5     Integer       0      0                                         
6     OpenRead      1      4      examp_idx1                         
7     Integer       50     0      50                            
8     MakeKey       1      0      n                                  
9     MemStore      0      0                                         
10    MoveTo        1      19                                             
11    MemLoad       0      0                                         
12    IdxGT         1      19                                             
13    IdxRecno      1      0                                         
14    MoveTo        0      0                                         
15    Column        0      0                                         
16    Column        0      1                                         
17    Callback      2      0                                         
18    Next         1      11                                        
19    Close         0      0                                         
20    Close         1      0                                         
21    Halt         0      0

SELECT 语句以一种熟悉的方式开始。首先,初始化列名并打开要查询的表。从第 5 和第 6 条指令开始,事情变得不同,其中索引文件也被打开。第 7 和第 8 条指令使用值为 50 的键创建了一个键。第 9 条指令中的 MemStore 将索引键存储在 VDBE 内存位置 0 中。VDBE 内存用于避免从堆栈深处获取值,这是可以做到的,但会使程序更难生成。下一条指令 MoveTo 在地址 10 处弹出键并将索引游标移动到具有该键的索引的第一行。这将初始化游标以在后续循环中使用。

第 11 到第 18 条指令实现了对由第 8 条指令获取的键的所有索引记录的循环。所有具有此键的索引记录在索引表中将是连续的,因此我们将遍历它们并从索引中获取相应的表键。然后,使用此表键将游标移动到表中的该行。循环的其余部分与非索引 SELECT 查询的循环相同。

循环从第 11 条指令的 MemLoad 开始,该指令将索引键的副本压回堆栈。第 12 条指令的 IdxGT 将该键与游标 P1 指向的当前索引记录中的键进行比较。如果当前游标位置的索引键大于我们正在查找的索引,则跳出循环。

第 13 条指令的 IdxRecno 将索引中的表记录号压入堆栈。接下来的 MoveTo 将其弹出并将表游标移动到该行。接下来的 3 条指令以与非索引情况相同的方式选择列数据。Column 指令获取列数据并调用回调函数。最后的 Next 指令将索引游标(而不是表游标)前进到下一行,然后如果还有索引记录,则分支回循环的开头。

由于索引用于在表中查找值,因此索引和表必须保持一致。现在,examp 表上有了一个索引,因此每当在 examp 表中插入、删除或更改数据时,我们都必须更新该索引。请记住上面的第一个示例,其中我们使用 12 条 VDBE 指令将新行插入“examp”表。现在此表被索引了,需要 19 条指令。SQL 语句如下:

INSERT INTO examp VALUES('Hello, World!',99);

生成的代码如下:

addr  opcode        p1     p2     p3                                      
----  ------------  -----  -----  -----------------------------------
0     Transaction   1      0                                         
1     Transaction   0      0                                         
2     VerifyCookie  0      256                                            
3     Integer       0      0                                         
4     OpenWrite     0      3      examp                              
5     Integer       0      0                                         
6     OpenWrite     1      4      examp_idx1                         
7     NewRecno      0      0                                         
8     String       0      0      Hello, World!                      
9     Integer       99     0      99                                 
10    Dup           2      1                                         
11    Dup           1      1                                         
12    MakeIdxKey    1      0      n                                  
13    IdxPut        1      0                                         
14    MakeRecord    2      0                                         
15    PutIntKey     0      1                                         
16    Close         0      0                                         
17    Close         1      0                                         
18    Commit        0      0                                         
19    Halt         0      0

此时,您应该对 VDBE 有足够的了解,可以自己弄清楚上述程序是如何工作的。因此,我们不会在本文中进一步讨论它。

连接

在连接中,组合两个或多个表以生成单个结果。结果表包含要连接的表中的每一行的所有可能组合。实现此目的最简单、最自然的方法是使用嵌套循环。

回想一下上面讨论的查询模板,其中有一个循环搜索表的每个记录。在连接中,我们基本上有相同的内容,除了有嵌套循环。例如,要连接两个表,查询模板可能看起来像这样:

  1. 为回调初始化 azColumnName[] 数组。
  2. 打开两个游标,一个指向每个要查询的表。
  3. 对于第一个表中的每个记录,执行以下操作:
    1. 对于第二个表中的每个记录,执行以下操作:
      1. 如果 WHERE 子句计算结果为 FALSE,则跳过以下步骤,并继续执行下一条记录。
      2. 计算结果当前行的所有列。
      3. 调用结果当前行的回调函数。
  4. 关闭两个游标。

此模板将起作用,但它很可能很慢,因为我们现在处理的是一个 O(N2) 循环。但通常情况下,WHERE 子句可以分解为项,并且其中一个或多个项将仅涉及第一个表中的列。发生这种情况时,我们可以将 WHERE 子句测试的一部分从内部循环中分解出来并获得更高的效率。因此,一个更好的模板将类似于以下内容:

  1. 为回调初始化 azColumnName[] 数组。
  2. 打开两个游标,一个指向每个要查询的表。
  3. 对于第一个表中的每个记录,执行以下操作:
    1. 评估仅涉及第一个表中的列的 WHERE 子句的项。如果任何项为假(意味着整个 WHERE 子句必须为假),则跳过此循环的其余部分并继续进行下一个记录。
    2. 对于第二个表中的每个记录,执行以下操作:
      1. 如果 WHERE 子句计算结果为 FALSE,则跳过以下步骤,并继续执行下一条记录。
      2. 计算结果当前行的所有列。
      3. 调用结果当前行的回调函数。
  4. 关闭两个游标。

如果可以使用索引来加速对两个循环中的任何一个的搜索,则可以进一步加速。

SQLite 始终以 FROM 子句中表的出现顺序构建循环。最左边的表成为外部循环,最右边的表成为内部循环。理论上,在某些情况下,可以重新排列循环以加速连接的评估。但是,SQLite 不会尝试这种优化。

您可以在以下示例中看到 SQLite 如何构建嵌套循环:

CREATE TABLE examp2(three int, four int);
SELECT * FROM examp, examp2 WHERE two<50 AND four==two;
addr  opcode        p1     p2     p3                                      
----  ------------  -----  -----  -----------------------------------
0     ColumnName    0      0      examp.one                          
1     ColumnName    1      0      examp.two                          
2     ColumnName    2      0      examp2.three                       
3     ColumnName    3      0      examp2.four                        
4     Integer       0      0                                         
5     OpenRead      0      3      examp                              
6     VerifyCookie  0      909                                            
7     Integer       0      0                                         
8     OpenRead      1      5      examp2                             
9     Rewind        0      24                                             
10    Column        0      1                                         
11    Integer       50     0      50                                 
12    Ge            1      23                                             
13    倒带        1      23                                             
14    列        1      1                                         
15    列        0      1                                         
16    Ne            1      22                                        
17    列        0      0                                         
18    列        0      1                                         
19    列        1      0                                         
20    列        1      1                                         
21    回调      4      0                                         
22    下一个        1      14                                            
23    下一个        0      10                                       
24    关闭        0      0                                         
25    Close         1      0                                         
26    停止         0      0

外部循环遍历表 examp 由第 7 到 23 行指令实现。内部循环是第 13 到 22 行指令。注意,WHERE 表达式的“two<50”项仅涉及第一个表中的列,可以从内部循环中分解出来。SQLite 确实会这样做,并在第 10 到 12 行指令中实现“two<50”测试。 “four==two”测试由内部循环中的第 14 到 16 行指令实现。

SQLite 不会对连接中的表施加任何任意限制。它还允许将表与自身连接。

ORDER BY 子句

出于历史原因和效率考虑,当前所有排序都在内存中完成。

SQLite 使用一组特殊的指令来实现 ORDER BY 子句,以控制称为排序器的对象。在查询的最内层循环中,通常会有一个 Callback 指令,而现在则构建一个包含回调参数和键的记录。将此记录添加到排序器(在链接列表中)。查询循环结束后,将对记录列表进行排序,并遍历此列表。对于列表上的每个记录,都会调用回调。最后,排序器会关闭,内存将被释放。

我们可以看到此过程在以下查询中的作用

SELECT * FROM examp ORDER BY one DESC, two;
addr  opcode        p1     p2     p3                                      
----  ------------  -----  -----  -----------------------------------
0     ColumnName    0      0      one                                
1     ColumnName    1      0      two                                
2     Integer       0      0                                         
3     OpenRead      0      3      examp                              
4     VerifyCookie  0      909                                            
5     倒带        0      14                                             
6     Column        0      0                                         
7     Column        0      1                                         
8     SortMakeRec   2      0                                              
9     列        0      0                                         
10    Column        0      1                                         
11    SortMakeKey   2      0      D+                                 
12    SortPut       0      0                                              
13    下一个        0      6                                             
14    关闭        0      0                                             
15    排序        0      0                                             
16    SortNext      0      19                                             
17    SortCallback  2      0                                              
18    跳转          0      16                                             
19    SortReset     0      0                                         
20    停止         0      0

只有一个排序器对象,因此没有指令来打开或关闭它。它在需要时会自动打开,并在 VDBE 程序停止时关闭。

查询循环由第 5 到 13 行指令构建。第 6 到 8 行指令构建一个记录,其中包含单个回调调用的 azData[] 值。第 9 到 11 行指令生成排序键。第 12 行指令将调用记录和排序键组合成一个条目,并将该条目放入排序列表中。

指令 11 的 P3 参数特别有趣。排序键是通过将 P3 中的一个字符预先添加到每个字符串并连接所有字符串而形成的。排序比较函数将查看此字符以确定排序顺序是升序还是降序,以及是按字符串排序还是按数字排序。在此示例中,第一列应按字符串降序排序,因此其前缀为“D”,第二列应按数字升序排序,因此其前缀为“+”。升序字符串排序使用“A”,降序数字排序使用“-”。

查询循环结束后,将在第 14 行指令中关闭正在查询的表。这样做是为了尽早允许其他进程或线程访问该表(如果需要)。在查询循环中构建的记录列表将在第 15 行指令中进行排序。第 16 到 18 行指令遍历记录列表(现在已按排序顺序排序),并对每个记录调用一次回调。最后,将在第 19 行指令中关闭排序器。

聚合函数和 GROUP BY 和 HAVING 子句

为了计算聚合函数,VDBE 实现了特殊的的数据结构和指令来控制该数据结构。该数据结构是一个无序的桶集,每个桶都有一个键和一个或多个内存位置。在查询循环中,GROUP BY 子句用于构建键,并使具有该键的桶成为焦点。如果以前不存在,则使用该键创建一个新桶。一旦桶成为焦点,桶的内存位置将用于累积各个聚合函数的值。查询循环结束后,将访问每个桶一次以生成结果的单行。

一个例子将有助于阐明这个概念。考虑以下查询

SELECT three, min(three+four)+avg(four) 
FROM examp2
GROUP BY three;

为此查询生成的 VDBE 代码如下

addr  opcode        p1     p2     p3                                      
----  ------------  -----  -----  -----------------------------------
0     ColumnName    0      0      three                              
1     ColumnName    1      0      min(three+four)+avg(four)          
2     AggReset      0      3                                              
3     AggInit       0      1      ptr(0x7903a0)                      
4     AggInit       0      2      ptr(0x790700)                      
5     Integer       0      0                                         
6     OpenRead      0      5      examp2                             
7     VerifyCookie  0      909                                            
8     倒带        0      23                                             
9     列        0      0                                         
10    MakeKey       1      0      n                                  
11    AggFocus      0      14                                             
12    列        0      0                                         
13    AggSet        0      0                                         
14    列        0      0                                         
15    列        0      1                                         
16    加          0      0                                         
17    整数       1      0                                         
18    AggFunc       0      1      ptr(0x7903a0)                      
19    Column        0      1                                         
20    Integer       2      0                                         
21    AggFunc       0      1      ptr(0x790700)                      
22    Next         0      9                                              
23    Close         0      0                                              
24    AggNext       0      31                                        
25    AggGet        0      0                                             
26    AggGet        0      1                                              
27    AggGet        0      2                                         
28    Add          0      0                                         
29    Callback      2      0                                         
30    Goto          0      24                                             
31    Noop          0      0                                         
32    Halt          0      0

第一个需要关注的指令是位于第2行的 AggReset。AggReset 指令将桶集合初始化为空集,并指定每个桶中可用的内存槽位数量为 P2。在这个例子中,每个桶将包含 3 个内存槽位。虽然不明显,但如果你仔细观察程序的其余部分,就可以弄清楚每个槽位的预期用途。

内存槽该内存槽的预期用途
0"three" 列 - 桶的键值
1"three+four" 的最小值
2所有 "four" 值的总和。这用于计算 "avg(four)"。

查询循环由第 8 行到第 22 行的指令实现。由 GROUP BY 子句指定的聚合键由第 9 行和第 10 行的指令计算。第 11 行的指令使相应的桶成为焦点。如果具有给定键的桶不存在,则会创建一个新的桶,并将控制流转到第 12 行和第 13 行,它们会初始化该桶。如果该桶已经存在,则会跳转到第 14 行。聚合函数的值由第 11 行到第 21 行之间的指令更新。第 14 行到第 18 行的指令会更新内存槽 1,使它保存下一个 "min(three+four)" 值。然后,第 19 行到第 21 行的指令会更新 "four" 列的总和。

查询循环完成后,第 23 行的指令会关闭 "examp2" 表,以便释放其锁,以便其他线程或进程可以使用它。下一步是遍历所有聚合桶,并为每个桶输出结果表的一行。这是通过第 24 行到第 30 行的循环完成的。第 24 行的 AggNext 指令会使下一个桶成为焦点,或者如果所有桶都已检查完毕,则跳转到循环末尾。结果的 3 列按顺序从聚合桶中获取,分别在第 25 行到第 27 行。最后,在第 29 行调用回调函数。

总之,任何具有聚合函数的查询都是通过两个循环实现的。第一个循环扫描输入表并计算聚合信息到桶中,第二个循环扫描所有桶以计算最终结果。

认识到聚合查询实际上是两个连续的循环,这使得理解 SQL 查询语句中的 WHERE 子句和 HAVING 子句之间的区别变得容易得多。WHERE 子句是对第一个循环的限制,而 HAVING 子句是对第二个循环的限制。你可以通过向我们的示例查询中添加 WHERE 和 HAVING 子句来看到这一点。

SELECT three, min(three+four)+avg(four) 
FROM examp2
WHERE three>four
GROUP BY three
HAVING avg(four)<10;
addr  opcode        p1     p2     p3                                      
----  ------------  -----  -----  -----------------------------------
0     ColumnName    0      0      three                              
1     ColumnName    1      0      min(three+four)+avg(four)          
2     AggReset      0      3                                              
3     AggInit       0      1      ptr(0x7903a0)                      
4     AggInit       0      2      ptr(0x790700)                      
5     Integer       0      0                                         
6     OpenRead      0      5      examp2                             
7     VerifyCookie  0      909                                            
8     Rewind        0      26                                             
9     列        0      0                                         
10    Column        0      1                                         
11    Le            1      25                                             
12    列        0      0                                         
13    MakeKey       1      0      n                                  
14    AggFocus      0      17                                             
15    Column        0      0                                         
16    AggSet        0      0                                         
17    列        0      0                                         
18    列        0      1                                         
19    Add           0      0                                         
20    Integer       1      0                                         
21    AggFunc       0      1      ptr(0x7903a0)                      
22    Column        0      1                                         
23    Integer       2      0                                         
24    AggFunc       0      1      ptr(0x790700)                      
25    Next          0      9                                              
26    Close         0      0                                              
27    AggNext       0      37                                             
28    AggGet        0      2                                         
29    Integer       10     0      10                                 
30    Ge            1      27                                             
31    AggGet        0      0                                         
32    AggGet        0      1                                         
33    AggGet        0      2                                         
34    Add           0      0                                         
35    Callback      2      0                                         
36    Goto          0      27                                             
37    Noop          0      0                                         
38    Halt          0      0

在这个最后一个例子中生成的代码与上一个相同,只是添加了两个条件跳转用于实现额外的 WHERE 和 HAVING 子句。WHERE 子句由查询循环中的第 9 行到第 11 行的指令实现。HAVING 子句由输出循环中的第 28 行到第 30 行的指令实现。

将 SELECT 语句用作表达式中的项

"结构化查询语言" 这个名字本身就告诉我们,SQL 应该支持嵌套查询。实际上,它支持两种不同的嵌套方式。任何返回单行单列结果的 SELECT 语句都可以用作另一个 SELECT 语句表达式中的项。而返回单列多行结果的 SELECT 语句可以用作 IN 和 NOT IN 运算符的右侧操作数。我们将从第一个嵌套示例开始,其中单行单列 SELECT 用作另一个 SELECT 表达式中的项。以下就是我们的示例

SELECT * FROM examp
WHERE two!=(SELECT three FROM examp2
            WHERE four=5);

SQLite 处理这种情况的方式是首先执行内部 SELECT(针对 examp2 的那个),并将结果存储在一个私有内存单元中。然后,SQLite 在评估外部 SELECT 时用此私有内存单元的值替换内部 SELECT。代码如下所示

addr  opcode        p1     p2     p3                                      
----  ------------  -----  -----  -----------------------------------
0     String        0      0                                         
1     MemStore      0      1                                        
2     Integer       0      0                                         
3     OpenRead      1      5      examp2                             
4     VerifyCookie  0      909                                            
5     Rewind        1      13                                             
6     Column        1      1                                         
7     Integer       5      0      5                                  
8     Ne           1      12                                        
9     Column        1      0                                         
10    MemStore      0      1                                         
11    Goto          0      13                                             
12    Next          1      6                                              
13    Close         1      0                                         
14    ColumnName    0      0      one                                
15    ColumnName    1      0      two                                
16    Integer       0      0                                         
17    OpenRead      0      3      examp                              
18    Rewind        0      26                                             
19    Column        0      1                                         
20    MemLoad       0      0                                         
21    Eq           1      25                                             
22    Column        0      0                                         
23    Column        0      1                                         
24    Callback      2      0                                         
25    Next          0      19                                             
26    Close         0      0                                         
27    Halt          0      0

私有内存单元由前两条指令初始化为 NULL。指令 2 到 13 实施了针对 examp2 表的内部 SELECT 语句。请注意,查询结果不是发送到回调或存储在排序器上,而是由指令 10 推入内存单元,并且循环通过指令 11 处的跳转被放弃。指令 11 处的跳转是残留的,永远不会执行。

外部 SELECT 由指令 14 到 25 实施。特别地,包含嵌套选择的 WHERE 子句由指令 19 到 21 实施。您可以看到,内部选择的結果由指令 20 加载到堆栈中,并在 21 处的条件跳转中使用。

当子选择的結果是标量时,可以使用单个私有内存单元,如前例所示。但是,当子选择的結果是向量时,例如当子选择是 IN 或 NOT IN 的右侧操作数时,则需要使用不同的方法。在这种情况下,子选择的結果将存储在一个瞬态表中,并使用 Found 或 NotFound 运算符测试该表的内容。考虑以下示例

SELECT * FROM examp
WHERE two IN (SELECT three FROM examp2);

实施最后一次查询生成的代码如下所示

addr  opcode        p1     p2     p3                                      
----  ------------  -----  -----  -----------------------------------
0     OpenTemp      1      1                                         
1     Integer       0      0                                         
2     OpenRead      2      5      examp2                             
3     VerifyCookie  0      909                                           
4     Rewind        2      10                                        
5     Column        2      0                                         
6     IsNull        -1     9                                             
7     String        0      0                                         
8     PutStrKey     1      0                                         
9     Next          2      5                                              
10    Close         2      0                                         
11    ColumnName    0      0      one                                
12    ColumnName    1      0      two                                
13    Integer       0      0                                         
14    OpenRead      0      3      examp                              
15    Rewind        0      25                                             
16    Column        0      1                                         
17    NotNull       -1     20                                        
18    Pop           1      0                                         
19    Goto          0      24                                             
20    NotFound      1      24                                             
21    Column        0      0                                         
22    Column        0      1                                         
23    Callback      2      0                                         
24    Next          0      16                                             
25    Close         0      0                                         
26    停止         0      0

存储内部 SELECT 结果的瞬态表由 0 处的 OpenTemp 指令创建。此操作码用于仅在单个 SQL 语句持续时间内存在的表。即使主数据库是只读的,瞬态游标也始终以读写方式打开。瞬态表在游标关闭时会自动删除。P2 值为 1 表示游标指向一个 BTree 索引,该索引没有数据,但可以具有任意键。

内部的 SELECT 语句由指令 1 到 10 实现。这段代码所做的只是为 examp2 表中“three”列非 NULL 值的每一行在临时表中创建一个条目。每个临时表条目的键是 examp2 的“three”列,数据是空字符串,因为它从未使用过。

外部 SELECT 由指令 11 到 25 实现。特别是,包含 IN 运算符的 WHERE 子句由指令 16、17 和 20 实现。指令 16 将当前行的“two”列的值压入堆栈,指令 17 检查它是否为非 NULL 值。如果成功,执行跳转到 20,在那里它测试堆栈顶端是否与临时表中的任何键匹配。其余的代码与之前显示的相同。

复合 SELECT 语句

SQLite 还允许使用 UNION、UNION ALL、INTERSECT 和 EXCEPT 运算符将两个或多个 SELECT 语句作为对等体连接起来。这些复合选择语句使用临时表实现。每个运算符的实现略有不同,但基本思想是相同的。举个例子,我们将使用 EXCEPT 运算符。

SELECT two FROM examp
EXCEPT
SELECT four FROM examp2;

最后一个例子的结果应该是 examp 表中“two”列的每个唯一值,除了任何出现在 examp2 的“four”列中的值都被移除。实现此查询的代码如下所示

addr  opcode        p1     p2     p3                                      
----  ------------  -----  -----  -----------------------------------
0     OpenTemp      0      1                                         
1     KeyAsData     0      1                                              
2     Integer       0      0                                         
3     OpenRead      1      3      examp                              
4     VerifyCookie  0      909                                            
5     Rewind        1      11                                        
6     Column        1      1                                         
7     MakeRecord    1      0                                         
8     String        0      0                                         
9     PutStrKey     0      0                                         
10    Next         1      6                                              
11    Close         1      0                                         
12    Integer       0      0                                         
13    OpenRead      2      5      examp2                             
14    Rewind        2      20                                        
15    Column        2      1                                         
16    MakeRecord    1      0                                         
17    NotFound      0      19                                             
18    Delete        0      0                                         
19    Next         2      15                                             
20    Close         2      0                                         
21    ColumnName    0      0      four                               
22    Rewind        0      26                                             
23    Column        0      0                                         
24    Callback      1      0                                         
25    Next         0      23                                             
26    Close         0      0                                         
27    Halt          0      0

指令 0 创建了用于构建结果的临时表。然后是三个循环。指令 5 到 10 的循环实现了第一个 SELECT 语句。第二个 SELECT 语句由指令 14 到 19 的循环实现。最后,指令 22 到 25 的循环读取临时表,并为结果中的每一行调用一次回调函数。

在本例中,指令 1 尤其重要。通常,Column 指令从 SQLite 文件条目数据中更大的记录中提取列值。指令 1 在临时表上设置一个标志,以便 Column 会将 SQLite 文件条目的键当作数据,并从键中提取列信息。

将发生以下情况:第一个 SELECT 语句将构造结果行并将每行保存为临时表条目中的键。每个临时表条目的数据从未使用过,因此我们用一个空字符串填充它。第二个 SELECT 语句也构造行,但第二个 SELECT 构造的行将从临时表中删除。这就是为什么我们要将行存储在 SQLite 文件的键中而不是数据中的原因——这样可以很容易地找到和删除它们。

让我们更仔细地看看这里发生了什么。第一个 SELECT 由指令 5 到 10 的循环实现。指令 5 通过倒回游标来初始化循环。指令 6 从“examp”中提取“two”列的值,指令 7 将其转换为一行。指令 8 将一个空字符串压入堆栈。最后,指令 9 将行写入临时表。但是请记住,PutStrKey 操作码使用堆栈顶端作为记录数据,并将堆栈上的下一个作为键。对于 INSERT 语句,MakeRecord 操作码生成的行列为记录数据,而记录键是 NewRecno 操作码创建的整数。但在这里,角色发生了反转,MakeRecord 创建的行是记录键,而记录数据只是一个空字符串。

第二个 SELECT 由指令 14 到 19 实现。指令 14 通过倒回游标来初始化循环。指令 15 和 16 从表“examp2”的“four”列创建一个新的结果行。但我们没有使用 PutStrKey 将此新行写入临时表,而是调用 Delete 来从临时表中删除它(如果它存在)。

复合选择的結果由指令 22 到 25 的循环发送到回调例程。此循环没有任何新奇之处,除了指令 23 中的 Column 指令将从记录键而不是记录数据中提取列。

总结

本文回顾了 SQLite 的 VDBE 用于实现 SQL 语句的所有主要技术。本文没有展示的是,大多数这些技术可以组合使用来为适当复杂的查询语句生成代码。例如,我们展示了如何在简单的查询中完成排序,以及如何实现复合查询。但我们没有给出在复合查询中排序的示例。这是因为对复合查询进行排序不会引入任何新概念:它只是在同一个 VDBE 程序中结合了两个之前的想法(排序和复合)。

有关 SQLite 库函数的更多信息,请读者直接查看 SQLite 源代码。如果您理解本文中的内容,您应该不会对理解源代码有任何困难。认真研究 SQLite 内部机制的学生可能还想仔细研究 VDBE 操作码,如 此处 所述。大多数操作码文档是从源代码中的注释中提取出来的,使用一个脚本,因此您也可以直接从 vdbe.c 源文件中获取有关各种操作码的信息。如果您成功地读到了这里,您应该能够轻松地理解其余内容。

如果您在文档或代码中发现错误,请随时修复它们和/或通过 [email protected] 联系作者。您的错误修复或建议始终受欢迎。

此页面最后修改于 2022-01-08 05:02:57 UTC