cmu15445 数据库系统实验一:buffer pool manager

1,152 阅读9分钟

「这是我参与2022首次更文挑战的第 16 天,活动详情查看:2022首次更文挑战」。

cmu15445 是一门关于数据库管理系统(DBMS)设计与实现的经典公开课。该课程以 Database System Concepts 为教材,提供随堂讲义、笔记和视频,精心准备了几个互相勾连的小实验。该课程十分注重系统设计和编程实现,用主讲教授 Andy Pavlo 的话说,这是一门可以写在简历上、并且能帮你拿到好 offer 的课程。

这个假期得空,翻出这门课程,即被其翔实的内容、精当的组织所折服。无奈时间有限,只能以实验为主线,辅以讲义笔记,简单跟一跟。如果再有时间,就去扫下教材视频。从实验一开始,每个实验 autograder 跑过之后,出一篇笔记,聊以备忘。 Andy Pavlo 教授建议不要公开实验代码仓库,因此文章尽量少贴代码,多写思路。

本篇是实验一,管理文件系统的页在内存中的缓存 —— buffer pool manager。

概览

实验的目标系统 BusTub 是一个面向磁盘的 DBMS,但磁盘上的数据不支持字节粒度的访问。这就需要一个管理页的中间层,但 Andy Pavlo 教授坚持不使用 mmap 将页管理权力让渡给操作系统,因此实验一 的目标便在于主动管理磁盘中的页(page)在内存中的缓存,从而,最小化磁盘访问次数(时间上)、最大化相关数据连续(空间上)。

该实验可以分解为相对独立的两个子任务:

  1. 维护替换策略的: LRU replacement policy
  2. 管理缓冲池的: buffer pool manager

两个组件都要求线程安全。

本文首先从基本概念、核心数据流总体分析下实验内容,然后分别对两个子任务进行梳理。

作者:木鸟杂记 www.qtmuniao.com/2021/02/10/…

实验分析

刚开始写实验代码的时候,感觉细节很多,实现时很容易丢三落四。但随着实现和思考的深入,渐渐摸清了全貌,发现只要明确几个基本概念和核心数据流,便能够提纲挈领。

基本概念

buffer pool 的操作的基本单位为一段逻辑连续的字节数组,在磁盘上表现为页(page),有唯一的标识 page_id;在内存中表现为帧(frame),有唯一的标识 frame_id。为了记下哪些 frame 存的哪些 page,需要使用一个页表(page table)

下边行文可能会混用 page 和 frame,因为这两个概念都是 buffer pool 管理数据的基本单位,一般为 4k,其区别如下:

  1. page id 是这一段单位数据的全局标识,而 frame id 只是在内存池(frame 数组)中索引某个 page 下标
  2. page 在文件系统中是一段逻辑连续的字节数组;在内存中,我们会给其附加一些元信息:pin_count_is_dirty_

基本概念

而管理帧的内存池大小一般来说是远小于磁盘的,因此在内存池满了后,再从磁盘加载新的页到内存池,需要**某种替换策略(replacer)**将一些不再使用的页踢出内存池以腾出空间。

核心数据流

先说结论,buffer pool manager 的实现核心,在于对内存池中所有 frame 的状态的管理。因此,如果我们能梳理出 frame 的状态机,便可以把握好核心数据流。

buffer pool 维护了一个 frame 数组,每个 frame 有三种状态:

  1. free:初始状态,没有存放任何 page
  2. pinned:存放了 thread 正在使用的 page
  3. unpinned:存放了 page,但 page 已经不再为任何 thread 所使用

而待实现函数:

FetchPageImpl(page_id)
NewPageImpl(page_id)
UnpinPageImpl(page_id, is_dirty)
DeletePageImpl(page_id)

便是驱动状态机中上述状态发生改变的动作(action),状态机如下:

frame 状态机

