数据库是怎么工作的?
当你打开这篇文章的时候你一定也好奇:
- SQL语句为什么能对数据库操作
- 事务回滚是怎么滚的?
- 数据是怎么被保存到硬盘里的?
- …
接下来我们将一步步搭建一个框架并逐渐完善,并尝试着把这些问题讲明白。
我选择基于 SQLite ,因为 SQLite 体积小又功能齐全,整个数据库就存储在一个文件中。
我认为可以以此为起点以小见大,启发大家对数据库有更多的思考。
一个 SQL 语句需要经过一连串的过程才能检索或修改数据。
- 前端包括:
- tokenizer 词法解析器
- parser 语法解析器
- code generator 代码生成器
前端输入一个 SQL 查询,输出为 SQLite 虚拟机字节码。字节码本质上就是可以在数据库上运行的程序。
- 后端包括:
- Virtual Machine 虚拟机
- B-Tree B 树
- Pager 页缓存管理器
- OS Interface 操作系统接口
虚拟机将前端生成的字节码作为指令,可以对一个或多个索引执行操作,每个表或索引都存储在 B 树上(B 树:一种树状数据结构)。其实虚拟机本质上就是一堆判断字节码指令的switch语句。
B 树由许多结点组成。每个结点的长度为一页。B 树可以通过向 pager 发出指令来执行 从磁盘检索页面或保存回磁盘的操作。
pager通过接收指令读取或写入页,它负责以适当的偏移量在数据库文件中进行读取或写入。它还在内存中保留了最近访问页面的缓存,并决定何时将这些页面写回磁盘。
操作系统接口与上面提到的不在同一层。具体什么样的接口取决于为哪个操作系统的 SQLite 编译。在这个系列中,暂不支持跨平台。
本系列环境:
【操作系统】:Ubuntu 20.04 LTS
【语言】:C 语言
【编译器】: GCC 9.3.0
前置知识要求:
1. 会C语言,不要求精通,懂基本的 顺序结构、分支结构、循环结构 和 指针即可。
2. 对数据结构略有耳闻
3. 会 SQL 或 了解 SQLite
先整点好玩的~~
REPL
REPL,全称 Read Eval Print Loop:交互式解释器。
例如 Python 安装以后就自带 REPL ,在命令行下输入 Python 就可以启动
$ python
Python 3.8.5 (default, Aug 19 2020, 17:31:43)
[GCC 4.8.5 20150623 (Red Hat 4.8.5-39)] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>>
>>> print("Hello World!")
Hello World!
>>>
动手实现
现在我们也自己实现一个
// MyREPL.c
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <string.h>
int main() {
char command[100];
while (true) {
printf("BoiiDB > "); // 打印提示符
scanf("%s", command); // 接收输入
if(strcmp(command, ".q") == 0) { // 判断退出条件
printf("Bye~ \n");
exit(EXIT_SUCCESS); // 退出程序
} else {
printf("You typed the command [ %s ].\n", command);
}
}
}
编译后执行的效果如下:
$ gcc MyREPL.c -o go
$ ./go
BoiiDB > Hello
You typed the command [ Hello ].
BoiiDB > REPL
You typed the command [ REPL ].
BoiiDB > .q
Bye~
$
非常简单,就是做一个无限循环,不断接收输入。
在接受输入之前会先打印提示符 BoiiDB > 。
然后通过判断输入的是不是 .q 来决定是否退出。
这就是一个最最简单的 REPL 了。
这只是一个雏形,让你感受一下 REPL , 给你一点信心。接下来我们继续完善。
升级版
首先我们不能像上面一样使用一个固定 100 个字符的字符数组来保存用户输入的内容了。
REPL 有时候可能用户会输入很长很长的语句,或者压根没输入。
我们需要专门定义一个结构体来保存用户输入的内容。
typedef struct {
char *buffer;
size_t buffer_length;
ssize_t input_length;
}InputBuffer;
这里我们定义了一个 InputBuffer 结构体,结构体中有三个属性:一个字符指针、一个 size_t 型的字符长度、一个 ssize_t 的输入长度。
[ ========== TIPS ========== ]
size_t 是无符号超长整型,一般被用来存放长度,ssize_t 是超长整型,也是被用来存放长度。
它们的定义长这样:
typedef unsigned long long size_t
typedef long long ssize_t
也就是说:
size_t 是 unsigned long long 的别名
ssize_t 是 long long 的别名
在使用这种结构体之前我们需要将其先初始化,在退出之前也要将占用的空间释放:
InputBuffer* new_input_buffer() {
InputBuffer* input_buffer = (InputBuffer*)malloc(sizeof(InputBuffer));
input_buffer->buffer = NULL;
input_buffer->buffer_length = 0;
input_buffer->input_length = 0;
return input_buffer;
}
void close_input_buffer(InputBuffer* input_buffer) {
free(input_buffer->buffer);
free(input_buffer);
}
接着我们的提示符和输入也要规范起来。我们把它们封装成两个函数
void print_prompt() {
printf("BoiiDB > ");
}
void read_input(InputBuffer* input_buffer) {
ssize_t bytes_read = getline(&(input_buffer->buffer), &(input_buffer->buffer_length), stdin);
if (bytes_read <= 0) {
printf("Error reading input. \n");
exit(EXIT_FAILURE);
}
// 忽略结尾的换行符
input_buffer->input_length = bytes_read - 1;
input_buffer->buffer[bytes_read - 1] = 0;
}
[ ========== TIPS ========== ]
注意:在windows 下使用C语言,一般使用不了这个 getline() 函数,因为没有定义。
getline() 的函数签名为:
ssize_t getline(char** lineprt, size_t* n, FILE* stream)
它的作用是:
读取一行内容到 *lineptr 所指定的 buffer 中(所以我们才要定义 InputBuffer 结构体),当遇到换行符或 EOF 时读取结束。
*n 是 *lineprt 所指定的 buffer 的大小,如果 *n 小于读入的内容长度,getline 会自动扩展 buffer 的长度,并更新 *lineprt 和 *n 的值。
读取成功时返回读入字符的个数,失败时返回 -1.
Tip:getline 读取的内容中会包括行末的 ‘\n’ 字符。
最后修改一下 main 函数:
int main() {
InputBuffer* input_buffer = new_input_buffer();
while (true) {
print_prompt(); // 打印提示符
read_input(input_buffer); // 读取输入
// 如果没有输入只是回车,则跳过
if (input_buffer->input_length == 0)
continue;
if (strcmp(input_buffer->buffer, ".q") == 0) { // 如果输入退出指令
close_input_buffer(input_buffer);
exit(EXIT_SUCCESS); // 退出程序
} else {
printf("Unrecognized command [ %s ].\n", input_buffer->buffer);
}
}
}
OK!这样我们就实现了一个比较正式的 REPL 了。
完整代码如下:
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
char* buffer;
size_t buffer_length;
ssize_t input_length;
} InputBuffer;
InputBuffer* new_input_buffer()
{
InputBuffer* input_buffer = malloc(sizeof(InputBuffer));
input_buffer->buffer = NULL;
input_buffer->buffer_length = 0;
input_buffer->input_length = 0;
return input_buffer;
}
void print_prompt() { printf("BoiiDB > "); }
void read_input(InputBuffer* input_buffer)
{
ssize_t bytes_read = getline(&(input_buffer->buffer), &(input_buffer->buffer_length), stdin);
if (bytes_read <= 0) {
printf("Error reading input\n");
exit(EXIT_FAILURE);
}
// 忽略结尾换行符
input_buffer->input_length = bytes_read - 1;
input_buffer->buffer[bytes_read - 1] = 0;
}
void close_input_buffer(InputBuffer* input_buffer)
{
free(input_buffer->buffer);
free(input_buffer);
}
int main()
{
InputBuffer* input_buffer = new_input_buffer();
while (true) {
print_prompt();
read_input(input_buffer);
if (input_buffer->input_length == 0)
continue;
if (strcmp(input_buffer->buffer, ".q") == 0) {
close_input_buffer(input_buffer);
exit(EXIT_SUCCESS);
} else {
printf("Unrecognized command [ %s ].\n", input_buffer->buffer);
}
}
}
后续敬请期待,等我整理完再发出来。
本文介绍了如何逐步搭建一个SQL编译器的框架,从基础的REPL(Read-Eval-Print Loop)开始。文章选择了SQLite作为起点,解释了SQL语句从输入到执行的流程,涉及词法解析器、语法解析器、代码生成器和虚拟机等概念。还实现了简单的REPL,允许用户输入SQL查询,并展示了如何逐步升级这个REPL以处理更复杂的输入。
5738

被折叠的 条评论
为什么被折叠?



