0%

The Virtual Machine Module

本文讨论了SQLite虚拟机(VM)是如何解释执行了一个通过SQLite内部的字节码语言编写的程序。VM是存储在本地数据库文件中数据唯一的操作者。它支持5种原始的数据类型:NULL,integer,real,character string,blob,这五种数据类型代表了在文件或者内存中存储的原始的数据项。由VM来完成用户数据和内部数据表示之间的转换。在做表达式评估的时候它也会做一些数据类型转换。VM是如何工作的,以及它在存储表和索引记录到B-,B+树之前,如何做数据格式化。

本文目标

  • 5种数据类型和它们代表的含义
  • 一个编译好的SQL语句的内部表述
  • 在SQL类型和C类型之间是如何做转换的
  • 表记录和索引记录的逻辑结构
  • 清单类型在编码数据值中的优势

虚拟机

后端最顶层的模块在SQLite术语中通常被称之为虚拟数据库引擎,或虚拟机(VM)。VM是SQLite的核心,并且是前端和后端之间的接口。一些核心信息(计算和逻辑)只会在VM中处理,因为下层模块就存储的信息而言是被动的。它在原生的操作系统上层实现了一个抽象的虚拟的机器,并且它可以执行用SQLite内部的字节码程序语言编写的程序。这个程序语言是特地设计用来搜索,读取和修改数据库的。VM接受由前端生成的字节码程序,并且执行这个程序。VM使用B+树提供的”基础能力”来执行字节码程序,并且输出程序的执行结果。

SQLite开发团队认为VM的使用对于这个库的开发和调试有巨大的好处。VM在前端(解析SQL语句并生成VM应用程序的部分)和后端(执行字节码应用程序并计算结果的部分)之间扮演了一个粘合剂的角色。字节码程序比解释集成数据对象的复杂网格更易于阅读。虚拟机还可以帮助SQLite引擎开发人员清楚地了解引擎正在尝试对其所编译的每个SQL语句执行的操作。

字节码执行跟踪:对于人类来说,读取字节码程序比解释数据结构值更容易。它还具有跟踪每个VM应用程序的程序执行的能力–打印每个字节码指令以及随着执行的进展而生成的结果。这个取决于SQLite源代码的编译方式。

一个字节码应用程序在内存中由一个sqlite3_stmt对象表示(内部称为Vdbe)。以下SQLite API 可以用在这个对象上,从而实现把某个输入的值关联到SQL参数上,或者执行字节码程序,或者检索程序执行产生的输出。

  1. sqlite3_bind_*:这系列方法会指派值给SQL参数,并且作为字节码程序的输入。
  2. sqlite3_step:它会让字节码程序执行推进到下一个断点,或者到一个终止点。
  3. sqlite3_reset:它让程序执行倒退到起点,并且使得同一个程序可以准备进行二次执行。
  4. sqlite3_column_*:这系列方法会从程序产生的当前输出行中逐列提取结果。
  5. sqlite3_finalize:它会销毁sqlite3_stmt对象和字节码程序

Vdbe对象(又称预处理语句)的内部状态包括以下内容:

  • 一个字节码程序
  • 所有结果列的名字和数据类型
  • 绑定到输出参数的值
  • 一个程序计数器
  • 任意数量的“编号”存储单元(称为寄存器位置)
  • 其他运行时的状态信息(例如一个打开的BTree对象,集合,列表,排序器…)

VM不会做任何查询优化操作。它会无条件地执行字节码程序。这样,它可以按需将数据从一个格式转换为另一个格式。即时的数据转换,是VM的主要任务。所有事情都会在它所执行的字节码程序的控制之下。本文主要介绍数据转换和操作任务。在此之前,下一部分概述字节码编程语言,字节码程序和程序执行逻辑。

字节码编程语言

SQLite定义了一种内部编程语言来准备字节码程序。该语言类似于物理和虚拟机(例如Java以及使用的字节码)使用的汇编语言:它定义了字节码指令。每个字节码指令执行少量信息处理工作或做出逻辑决策。而一个字节码程序则包含了一个线性的字节码指令序列。一个字节码指令可以最多有5个操作数,<opcode,P1,P2,P3,P4,P5>,opcode定义了一系列的字节码操作,而P1,P2,P3,P4,P5则是当前操作的操作数或者操作数对应的寄存器。前三个操作数都是一个32位的有符号整数。如果当前操作触发了一个跳转,那么P2就一定是一个目标地址。P4是一个32/64为的有符号整数,或者是64位的浮点小数,或者是一个指向具有终止符的字符串,字节块,一个归类的比较方法,一个SQL方法,等等。P5操作数是一个无符号的字符。这5个操作数不是左右的操作符都会使用的。

注意
操作码是VM操作的内部名称,不属于SQLite的接口定义。总之,它们的含义可能会由于版本的更新而更新。SQLite的开发团队不鼓励SQLite的用户来直接编写字节码。字节码编程语言仅供内部使用。

下面的表格展示了一个典型的字节码程序。这个字节码程序等价于SELECT * FROM t1。这个表格有x,y两列。字节码程序从0号指令开始执行,一直执行到一个终止指令或者超出了最后一条指令。

字节码指令

大概一共有142个操作码。操作码可以分为5类:1.计算和逻辑。2.数据移动。3.控制流。4.B-和B+树相关。5.专门的类型。第一类操作码包含 加, 减, 乘, 除, 求余, 位或, 位与, 二进制反码, 二进制补码, 右移动, 左移 和 字符串连接。第二类操作码在内存单元之间移动值。第三类操作码包含goto , gosub, return , halt 和 条件转移。第四类操作码包含(i) 创建, 销毁, 和 清空 B/ B+树, (ii) 打开 和 关闭 B/B+ 树上的游标, (iii) 向前 和 向后移动游标 或 移动到指定的key, (iv)光标移动的分支,(v)插入, 删除 B/B+ 树的记录,(vi)开始, 提交 和 回滚 事务。第五类操作码包含(a)获取一个不在使用的rowid,(b)组合n个内存元素为一个记录,(c)从一个表行中提取第i个列的数据,等等。

