数据库是构建软件系统的重要组成部分,用于高效地存储和读取数据。在这里,我们将使用 SQLite 的早期版本来讨论数据库实现的一些架构细节。
SQLite 是一个小型数据库应用程序,用于数百万个软件和设备。SQLite 是由 D.Richard Hipp 于 2000 年 8 月发明的。SQLite 是一种高性能、轻量级的关系数据库。如果您愿意在编码级别学习数据库的内部结构,那么 SQLite 是最好的开源数据库,它具有高度可读的源代码和大量文档。阅读更高版本的 SQLite 变得有点困难,因为它包含许多新功能。为了理解数据库内部的基本实现,你应该对数据结构有很好的了解,一些关于计算理论的知识,以及操作系统是如何工作的。
在这里,我们将研究 SQLite 2.5.0 版本。您可以在GitHub上找到SQLite 后端的简单实现。 另外,我发现这个存储库 包含用于代码读取的 SQLite 2.5.0 实现。
什么是数据库?
将数据保存在平面文件中读取和存储数据的效率不高。数据库以适当的顺序组织数据,以便数据读取和写入速度更快。数据可以是结构化的、半结构化的或非结构化的。数据库主要用于存储结构化和半结构化数据。可以根据要存储的数据结构类型对数据库进行如下挖掘。
- 关系型数据库:常用的具有表结构的数据库类型。表可以与其他表有关系。SQL 语言用于操作此类数据库上的数据。
- 键值数据库:与关联的键一起存储的数据。可以使用给定的键检索数据。内存数据库通常是这种类型的数据库。
- 对象数据库:数据结构更像是一个对象而不是一个表。
- 图数据库:图数据库是节点和边的集合,主要用于数据挖掘和社交媒体应用程序。
SQLite 数据库架构
SQLite 数据库架构分为两个不同的部分,分别命名为核心和后端。核心部分包含接口、分词器、解析器、代码生成器和虚拟机,它们为数据库事务创建执行顺序。后端包含 B-tree、Pager 和 OS 接口来访问文件系统。Tokenizer、Parser 和代码生成器统称为编译器,它生成一组运行在虚拟机上的操作码。
从哪里开始?
要了解数据库的架构,您需要具备以下先决条件。
- 对数据结构和算法有很好的理解。尤其是 B 树、链表、Hashmaps 等数据结构。
- 对计算机体系结构有一定的了解。如何读写磁盘,分页和缓存如何工作。
- 有限自动机、上下文无关语法等理论计算机和一些正则表达式知识。
SQLite 架构
VFS(虚拟文件系统)
Unix 和 Windows 上的文件访问彼此不同。VFS 提供通用 API 来访问文件,而无需考虑其运行的操作系统类型。该 API 包括打开、读取、写入和关闭文件的函数。以下是 VFS 中用于读取、写入数据到文件中的一些 API。
/*
Create a connection to file to read write
zFilename : file name
id : file pointer
pReadonly : read or write
*/
int sqliteOsOpenReadWrite(const char *zFilename, OsFile *id,int *pReadonly);
/*
Acqure the lock to read file. This function should be
called before caling to any file read function.
Return 0 if success
id : file pointer
*/
int sqliteOsReadLock(OsFile *id);
/*
Get the write lock to write into a file. This function should called before
doing any write operation into the file system.
Return 0 if success
id : file pointer
*/
int sqliteOsWriteLock(OsFile *id);
/*
Move to the given number of offest to read or write into the file
*/
int sqliteOsSeek(OsFile *id, int offset);
/*
Read amt bytes from the file with offset pointed by sqliteOsSeek
*/
int sqliteOsRead(OsFile *id, void *pBuf, int amt);
/*
Write amt bytes from the pBuf into the file
*/
int sqliteOsWrite(OsFile *id, const void *pBuf, int amt);
在这里,您可以使用 sqliteOpenReadWrite 函数开始使用文件。此函数为您提供一个指向文件的指针,该文件可用于读取或写入数据。接下来,应该在执行任何读写操作之前获取锁。如果它只是一个读操作,那么应该获取读锁。应该为读和写事务获取写锁。
然后可以通过将文件的偏移量提供给 sqliteOsSeek 函数来查找位置来完成读写。偏移量是从文件的起点到数据应该写入或读取的位置的字节数。
Pager
页是文件系统上数据库事务的最小单位。当数据库需要从文件中读取数据时,它会将它作为一个页面来请求。一旦页面被加载到数据库引擎中,如果它经常访问它的缓存,它就可以存储该页面。页数从一页开始编号。第一个页面称为根页面。页的大小是恒定的。
/*
Open pager with the given file name
*/
int sqlitepager_open(Pager **ppPager,const char *zFilename,int nPage,int nEx);
/*
Get page specified by the page number
*/
int sqlitepager_get(Pager *pPager, Pgno pgno, void **ppPage);
/*
Start to write data into a page specified in pData
*/
int sqlitepager_write(void *pData);
/*
Commit page changes into the file
*/
int sqlitepager_commit(Pager*);
/*
Close the connection to the file
*/
int sqlitepager_close(Pager *pPager);
Btree
Btree 是一种数据结构,用于根据数据的值将数据存储为树。BTree 最简单的形式是二叉树。数据库使用 Btree 数据结构来存储索引以提高数据库的性能。Btree 的每个节点都包含一个用于索引的键列表。可以为表中的每一列创建 Btree 索引。每个 Btree 都有根页面,这是任何 Btree 搜索的起点。
为了指向 Btree 上的一行,使用了称为“Cursor”的特殊指针。游标用于指向一个记录,该记录由页面 id 和偏移量指定,称为 idx。SQLite 将数据库模式存储在称为“master_table”的表上。master_table 总是存储在数据库的第一页上。
了解更多关于SQLite的B树的设计在这个文章。
/*
Open file connection to a page file name specified by zFileName with
nCache size cache
*/
int sqliteBtreeOpen(const char *zFilename, int mode, int nCache, Btree **ppBtree)
/*
Start transaction. This function should called before any btree modification
operations
*/
int sqliteBtreeBeginTrans(Btree *pBt)
/*
Insert key pKey with nKey byte and value pData with nData byte put
into the Btree
*/
int sqliteBtreeInsert(BtCursor *pCur, const void *pKey, int nKey,
const void *pData, int nData)
/*
Write data into the file
*/
int sqliteBtreeCommit(Btree *pBt)
/*
Move cursor to the matching pKey with nKey bytes
*/
int sqliteBtreeMoveto(BtCursor *pCur, const void *pKey, int nKey, int *pRes)
VDBE(虚拟数据库引擎)
VDBE 是运行一组操作的虚拟机,由代码生成器生成。包括插入、删除、更新、选择在内的所有 SQL 命令都转换成一组操作码,然后在虚拟机上运行。每个操作码包含三个名为 p1、p2 和 p3 的输入。您可以将此输入视为函数的输入。
下面我为以下 SQL 选择语句添加了示例执行操作码堆栈。PC 是程序计数器的指令 ID。 对我来说,SQLite 中最有趣的事情是我可以通过在 SQL 查询的开头附加“explain”关键字来查看给定 SQL 代码的一组 VBDE 操作码指令。
explain select * from foo;
个人电脑 | 操作码 | P1 | P2 | P3 | 评论 |
1 | 列数 | 1 | 0 | 将列数设置为 1 | |
2 | 列名 | 0 | 0 | 价值 | 将列名设置为“值” |
3 | 打开 | 0 | 3 | 富 | 打开光标并将其指向第三页,即 foo 表的根页(p3 不重要 |
4 | 验证Cookies | 46 | 0 | 确保架构未更改 | |
5 | 倒带 | 0 | 11 | 将光标移动到第一个条目 | |
6 | 柱子 | 0 | 0 | 从 Btree 负载读取数据并将其放入堆栈 | |
7 | 柱子 | 0 | 0 | ||
8 | ne | 1 | 10 | 从堆栈中弹出顶部的两个元素。如果它们不相等,则跳转到指令 P2。否则,继续执行下一条指令。 | |
9 | 打回来 | 1 | 0 | 从堆栈中弹出 P1 值并将它们组成一个数组。这将是此 SQL 表达式的结果行 | |
10 | 下一个 | 0 | 5 | 将光标移动到下一条记录,如果数据退出转到 P2 否则转到下一行 | |
11 | 关闭 | 0 | 0 | 关闭光标 |
编译器
Tokenizer、Parser 和 Code Generator 统称为编译器,可生成在 VBDE 上运行的操作码集。Tokenizer 通过扫描 SQL 代码生成一组令牌。然后,它验证语法并生成解析树。Lemon 解析器用于通过预定义的上下文无关语法来解析给定的 SQL 代码。代码生成器将这个解析树转换成一个用 SQLite 操作码编写的小程序。
结论
SQLite 是一种简单、轻量级、高性能的关系型数据库,广泛用于软件设计。SQLite 的早期版本是用简单的架构和高度可读的代码编写的。Pager 提供了一个抽象层,将数据作为固定大小的块读写到文件系统中。而 Btree 提供了一种在内存中存储数据的高效方式,以更快地访问数据。当 SQL 进入 SQLite 时,它会将 SQL 转换为 SQLite 机器码并在 VBDE 上运行。结果通过 API 返回给用户。