SQLite剖析之内核研究

时间:2022-05-22 13:06:41

  先从全局的角度把握SQLite内核各个模块的设计和功能。SQLite采用了层次化、模块化的设计,而这些使得它的可扩展性和可移植性非常强。而且SQLite的架构与通用DBMS的结构差别不是很大,所以它对于理解通用DBMS具有重要意义。SQLite的内核总的来说分为三个部分,虚拟机(Virtual Machine)、Back-end(后端)和compiler(编译器)。

1、虚拟机(Virtual Machine)
VDBE是SQLite的核心,它的上层模块和下层模块本质上都是为它服务的。它的实现位于vbde.c, vdbe.h, vdbeapi.c, vdbeInt.h和vdbemem.c几个文件中。它通过底层的基础设施B+Tree执行由编译器(Compiler)生成的字节代码,这种字节代码程序语言(bytecode programming lauguage)是为了进行查询,读取和修改数据库而专门设计的。
字节代码在内存中被封装成sqlite3_stmt对象(内部叫做Vdbe,见vdbeInt.h),Vdbe(或者说statement)包含执行程序所需要的一切:
a)    a bytecode program
b)    names and data types for all result columns
c)    values bound to input parameters
d)    a program counter
e)    an execution stack of operands
f)    an arbitrary amount of "numbered" memory cells
g)    other run-time state information (such as open BTree objects, sorters, lists, sets)

字节代码和汇编程序十分类似,每一条指令由操作码和三个操作数构成:<opcode, P1, P2, P3>。Opcode为一定功能的操作码,为了理解,可以看成一个函数。P1是32位的有符号整数,p2是31位的无符号整数,它通常是导致跳转(jump)的指令的目标地址(destination),当然这里有其它用途;p3为一个以null结尾的字符串或者其它结构体的指针。和C API不同的是,VDBE操作码经常变化,所以不应该用字节码写程序。
下面的几个C API直接和VDBE交互:
• sqlite3_bind_xxx() functions
• sqlite3_step()
• sqlite3_reset()
• sqlite3_column_xxx() functions
• sqlite3_finalize()

为了有个感性,下面看一个具体的字节码程序:

sqlite> .m col
sqlite> .h on
sqlite> .w
sqlite> explain select * from episodes;
addr  opcode           p1   p2   p3
----  ---------------  ---  ---  ---------------
     Goto
     Integer
     OpenRead                 # episodes
     SetNumColumns
     Rewind
     Recno
     Column
     Column
     Callback
     Next
    Close
    Halt
    Transaction
    VerifyCookie
    Goto
    Noop                 

1.1、栈(Stack)
一个VDBE程序通常由不同完成特定任务的段(section)构成,每一个段中,都有一些操作栈的指令。这是由于不同的指令有不同个数的参数,一些指令只有一个参数;一些指令没有参数;一些指令有好几个参数,这种情况下,三个操作数就不能满足。
考虑到这些情况,指令采用栈来传递参数。(注:从汇编的角度来看,传递参数的方式有好几种,比如:寄存器,全局变量,而堆栈是现代语言常用的方式,它具有很大的灵活性)。而这些指令不会自己做这些事情,所以在它们之前,需要其它一些指令的帮助。VDBE把计算的中间结果保存到内存单元(memory cells)中,其实,堆栈和内存单元都是基于Mem(见vdbeInt.h)数据结构(注:这里的栈、内存单元都是虚拟的,有位计算机科学家说过:计算机科学中90%以上的科学都是虚拟化问题。OS本质上也是虚拟机,而SQLite也同样用到虚拟化,比如后面讨论的OS interface模块)。

1.2、程序体(Program Body)
上面的程序是一个打开episodes表的过程。
第一条指令:Integer是为第二条指令作准备的,也就是把第二条指令执行需要的参数压入堆栈,OpenRead从堆栈中取出参数值然后执行。SQLite可以通过ATTACH命令在一个连接中打开多个数据库文件,每当SQLite打开一个数据,它就为之赋一个索引号(index),main database的索引为0,第一个数据库为1,依次如此。Integer指令数据库索引的值压入栈,而OpenRead从中取出值,并决定打开哪个数据库,来看看SQLite文档中的解释:
     Open a read-only cursor for the database table whose root page is P2 in a database file.
  The database file is determined by an integer from the top of the stack. 0 means the main database and 1 means the database used for temporary tables. Give the new cursor an identifier of P1. The P1 values need not be contiguous but all P1 values should be small integers. It is an error for P1 to be negative.
     If P2==0 then take the root page number from off of the stack.
     There will be a read lock on the database whenever there is an open cursor. If the database was unlocked prior to this instruction then a read lock is acquired as part of this instruction. A read lock allows other processes to read the database but prohibits any other process from modifying the database. The read lock is released when all cursors are closed. If this instruction attempts to get a read lock but fails, the script terminates with an SQLITE_BUSY error code.
     The P3 value is a pointer to a KeyInfo structure that defines the content and collating sequence of indices. P3 is NULL for cursors that are not pointing to indices.