每一个字节码指令在内部都由一个VdbeOp对象表示。这是一个比较简单的对象,它有以下成员:(1)opcode,表明需要执行的操作,(2)(3)(4)(5)(6)p1-p5持有5个操作数。(7)p4type表明了p4操作数的数据类型。p4有13个类型。一个虚拟机应用程序实际上是一个VdbeOp对象的线性集合。

下面展示了一些操作码的含义。最新SQLite版本的操作码都在SQLite官方网站找到。在SQLite源码中,每一个操作码的名字都由OP_开头,并且都分配了不同的整数。

  1. Trace: 这个操作码检查了SQLite库是否开启了tracing mode。如果开启了,那么在每次跟踪回调的时候都会输出P4的内容(一个UTF8字符串)。你可以使用sqlite3_traceAPI方法开启跟踪。
  2. Goto: 无条件跳转到P2所指定的地址。VM执行的下一条指令将是距程序开始偏移为P2的指令。
  3. OpenRead: 打开一个B/B+树的只读的游标。这个树的root page由P2指定。(如果P5不为0,那么就是寄存器P2包含了这个page号,而不是P2的内容)数据库文件由P3操作数指定–0表示主数据库,1表示临时数据库,大于1表示附加连接的数据库。P1操作数(一个非负的整数)将会是新的游标的标识。P4的值是一个整数或者一个指向KeyInfo结构体的指针。如果光标在B树(SQL索引)上打开,则KeyInfo结构定义了 内容和排序 的顺序。如果P4是一个整型的值,则代表了表列的数量。

如果在只读游标打开的时候,数据库没有上锁,那么在这个指令指向的时候,会请求一个共享锁。如果它无法获取一个共享锁,虚拟机会终止字节码的执行,并且返回一个SQLITE_BUSY错误码。(这个可以看后端的实现逻辑)
4. Rewind:重置游标P1。这个游标将会指向表或者索引的第一项,对应树中最小的一项。如果树是空的,并且P2 > 0,那么就会立刻跳转到P2对应的地址。否则,遵循以下说明。
5. Column: 获取P1游标指向的记录中,P2对应的列的值。(这里会把P1指向的记录解析为一个数据结构,而这个数据结构是由MakeRecord指令构建的,可以看下面的MakeRecord指令),如果记录中的字段个数少于P2个,那么提取的值就会变成NULL;如果P4的数据类型是一个P4_MEM,那么就会使用P4的值作为结果。返回的值会存储在P3寄存器内。
6. MakeRecord:将从寄存器P1开始的P2个寄存器合并转换为一个可以用作数据库表中的记录项,或者转换为一个索引的key。也就是说P1包含了第一个数据项目,P1+1是第二个数据项目,以此类推。
7. ResultRow: 寄存器P1到P1+P2-1包含一个单行记录。在上层应用执行sqlite_step方法的时候这个指令就会执行。并且这个方法执行返回SQLITE_ROW。
8. Next: 推进P1游标,这样它就可以指向树上的下一项。如果树上面没有下一项了,那么就会遵循以下说明。否则就会立刻跳转到P2地址。
9. Close: 关闭先前打开的P1游标。如果P1游标没有打开,那么这个指令就无操作。
10. Halt: 在关闭所有已打开的游标、FIFOs(又名RowSet对象)后立刻退出。P1是sqlite3_exec,sqlite3_reset,sqlite3_finalize三个API返回的结果值。对于通常的结束来说,返回的结果值都是SQLITE_OK(=0)。如果出现了错误,那么这个错误码就是其他值了。如果P1!=0,那么P2就决定了当前的事务是否需要做一个回滚。当P2=OE_Fail的时候就无需回滚;当P2=OE_Rollback的时候就需要回滚;当P2=OE_Abort的时候,撤消此执行过程中发生的所有更改,但不回滚事务。P4指向的是一个错误信息。

Halt 0 0 0 0 0是一个默认的终止指令,会被插入到每一个字节码程序的最后面。因此,跳过程序的最后一条指令与执行终止指令是一样的。
11. Transaction: 开启一个新的事务。P1是标识了开启事务的数据库文件类型。0代表主数据库文件,1代表临时数据库,大于1的时候代表附加连接的数据库。如果P2是0,表示数据库文件上会获取一个共享读锁。如果P2非零,就会开始一个写事务,数据库上会获取一个保留锁。如果P2是2或者更大,那么就会获取一个排它锁。开启写事务的时候也需要创建一个回滚文件。
12. VerifyCookie: 检查全局数据库参数号0的值(schema版本的cookie),并且保证这个值和P2的值是一致的,并且在本地模式解析中的迭代计数器和P3的值是一致的(?)。P1是标识了数据库文件类型与上面一致。(你可以回想一下,每当数据库模式更改时,cookie值都会更改)这个验证的操作是用来检测,当cookie发生便跟的时候,当前的进程需要重新去读取schema。在执行此操作码之前,要么需要启动事务,要么需要执行OpenRead / Open Write(至少在数据库上建立共享锁)。
13. TableLock: 在数据库P1上获取根page为P2的表的锁。如果P3为0,那么加读锁,如果P3为1,加写锁。P4包含了一个指向这个表名的指针。

在下面两个小节中,将会表述SQL insert 和join处理的VM执行逻辑。这样可以给出更加清晰的描述来显示如何为一个SQL语句生成一个字节码程序的。

插入逻辑

假设,有一个包含两列的表T1。这两列分别是c1 text和c2 integer;这个表没有任何的索引。如果你执行insert into T1 values('Hello,Wold!', 2000)的语句,VM会根据下面的算法步骤来执行:

  1. 在主数据库上打开一个写事务。
  2. 检查数据库的schema版本,保证在这个语句的字节码生成期间没有被别人修改过。
  3. 在表”T1”的B+树上,打开一个写游标。
  4. 创建一个新的rowid,并且将’Hello,Wold!’和2000合并创建一个记录项。
  5. 通过打开的游标,将这个记录项插入到B+树中。
  6. 关闭游标。
  7. 给调用者返回执行结果。

