首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

The Virtual Database Engine of SQLite

代码语言:javascript
复制
 Obsolete Documentation Warning: This document describes the virtual machine used in SQLite version 2.8.0. The virtual machine in SQLite version 3.0 and 3.1 is similar in concept but is now register-based instead of stack-based, has five operands per opcode instead of three, and has a different set of opcodes from those shown below. See the virtual machine instructions document for the current set of VDBE opcodes and a brief overview of how the VDBE operates. This document is retained as an historical reference. 

如果您想知道SQLite库如何在内部工作,您需要从对Virtual Database Engine或VDBE的深入了解开始。VDBE正好出现在处理流的中间(见体系结构图),因此它似乎触及库的大部分,即使不直接与VDBE交互的代码通常也起着支持作用。VDBE是SQLite的核心。

本文简要介绍了VDBE的工作原理,特别是VDBE各种指令(这里记录的)如何协同工作来完成数据库的有用工作。风格是教程,从简单的任务开始,并致力于解决更复杂的问题。在过程中,我们将访问SQLite库中的大多数子模块。完成本教程后,您应该非常了解SQLite的工作原理,并准备好开始研究实际的源代码。

准备工作

VDBE实现了以虚拟机语言运行程序的虚拟计算机。每个程序的目标是询问或更改数据库。为此,VDBE实现的机器语言专门用于搜索,读取和修改数据库。

VDBE语言的每条指令都包含一个操作码和三个标记为P1,P2和P3的操作数。操作数P1是一个任意整数。P2是一个非负整数。P3是指向数据结构或空终止字符串的指针,可能为null。只有少数VDBE指令使用全部三个操作数。许多指令只使用一个或两个操作数。大量的指令完全不使用操作数,而是将其数据存储在执行堆栈中。每个指令执行的操作以及它使用的操作数的细节在单独的操作码描述文档中有描述。

VDBE程序从指令0开始执行,并继续执行相继的指令,直到

(1)遇到致命错误

(2)执行停止指令

(3)使程序计数器超过程序的最后一条指令为止。

当VDBE完成执行时,所有打开的数据库游标都关闭,所有内存都被释放,并且所有内容都从堆栈中弹出,所以从不担心内存泄漏或未分配的资源。

如果你已经完成了任何汇编语言编程,或者之前已经使用过任何一种抽象机器,那么所有这些细节都应该是你熟悉的。所以让我们直接跳入并开始寻找一些代码。

将记录插入数据库

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

代码语言:javascript
复制
CREATE TABLE examp(one text, two int);

换句话说,我们有一个名为“examp”的数据库表,其中有两列数据名为“one”和“two”。现在假设我们想要在这个表中插入一条记录。比如这个:

代码语言:javascript
复制
INSERT INTO examp VALUES('Hello, World!',99);

我们可以看到SQLite使用sqlite命令行工具来实现这个INSERT的VDBE程序。首先在新的空数据库上启动sqlite,然后创建表。接下来,通过输入“.explain”命令,将sqlite的输出格式更改为设计用于VDBE程序转储的表单。最后,输入上面显示的INSERT语句,但需在特殊关键字EXPLAIN的INSERT之前。EXPLAIN关键字将导致sqlite打印VDBE程序而不是执行它。我们有如下代码:

代码语言:javascript
复制
$ 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

指令开始进行,当此事务在遇到Commit或Rollback操作码时结束。P1是事务开始的数据库文件的索引。索引0是主数据库文件。在事务启动时,数据库文件会获得写入锁定。在事务正在进行时,没有其他进程可以读取或写入文件。开始一个事务也会创建一个回退日志。事务必须在对数据库进行任何更改之前启动。

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

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

3 Integer 0 0 4 OpenWrite 0 3 examp

Integer指令将整数值P1(0)推入堆栈。这里0是在下面的OpenWrite指令中使用的数据库的编号。如果P3不是NULL,那么它是同一整数的字符串表示。之后,堆栈如下:

(integer) 0

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

5 NewRecno 0 0

指令NewRecno为光标P1指向的表格创建一个新的整数记录编号。记录号是当前没有用作表中的一个键,新的记录号码被压入堆栈。之后,堆栈如下:

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

指令字符串将其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,因此行更改计数会递增,并存储rowid以供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”开始追踪。如下:

代码语言:javascript
复制
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语句作为示例:

代码语言:javascript
复制
SELECT * FROM examp;

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

代码语言:javascript
复制
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将使用以下原型调用回调函数:

代码语言:javascript
复制
int Callback(void *pUserData, int nColumn, char *azData[], char *azColumnName[]);

SQLite库为VDBE提供了一个指向回调函数和pUserData指针的指针。(回调函数和用户数据最初都是作为参数传递给sqlite_exec() API函数的)。VDBE的工作是为nColumn、azData []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指令初始化循环遍历“示例”表。它将光标P1回退到其表中的第一个条目。这是Column和Next指令所要求的,它们使用光标遍历表格。如果表格为空,则跳转到P2(10),这是刚刚通过循环的指令。如果表格不是空的,则循环到以下指令6,这是循环体的开始。

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

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

9 Next 0 6

地址9的指令实现了循环的分支部分。与地址5处的倒回一起,它形成循环逻辑。这是你应该密切关注的一个关键概念。Next指令将光标P1推进到下一条记录。如果光标前进成功,则立即跳转到P2(6,循环体的开始处)。如果光标在最后,则转到下面的指令,这会结束循环。

10 Close 0 0 11 Halt 0 0

程序结束处的关闭指令关闭指向表“示例”的光标。在这里调用Close并不是必须的,因为当程序停止时,所有的游标都将被VDBE自动关闭。但是我们需要一条指令让倒带跳转到所以我们可以继续前进,并让该指令做一些有用的事情。停止指令结束VDBE程序。

请注意,此SELECT查询的程序不包含INSERT示例中使用的事务和提交指令。由于SELECT是一种不改变数据库的读取操作,因此不需要事务。

一个稍微复杂的查询

前一个示例的关键点在于使用Callback指令来调用回调函数,并使用Next指令对数据库文件的所有记录执行循环。这个例子试图通过演示一个稍微复杂一点的查询来解决这些想法,这个查询涉及更多的输出列,其中一些是计算值,还有一个WHERE子句限制哪些记录实际上使它成为回调函数。思考这个查询:

代码语言:javascript
复制
SELECT one, two, one || two AS 'both'
FROM examp
WHERE one LIKE 'H%'

这个查询可能有点不常规,但它确实有助于说明我们的观点。结果将有三个名称分别为“one”,“two”和“both”的列。前两列是表中两列的直接副本,第三个结果列是通过连接表的第一列和第二列形成的字符串。最后,WHERE子句说,我们只会选择行的结果,其中“一”列以“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列,并且有三个ColumnName指令。使用OpenRead指令打开游标,就像在前面的例子中一样。地址6处的倒回指令和地址17处的下一个地址在表格的所有记录上形成循环。最后的关闭指令可以让倒带指令在完成时跳转到某个位置。这一切就像在第一个查询演示中一样。

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

关于当前例子的唯一新东西是WHERE子句,它由地址7到10的指令实现。地址7和8的指令将表中的“one”列的值和字面值字符串“H%”。地址9处的Function指令从堆栈弹出这两个值,并将LIKE()函数的结果返回到堆栈。该指令如不方便弹出栈顶值,并导致立即跳跃式前进到下一条指令,如果顶部的值是假的(并非不像文本字符串“H%”)。有效地跳过回调,这是WHERE子句的全部要点。如果比较结果为真,

注意LIKE操作符是如何实现的。它是SQLite中的用户定义函数,因此其函数定义的地址在P3中指定。操作数P1是它从堆栈中获取的函数参数的数量。在这种情况下,LIKE()函数需要2个参数。参数以相反的顺序(从右到左)从堆栈中取出,因此要匹配的模式是顶层堆栈元素,下一个元素是要比较的数据。返回值被压入堆栈。