再来看看SetNumColumns指令设置游标到将指向的行。P1为游标的索引(这里为0,刚刚打开),P2为列的数目,episodes表有三列。
继续Rewind指令,它将游标重新设置到表的开始,它会检查表是否为空(即没有记录),如果没有记录,它会导致指令指针跳到P2指定的指令处。在这里,P2为10,即Close指令。一旦Rewind设置游标,接下来就执行5-9这几条指令,它们的主要功能是遍历结果集,Recno把由游标P1指定的记录的关键字压入堆栈。Column指令从由P1指定的游标,P2指定的列取值。5,6,7三条指令分别把id(primary key),season和name字段的值压入栈。接下来,Callback指令从栈中取出三个值(P1),然后形成一个记录数组,存储在内存单元中(memory cell)。Callback会停止VDBE的操作,把控制权交给sqlite3_stemp(),该函数返回SQLITE_ROW。
SQLite剖析之内核研究
一旦VDBE创建了记录结构,我们就可以通过sqlite3_column_xxx() functions从记录结构的域内取出值。当下次调用sqlite3_step()时,指令指针会指向Next指令,而Next指令会把游标移向下一行,如果有其它的记录,它会跳到由P2指定的指令,在这里为指令5,创建一个新的记录结构,一直循环,直到结果集的最后。Close指令会关闭游标,然后执行Halt指令,结束VDBE程序。

1.3、程序开始与停止
其余的指令中,Goto指令是一条跳转指令,跳到P2处,即第12条指令。指令12是Transaction,它开始一个新的事务;然后执行VerifyCookie,它的主要功能是检查VDBE程序编译后,数据库模式是否改变(即是否进行过更新操作),这在SQLite中是一个很重要的概念,在SQL被sqlite3_prepare()编译成VDBE代码至程序调用sqlite3_step()执行字节码的这段时间,另一个SQL命令可能会改变数据库模式(such as ALTER TABLE, DROP TABLE, or CREATE TABLE)。一旦发生这种情况,之前编译的statement就会变得无效,数据库模式信息记录在数据库文件的根页面中。类似,每一个statement都有一份用来比较的备份,是在编译时刻对该模式的备份,VerifyCookie的功能就是检查它们是否匹配,如果不匹配,将采取相关操作。
SQLite剖析之内核研究

如果两者匹配,会执行下一条指令Goto;它会跳到程序的主要部分,即第一条指令,打开表读取记录。这里有两点值得注意:
(1)Transaction指令自己不会获取锁(The Transaction instruction doesn’t acquire any locks in itself)。它的功能相当于BEGIN,而实际是由OpenRead指令获取share lock的。当事务关闭时释放锁,这取决于Halt指令,它会进行扫尾工作。
(2)statement对象(VDBE程序)所需的存储空间在程序执行前就已经确定。这是基于两个重要事实:首先,栈的深度不会比指令的数目还多(通常少得多)。其次,在执行VDBE程序之前,SQLite可以计算出为分配资源所需要的内存。

1.4、指令的类型(Instruction Types)
每条指令都完成特定的任务,而且通常和别的指令有关。大体上来说,指令可分为三类:
(1)Value manipulation:这类指令通常完成算术运算(比如:add、subtract、 divide),逻辑运算(比如:AND、OR)和字符串操作。
(2)Data management:这类指令操作在内存和磁盘上的数据。内存指令进行栈操作或者在内存单元之间传递数据。磁盘操作指令控制B-tree和pager打开或操作游标,开始或结束事务等等。
(3)Control flow:控制指令主要是移动指令指针。

1.5、程序的执行(Program execution)
VM解释器是如何实现的?字节代码是如何执行的?
在vdbe.c文件中有一个很关键的函数:

//执行VDBE程序
int sqlite3VdbeExec(
  Vdbe *p                    /* The VDBE */
)
该函数是执行VDBE程序的入口。来看看它的内部实现:

/*从这里开始执行指令
**pc为程序计数器(int)
*/
for(pc=p->pc; rc==SQLITE_OK; pc++){
  //取得操作码
  pOp = &p->aOp[pc];
  switch( pOp->opcode ){
  case OP_Goto: {             /* jump */
      CHECK_FOR_INTERRUPT;
      pc = pOp->p2 - ;
      break;
     }
    … …
   }
}

从这段代码,我们大致可以推出VM执行的原理:VM解释器实际上是一个包含大量switch语句的for循环,每一个switch语句实现一个特定的操作指令。