如果在T1上还有索引,在第三步的时候,VM会在每一个索引表上打开一个写游标,并且分别在第四步和第五步的时候准备和插入一个记录项。

连接逻辑

在连接操作中,会合并两个或者多个表来创建一个结果表。结果表包含要连接的表中所有可能的行组合。实现连接的最简单,最自然的方法是使用嵌套循环。SQLite仅执行循环联接,而不执行合并联接。在FROM语句中的最左侧的表构成最外侧的循环,最右侧的表构成了最内侧的循环。

考虑以下SQL 查询语句: select * from t1, t2 where ...,假设这两个表上都没有索引。那么这个查询语句的伪代码,就类似于下面这样。

  1. 在主数据库上打开一个读事务。
  2. 同样的检查schema版本。
  3. 打开两个游标,一个是T1的,一个是t2的。
  4. 1
    2
    3
    4
    5
    6
    for each record in T1, do:
    for each record in T2, do:
    if WHERE语句为TRUE
    计算当前行的所有结果列
    为当前结果行调用默认回调函数的

  5. 关闭两个游标。

程序的执行

VM从序号为0的指令开始执行一个字节码程序,直到它(1)处理一个停止指令或者(2)遇到一个错误,或者(2)程序计数器超过了最后一条指令。当程序终止的时候,它会释放所有已经分配的内存,并且关闭所有打开的游标。如果执行因为错误而终止了,后续的事务就会终止,并且回滚引起的数据库变更。

下面用C语言给出了这个VM解释器的结构。解释器(带Vdbe对象指针的sqlite3VdbeExec函数)是一个简单的for循环,其中包含了一个大量case的Switch语句。每一个case的语句实现了一个字节码指令(在源代码中,操作码名称以OP_prefix开头。操作码名称的数字值不是静态编号的。它们是在SQLite源代码编译时分配的。数字可能因一个SQLite版本而异。)在每一次迭代中,VM从程序中拉取下一次的字节码指令。例如:从aOp数组中以pc作为下标(这两个都是Vdbe对象的成员变量)索引获取下一个字节码指令。它会解码并执行指令指定的操作。一般来说,程序的执行会从一个字节码执行到下一个(pc++),但是pc可以由跳转指令变更。这个for循环会一直执行,一直到VM处理了一个终止指令或者循环的条件不成立了(也就是pc计数超过了指令个数),那么我们就说字节码程序已经终止了。这个是一个正常的终止。

1
2
3
4
5
6
7
8
9
10
11
12
13
for (; pc < nOp && rc == SQLITE_OK; pc++){ 
switch (aOp[pc].opcode){
case OP_Add:
/* Implementation of the ADD operation here */
break;
case OP_Goto:
pc = op[pc].p2-1;
break;
case OP_Halt:
pc = nOp;
break;
/* other cases for other opcodes */ }
}

sqlite3_prepareAPI方法返回了sqlite3_stmt对象指针。而这个对象实际上指向的是Vbe对象(它代表了虚拟数据库引擎)。这个对象包含了完整的VM状态。下面展示了这个对象的一些组件。aOp数组包含了所有的操作码。这个程序执行需要的所有内存均位于大小为nMemaMem数组中。

成员变量 描述
db sqlite3数据库连接对象
aOp VM程序
nOp aOp的数量
apCsr 打开的游标数组
nCursor apCsr的大小
aMem 程序执行需要的内存
nMem aMem的大小
rc 需要返回的值
pc 程序计数器
其他变量

VM使用游标来访问数据库(这些游标和在Tree模块使用的BtCursors不一样)。在数据库上可能有几个已经打开游标的数据库。每一个游标都是指向数据库中表和索引的树。游标可以通过一个key指向到任何一个项或者遍历树中的所有项。VM可以在游标指向的当前项创建新的或者检索key/value值,或者删除项。

在同一个索引或者表的树上可以同时有多个游标。尽管在同一个索引或者表上可以有多个游标,但是它们都是独立操作的。在VM中,一个VdbeCursor对象代表了一个打开的游标。下面的表格展示了VdbeCursor的结构体。字节码程序的质量可以创建一个新的游标(通过操作码OP_OpenRead或者OP_OpenWrite),从游标中读取数据(通过操作码OP_Column),然后在表或者树上推进游标到下一项(通过操作码OP_Next)以及其他的一些操作。在VM终止执行字节码程序的时候,所有的游标都会自动关闭。

成员变量 描述
pCursor 后端的(BtCursor)游标结构体
iDb 游标所在的数据库
pBt 这个游标的临时文件的临时表
pKeyInfo 索引表的游标需要索引的key的一些信息
aType 在记录里所有项的类型值
aOffset 每一列数据缓存的从起始点开始的偏移
aRow 当前行的数据(如果所有的数据都在一页page内)
其他变量

内部的数据类型

VM使用任意数量的编号内存位置来保存所有中间结果。每一个内存地址存储了一个数据值。VM处理的每个值是一下五种数据类型之一:

  1. INTEGER: 一个有符号的整型数字;
  2. REAL: 一个有符号的浮点型数字;
  3. TEXT: 一个字符串值;
  4. BLOB: 一个字节块;
  5. NULL: 一个SQL NULL值;

VM只支持5种原始的数据类型。这个类型决定了一个数据在实际物理存储的时候应该如何表示。每一个存储在内存或者本地数据库文件中的数据类型都必须是这几种数据类型之一。有些值可能具有多种数据类型。举个例子,123可以是一个整型,一个浮点型,也可以是一个字符串。BLOB和NULL的值没有多种含义。在必要的情况下,SQLite会做一些数据转换。你可以使用SQLite内置的方法typeof来获取一个值的类型–select a,typeof(a) from t1将会返回a字段的值以及他们对应的存储类型。在使用sqlite3_step方法返回一行记录的时候,APIsqlite3_column_type会返回某列字段值的存储类型。

在VM内部,所有的操作对象几乎都是Mem对象。每一个Mem对象可能会缓存数据的多个存储类型。而一个值(也就是Mem对象)有如下的属性: 每个值恰好是以上提到的五种存储类型之一。(每个Vbe.aMem数组元素就是一个aMem对象。)