SELECT程序的模板

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

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

由于我们考虑了更多的复杂性,例如连接,复合选择,使用索引来加快搜索,排序和聚合函数(有和无GROUP BY和HAVING子句),此模板将大大扩展。但相同的基本思想将继续适用。

更新和删除语句

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

代码语言:javascript
复制
DELETE FROM examp WHERE two<50;

此DELETE语句将从“示例”表中删除“两个”列小于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

这是程序必须做的事情。首先,它必须找到要删除的表中的“示例”中的所有记录。这是通过一个循环完成的,非常像上面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示例中的一样。他们为主数据库和临时数据库启动事务,验证主数据库的数据库模式,并在表“示例”上打开读取光标。注意光标打开是为了阅读,而不是写作。在程序的这个阶段,我们只会扫描表格,而不是改变它。我们将在稍后的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子句的作业是跳过ListWrite。为此,如果“two”列(由Column指令提取)大于或等于50,则它跳到Next指令。

如前所述,Column指令使用游标P1并将列P2(1,列“2”)中的数据记录推入堆栈。整数指令将值50推到栈顶。在这两条指令之后,堆栈看起来像:

(integer) 50 (record) current record for column "two"

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

9 Recno 0 0 10 ListWrite 0 0

Recno指令会将一个整数(它是该键的前4个字节的整数)压入到由光标P1指向的表的顺序扫描中的当前入口。ListWrite指令将堆栈顶部的整数写入临时存储列表并弹出顶部元素。这是该循环的重要工作,用于存储要删除的记录的键,以便我们可以在第二个循环中删除它们。在此ListWrite指令之后,堆栈再次为空。

11 Next 0 6 12 Close 0 0

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

13 ListRewind 0 0

ListRewind指令将临时存储列表回卷到开头。这准备它在第二个循环中使用。

14 Integer 0 0 15 OpenWrite 0 3

与INSERT示例中一样,我们将数据库编号P1(0,主数据库)推入堆栈,然后使用OpenWrite打开表P2上的光标P1(基本页3,“示例”)以进行修改。

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

这个循环做了实际的删除。它的组织与UPDATE示例中的不同。ListRead指令扮演的角色是INSERT循环中的Next,但是因为它在失败时跳转到P2,并且Next在成功时跳转,所以我们把它放在循环的开始处而不是结束处。这意味着我们必须在循环结尾放置一个Goto,以便在开始时跳回到循环测试。因此,此循环的形式为C while(){...}循环,而INSERT示例中的循环具有do {...} while()循环的形式。Delete指令填充了回调函数在前面的例子中所做的角色。

ListRead指令从临时存储列表中读取一个元素并将其推入堆栈。如果这是成功的,它会继续下一条指令。如果由于列表为空而导致失败,它将跳转到P2,这是循环之后的指令。之后堆栈如下:

(integer) key for current record

注意ListRead和Next指令之间的相似性。两个操作都按照这个规则工作:

将下一个“thing”推入堆栈,然后通过或跳转到P2,具体取决于是否存在要推送的下一个“thing”。

Next和ListRead之间的一个区别是他们的“事物”的想法。Next指令的“thing”是数据库文件中的记录。ListRead的“Things”是列表中的整数键。另一个区别是,如果没有下一个“thing”,是跳跃还是跌倒。在这种情况下,Next通过,并且ListRead跳转。稍后,我们将看到其他循环指令(NextIdx和SortNext),它们使用相同的原理操作。

NotExists指令弹出顶层堆栈元素并将其用作整数键。如果表P1中不存在具有该键的记录,则跳转到P2。如果记录确实存在,则转入下一条指令。在这种情况下,P2在循环结束时将我们带到Goto,它在开始时跳回到ListRead。这可能已被编码为16(循环开始时的ListRead)但生成此代码的SQLite解析器没有进行优化。