2、B-tree和Pager
B-Tree使得VDBE可以在O(logN)下查询,插入和删除数据,以及O(1)下双向遍历结果集。B-Tree不会直接读写磁盘,它仅仅维护着页面(pager)之间的关系。当B-Tree需要页面或者修改页面时,它就会调用Pager。当修改页面时,pager保证原始页面首先写入日志文件,当它完成写操作时,pager根据事务状态决定如何做。B-tree不直接读写文件,而是通过page cache这个缓冲模块读写文件,对于性能是有重要意义的(这和操作系统读写文件类似,在Linux中,操作系统的上层模块并不直接调用设备驱动读写设备,而是通过一个高速缓冲模块调用设备驱动读写文件,并将结果存到高速缓冲区)。

2.1、数据库文件格式(Database File Format)
数据库中所有的页面都按从1开始顺序标记。一个数据库由许多B-tree构成——每一个表和索引都有一个B-tree(索引采用B-tree,而表采用B+tree,这主要是表和索引的需求不同以及B-tree和B+tree的结构不同决定的:B+tree的所有叶子节点包含了全部关键字信息,而且可以有两种顺序查找;而B-tree更适合用来作索引)。所有表和索引的根页面都存储在sqlite_master表中。
数据库中第一个页面(page 1)有点特殊,page 1的前100个字节包含一个描述数据库文件的特殊的文件头。它包括库的版本,模式的版本,页面大小,编码等所有创建数据库时设置的参数。这个特殊的文件头的内容在btree.c中定义,page 1也是sqlite_master表的根页面。

2.2、页面重用及回收(Page Reuse and Vacuum )
SQLite利用一个空闲列表(free list)进行页面回收。当一个页面的所有记录都被删除时,就被插入到该列表。当运行VACUUM命令时,会清除空闲列表的页面,此时数据库会缩小,本质上它是在新的文件中重新建立数据库,而所有使用的页再都被拷贝过去,而空闲列表页面却不会,结果就是一个新的、变小的数据库。当数据库的autovacuum开启时,SQLite不会使用free list,而且在每一次commit时自动压缩数据库。

2.3.1、B-Tree记录
B-tree页面由B-tree记录组成,B-tree记录也叫做payloads。每一个B-tree记录(payload)有两个域:关键字域(key field)和数据域(data field)。Key field就是rowid的值,或者数据库中表的关键字的值。从B-tree的角度,data field可以是任何无结构的数据。数据库的记录就保存在这些data fields中。B-tree的任务就是排序和遍历,它最需要的是关键字。Payloads的大小是不定的,这与内部的关键字和数据域有关,当一个payload太大不能存在一个页面内,便保存到多个页面。

2.3.2、B+Tree

B+Tree按关键字排序,所有的关键字必须唯一。表采用B+tree,内部页面不包含数据,如下:
SQLite剖析之内核研究

B+tree中根页面(root page)和内部页面(internal pages)都是用来导航的,这些页面的数据域都是指向下级页面的指针,仅仅包含关键字。所有的数据库记录都存储在叶子页面(leaf pages)内。在叶节点一级,记录和页面都是按照关键字的顺序的,所以B+tree可以水平方向遍历,时间复杂度为O(1)。

2.4、记录和域(Records and Fields)
位于叶节点页面的数据域的记录由VDBE管理,数据库记录以二进制的形式存储,但有一定的数据格式。记录格式包括一个逻辑头(logical header)和一个数据区(data segment),逻辑头区包括header的大小(hsize)和一个数据类型数组(T1~TN),数据类型即在data segment的数据的类型,如下:

SQLite剖析之内核研究

2.5、层次数据组织(Hierarchical Data Organization) 

SQLite剖析之内核研究

从上往下,数据越来越无序,从下向上,数据越来越结构化.

2.6、B-Tree API
B-Tree模块有自己的API,它可以独立于C API使用。另一个特点就是它支持事务。由pager处理的事务、锁和日志都是为B-tree服务的。根据功能可以分为以下几类:

2.6.1、访问事务函数
sqlite3BtreeOpen: Opens a new database file. Returns a B-tree object.
sqlite3BtreeClose: Closes a database.
sqlite3BtreeBeginTrans: Starts a new transaction.
sqlite3BtreeCommit: Commits the current transaction.
sqlite3BtreeRollback: Rolls back the current transaction.
sqlite3BtreeBeginStmt: Starts a statement transaction.
sqlite3BtreeCommitStmt: Commits a statement transaction.
sqlite3BtreeRollbackStmt: Rolls back a statement transaction.

2.6.2、表函数
sqlite3BtreeCreateTable: Creates a new, empty B-tree in a database file. 
sqlite3BtreeDropTable: Destroys a B-tree in a database file.
sqlite3BtreeClearTable: Removes all data from a B-tree, but keeps the B-tree intact.

