CMU15445 数据库系统实验一:Buffer Pool Manager

运维 数据库运维
本篇是实验一,管理文件系统的页在内存中的缓存 —— buffer pool manager。实验的目标系统 BusTub 是一个面向磁盘的 DBMS,但磁盘上的数据不支持字节粒度的访问。

[[382283]]

本文转载自微信公众号「分布式点滴」,作者穆尼奥。转载本文请联系分布式点滴公众号。  

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

概览

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

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

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

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

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

作者:青藤木鸟 https://www.qtmuniao.com/2021/02/10/cmu15445-project1-buffer-pool/, 转载请注明出处

实验分析

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

基本概念

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

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

page id 是这一段单位数据的全局标识,而 frame id 只是在内存池(frame 数组)中索引某个 page 下标

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 所使用

而待实现函数:

  1. FetchPageImpl(page_id) 
  2. NewPageImpl(page_id) 
  3. UnpinPageImpl(page_id, is_dirty) 
  4. 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 的内容,因此会报如下错误:

  1. 2021-02-18 16:53:47 [autograder/bustub/src/storage/disk/disk_manager.cpp:121:ReadPage] DEBUG - Read less than a page 
  2. 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 为例强调下一些实现的细节,注意到,实验已经通过注释给出了实现框架。

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

  1. Page *BufferPoolManager::FetchPageImpl(page_id_t page_id) { 
  2.   // a. 使用自动获取和释放锁 
  3.   std::scoped_lock<std::mutex> lock(latch_); 
  4.    
  5.   // 1.     Search the page table for the requested page (P). 
  6.   // 1.1    If P exists, pin it and return it immediately. 
  7.   auto target = page_table_.find(page_id); // b. 判断存在与访问数据只用一次查找 
  8.   if (target != page_table_.end()) { 
  9.     frame_id_t frame_id = target->second
  10.     // c. 通过指针运算获取 frame_id 处存放的 Page 结构体 
  11.     Page *p = pages_ + frame_id;  
  12.     p->pin_count_++; 
  13.     replacer_->Pin(frame_id); // d. 将对应 page 从“回收站”中捞出 
  14.     return p; 
  15.   } 
  16.  
  17.   // 1.2    If P does not exist, find a replacement page (R) from either the free list or the replacer. 
  18.   //        Note that pages are always found from the free list first
  19.   frame_id_t frame_id = -1;  
  20.   Page *p = nullptr; 
  21.   if (!free_list_.empty()) { 
  22.     frame_id = free_list_.back(); // e. 在结尾处操作效率高一点 
  23.     free_list_.pop_back(); 
  24.     assert(frame_id >= 0 && frame_id < static_cast<int>(pool_size_)); 
  25.     p = pages_ + frame_id; 
  26.      
  27.     // f. 从 freelist 中获取的 dirty page 已经在 delete 时写回了 
  28.   } else { 
  29.     bool victimized = replacer_->Victim(&frame_id); 
  30.     if (!victimized) { 
  31.       return nullptr; 
  32.     } 
  33.     assert(frame_id >= 0 && frame_id < static_cast<int>(pool_size_)); 
  34.     p = pages_ + frame_id; 
  35.  
  36.     // 2.     If R is dirty, write it back to the disk. 
  37.     if (p->IsDirty()) { 
  38.       disk_manager_->WritePage(p->GetPageId(), p->GetData()); 
  39.       p->is_dirty_ = false
  40.     } 
  41.     p->pin_count_ = 0; // g. 将元信息 pin_count_ 清空 
  42.   } 
  43.  
  44.   // 3.     Delete R from the page table and insert P. 
  45.   page_table_.erase(p->GetPageId()); // h. 时刻注意区分 p->GetPageId() 与 page_id 是否相等,别混用 
  46.   page_table_[page_id] = frame_id; 
  47.  
  48.   // 4.     Update P's metadata, read in the page content from disk, and then return a pointer to P. 
  49.   p->page_id_ = page_id; 
  50.   p->ResetMemory(); 
  51.   disk_manager_->ReadPage(page_id, p->GetData()); 
  52.   p->pin_count_++; 
  53.   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 结果

小结

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

责任编辑:武晓燕 来源: 分布式点滴
相关推荐

2022-10-12 08:52:00

内存缓冲管理

2022-10-09 08:53:06

存储容量SSD

2022-10-08 00:00:00

SQLDDL数据

2022-10-17 08:49:47

2022-10-30 10:03:20

B+数据库数据

2022-09-30 11:08:44

MySQLPostgreSQLOracle

2011-04-13 15:07:30

数据库系统设计

2011-04-13 15:25:12

数据库系统设计

2022-04-05 13:46:21

日志数据库系统

2011-02-28 17:12:20

Oracle数据库

2011-04-13 15:17:09

数据库系统设计

2011-02-25 13:49:12

2019-03-01 18:27:09

MySQL安装数据库

2011-06-07 17:01:44

2011-07-26 14:56:03

数据库发展

2010-07-11 18:42:17

CassandraTwitter

2021-03-01 18:37:15

MySQL存储数据

2023-12-20 16:12:37

数据库复制延迟

2011-05-24 09:45:41

Oracle数据库系统调优

2010-09-17 20:09:25

点赞
收藏

51CTO技术栈公众号