Delete执行这个循环的工作; 它从堆栈中弹出一个整数键(由前面的ListRead放置)并删除具有该键的光标P1的记录。由于P2为真,行的更改计数器递增。

Goto跳回到循环的开始。这是循环的结束。

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

这块指令清除了VDBE程序。这些指令中有三条并不是真正需要的,但它们是由SQLite解析器从它的代码模板生成的,这些代码模板旨在处理更复杂的情况。

ListReset指令清空临时存储列表。该列表在VDBE程序终止时自动清空,因此在这种情况下不需要。关闭指令关闭光标P1。再次,这是由VDBE引擎完成运行该程序时完成的。提交成功结束当前事务,并导致在此事务中发生的所有更改都保存到数据库中。最后的停止也是不必要的,因为它在准备运行时被添加到每个VDBE程序中。

UPDATE语句的工作方式与DELETE语句非常相似,不同之处在于它们不是删除记录,而是使用新语句替换它。思考这个例子:

代码语言:javascript
复制
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) new data record (integer) key

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

CREATE 与 DROP

至少从VDBE的角度来看,使用CREATE或DROP创建或销毁表或索引与从特殊的“sqlite_master”表执行INSERT或DELETE的操作是相同的。sqlite_master表是一个为每个SQLite数据库自动创建的特殊表。如下:

代码语言:javascript
复制
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子句包含相等或不等的测试时使用索引。如果以下查询可用,则可以使用索引:

代码语言:javascript
复制
SELECT * FROM examp WHERE two==50;
SELECT * FROM examp WHERE two<50;
SELECT * FROM examp WHERE two IN (50, 100);

如果存在将“examp”表的“two”列映射为整数的索引,则SQLite将使用该索引来查找示例中所有行的整数键,其中第二列的值为50,或者所有行小于50等,但以下查询不能使用索引:

代码语言:javascript
复制
SELECT * FROM examp WHERE two%50 == 10;
SELECT * FROM examp WHERE two&127 == 3;

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

代码语言:javascript
复制
SELECT * FROM examp WHERE two+10 == 50;
SELECT * FROM examp WHERE two==50 OR two==100;

为了更好地理解指数是如何工作的,让我们先看看它们是如何创建的。让我们继续并在实例表的两列上放置一个索引。我们有:

代码语言:javascript
复制
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 indexed columns are not unique 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

第一件事是打开被索引读取的表格。为了构建表的索引,我们必须知道该表中的内容。索引已经被打开,以便通过指令3和4使用光标0进行写入。

18 Rewind 2 24 19 Recno 2 0 20 Column 2 1 21 MakeIdxKey 1 0 n 22 IdxPut 1 0 indexed columns are not unique 23 Next 2 19

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

现在我们来看看如何使用这个索引。思索以下查询:

代码语言:javascript
复制
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存储器用于避免必须从堆栈深处获取值,这可以完成,但使程序更难生成。在地址10处的以下指令MoveTo将该键从堆栈中弹出,并使用该键将索引光标移动到索引的第一行。这将初始化光标以在以下循环中使用。

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

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

位于13的指令IdxRecno将来自索引的表记录号码压入堆栈。以下MoveTo将其弹出并将表格光标移动到该行。接下来的3条指令以与非索引情况下相同的方式选择列数据。列指令获取列数据并调用回调函数。最后的下一条指令将索引游标,而不是表游标,推进到下一行,然后分支回循环的开始,如果有任何索引记录剩下的话。

由于索引用于查找表中的值,因此索引和表保持一致非常重要。既然实例表中有一个索引,那么只要在实例表中插入,删除或更改数据,就必须更新该索引。请记住上面的第一个示例,我们可以使用12 VDBE指令将新行插入“示例”表中。现在该表已编入索引,需要19条说明。SQL语句是这样的:

代码语言:javascript
复制
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,以便自己弄清楚上面的程序是如何工作的。所以我们不会在本文中进一步讨论它。