2.6.3、游标函数(Cursor Functions)
sqlite3BtreeCursor: Creates a new cursor pointing to a particular B-tree. 
sqlite3BtreeCloseCursor: Closes the B-tree cursor.
sqlite3BtreeFirst: Moves the cursor to the first element in a B-tree.
sqlite3BtreeLast: Moves the cursor to the last element in a B-tree.
sqlite3BtreeNext: Moves the cursor to the next element after the one it is currently pointing to.
sqlite3BtreePrevious: Moves the cursor to the previous element before the one it is currently pointing to.       
sqlite3BtreeMoveto: Moves the cursor to an element that matches the key value passed  in as a parameter.

2.6.4、记录函数(Record Functions)
sqlite3BtreeDelete: Deletes the record that the cursor is pointing to.
sqlite3BtreeInsert: Inserts a new element in the appropriate place of the B-tree.
sqlite3BtreeKeySize: Returns the number of bytes in the key of the record that the cursor is pointing to.
sqlite3BtreeKey: Returns the key of the record the cursor is currently pointing to.
sqlite3BtreeDataSize: Returns the number of bytes in the data record that the cursor is currently pointing to.
sqlite3BtreeData: Returns the data in the record the cursor is currently pointing to.

2.6.5、配置函数(Configuration Functions)
sqlite3BtreeSetCacheSize: Controls the page cache size as well as the synchronous writes (as defined in the synchronous pragma).
sqlite3BtreeSetSafetyLevel: Changes the way data is synced to disk in order to increase or decrease how well the database resists damage due to OS crashes and power failures. 
           Level 1 is the same as asynchronous (no syncs() occur and there is a high probability of damage). This is the equivalent to pragma synchronous=OFF.
           Level 2 is the default. There is a very low but non-zero probability of damage. This is the equivalent to pragma synchronous=NORMAL.
           Level 3 reduces the probability of damage to near zero but with a write performance reduction. This is the equivalent to pragma synchronous=FULL. 
sqlite3BtreeSetPageSize: Sets the database page size.
sqlite3BtreeGetPageSize: Returns the database page size.
sqlite3BtreeSetAutoVacuum: Sets the autovacuum property of the database.
sqlite3BtreeGetAutoVacuum: Returns whether the database uses autovacuum.
sqlite3BtreeSetBusyHandler: Sets the busy handler.           

2.7、实例分析
sqlite3_open的具体实现过程如下:

SQLite剖析之内核研究

由上图可知,SQLite的所有IO操作,最终都转化为操作系统的系统调用(DBMS建立在OS上)。同时也可以看到SQLite的实现非常层次化,模块化,使得SQLite更易扩展,可移植性非常强。

3、编译器(Compiler)
3.1、分词器(Tokenizer)
接口把要执行的SQL语句传递给Tokenizer,Tokenizer按照SQL的词法定义把它切分成一个一个的词,并传递给分析器(Parser)进行语法分析。分词器是手工写的,主要在Tokenizer.c中实现。

3.2、分析器(Parser)
SQLite的语法分析器是用Lemon(一个开源的LALR(1)语法分析器的生成器)生成的,生成的文件为parser.c。
一个简单的语法树:

 SELECT rowid, name, season FROM episodes WHERE rowid= LIMIT 

SQLite剖析之内核研究

3.3、代码生成器(Code Generator)
代码生成器是SQLite中最庞大,最复杂的部分。它与Parser关系紧密,根据语法分析树生成VDBE程序执行SQL语句的功能。由诸多文件构成:select.c,update.c,insert.c,delete.c,trigger.c,where.c等文件。这些文件生成相应的VDBE程序指令,比如SELECT语句就由select.c生成。
下面是一个读操作中打开表的代码生成实现

/* Generate code that will open a table for reading.
*/
void sqlite3OpenTableForReading(
  Vdbe *v,        /* Generate code into this VDBE */
  int iCur,       /* The cursor number of the table */
  Table *pTab     /* The table to be opened */
){
  sqlite3VdbeAddOp(v, OP_Integer, pTab->iDb, );
  sqlite3VdbeAddOp(v, OP_OpenRead, iCur, pTab->tnum);
  VdbeComment((v, "# %s", pTab->zName));
  sqlite3VdbeAddOp(v, OP_SetNumColumns, iCur, pTab->nCol);
}

Sqlite3vdbeAddOp函数有三个参数:(1)VDBE实例(它将添加指令),(2)操作码(一条指令),(3)两个操作数。

3.4、查询优化
代码生成器不仅负责生成代码,也负责进行查询优化。主要的实现位于where.c中,生成的WHERE语句块通常被其它模块共享,比如select.c,update.c以及delete.c。这些模块调用sqlite3WhereBegin()开始WHERE语句块的指令生成,然后加入它们自己的VDBE代码返回,最后调用sqlite3WhereEnd()结束指令生成,如下:

SQLite剖析之内核研究