成员 描述
type 这个Mem对象锁代表的值的类型
i 整型值
r 浮点型值
z 字符串或者字节块值
n z的大小
enc 如果z是一个字符串的话,这个字符串的UTF编码类型
其他变量

记录格式

VM会把数据组合为一条记录存储在他们对应的B树或者B+树内。每一个记录都包含key和一个可选的value。VM仅负责维护键和值的内部结构(尽管,Tree模块可能会把一条记录拆分为叶子节点,内部节点或者多个溢出页,VM把整条记录视为一个连续的字节串)。VM使用了两个相似但是也略有不同的结构来描述表和索引记录。

有两种格式来格式化data/key记录:固定长度和可变长度。对于固定的长度来说,对于表或者索引的所有记录都是使用了相同大小的空间;在表或者索引的创建的时候就已经知道每一个字段的大小了。对于可变长度的格式来说,每一个字段的空间大小可能根据不同的记录而不一样。SQLite使用可变的变量长度来格式化记录,因为它有几个优势。数据库因为没有空间的浪费而变得更小。同样也会让整个系统跑得更快,因为在内存和磁盘之间需要同步的bytes数量更小。另外,使用可变长度的记录可以允许SQLite可以使用清单类型而不是静态类型。接下来两节先讨论一下这个清单类型。

清单类型

不论是存储在内存中还是在文件中,每一个原始的数据都有一个数据类型与之关联。这个数据类型称之为存储类型。大部分SQL数据库使用静态类型:一个表内的每列关联了一个数据类型,并且只允许存储每列关联的数据类型。这样非常死板,也有它自己的优缺点。SQLite通过使用清单类型解除了这一个限制。在这个方法下,数据的类型是数据本身的一个属性,而不是数据存储列的。这样,每一列就允许你存储任何变量,任何类型的数据,并且不会丢失这个数据的类型。SQLite会将数据的类型作为数据的一部分存储。它允许你在列内存储任何类型的数据,而不用关心每一列声明的数据类型。(在这里有一个例外,就是一个声明了integer primary key的列只能存储[-2^63, 2^63-1]的整型值)。

类型编码

存储类型会被编码为整型。这个整型值的编码关系在下表给出了。这种编码的好处在于类型编码也会包含数据长度。NULL类型代表了SQL NULL类型。对于INTEGER类型,数据的值是一个有符号的整型数字,由(1,2,3,4,6或8个字节)组成。具体的长度取决于值的大小。对于REAL类型,数据值是一个浮点小数,根据IEEE的浮点数标准,存储在8个字节内。8,9两个数据类型,分别代表了整型常量0和1.对于TEXT类型,数据值就是一个文本字符串,使用默认编码(UTF-8, UTF-16BE, or UTF-16-LE)格式存储文本。对于后两者来说,字节顺序分别是大端或者小端。(每个数据库文件只会用一种UTF编码)对于BLOB类型,数据值是一个字节流,完全和用户输入的字节流一致。

**布尔值:**布尔值就使用8,9两个常量类型来表述。

类型值 含义 数据长度
0 NULL 0
N in {1..4} 有符号整数 N
5 有符号整数 6
6 有符号整数 8
7 IEEE 浮点 8
8 常数 0 0
9 常数 1 0
10、11 扩展保留 N/A
N>=12 偶数 字节流 (N-12)/2
N>=12 奇数 纯文本 (N-13)/2

表记录格式

下表中描述了一个表记录的格式。由两部分组成:头部和记录内容。头部由一个size字段开始,后面跟着字段的类型。头部后面跟着记录的数据项。(SQLite不会修改在创建语句中声明的字段顺序。建议表结构的设计者在设计早期,把小而使用频率高的列放在记录中,从而尽可能避免触发溢出的规则。)

头部 记录内容
Header size Type 1 Type 2 ... Type N Data 1 Data 2 ... Data N

Header size是Data1之前的字节数。这个大小是一个哈夫曼编码的64位的可变长度的integer值,并且它包含了它自身所占用的大小。这个大小也可以被用作Data1项的指针。在header size大小之后紧跟着的是数据类型字段,每一个数据值都按照它在字段中出现的顺序排列。每一个类型字段Type i是一个可变长的无符号整型(最大是2^64),对应编码了数据字段Data i的存储类型。

**零长度的数据:**对于类型值为0,8,9,12和13来说,数据的长度是0,因此数据不会存储在记录中。

表key的格式

在SQLite中,每一个B+-Tree必须有一个唯一的key。尽管一个定义好的关系型表不包含相同的行,但是实际上用户是可以在关系表中存储重复的行的。但是数据库系统必须有办法来区分这些相同的行。该系统必须能够关联其他信息以实现差异化目的。这也就意味着,系统需要为这个关系提供一个新的唯一的属性。因此,在内部,每个表都有唯一的主键,并且该键由表的创建者或SQLite来定义。主键是一个名为rowid的整型值。

Rowid列

在每一个SQL表里,SQLite会指定一列作为rowid(也称为oid或_rowid_)。这一列中的值在表内的每一行都是唯一定义的。这列将会是表的隐式主键,也是表B+树的唯一搜索关键字。如果表内的任何一列被声明为 integer primary key,那么这列就会被当做这个表的rowid(作为别名)。否则的话,SQLite会创建一个独立的名为rowid作为唯一主键列。(如果表中已经存在了相同的这三个名字(rowid,oid,rowid),那么名称将引用这些列,而不是内部rowid列)因此,每一个表不论是否声明了 integer primary key列,都有一个唯一的整型key,命名为rowid。对于后一种情况,rowid本身在内部被视为表的整数主键。无论哪种情况,rowid都是[-2^63, 2^63-1]范围内的有符号整数值(在编译器有宏可以限制这个rowid为32位)。在表的B+树上的排序顺序是按照整型的大小,并且无法用其他的排序顺序。这些B+树是表的主要索引。rowid作为表B+树的逆向指针存储在二级索引里。

Rowid值