Joins

在一个连接中,两个或更多的表格被组合起来产生一个结果。结果表由来自正在连接的表的行的每个可能的组合组成。最简单也是最自然的方法是使用嵌套循环。

回想一下上面讨论的查询模板,其中有一个循环遍历表中的每条记录。在一个连接中,除了嵌套循环之外,我们基本上有相同的事情。例如,要连接两个表,查询模板可能如下所示:

  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总是按照与SELECT语句的FROM子句中出现的表相同的顺序构造循环。最左边的表成为外部循环,最右边的表成为内部循环。理论上,有可能在某些情况下对循环进行重新排序以加速对连接的评估。但SQLite不会尝试这种优化。

你可以在下面的例子中看到SQLite如何构造嵌套循环:

代码语言:javascript
复制
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 Rewind 1 23 14 Column 1 1 15 Column 0 1 16 Ne 1 22 17 Column 0 0 18 Column 0 1 19 Column 1 0 20 Column 1 1 21 Callback 4 0 22 Next 1 14 23 Next 0 10 24 Close 0 0 25 Close 1 0 26 Halt 0 0

表格例外的外部循环由指令7至23实现。内部循环指令13至22.请注意,WHERE表达式的“two <50”项只包含来自第一个表的列,并且可以从内循环。SQLite执行此操作,并在指令10至12中执行“two <50”测试。“four == two”测试由内循环中的指令14至16执行。

SQLite不会对连接中的表施加任何限制。它也允许一个表与自己连接。

ORDER BY子句

出于历史原因,为了提高效率,目前所有的分类都是在内存中完成的。

SQLite使用一组特殊的指令来实现ORDER BY子句来控制一个称为分类器的对象。在查询的最内层循环中,通常会有一个回调指令,而不是一个包含回调参数和一个键的记录。该记录被添加到分拣机(在链接列表中)。查询循环完成后,记录列表将被排序,并且此列表将被执行。对于列表中的每条记录,都会调用回调。最后,分拣机关闭并且记忆被重新分配。

我们可以在以下查询中看到正在执行的流程:

代码语言:javascript
复制
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 Rewind 0 14 6 Column 0 0 7 Column 0 1 8 SortMakeRec 2 0 9 Column 0 0 10 Column 0 1 11 SortMakeKey 2 0 D+ 12 SortPut 0 0 13 Next 0 6 14 Close 0 0 15 Sort 0 0 16 SortNext 0 19 17 SortCallback 2 0 18 Goto 0 16 19 SortReset 0 0 20 Halt 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子句用于构造一个关键字,并将包含该关键字的存储区带入焦点。如果以前没有存在,则使用该密钥创建新桶。一旦桶被聚焦,桶的存储位置被用来累积各种聚合函数的值。在查询循环终止后,每个存储桶被访问一次以生成单行结果。

一个例子将有助于澄清这个概念。考虑以下查询:

代码语言:javascript
复制
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 Rewind 0 23 9 Column 0 0 10 MakeKey 1 0 n 11 AggFocus 0 14 12 Column 0 0 13 AggSet 0 0 14 Column 0 0 15 Column 0 1 16 Add 0 0 17 Integer 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

第一条指令是AggReset at 2. AggReset指令将该组存储区初始化为空集,并将每个存储区中可用的内存插槽数指定为P2。在这个例子中,每个桶将容纳3个内存插槽。这并不明显,但如果仔细观察程序的其余部分,可以确定每个插槽的用途。

Memory Slot Intended Use Of This Memory Slot 0 The "three" column -- the key to the bucket 1 The minimum "three+four" value 2 The sum of all "four" values. This is used to compute "avg(four)".

查询循环由指令8至22实现。由GROUP BY子句指定的聚合键由指令9和10计算。指令11使适当的存储区进入焦点。如果具有给定键的桶不存在,则创建新的桶并且控制进入初始化桶的指令12和13。如果存储桶已经存在,则跳转到指令14.集合函数的值由11和21之间的指令更新。指令14到18更新存储器槽1以保存下一个值“min(three + four )”。然后,“四”列的总和由指令19至21更新。

