C语言从零实现SQLite数据库(五)

“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架构的对应关系:
How our program matches up with SQLite architecture
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

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值