如果rowid是一个别名列(例如声明了INTEGER PRIMARY KEY),数据库的使用者是知道这个列的。如果SQLite插入了一列rowid,那么他们是不知道这列的存在的。无论哪种情况,用户都可以定义rowid值,或者SQLite可以为用户定义值。它保证了他们的唯一性。当有一条记录插入到表中而没有指定rowid值的时候,SQLite会访问B+树,为rowid获取到一个未使用的整型数字。通常来说,这个值都是比表格内最大的值要大。但是,如果已经到了最大值,那么SQLite就随机选一个未使用的值。如果找不到,那么就会返回SQLITE_FULL错误码。

Rowid的含义

Rowid的含义取决于是谁创建的。如果rowid列是由SQLite创建的,那么表记录中就不包含它。否则,将在每个表记录中存储一个NULL(类型值0)。SQLite通过key来获取到真正的值。这个值是可变长的哈夫曼编码。可以允许负值的rowid,但是这样的话,它们就会占据9个字节,所以不鼓励这样做。如果rowid是由SQLite生成的,那么它们一定不会是负数,尽管你可以指定一个负整数。

索引key的格式

在前两节中你可以看到,每一个表的B+树都是一个整型key,然后这个表格的一行就是一个数据记录。索引与之相反。对于一个索引项,key是存储在索引表内的一行所有索引列值的一个组合。而数据才是这一行的rowid。为了通过一个索引列的指定数据来访问某一行,VM首先搜索索引表来找到对应的rowid,然后使用这个整型来找到表B+树内完整的记录。尽管许多人认为通过主键来指向每一行,开销太大,但是SQLite简单起见。

SQLite通过创建一个存储在一个单独的B树内的单独的’索引表’来实现表内的每一个索引。每一个索引B树映射了搜索的关键字和rowid。索引B树有自己的关键字比较器,来排序索引项。VM会提供给Tree模块一个合适的关键字比较函数指针。

SQLite会为create语句中的每一个UNIQUE唯一列创建一个索引,包括PRIMARY KEY列。你不能单独删除这些索引。你可以在一个非目录表上使用CREATE INDEX为指定列创建索引。你也可以单独删除这些索引。当一个表被删除的时候,这个表对应的所有的索引都被删除了。还有一些其他方法在SQLite中创建索引。以下是在表T1(a,b,c)上的a,b列创建索引的例子。

  1. 直接声明创建索引
  • CREATE TABLE T1(a,b,c)
  • CREATE INDEX idx1 ON T1(a,b)
  1. 声明列唯一
  • CREATE TABLE T1(a,b,c,UNIQUE(a,b))
  1. 声明列为主键
  • CREATE TABLE T1(a,b,c,PRIMARY KEY(a,b))

注意:INTEGER PRIMARY KEY比较特殊,它不会生成一个单独的索引。表的B+树由这个别名为rowid的列来排序。

SQLite可以在一列上同时创建多个索引,思考一下下面这个例子
CREATE TABLE T2(x VARCHAR(5) UNIQUE PRIMARY KEY , y BLOB);
CREATE INDEX idx2 ON T2(x);

上面的例子中,SQLite在x列上创建了三个索引,一个是PRIMARY KEY,一个是UNIQUE,一个是指定创建的索引。索引会减慢INSERT,UPDATEDELETE的执行,并且加大了数据库文件的大小。

Header size Type 1 Type 2 ··· Data1 Data2 rowid

正如上面提及的,SQLite会把索引当做一个表,然后在它自己的B树中存储。它把关键字隐射到了rowid上。下面描述了一个索引记录的格式。整个记录都当做了B-Tree的key;没有数据部分。索引记录的编码和数据表的记录的一样,除了后面跟了一个rowid,并且rowid的数据类型,没有在记录头部显式声明。因为rowid的类型只能是有符号整数并且是用哈夫曼编码表示的(不是内部的整型类型)。(其它数据的值和数据的存储类型都从索引表内拷贝)在x列上的内容索引,如下所示.

x rowid
NULL 54321
456 2
abc -5
abc 1
hello 100

索引主要是用来加速数据库搜索的。两次B+树搜索时间一般都会远小于一个全表扫描搜索。

**临时索引:**SQLite可以在执行SQL语句时使用order by或group by子句,或在聚合查询中进行except,或使用union或intersect进行复合选择,从而动态创建临时索引。临时索引会存储在临时文件中。

数据类型管理

SQLite的数据处理是发生在VM模块的,这个模块驱动了后端在数据库中存储,检索数据。VM是数据库内存储的数据的唯一操作者;所有事情都是通过字节码的执行来控制的。它决定了在某个地方存储什么数据,以及在某个地方检索什么数据。给合适的数据分配合适的数据类型,并且做必要的数据转换都是VM的主要任务。有三个数据交换的地方可能会触发数据转换:从SQLite应用到引擎,从引擎到SQLite应用,从引擎到引擎。对于前两个情况,VM为用户数据分配数据类型,VM会尽可能将用户提供的数据转换为在列上声明的数据类型,反之亦然。对于后一种情况,表达式转换会引起数据转换。在下面三个小节中,我们讨论一下这三种数据转换。

指派类型给用户数据

之前已经讨论过表记录和索引记录的存储格式了。每一个记录的字段都有一个存储类型。应用层传递字段值给SQLite有两种方式:(1)嵌入在SQL语句中,(2)通过预处理绑定值。(VM也是通过执行表达式语句来分派字段值。)在VM执行预处理语句之前,它会给每一个输入的值分配一个存储类型。这个存储类型是用来将输入的数据编码为一个合适的屋里存储格式。

VM通过三个步骤来决定一个输入值的存储类型:它首先确定输入数据的存储类型,然后确定列的声明的SQL类型,最后,如果需要,它再进行类型转换。在后面章节的讲述中,SQLite可能会在数字存储类型(INTEGER和REAL)与TEXT之间转换数据。(你可能注意到了,SQL标准里没有提供关于数据编码的指南,除了一些日期,时间和时间戳。)这些会在下面的章节中讨论。

决定存储类型