在查询循环结束后,表23在表23中关闭表“examp2”,这样它的锁将被释放,并且可以被其他线程或进程使用。下一步是循环所有聚合桶并为每个桶输出一行结果。这通过指令24到30处的循环来完成。在24处的AggNext指令使下一个桶进入焦点,或者如果所有桶已经被检查,则跳转到循环的结尾。结果的3列从指令25到27的顺序从聚集器桶中取出。最后,在指令29处调用回调。

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

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

代码语言:javascript
复制
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 Column 0 0 10 Column 0 1 11 Le 1 25 12 Column 0 0 13 MakeKey 1 0 n 14 AggFocus 0 17 15 Column 0 0 16 AggSet 0 0 17 Column 0 0 18 Column 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表达式中的术语。这是我们的例子:

代码语言:javascript
复制
SELECT * FROM examp
WHERE two!=(SELECT three FROM examp2
            WHERE four=5);

SQLite处理这个问题的方式是首先运行内部SELECT(对照例2),并将其结果存储在专用存储单元中。然后,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运算符测试该表的内容。考虑这个例子:

代码语言:javascript
复制
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 Halt 0 0

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

内部SELECT语句通过指令1到10来实现。所有这些代码所做的就是在临时表中为examp2表的每一行创建一个条目,其中“3”列为非NULL值。每个临时表条目的关键是实例2的“三”列,并且数据是一个空字符串,因为它从不使用。

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

复合SELECT语句

SQLite还允许使用运算符UNION,UNION ALL,INTERSECT和EXCEPT将两个或多个SELECT语句作为对等体进行连接。这些复合选择语句是使用瞬态表实现的。每个操作员的实施情况略有不同,但基本思路相同。例如,我们将使用EXCEPT运算符。

代码语言:javascript
复制
SELECT two FROM examp
EXCEPT
SELECT four FROM examp2;

最后一个示例的结果应该是实例表中“两个”列的每个唯一值,除了实例2的“四个”列中的任何值都将被删除。实现此查询的代码如下所示:

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从“示例”中提取“两个”列的值,指令7将其转换成一行。指令8将一个空字符串压入堆栈。最后,指令9将行写入临时表。但请记住,PutStrKey操作码使用堆栈的顶部作为记录数据,堆栈的下一个作为键。对于INSERT语句,由MakeRecord操作码生成的行是记录数据,记录键是由NewRecno操作码创建的整数。但是这里的角色是相反的,MakeRecord创建的行是记录键,记录数据只是一个空字符串。

第二个SELECT由指令14至19执行。指令14通过倒回其光标来初始化循环。根据指令15和16从表“examp2”的“four”列中创建一个新的结果行。但是,如果不使用PutStrKey将此新行写入临时表,我们改为调用Delete将其从临时表中删除(假若它存在)。

复合选择的结果由指令22到25的循环发送到回调例程。对于这个循环没有什么新的或显着的,除了在23处的列指令将从记录中提取列而不是记录数据。

概要

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

有关SQLite库如何运行的更多信息,请直接阅读SQLite源代码。如果你理解这篇文章中的内容,你不应该很难追踪源代码。SQLite内部的严重学生可能也想仔细研究这里记录的VDBE操作码。大多数操作码文档是使用脚本从源代码中的注释中提取的,因此您还可以直接从vdbe.c源文件获取有关各种操作码的信息。如果你已经成功阅读了这些,你应该很难理解其余的。

如果您在文档或代码中发现错误,请随时修复这些错误并/或联系作者drh@hwaci.com。欢迎提出错误修复或建议。

代码语言:txt
复制
 SQLite is in the Public Domain.

扫码关注腾讯云开发者

领取腾讯云代金券

http://www.vxiaotou.com