对应到实现时数据结构上:

  1. 保存 page 数据的 frame 数组为 pages_
  2. 所有 free frame 的索引(frame_id)保存在 free_list_
  3. 所有 unpinned frame 的索引保存在 replacer_
  4. 所有 pinned frame 索引和 unpinned frame 的索引保存在 page_table_ 中,并通过 page 中 pin_count_ 字段来区分两个状态。

上图中,NewPage1 和 NewPage2 表示在 NewPage 函数中,每次获取空闲 frame 时,会先去空闲列表(freelist_)中取一个 free frame,如果取不到,才会去 replacer_ 中驱逐一个 unpinned 的 frame 后使用。这体现了 buffer pool manager 实现的一个目标:最小化磁盘访问,原因后面分析。

实验组件

把握了本实验的基本概念和核心数据流后,再来分析两个子任务。

TASK #1 - LRU REPLACEMENT POLICY

以前在 LeetCode 上写过相关实现,因此很自然的带入之前经验,但随后发现这两个接口有一些不同。

LeetCode 上提供的是 kv store 接口,在 get/set 的时候完成新老顺序的维护,并在内存池满后自动替换最老的 KV。

但本实验提供的是 replacer 接口,维护一个 unpinned 的 frame_id 列表 ,在调用 Unpin 时将 frame_id 加入列表并维护新老顺序、在调用 Pin 时将 frame_id 从列表中摘除、在调用 Victim 的时候将最老的 frame_id 返回。

当然,本质上还是一样,因此本实验我也是采用 unordered_map 和 doubly linked list 的数据结构,实现细节不再赘述。需要注意的是,如果 Unpin 时发现 frame_id 已经在 replacer 中,则直接返回,并不改变列表的新老顺序。因为逻辑上来说,同一个 frame_id,并不能被 Unpin 多次,因此我们只需要考虑 frame_id 第一次 Unpin。

放到更大的语境中,本质上,replacer 就是一个维护了回收顺序的回收站,即我们将所有 pin_count_ = 0 的 page 不直接从内存中删除,而是放入回收站中。根据数据访问的时间局部性原理,刚刚被访问的 page 很可能再次被访问,因此当我们不得不从回收站中真删(Victim)一个 frame 时,需要删最老的 frame。当之后我们想访问一个刚加入回收站的数据时, 只需要将 page 从这个回收站中捞出来,从而省去一次磁盘访问,这也就达到了最小化磁盘访问的目标。

TASK #2 - BUFFER POOL MANAGER

在实验分析部分已经把核心逻辑说的差不多了,这里简单罗列一下我实现中遇到的问题。

page_table_ 的范围。在最初实现时,画出 frame 的状态机之后,感觉 page_table_ 中只放 pinned frame id 很完美:可以使 frame id 按状态互斥的分布在 free_list_replacer_page_table_ 中。但后来发现,如果不将 unpinned frame id 保存在 page_table_ 中,就不能很好地复用 pin_count_ = 0 的 page 了,replacer 也就没有了意义。

dirty page 的刷盘时机。有两种策略,一种是每次 Unpin 的时候都刷,这样会刷比较频繁,但能保证异常掉电重启后内容不丢;一种是在 replacer victimized 的时候 lazily 的刷,这样能保证刷的次数最少。这是性能和可靠性取舍,仅考虑本实验,两者肯定都能过。

NewPage 不要读盘。这个就是我写的 bug 了,毕竟 NewPage 的时候,磁盘上根本没有对应 page 的内容,因此会报如下错误:

2021-02-18 16:53:47 [autograder/bustub/src/storage/disk/disk_manager.cpp:121:ReadPage] DEBUG - Read less than a page
2021-02-18 16:53:47 [autograder/bustub/src/storage/disk/disk_manager.cpp:108:ReadPage] DEBUG - I/O error reading past end of file

复用 frame 时清空元信息。在复用一个从 replacer 中驱逐的 frame 时尤其要注意,使用前一定要将 pin_count_\is_dirty_ 这些字段清空。当然,在 DeletePage 的时候,也需要注意将 page_id_ 置为 INVALID_PAGE_ID 、清空上述字段。否则,再次使用时, 如果 pin_count_Unpin 后,数值不为 0,会导致 DeletePage 时删不掉该 page。