SQLite是‘无类型’的,例如,没有预约束。(SQLite开发者团队希望一直是无类型的。)无类型允许在任何表里面存储任何数据类型,而不用在意那一列声明的是什么数据类型。(除了声明了INTEGER PRIMARY KEY的,这一列只能存储整型,其他类型的VM都会拒绝。)SQLite允许在创建语句中无SQL类型声明。例如create table T1(a,b,c)在SQLite中其实是一个有效的SQL语句。那现在的问题是,VM是如何在一个值存入指定列的时候为它分配具体的数据类型的呢?

VM按照如下的步骤来给用户输入的数据分配一个初始化的存储类型。正如前面提到的,有两种方法给SQLite提供输入数据。

  1. 如果一个值是嵌入SQL语句中提供的,那么会被指派为一下数据类型之一:

    • 如果值被单双引号包含,那么指派为TEXT
    • 如果值是不带小数点或指数的,不带引号的数字,那么指派为INTEGER
    • 如果值是带小数点或指数的不带引号的数字,那么指派为REAL
    • 如果值是字符串NULL,且周围没有引号,那么指派为NULL
    • 如果使用X’ABCD’标记指定值,那么指派为BLOB(ABCD是十六进制数字)

    否则输入的值将会被VM拒绝,并且查询将会失败。

  2. sqlite3_bind_*API方法中提供的SQL参数值将会指派为与原生类型最接近的数据类型。例如,sqlite3_bind_blob绑定了一个存储类型为BLOB的值。

SQL标量运算符结果的值的存储类型取决于表达式的最外层运算符。用户定义的函数可以返回任何存储类型的值。通常在SQL语句的预处理阶段无法确定表达式结果的类型。VM在运行时一获取值就会分配存储类型。

决定列的相似关联性

SQLite允许在任何一列存储任一类型的数据。因此数据类型和数据值存在一起。其他SQL数据库引擎使用限制性更强的静态类型,其中类型与容器关联,而不与值关联。SQLite更具有灵活性。为了最大程度地提高SQLite与其他数据库引擎之间的兼容性,SQLite支持在列上使用类型相似性的概念。每一个输入的值可能具有SQL语句声明的类型的一个相关联的类型。对于该列中存储的值,建议使用列类型的相似类型:”建议使用,不是必需的”。

某一个列优先使用的值的类型,称之为这个列的关联类型。列的关联类型和列的声明类型是不一样的,尽管前者还是由后者派生而来。每一个列有以下5种相似类型的其中一个:TEXT,NUMERIC,INTEGER,REAL和NONE。根据CREATE TABLE语句中声明的SQL类型,SQLite遵循以下的规则,来决定某个列的关联类型。

  1. 如果SQL类型包含子字符串INT,则该列具有INTEGER关联。
  2. 如果SQL类型包含任何子字符串CHAR,BLOB或TEXT,则该列具有TEXT关联性。 (SQL类型VARCHAR包含字符串CHAR,因此也具有TEXT关联性。)
  3. 如果SQL类型包含子字符串BLOB,或者未指定类型,则列具有NONE关联。
  4. 如果SQL类型包含任何子串REAL,FLOA或DOUB,则列具有REAL关联。
  5. 除此之外, 列具有NUMERIC关联。

VM会按照上面给出的顺序来评估这个规则。模式匹配不区分大小写。例如,如果声明的列的SQL类型为BLOBINT,则关联性为INTEGER,而不是NONE。

**注意:**如果使用create table table1 as select ...语句创建了SQL表,每列的声明类型由create table语句的select部分中相应表达式的相关联类型确定。如果一个表达式的关联类型是text, numeric, integer, real, 或 none,那么声明的类型分别就是text, num, int, real, 或 “”。每个此类列的默认值为SQL NULL。隐式rowid的类型始终是整数,不能为NULL。

数据转换

SQLite在关联类型和存储类型之间定义了一种关系。如果用户给某一列提供的数据不满足这个关系,那么这个值就会拒绝或者转换为一个合适的格式。当一个值即将被插入到一列的时候,VM首先会给这个值指派一个最合适的存储类型,然后来决定存入的这个列的关联类型,最后再尝试将这个初始的存储类型转换为这个列的关联类型。它会按照如下规则转换:

  1. 具有TEXT关联关系的列可以存储所有NULL,TEXT,BLOB存储类型的数据。如果一个数字类型的(整型或者浮点型)被插入的时候,这个数字会被转换为文本类型,那么它的最终存储类型就会变成TEXT。
  2. 一个具有NUMERIC关联类型的列可以存储所有的5种类型。当一个文本类型的数据插入到这一列的时候,VM会尝试把这个文本转换为整型或者浮点型。如果这个转换是成功的(无损且可逆),那么这个转换的值就会相应的使用整型和浮点型的存储类型。如果这个转换无法执行的话,那么这个值就会以TEXT的存储类型来存储。VM不会尝试转换NULL或者BLOB值。
  3. 一个具有INTEGER关联类型的列和上面的NUMERIC的行为一致,除了一种情况:当一个没有小数部分的浮点型值或者一个文本值,VM会把这个值转换为整型并且存储的最终存储类型为INTEGER。
  4. 一个具有REAL关联类型的列和上面的NUMERIC的行为一致,除了一种情况:他会强制转换一个整型的值为一个浮点数。(但是,SQLite会做一个优化,只在磁盘上存储整数,等待读出数据的时候才会转换为浮点数)
  5. 一个具有NONE关联类型的列可以存储所有的5种类型,并且不会转换任何类型。

**注意:**所有的SQL数据库引擎都会转换数据类型。这些数据库都是类型强要求的,但SQLite可以是不是强要求的。举个例子,如果你有个表的列声明了SQL类型为整型的并且尝试插入一个字符串(“123”或”abc”),那么VM会分析这个字符串看看是否像一个数字。如果看起来像是一个数字,那么它就会被转换为一个数字(如果没有小数部分,会被转换为一个整型)。如果不是一个数字的格式,那么就会以TEXT的数据类型存储。一个具有TEXT关联类型的会尝试把输入数字以ASCII编码的形式存储。

一个简单的例子

