欢迎转载。转载请注明出处。
文章主体为SQLite文档中的vdbe.html文档。此文介绍了SQLite虚拟机的细节。但此文档并没有及时更新。文档中描述的虚拟机是SQLite老版本的虚拟机。老的虚拟机基于栈。新版本的虚拟机基于寄存器。差别较大。基于本人对寄存器机制的理解,更新了部分文档。如果错误,请指正。
SQLite虚拟数据库引擎
如果你想知道SQLite内部是如何工作的,那么你先要对虚拟数据库引擎或VDBE(The Virtual Database Engine of SQLite)有深入的了解。 VDBE在处理流程中处在中心位置(参见architecture diagram), 且似乎遍及了SQLite的绝大部分地方。 尽管有些代码没有直接地与VDBE交互,这些代码通常是配角。VDBE是SQLite真正的核心。
这篇文章主要介绍了VDBE是如何工作的,尤其说明了各种VDBE指令(指令文档) 是如何协同工作来操作数据库的。 此教程的风格,以简单的任务开始,之后解决复杂的问题。 遵循这一方法,我们将探讨SQLite的大部分子模块。 在完整地学习此教程后,你对SQLite是如何工作的,会有很好的理解。 为开始学习实际的源码做了准备。
前言
VDBE实现了一个虚拟机。此虚拟机运行它自己的虚拟机语言构成的程序。 每一个程序的目标是为了访问或改变数据库。 为了此目标,VDBE实现的虚拟机语言进行了专门的设计,以便来搜索、读取、修改数据库。
虚拟机语言的每一个指令包含了一个操作码(opcode)和5个操作数(operand)。五个操作数名为P1、P2、P3、P4、P5。P1,P2,P3都是整数类型。P2常作为跳转的目标地址。P4是一个联合体。即可代表多种数据类型。可为整数类型、字符串类型,double类型,FuncDef类型,Mem*类型(寄存器类型),等等。更多个类型,请看vdbe.h文件。P5是一个无符号char类型,一个字节长。
虚拟机程序从0号指令开始执行,之后执行后续指令,直到出现以下三种情况,才终止执行。 (1)发生了致命性错误。(2)执行Halt指令。(3)步进程序计数器,计数器超过了程序中的最后一条指令。 当虚拟机执行完后,所以打开的数据库cursor都会被关闭,所有的内存会被释放。 所以无需担心任何内存泄漏问题和资源未释放问题。
如果你之前用汇编语言写过程序,或玩过任何一种抽象机(abstract machine),那么SQLite虚拟机的这些细节,你会很熟悉。 所以,让我们看看代码。
SQLite之前使用栈结构实现虚拟机,现在使用寄存器结构实现虚拟机。
插入记录到数据库
我们从一个指令少的程序来开始研究VDBE程序。假设我们有一个SQL表,表结构为:
CREATE TABLE examp(one text, two int);
换句话说,我们有一个数据表,名为"examp"。此表有两列.一列名为"one",一列名为"two"。现在假设我们想向此表插入一条记录。像这样:
INSERT INTO examp VALUES('Hello, World!',99);
我们看一下VDBE程序。SQLite使用VDBE程序实现了INSERT操作。INSERT语句通过sqllite的命令行工具执行。 刚开始时,sqlite启动时,创建了一个新的、空的数据库,之后创建表。下一步,改变 sqlite的输出格式,格式变成VDBE程序dump的形式。要输入".explain"命令才可以。最后, 执行[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 p4 p5 comment
---- ------------- ---- ---- ---- ------------- -- -------------
0 Init 0 9 0 00 Start at 9
1 OpenWrite 0 2 0 2 00 root=2 iDb=0; examp
2 NewRowid 0 1 0 00 r[1]=rowid
3 String8 0 2 0 Hello, World! 00 r[2]='Hello, World!'
4 Integer 99 3 0 00 r[3]=99
5 MakeRecord 2 2 4 BD 00 r[4]=mkrec(r[2..3])
6 Insert 0 4 1 examp 1b intkey=r[1] data=r[4]
7 Noop 0 0 0 00
8 Halt 0 0 0 00
9 Transaction 0 1 1 0 01 usesStmtJournal=0
10 TableLock 0 2 1 examp 00 iDb=0 root=2 write=1
11 Goto 0 1 0 00
正如上面我们看到的那样,我们写的简单的插入语句编译成了12个指令。第一条指令和最后三条指令是开始、结束的通用指令,所以实际起作用的是中间的8条指令。 在执行地址0处的指令Init后,程序跳转到地址9处的指令。之后执行地址10处的指令。执行地址11处的指令后,跳转到地址1处。现在让我们看看每条指令的细节。
0 Init 0 9 0
9 Transaction 0 1 1 0 01
10 TableLock 0 2 1 examp
11 Goto 0 1 0
VDBE程序包含了一个OP_Init指令,并总是作为第一条指令。如果P2不为零,跳转到由P2指定的地址处。
Transaction指令开启了一个事务。 当遇到Comit或Rollback指令时,事件结束。 P1是开始此事务的数据库文件的索引。 索引0是主数据库文件。如果P2不为零,那么开始写-事务,或如果读事务已经被激活,则将它升级成一个写事务。如果P2为零,那么开始一个读事务。P3操作数保存了编译此SQL时的schema版本号。
当开始一个事务时,获得了此数据库文件的写锁。 当此事务正在进行中时,没有其它进程可以读或写此文件。 开始一个事务时,也会创建回滚日志。 事务要在数据库发生任何变化之前开始。
编译此SQL时的schema版本号(me:设为版本号为v1)保存在内部的缓存中。在此指令中,会检查此查询语句被执行时的数据库版本号(me:设此版本号为v2),与此版本号(me:v1)是否相匹配。不匹配则出错。
TableLock指令获得某个表的锁。只有当shared-cache功能开始时,此指令才会被使用。P1是数据库的索引。此索引其实是sqlite3.aDb数据库数组的索引。P2包含了要被锁定的表的根页面。如果P3==0,则获得一个读锁。如果P3==1,则获得一个写锁。
Goto指令,是一个无条件的跳转指令,跳转到P2地址上。下一条指令将从地址P2处执行。
程序运行时,执行指令的顺序为0: Init--->9:Transaction--->10:TableLock--->11:Goto--->1:OpenWrite。
OpenWrite打开一个新的读/写cursor,此cursor的ID为P1,P1指向了一张表或一个索引。Cursor句柄可以是任意非负数。VDBE会在一个数组中分配cursor。数据的大小会比最大的cursor大1。(But the VDBE allocates cursors in an array with the size of the array being one more than the largest cursor. )为了节省内存,句柄最好从0开始,并连续的向上增长。 此cursor指向了表"examp"。
表或索引的根页面为P2。
P3==0,意思是主数据库,P3==1意思是此数据库用于临时表。P3>1意思是使用对应的附属数据库。
P4值要么是一个整数(P4_INT32)或一个KeyInfo结构体(P4_KEYINFO)指针。如果P4是一个整数值,P4被设置成此表中列的数量。
NewRowid生成一个新的整数记录号(又名rowid),用作表中记录的key。生成的rowid保存在P2指向的寄存器中。在此程序中,rowid保存在1号寄存器中(r[1]=rowid)。
String8操作,将P4指向的UTF8字符串,保存在P2指向的寄存器中。在此程序中,字符串保存在2号寄存器中(r[2]='Hello, World!')。
Integer指令,要P1中的32位整数写入P2代表的寄存器中(r[3]=99)。
MakeRecord指令,从P1开始。转换P2个寄存器,多个寄存器为r[P1,P1+P2]。将这些寄存器写成record format,作为数据库表的数据记录,或索引的key。P3指向了输出结果寄存器。即生成的记录保存在r[P3]中。P4指向了一个字符串。标识了r[P1, P1+P2]中每个寄存器的数据类型。
Insert指令,从P3指定的寄存器中读取rowid,从P2指定的寄存器读取要插入的数据,形成一条记录。将此记录保存到P1代表的游标所指的表中。
Noop指令,什么也不做。
Halt指令,立即退出。所有打开的游标,都会被自动释放。每个程序结束的地方都会被插入"Halt 0 0 0"指令。如果开启自动事务功能,若执行过程中无错,则提交事务。若出错,则执行回滚操作。
跟踪VDBE程序的执行
如果SQLite库编译时没有使用NDEBUG预处理宏,那么PRAGMAvdbe_trace 令VDBE跟踪程序的执行。 这个功能最开始意图是测试和debug,它也用来学习VDBE是如何执行的。 使用"PRAGMA vdbe_trace=ON;"来开启跟踪,使用 "PRAGMA vdbe_trace=OFF"来关闭跟踪。像这样:
sqlite> PRAGMA vdbe_trace=ON;
SQL: [PRAGMA vdbe_trace=ON;]
VDBE Trace:
0 Init 0 0 0 00 Start at 0
1 Expire 0 0 0 00
2 Halt 0 0 0 00
sqlite> INSERT INTO examp VALUES('Hello, World!',99);
SQL: [INSERT INTO examp VALUES('Hello, World!',99);]
VDBE Trace:
0 Init 0 9 0 00 Start at 9
9 Transaction 0 1 2 0 01 usesStmtJournal=0
10 TableLock 0 4 1 examp 00 iDb=0 root=4 write=1
11 Goto 0 1 0 00
1 OpenWrite 0 4 0 2 00 root=4 iDb=0; examp
2 NewRowid 0 1 0 00 r[1]=rowid
REG[1] = i:1
3 String8 0 2 0 Hello, World! 00 r[2]='Hello, World!'
REG[2] = t13[Hello, World!](8)
4 Integer 99 3 0 00 r[3]=99
REG[3] = i:99
5 MakeRecord 2 2 4 BD 00 r[4]=mkrec(r[2..3])
REG[4] = s17[03270148656C6C6F2C20576F726C6421.'.Hello, World!](8)
6 Insert 0 4 1 examp 1B intkey=r[1] data=r[4]
REG[4] = s17[03270148656C6C6F2C20576F726C6421.'.Hello, World!](8)
REG[1] = i:1
7 Noop 0 0 0 00
8 Halt 0 0 0 00
在trace模式中,VDBE在执行每一条指令之前都会打印它们。在这些指令执行之前,会显示操作数的内容。在执行之后,会显示所修改的寄存器的内容。如果没有修改寄存器,则不显示。
在显示寄存器内容时,会显示一个前缀,来说明它的数据类型。整数(Integer)类型的前缀为"i:"。 浮点类型的前缀为"r:".("r"代表real-number".)字符串类型的前缀为 "s:", "t:", "e:"或"z:". 这些不同的前缀是因为内存分配方式不同而导致的。z:类型字符串的内存由malloc()分配。 t类型的字符串的内存是静态分配的。e:类型的字符串是临时分配的。所有其它内存分配类型都加上s:前缀。对于使用者来说,这些类型没有什么不一样,但时对于VDBE来说则非常重要。因为当z类型的字符串出栈时,为了 避免内存泄漏,z类型的字符串需要被free()释放。 注意,只有字符串的前10个字符会被显示出来。二进制值(如MakeRecord指令的结果)被当作字符串处理。
简单查询
你将了解VDBE是如何写数据库的。现在,让我们看一下它是如何查询的。我们将使用下面的简单的SELECT语句作为我们的例子。
sqlite> explain select * from examp;
addr opcode p1 p2 p3 p4 p5 comment
---- ------------- ---- ---- ---- ------------- -- -------------
0 Init 0 10 0 00 Start at 10 ????????????????????10
1 OpenRead 0 2 0 2 00 root=2 iDb=0; examp
2 Explain 0 0 0 SCAN TABLE examp 00
3 Rewind 0 8 0 00
4 Column 0 0 1 00 r[1]=examp.one
5 Column 0 1 2 00 r[2]=examp.two
6 ResultRow 1 2 0 00 output=r[1..2]
7 Next 0 4 0 01
8 Close 0 0 0 00
9 Halt 0 0 0 00
10 Transaction 0 0 1 0 01 usesStmtJournal=0
11 TableLock 0 2 0 examp 00 iDb=0 root=2 write=0
12 Goto 0 1 0 00
在我们查看此问题之前,让我们对SQLite的查询是如何工作的有个大概的了解。 我们将知道我们尝试完成什么。对于查询结果中的每一行,SQLite将调用callback函数。此函数的原型为:
int Callback(void *pUserData, int nColumn, char **azData, char **azColumnName);
SQLite库为VDBE提供此回调函数和pUserData指针。 (此callback和user data作为参数传递给sqlite_exec()函数。) VDBE的工作是为了填充nColumn, azData, and azColumnName的值。(The job of the VDBE is to come up with values for nColumn, azData[], and azColumnName[])。nColumn是结果集中的列数, azColumnName是一个字符串数组。每一个字符串是结果集中一列的名称。 azData是一个字符数组,它保存了实际的数据。
0 Init 0 10 0 00
10 Transaction 0 0 1 0 01 usesStmtJournal=0
11 TableLock 0 2 0 examp 00 iDb=0 root=2 write=0
12 Goto 0 1 0 00
此四条指令,是通用的初始化指令。在之前已经介绍过了。这里不再介绍了。
1 OpenRead 0 2 0 2 00 root=2 iDb=0; examp
指令1打开一个读cursor。这个cursor指向了要查询的数据库中的表。INSERT例子中的OpenWrite指令也具有同样的功能。 不同之处是,这次是为了读而打开一个cursor。
2 Explain 0 0 0 SCAN TABLE examp 00
此指令并不影响查询操作。略过。
3 Rewind 0 8 0 00
Rewind指令初始化一个循环,此循环用来迭代遍历"examp"表。此指令使P1重新指向此表中的第一个entry。此循环需要Column和Next指令,这两个指令使用此游标来遍历此表。 如果此表为空,则跳转到P2(8)指向的指令,此指令(me:Close指令)会跳出循环。如果此表不为空,则运行在地址4处的指令(me:Column指令),此指令为循环体的第一个指令。
4 Column 0 0 1 00 r[1]=examp.one
5 Column 0 1 2 00 r[2]=examp.two
6 ResultRow 1 2 0 00 output=r[1..2]
地址4到地址6形成了循环的主体,此循环对于数据库文件中的每条记录执行一次。 地址4到5处的Column指令,解析游标P1指向的数据。此数据的格式为数据记录,由MakeRecord指令生成此数据记录。(关于此记录格式的更多信息请看MakeRecord操作码。) 从此记录中抽取第P2列。抽取出的值保存在P3寄存器中。在此例中,第一个Column指令将"one"列的值放入寄存器1中。第二个Column指令将"two"列的值放入寄存器2中。
地址6处的ResultRow指令,将之前由Column指令填充好的多个连续的寄存器,赋给了sqlite3_stmt。即将r(P1)到r(P1+P2-1)寄存器赋给sqlite3_stmt。r(P1)到r(P1+P2-1)寄存器即是查询结果中的一行的内容。ResultRow指令可返回SQLITE_ROW,终止sqlite3_step()。
假设使用sqlite3_exec函数执行SQL查询语句。从sqlite3_step函数返回后,从sqlite3_stmt取出此行数据。并调用用户提供的回调函数,传出此行的列数量,列名称,列内容。
7 Next 0 4 0 01
地址7处的Next指令实现了循环的分支。与地址3处的Rewind指令一起形成了循环逻辑。 这是一个关键的概念,你应该着重关注。 Next指令步进此P1代表的游标,使之批向下一条记录。如果此游标步进成功,那么立即跳转到P2(4,循环开始的地方)。 如果此游标已指向结尾,那么运行之后的指令,不再执行循环。
8 Close 0 0 0 00
9 Halt 0 0 0 00
程序尾部的Close指令关闭了cursor。此cursor指向了表"examp"。调用Close也不是必需的, 因为当此程序结束时,VDBE会自动地关闭所有的cursor。但是为了Rewind指令跳转,我们需要一个指令。Halt指令结束此VDBE程序。