“Nothing in the world can take the place of persistence.” – Calvin Coolidge
持久化
我们的数据库已经能支持数据的插入和读取了,但是这些数据只在程序运行时有效,一旦程序退出或者重启所有的记录都将丢失。下面是针对数据持久化的测试用例:
def persistence_test():
cmd_1st = ["insert 1 user1 person1@example.com\n", ".exit\n"]
cmd_2nd = ["select\n", ".exit\n"]
res_1st = ["db > Executed.", "db > "]
res_2nd = ["db > (1, user1, person1@example.com)", "Executed.", "db > "]
if os.path.exists(f"{DB_FILE}"):
os.remove(f"{DB_FILE}")
output_1st = run_script(cmd_1st)
output_2nd = run_script(cmd_2nd)
if is_match(output_1st, res_1st) and is_match(output_2nd, res_2nd):
print("persistence_test passed!")
else:
print("persistence_test failed!")
为了简化运行结果的比较,我们封装了一个比较函数is_match():
def is_match(source, target):
if not len(source) == len(target):
return False
for i in range(len(source)):
if (not source[i] == target[i]):
return False
return True
像SQLite一样,我们把整个数据库保存到单个文件中,以此来实现数据的持久化。
之前章节中,我们已经实现了把行(row)序列化成页大小的内存块的功能。实现持久化时,我们只需要把这些内存块写到文件中,在下次程序启动时再将它们读取到内存即可。
我们准备实现一个抽象的pager来管理页,每次向pager请求某一页时,pager会返回该页对应的内存块。pager在寻找内存块时首先会先检查自身的缓存,如果缓存未命中,则将对应数据从硬盘拷贝到内存中(通过读取数据库文件)。
下图是我们的数据库程序与SQLite架构的对应关系:
Pager和Table之间存在这样的关系:Pager负责访问页缓存和数据库文件,Table对象通过pager请求页面。
typedef struct {
int file_descriptor;
__uint32_t file_length;
void* pages[TABLE_MAX_PAGES];
} Pager;
typedef struct {
Pager* pager;
__uint32_t num_rows;
} Table;
我准备把new_table()重命名为open_db(),因为它现在负责开启与数据库之间的连接。这里的开启连接指的是:
- 打开数据库文件
- 初始化pager数据结构
- 初始化table数据结构
Table* db_open(const char* filename) {
Pager* pager = pager_open(filename);
__uint32_t num_rows = pager->file_length / ROW_SIZE;
Table* table = (Table*)malloc(sizeof(Table));
table->pager = pager;
table->num_rows = num_rows;
return table;
}
db_open()会调用pager_open()打开数据库文件并追踪它的大小。与此同时,它会把page缓存都初始化为NULL。
Pager* pager_open(const char* filename) {
int fd = open(filename,
O_RDWR | // Read/Write mode
O_CREAT, // Create file if does not exist
S_IWUSR| // User write permission
S_IRUSR // User read permission
);
if (fd == -1) {
printf("Unable to open file\n");
exit(EXIT_FAILURE);
}
off_t file_length = lseek(fd, 0, SEEK_END);
Pager* pager = malloc(sizeof(Pager));
pager->file_descriptor = fd;
pager->file_length = file_length;
for (__uint32_t i = 0; i < TABLE_MAX_PAGES; i++) {
pager->pages[i] = NULL;
}
return pager;
}
在引入Pager之后,获取页面的逻辑也要进行相应调整:
void* row_slot(Table* table, __uint32_t row_num) {
__uint32_t page_num = row_num / ROWS_PER_PAGE;
void* page = get_page(table->pager, page_num);
__uint32_t row_offset = row_num % ROWS_PER_PAGE;
__uint32_t byte_offset = row_offset * ROW_SIZE;
return page + byte_offset;
}
get_page()能处理缓存未命中的情况。我们假设页在数据库文件中是一个接一个存放的:Page 0的offset是0,Page 1的offset是4096,Page 2的offset是8192,等等。如果请求的页面位于文件边界之外,我们知道它应该是空的,所以只需要分配一些内存并返回即可。稍后我们在退出数据库时,会将缓存数据刷新到磁盘,该页会被添加到数据库文件中。
void* get_page(Pager* pager, __uint32_t page_num) {
if (page_num > TABLE_MAX_PAGES) {
printf("Tried to fetch page number out of bounds. %d > %d\n", page_num, TABLE_MAX_PAGES);
exit(EXIT_FAILURE);
}
if (pager->pages[page_num] == NULL) {
// Cache miss. Allocate memory and load from file.
void* page = malloc(PAGE_SIZE);
__uint32_t num_pages = pager->file_length / PAGE_SIZE;
// we might save a partial page at the and of file
if (pager->file_length % PAGE_SIZE) {
num_pages += 1;
}
if (page_num <= num_pages) {
lseek(pager->file_descriptor, page_num * PAGE_SIZE, SEEK_SET);
ssize_t bytes_read = read(pager->file_descriptor, page, PAGE_SIZE);
if (bytes_read == -1) {
printf("Error reading file: %d\n", errno);
exit(EXIT_FAILURE);
}
}
pager->pages[page_num] = page;
}
return pager->pages[page_num];
}
现在,我们需要等到用户关闭与数据库的连接时再将缓存刷新到硬盘。当用户退出时,我们调用新的函数db_close(),它能:
- 将页面缓存刷新到硬盘
- 关闭数据库文件
- 释放Pager和Table数据结构的内存
void db_close(Table* table) {
Pager* pager = table->pager;
uint32_t num_full_pages = table->num_rows / ROWS_PER_PAGE;
for (uint32_t i = 0; i < num_full_pages; i++) {
if (pager->pages[i] == NULL) {
continue;
}
pager_flush(pager, i, PAGE_SIZE);
free(pager->pages[i]);
pager->pages[i] = NULL;
}
// There may be a partial page to write to the end of the file
// This should not be needed after we switch to a B-tree
uint32_t num_additional_rows = table->num_rows % ROWS_PER_PAGE;
if (num_additional_rows > 0) {
uint32_t page_num = num_full_pages;
if (pager->pages[page_num] != NULL) {
pager_flush(pager, page_num, num_additional_rows * ROW_SIZE);
free(pager->pages[page_num]);
pager->pages[page_num] = NULL;
}
}
int result = close(pager->file_descriptor);
if (result == -1) {
printf("Error closing db file.\n");
exit(EXIT_FAILURE);
}
for (uint32_t i = 0; i < TABLE_MAX_PAGES; i++) {
void* page = pager->pages[i];
if (page) {
free(page);
pager->pages[i] = NULL;
}
}
free(pager);
free(table);
}
MateCommandResult do_mate_command(InputBuffer* input_buffer, Table* table) {
if (strcmp(input_buffer->buffer, ".exit") == 0) {
db_close(table);
exit(EXIT_SUCCESS);
} else {
return MATE_COMMAND_UNRECOGNIZED_COMMAND;
}
}
在我们当前的设计中,数据库文件的长度是对当前数据库中总行数的编码,即页与页之间没有空闲的内存。所以在文件末尾,即最后一页,可能会出现页面没填满的情况。这也是为什么pager_flush()同时需要页号(page_num)和页面尺寸(size),因为通过这两个变量才能定位每一页在文件中的偏移。这不算最优的设计,所以当我们之后使用B-tree时很快就会弃用这种方式。
void pager_flush(Pager* pager, uint32_t page_num, uint32_t size) {
if (pager->pages[page_num] == NULL) {
printf("Tried to flush null page\n");
exit(EXIT_FAILURE);
}
off_t offset = lseek(pager->file_descriptor, page_num * size, SEEK_SET);
if (offset == -1) {
printf("Error seeking: %d\n", errno);
exit(EXIT_FAILURE);
}
ssize_t bytes_written = write(pager->file_descriptor, pager->pages[page_num], size);
if (bytes_written == -1) {
printf("Error writing: %d\n", errno);
exit(EXIT_FAILURE);
}
}
最后,我们需要接受文件名作为命令行参数。不要忘记在do_meta_command中添加额外参数。
int main(int argc, char* argv[]) {
if (argc < 2) {
printf("Must supply a database filename.\n");
EXIT_FAILURE;
}
char* filename = argv[1];
Table* table = db_open(filename);
InputBuffer* input_buffer = new_input_buffer();
while (true) {
print_prompt();
read_input(input_buffer);
if (strcmp(input_buffer->buffer, ".exit") == 0) {
if (input_buffer->buffer[0] == '.') {
switch (do_mate_command(input_buffer, table)) {
...
通过上述修改,我们的数据库可以实现持久化功能了。
.\db.exe mydb.db
db > insert 1 bob foo@bar.com
Executed.
db > .exit
.\db.exe mydb.db
db > select
(1, bob, foo@bar.com)
Executed.
db > insert 2 candy foo@bar.com
Executed.
db > .exit
.\db.exe mydb.db
db > select
(1, bob, foo@bar.com)
(2, candy, foo@bar.com)
Executed.
db > .exit
数据库文件格式
接下来,让我们用hex编辑器打开数据库文件,看看我们的数据在内存中是如何存储的:
第一行的前4字节是id值(因为我们使用了uint32_t),以字节小端序存储,所以最低有效位在前(01),后面跟着的是高有效位(00 00 00)。我们用memcpy()将字节数据从Row结构体拷贝到页的缓存中,所以这意味着结构体在内存中也是以字节小端序排列的,从而说明我编写程序所使用的机器是小端模式。如果我想在一个大段模式的机器上运行我们的数据库程序,需要修改 serialize_row()和 deserialize_row(),让它们永远以相同的顺序存储和读取数据。
接下来的33字节存储的是username,并以空字符串结尾。“bob"用ASCII码十六进制表示为“62 6F 62”,空字符为"00”,剩下的29字节未使用。
后续的256字节以相同的方式存储email。在字符串结尾的空字符之后我们能看到有一些垃圾值。很有可能是因为我们的Row结构体中有未初始化的内存。我们每次都会将256字节的email数据拷贝到文件中,其中就包括字符串结尾后的垃圾字符。因为我们在字符串结尾加上了空字符,所以这些垃圾值并不会影响我们的程序。
注:如果我们想确保所有的字节都已初始化,在serialize_row()中拷贝username和email时,将memcpy()替换为strncpy()即可:
void serialize_row(Row* source, void* destination) {
memcpy(destination + ID_OFFSET, &(source->id), ID_SIZE);
strncpy(destination + USERNAME_OFFSET, source->username, USERNAME_SIZE);
strncpy(destination + EMAIL_OFFSET, source->email, EMAIL_SIZE);
}
修改后效果如下:
结论
至此,我们已经实现了数据库持久化功能,虽然当前还不是最佳解决方案。比如不输入".exit"命令,而是直接杀死程序进程,会丢失当前数据库的改动。除此之外,我们每次都要将所有页写回硬盘,即使有些页从硬盘中读取后并没有发生改变。这些问题我们后续都会解决。
下一节我们将介绍cursors,它能让我们后续实现B-tree时更容易。
原文链接:Let’s Build a Simple Database: Part 5 - Persistence to Disk