从一个简单的例子来看存储类型,关联类型和类型转换。下面是一个典型的字段无类型的图表。假设你在这个表内执行了一个插入的语句。表格里也同样描述了插入到T1表B+树的记录。输入值(a,b,c)初始化的存储类型分别是整型,NULL和文本。所有列的关联类型都是NONE,并且VM不会转换初始化存储类型。在图里,这个记录(头+数据项)包含了11个字节。(记录里所有的数字都是以十六进制给出的。上面提及到SQLite不会重排序每列的位置:和创建语句中的顺序保持一致。)

  1. 头部有四个字节:第一个字节是头部的大小,加上三个字节分别是每一个值的类型。
  2. 类型1的数字是2,代表2个字节的有符号整型。
  3. 类型2的数字是0,代表NULL。
  4. 类型3的数字是22,代表(22-12)/2=5个字节的字符串。
  5. 数据1是2个字节的整型00B1,值是177。(一个字节是无法表示177的,因为B1的值是-79)
  6. 数据2是NULL,不占据数据段的任何空间。
  7. 数据3是5个字节的字符串 68 65 6C 6C 6F。忽略了终止符。

列关联性的例子

考虑下面的场景:有SQL语句create table T1(t TEXT,n NUMERIC,i INTEGER, r REAL,b BLOB)创建了一个表。假设执行如下的SQL语句:insert into T1 values('1.0', '1.0', '1.0', '1.0', '1.0')。所有值的初始化存储类型都是TEXT,因为他们都是有单引号的。基于之前给出的规则,列t,n,i,r,b的关联类型分别是TEXT,NUMERIC,INTEGER,REAL 和 NONE。那t,n,i,r,b几个列的最终的存储类型是TEXT,INTEGER,INTEGER,REAL 和 TEXT。对于NUMERIC关联类型,’1.0’看起来像是一个整型1,因此它的最终存储类型就是整型。而关联性为NONE的列不会做任何的数据类型转换,因此,依旧会把初始化类型存入。假设再执行insert into T1 values(1.0, 1.0, 1.0, 1.0, 1.0)。所有值的初始化存储类型都是REAL,并且最终的存储类型分别是TEXT,INTEGER,INTEGER,REAL 和 REAL。对于TEXT列,1.0会被转换为文本”1.0”作为TEXT存储类型。假设再执行insert into T1 values(1, 1, 1, 1, 1)。所有值的初始化存储类型都是INTEGER,并且最终的存储类型分别是TEXT, INTEGER, INTEGER, REAL 和 INTEGER。你可以在这个网站上找到更多关于列关联性类型的例子。

其他相似关联性的模式

上面小节中讨论的是数据库引擎在’通常’和默认关联模式下的数据库操作。SQLite支持另外两种关联性模式相关的特性。

  • 严格关联模式 在和这个模式下,如果需要在初始存储类型和关联类型之间进行转换,引擎就会返回一个错误,并且当前的语句执行就会失败。
  • 无关联模式 在这个模式下,VM不会做任何的存储类型的转换。

给应用层转换引擎的数据

应用层可以通过调用sqlite3_column_*API方法来从SQLite引擎中读取数据。这些方法在合适的地方会尝试转换数据值。举个例子,如果内部的表示是REAL,但是通过APIsqlite3_column_text方法请求了一个字符串值,VM会使用sprintf()库方法内部做一个转换,并且把值返回给应用层。下面的表展示了VM应用在内部数据的转换规则。

内部类型 请求类型 转换
NULL INTEGER 返回结果为0
NULL FLOAT 返回结果为0
NULL TEXT 返回结果是NULL指针
NULL BLOB 返回结果是NULL指针
INTEGER FLOAT 从整型转换为浮点型
INTEGER TEXT 通过ASCII码转换
INTEGER BLOB 和上面一样
FLOAT INTEGER 从浮点型转换为整型
FLOAT TEXT 通过ASCII转换
FLOAT BLOB 和上面一样
TEXT INTEGER 使用atoi()
TEXT FLOAT 使用atof()
TEXT BLOB 没有变更
BLOB INTEGER 先转换为TEXT然后使用atoi()
BLOB FLOAT 先转换为TEXT然后使用atof()
BLOB TEXT 如果需要的话在后面增加一个\000

为表达式指派数据类型

VM可能先转换内部数据,然后再将内部数据与另一个内部数据进行比较或者评估表达式,在后面的章节中,我们将讨论VM是如何处理内部数据的。

处理SQL NULL值

SQL NULL值可以在任何一个表列使用,除了主键列。NULL值的存储类型是NULL。无论它们的存储类型是什么,NULL值与这个列的所有其他有效值都不同。SQL标准对于如何处理表达式中列的NULL值不是很具体。根据标准还尚不清楚在各种情况下应如何正确处理NULL值。例如,我们如何比较NULL和其他值?SQLite以许多其他RDBMS一样的方式处理NULL。在SELECT DISTINCT语句,使用UNION操作符的SELECT的组合语句,和GROUP BY语句中NULL是一样的。但是,NULL在UNIQUE列中却是不同的。NULL由SQL标准指定的内置SUM函数处理。对NULL进行算术运算的结果始终是NULL。

表达式中的类型

SQLite支持四种比较操作符:

  • 二元比较运算符 =, ==, <, <=, >, >=, <>, 和 !=
  • 三元比较运算符 ‘BETWEEN’
  • 成员运算符 ‘IN’ 和 ‘NOT IN’
  • 判空操作符 ‘IS NULL’ 和 ‘IS NOT NULL’

**注意:**判空操作符’IS NULL’和’IS NOT NULL’分别和’=’和’!=’操作符一样。

根据以下规则,比较的结果取决于要比较的两个值的存储类型:

  1. 如果运算符左侧的值是NULL,那么这个NULL一般都会认为比其他的小。(如果其他的值中有NULL,也是认为左侧的小)
  2. INTEGER或REAL比TEXT或BLOB值小。
  3. 如果INTEGER或REAL和另一个INTEGER或REAL比较,那么就是常规的数字比较了。
  4. TEXT值比BLOB值小。
  5. 如果两个TEXT值比较,那么就是使用标准C库的memcmp函数来比较。但是这个函数是可以被用户自定义的函数重写的。
  6. 当两个BLOB比较的时候,始终使用memcmp函数比较。