锁的粒度。最粗暴的就是每个函数范围粒度加锁即可,后期如果需要优化,再将锁的粒度变细。

实验代码

FetchPageImpl 为例强调下一些实现的细节,注意到,实验已经通过注释给出了实现框架。

我使用中文注释注出了一些我认为需要注意的点。

Page *BufferPoolManager::FetchPageImpl(page_id_t page_id) {
  // a. 使用自动获取和释放锁
  std::scoped_lock<std::mutex> lock(latch_);
  
  // 1.     Search the page table for the requested page (P).
  // 1.1    If P exists, pin it and return it immediately.
  auto target = page_table_.find(page_id); // b. 判断存在与访问数据只用一次查找
  if (target != page_table_.end()) {
    frame_id_t frame_id = target->second;
    // c. 通过指针运算获取 frame_id 处存放的 Page 结构体
    Page *p = pages_ + frame_id; 
    p->pin_count_++;
    replacer_->Pin(frame_id); // d. 将对应 page 从“回收站”中捞出
    return p;
  }

  // 1.2    If P does not exist, find a replacement page (R) from either the free list or the replacer.
  //        Note that pages are always found from the free list first.
  frame_id_t frame_id = -1; 
  Page *p = nullptr;
  if (!free_list_.empty()) {
    frame_id = free_list_.back(); // e. 在结尾处操作效率高一点
    free_list_.pop_back();
    assert(frame_id >= 0 && frame_id < static_cast<int>(pool_size_));
    p = pages_ + frame_id;
    
    // f. 从 freelist 中获取的 dirty page 已经在 delete 时写回了
  } else {
    bool victimized = replacer_->Victim(&frame_id);
    if (!victimized) {
      return nullptr;
    }
    assert(frame_id >= 0 && frame_id < static_cast<int>(pool_size_));
    p = pages_ + frame_id;

    // 2.     If R is dirty, write it back to the disk.
    if (p->IsDirty()) {
      disk_manager_->WritePage(p->GetPageId(), p->GetData());
      p->is_dirty_ = false;
    }
    p->pin_count_ = 0; // g. 将元信息 pin_count_ 清空
  }

  // 3.     Delete R from the page table and insert P.
  page_table_.erase(p->GetPageId()); // h. 时刻注意区分 p->GetPageId() 与 page_id 是否相等,别混用
  page_table_[page_id] = frame_id;

  // 4.     Update P's metadata, read in the page content from disk, and then return a pointer to P.
  p->page_id_ = page_id;
  p->ResetMemory();
  disk_manager_->ReadPage(page_id, p->GetData());
  p->pin_count_++;
  return p;
}

实验相关 autograder 可以在 FAQ 中找到注册地址和邀请码,提交代码的时候最好不要提交 github 仓库地址,会有很多格式问题。可以每次按照实验页面的指示,将相关文件按目录结构达成 zip 包提交即可。

提交事项

仔细阅读实验描述,提交前需要注意的事项:

  1. 在 build 目录运行 make format ,自动格式化。
  2. 在 build 目录运行 make check-lint,检查一些语法问题。
  3. 自己针对每个函数在本地设计一些测试,写到相关文件(本实验 buffer_pool_manager_test.cpp )中,并且打开测试开关,在 build 文件夹下,编译 make buffer_pool_manager_test,运行 ./test/buffer_pool_manager_test

贴一个 project1 autograder 的实验结果:

autograder-result

小结

这是 cmu15445 第一个实验,实现了在磁盘和内存间按需搬运页(page)的 buffer pool manager。本实验的关键之处在于把握基本概念,梳理出核心数据流,在此基础上注意一些实现的细节即可。


我是青藤木鸟,一个喜欢摄影的存储工程师,欢迎关注我的公众号:“木鸟杂记”。