在应用这些规则之前,VM的首要任务是确定比较运算符的操作数的存储类型。它首先确定操作数的初步存储类型,然后(如有必要)根据它们的列的关联性转换类型。最后,它使用以上四个规则进行比较。

如果一个表达式是某个列,或者是使用别名指向的某个列,或者是一个子查询返回的一个列,或者是rowid,那么这个这个表达式的关联性就会使用列的关联性。否则,这个表达式没有SQL类型,并且它的关联性是NONE。SQLite会尝试在比较运算之前,在(INTEGER和REAL)和TEXT之间做转换。对于二元比较,会在下列情况下完成(可以看expr.c文件中的sqlite3CompareAffinity方法)。这里说的表达式,是除了列值以外的任何SQL标量表达式或者纯文本。

  • 当两个列的值比较的时候,如果其中任何一列有NUMERIC关联性,那么这两个值会优先使用这个关联性。也就是说,VM尝试在比较之前转换其他列的值。
  • 当将列值与表达式的结果进行比较时,在进行比较之前,会将列的关联性同样应用于这个表达式的结果。
  • 比较两个表达式的值时,将不进行任何转换。按照上述标准规则比较这些值。例如,如果将字符串与数字进行比较,则数字将始终小于字符串。

在SQLite中表达式a BETWEEN b AND c等价于a >= b AND a <= c,但是在两次的比较中,a列的关联性会不一样。

对于表达式a IN (SELECT b ...)来说,就会使用上面提到的 = 号的二元操作符的规则来处理(例如,a=b)。举个例子,如果b是一个列的值,a是一个表达式,那么在比较之前b的关联性就会被应用到a上。SQLite处理表达式a IN (x,y,z)和处理a = x OR a = y OR a = z是一样的,但是a的关联性是不一样的。

有些简单的例子,你可以在这个网站上找到更多例子。假设,你有一个通过CREATE TABLE t1(a TEXT,b NUMERIC, c BLOB, d)语句创建的表。你可以通过执行INSERT INTO t1 VALUES(‘500’,‘500’,‘500’, 500)插入一条记录。那么最终,a,b,c三列的存储类型就会变成TEXT,INTEGER,TEXT 和 INTEGER。

  • SELECT a < 600, a < 60, a < 40 FROM t1,会把600,60和40转换为”600”,”60”和”40”,因为a列具有TEXT关联性,值就会被当做TEXT来比较。并且整个语句返回1|1|0作为输出,因为”500”比”600”和”60”小,但是比”40”大。
  • SELECT b < 40, b < 60, b < 600 FROM t1不会转换任何值,会被当做普通的数字比较,那么就会返回0|0|1
  • SELECT c < 40, c < 60, c < 600 FROM t1不会转换任何值,因为c列是NONE关联类型。三个值(存储类型为NUMERIC)都比”500”小(存储类型为TEXT),所以返回值为0|0|0
  • SELECT d < 40, d < 60, d < 600 FROM t1不会转换值,因为d的关联性是NUMERIC。返回0|0|1,因为存储的值都是整型比较。

操作符中的类型

所有的数学运算符(除了 || 串联运算符以外)都将NUMERIC关联到所有的操作数上,并求值。如果所有操作数都无法转换为NUMERIC,那么运算结果为NULL。对于串联运算符来说,TEXT将关联到两个操作数上。如果任何一个操作数都无法转换为TEXT(NULL或者BLOB),那么串联的结果为NULL。

order by中的类型

当值被ORDER BY语句排序的时候,在排序之前不会有存储类型的转换。遵循先前规定的标准比较规则:NULL在最前面,然后是按值大小的INTEGER和REAL,再然后是TEXT,最后是BLOB,后面两个通常是memcmp()排序顺序。同样的文本排序方法可以被用户定义的函数重写。

group by中的类型

当值被GROUP BY语句分组的时候,在分组之前不会有存储类型的转换。具有不同存储类型的值被认为是不同的,但INTEGER和REAL值除外,如果它们在数值上相等,则被视为相等。GROUP BY语句的结果中也不会做任何的存储类型的转换。

组合SELECTs中的类型

复合SELECT操作符(UNION,INTERSECT和EXCEPT),在值之间进行隐式比较。在比较之前,VM不会执行任何关联类型转换。它们就按照原始值进行比较。

总结

VM模块是后端数据库引擎。数据操作逻辑就在这个模块内。它会执行一个使用特殊的字节码语言编写的程序。(类似于其他汇编语言)一个字节码程序实际上就是字节码指令的线性组合。一个字节码指令可以有一个操作码和最多5个操作数或者持有操作数的寄存器组成。

一个Vdbe对象代表了一个由前端生成的字节码程序(在内部是一个sqlite3_stmt指针)。应用层可以在这个对象上执行SQLite API方法。举个例子,当应用层执行sqlite3_stepAPI方法的时候,它会循环迭代执行这个程序,一直执行遇到终止或者遇到一个可以输出的结果记录。

VM支持五种数据类型:integer,real,text,blob和SQL NULL。在数据库文件或者内存中的数据必须是这五种之一。这个类型称之为存储类型。当需要的时候,VM会做类型的转换。应用程序可以使用typeof SQL函数来确定值的数据类型。

VM是数据的唯一操作者。它维护表和索引树中的记录。这两者的记录格式很相似。SQLite使用一个可变长度的记录格式。它使用清单类型来表示记录中的单个值。这个清单类型是指,每一个数据的存储类型信息都被存在记录中了。此方案允许在任何列中存储任何存储类型的值,而与该列的声明的SQL类型无关,但有一个例外,即整数主键列仅存储整数值。值的存储类型被编码为整数,并且该整数还编码该值的(字节)长度。每个表记录均以头部大小(即可变长度的霍夫曼代码)开头,然后是数据值的清单类型,然后是各个数据值。

数据类型管理在SQLite中有点复杂。SQLite根据声明的SQL类型为列分配关联类型。首先尝试将输入数据值转换为关联类型。如果转换失败,则原始值仍将存储在该列中。 SQLite进行类似的数据转换来评估表达式和函数。