目录
Write In Front
或许是因为我几乎没写过比较大且完整的项目的原因,从编程环境配置到C++语法掌握,再到整个项目组织设计模式,我已经记不得多少次拍手称赞。
期间遇到各种问题,很多时候想过要不要放弃了,但是经过一次次内心的挣扎之后,耗时一个月满分完成了这个项目。
收获很多,感谢Bustub教学组!!!
PROJECT #1 - BUFFER POOL
TASK #1 - LRU REPLACEMENT POLICY
核心概念
-
page_id
磁盘被划分为若干块(页),每个块都用唯一的 page_id 来标识,disk_manager 就是根据 page_id 来读取和写入磁盘页
-
frame_id
磁盘页加载到内存,内存中也需要有相应的内存页(人为划分的内存页),BufferPoolManager(BMP)管理的正是这些内存块,和磁盘类似,内存页也需要有相应的标识来唯一标记这些内存页,frame_id 就是唯一标识内存页的标记
-
lru_hash
lru_hash 记录可以被置换出内存的那些内存页的 frame_id_t,用于在 O(1)时间查询某个 frame_id 是否可以被置换出内存
-
lru_list
lru_list 是一个双向链表,记录能够被置换出内存的页面的frame_id, 后加入链表的元素被放到链表头,尾部则是最近最久未使用的页面对应的 frame_id。
-
max_size
max_size 是 lru_replacer 可以管理的最大的置换页面数量,本文和 BPM 大小一致
-
Pin
把 frame_id 对应页面剔除 lru_list, 表示该页面被线程占用,不可以被置换出去
-
Unpin
把 frame_id 对应的页面加入 lru_list,表示该页面可以被加入lru_replacer
-
Victim
查看 lru_replacer 是否有可以被置换出的页面,有的话则取出 frame_id
数据结构
- 主要成员
std::mutex mtx; // 互斥锁 std::list<frame_id_t> lru_List; // 保存能够置换的页面信息,这里用 frame_id 标记相应页面 std::unordered_map<frame_id_t, std::list<frame_id_t>::iterator> lru_hash; // 便于快速判断某个 frame_id 对应页面是否在 lru中 size_t max_size; // lru_replacer 最大可以管理的置换页面数量
- 主要函数
/** * @brief 使用 LRU 策略选择出待从内存移除的页面对应的页帧编号(frame_id) * * @param frame_id 用于记录被替换出的页面对应的 frame_id,如果没有可被替换出的页面,则返回 false, frame_id 指向 nullptr * @return true 如果存在待移除的页面,返回 true * @return false 当不存在待移除的页面时,返回 false */ bool LRUReplacer::Victim(frame_id_t *frame_id); /** * @brief 固定 frame_id 对应的页面,表示该页面不可以被置换,即从 replacer 中移除该页 * * @param frame_id 将要从 replacer 中移除的页对应的 frame_id */ void LRUReplacer::Pin(frame_id_t frame_id); /** * @brief 加入一个新的可以置换的页面对应的 frame_id 到 replacer * * @param frame_id 待加入的 frame_id */ void LRUReplacer::Unpin(frame_id_t frame_id);
Other
- Victim 函数为什么不把选中的 frame_id 页面移动到 lru_List 头部(最新访问的移动到头部)
Victim 这里只用于选中 frame_id, Victim 一般搭配 Unpin 操作使用,而 Unpin 操作会把 frame_id 移动到头部,相当于把选择 frame_id 和 更新 frame_id 拆分了,由更上层进行调用
TASK #2 - BUFFER POOL MANAGER INSTANCE
核心概念
-
page_id 和 frame_id 参考 Task-1 LRUReplacer
-
BMP
BufferPoolManager 的简称,用户管理内存虚拟的 Page 页面,实际上就是管理 Page 类型的数组 pages_。
-
free_list_
空闲内存页面链表,刚实例化的 BPM 时,所有的页面都应该加入 free_list_, 此时 LRUReplacer 为空,没有可以置换的页面。
-
replacer_
页面置换策略,本文使用的是 LRUReplacer,应该优先使用 free_list_内页面,如果没有空闲页面,再考虑页面置换策略。
-
Page
为了方便管理内存页的数据、线程访问数量、数据是否修改等信息,BPM 通过 Page 对象管理内存页,每个 Page 对象对应一个内存页。Page 对象主要由 metadata 和 data[] 两部分构成,metedata 是一些元数据信息,如 page_id, pin_count、dirty等,而 data字符数组则是存储磁盘页的具体数据内容,为了方便处理,本文假设 data 大小和磁盘页大小一致。 Page 对象是可以被重用的,即每个Page 对象都可以装入不同的磁盘页面
-
pages_
Page 类型对象的数组,是一组 Page 对象的组合
-
pin_count
Page 的元数据之一,用于记录当前内存页被占用的线程数量。新建的 Page pin_count = 1,经过 FetchPgImp pin_count++, NewPgImp 后 pin_count = 1, UnpinPg 后 pin_count–, pin_count 为 0 后可以加入 LRUReplacer。
-
dirty
Page 的元数据之一,用于记录当前内存页数据是否被修改(相对于磁盘数据)
-
pool_size_
BPM 能够管理的最大内存页数量,实际上就是 pages_(Page类型) 数组的大小
-
disk_manager_
用于执行与磁盘相关的底层操作,常用的有写入数据到磁盘WritePage 和 从磁盘读取数据 ReadPage
主要函数
-
函数理解
需要注意的是以下函数都是 BPM 调用的,主要是对 BPM 的理解,本质上 BPM 的以下操作都是管理的内存页面 Page 数组(除了AllocatePage、DeallocatePage、FindVictimPage),以 FetchPgImp 为例,站在系统角度,需要从主存取 page_id 对应的页面,显示查看内存是否已经调入了该页面,如果没有,再考虑从磁盘读入。
-
FetchPgImp VS NewPgImp
- FetchPgImp是从 buffer pool 取 page_id 对应页面,如果不存在,则从磁盘调入,会把数据一起写入 buffer pool 内存页
- NewPgImp 是从磁盘创建一个新的物理页,然后在内存 buffer pool 找到一个位置放入,因为新申请的物理页没有有效的 data。因此, NewPgImp 不需要 ReadPage 操作,只需要完成元数据更新,ResetMemory 即可
- 主要函数
// Fetch the requested page from the buffer pool. Page *FetchPgImp(page_id_t page_id); // Creates a new page in the buffer pool. Page *NewPgImp(page_id_t *page_id); // Deletes a page from the buffer pool. bool DeletePgImp(page_id_t page_id); // Unpin the target page from the buffer pool. bool UnpinPgImp(page_id_t page_id, bool is_dirty); // Flushes the target page to disk. bool FlushPgImp(page_id_t page_id); // Flushes all the pages in the buffer pool to disk. void FlushAllPgsImp(); // Allocate a page on disk.∂ page_id_t AllocatePage(); // Deallocate a page on disk. void DeallocatePage(__attribute__((unused)) page_id_t page_id) // 自定义函数,查找空闲的 buffer pool Page,优先从 free_list_查找,其次采用 LRU 策略置换页面 bool FindVictimPage(frame_id_t *frame_id);
PROJECT #2 - EXTENDIBLE HASH INDEX
Task #1 PAGE LAYOUTS
HashTableDirectoryPage
- 成员变量
page_id_t page_id_; // 目录页自身的页编号 lsn_t lsn_; // 页面的 lsm 用于 recovery uint32_t global_depth_{0}; // 目录全局深度 uint8_t local_depths_[DIRECTORY_ARRAY_SIZE]; // 每个数据桶(bucket_page) 的局部深度 page_id_t bucket_page_ids_[DIRECTORY_ARRAY_SIZE]; // 每个数据桶所在的物理页面编号
- 获取镜像桶编号
uint32_t HashTableDirectoryPage::GetSplitImageIndex(uint32_t bucket_idx) { int n = local_depths_[bucket_idx]; // local_depth 记录分裂过后的局部深度 if (n == 0) { // 整个 hash 表只剩下最后一个桶了,这个就是最初的那个深度为 0 的桶,该桶不能进行合并了 return 0; } return (1 << (n - 1)) ^ bucket_idx; // 希望获得由分裂前深度 +0 or +1 得到的镜像桶的 index,则需要1 << (n - 1) }
- 当局部深度和全局深度相同的桶需要分裂时,增加全局深度,降低时不需要这么复杂
/** * 以 global_depth = 1 grow 到 global_depth = 2 为例: * 0 -> b1 * 1 -> b1 * after grow : * 00 -> b1 * 01 -> b2 * 10 -> b1 * 11 -> b2 * 本质上是在 grow 前的每个 bucket_idx 的二进制表示形式最高位的再高一位加上 1, 如原先的 0 + (1 << gd) -> 10 , 1 + (1 << * gd) -> 11 这样就可以保证所有的数在加上一个数后末尾的二进制表示形式总是不变的,也就使得 i + orig_max_bucket 与 i * 的二进制表示形式具有相同的低位二进制表示 本质上 i + orig_max_bucket 就是该桶分裂后的新桶的 bucket_idx */ void HashTableDirectoryPage::IncrGlobalDepth() { int orig_max_bucket = 1 << global_depth_; // 原来总的桶的数量 for (int i = 0; i < orig_max_bucket; ++i) { // 遍历原来的每个桶索引 bucket_idx bucket_page_ids_[i + orig_max_bucket] = bucket_page_ids_[i]; // 编号为 i 和编号为 i + orig_max_bucket 的索引指向同一个桶 local_depths_[i + orig_max_bucket] = local_depths_[i]; // 编号为 i 和编号为 i + orig_max_bucket 的桶的初始时深度保持一致 } global_depth_++; }
Hash Table Bucket Page
-
成员变量
/* * Bucket page format (keys are stored in order): * ---------------------------------------------------------------- * | KEY(1) + VALUE(1) | KEY(2) + VALUE(2) | ... | KEY(n) + VALUE(n) * ---------------------------------------------------------------- */ // 用比特位标记某个<key, value>位置是否被占用(这主要用于linear probe hash),一旦插入过数据,即便删除也是1,Extendiable Hash 没用到 // 0 if tombstone/brand new (never occupied), 1 otherwise. char occupied_[(BUCKET_ARRAY_SIZE - 1) / 8 + 1]; // 标记某个位置是否已经插入数据 char readable_[(BUCKET_ARRAY_SIZE - 1) / 8 + 1]; MappingType array_[0]; // 零长度数组 :occupied_ 和 readable_ 剩下的空间都用于数组
-
实现细节
-
readable_ 为 1 表示该位置为有效的键值对,0表示该位置为 0
-
occupied_ 为 0 表示该位置从未被使用过,1 表示该位置被占据过,当键值对删除时,occupied_值仍然为 1 ,该位不表示空与否,Extensible hash 其实可以不需要,这是linear probe hash 中锁使用的,tombstone的设计主要是为了在开放寻址法中防止探测中断
-
readable_ 设置位,采用 BitMap 思想实现
readable 为 char 类型,每个单位占 8bit ,我们可以把 readable—— 数组看成 r 行 8 列的数组,每个 bit,当数字 n 出现时,对应 n / 8 行 和 n % 8 列的数应该置为 1。当数组变成 int(32-bit) 类型时候,列数则为 32
// set bucket_idx int r = bucket_idx / 8; int c = bucket_idx % 8; readable_[r] |= (1 << c); // 其他位置不变,相应位置设置为 1 // clear bucket_idx int r = bucket_idx / 8; int c = bucket_idx % 8; readable_[r] &= (~(1 << c)); // 其他位置不变,相应位置设置为 0
-
MappingType array_[0] 采用零长度数组实现,整个 Bucket 类除去 occupied_ & readable_ 数组,其余空间全部用于这个数组
-
-
获取桶内某个键对应的值
template <typename KeyType, typename ValueType, typename KeyComparator> bool HASH_TABLE_BUCKET_TYPE::GetValue(KeyType key, KeyComparator cmp, std::vector<ValueType> *result) { for (size_t i = 0; i < BUCKET_ARRAY_SIZE; ++i) { // 遍历当前桶(页)所有 <key, value> if (IsReadable(i) && !cmp(KeyAt(i), key)) { // 如果该位置为有效键值对且该位置的键和传入的 key 相当 ,那么加入该位置的值到 result result->emplace_back(ValueAt(i)); } } return !result->empty(); // 如果存在至少一个 key 对应的 value,则返回 true, 否则返回 false }
-
插入键值对到桶中,可以重复键,但是不可以重复键值对
template <typename KeyType, typename ValueType, typename KeyComparator> bool HASH_TABLE_BUCKET_TYPE::Insert(KeyType key, ValueType value, KeyComparator cmp) { // 1. 检查待插入的键值对是否已经存在 for (size_t i = 0; i < BUCKET_ARRAY_SIZE; ++i) { // cmp 相等时返回 0, !cmp(x,x) 表示相等为真 // IsReadable(i) 为真表示 Bucket 第 i 个位置为有效的键值对 if (IsReadable(i) && !cmp(KeyAt(i), key) && ValueAt(i) == value) { return false; // <key, value> pair 重复 } } for (size_t i = 0; i < BUCKET_ARRAY_SIZE; ++i) { if (!IsReadable(i)) { // 该位置没有有效的键值对,即该位置可以插入(无论Occupied 与否),即存在空位置 SetOccupied(i); // 表示该位置已经被占用,删除时,Ocuupied 值不变 SetReadable(i); // 该位置已经插入有效键值对 array_[i] = MappingType(key, value); // 插入新的键值对 return true; } } return false; // 没有空的位置,插入失败 }
-
删除某个键值对只需要更改
readable_
即可template <typename KeyType, typename ValueType, typename KeyComparator> void HASH_TABLE_BUCKET_TYPE::RemoveAt(uint32_t bucket_idx) { // char 类型数组,每个单位 8 bit,能标记8个位置是否有键值对存在, bucket_idx / 8 找到应该修改的字节位置 // bucket_idx % 8 找出在该字节的对应 bit 位 pos_ // 构建一个 8 位长度的,除该 pos_ 位为 0 外,其他全为 1 的Byte ,例如: 11110111 // 然后与相应字节取按位与操作,则实现清除该位置的 1 的操作,而其他位置保持不变 readable_[bucket_idx / 8] &= (~(1 << (bucket_idx % 8))); }
-
判断某个位置是否存在键值对
template <typename KeyType, typename ValueType, typename KeyComparator> bool HASH_TABLE_BUCKET_TYPE::IsReadable(uint32_t bucket_idx) const { return readable_[bucket_idx / 8] & (1 << (bucket_idx % 8)); // 只要存在,那么相应位置为 000001000000,肯定不为 0 }
Task #2 HASH TABLE IMPLEMENTATION
这个ExtendiableHash
很繁琐,实现细节非常多,需要非常了解其原理才可以正确实现,建议先看看Extendible Hashing (Dynamic approach to DBMS),有个粗略的了解。然后理解其全局深度和局部深度之间的关系,搞明白分裂桶和镜像桶之间的区别与联系很重要。
实现细节
- 分裂桶编号的扩展方向
- 本文采用的是低位向高位扩展的方式,最开始全局深度为0,指向第一个桶。
- 低位向高位扩展举例:
假设局部深度为2,原先桶编号为 10, 那么其分裂的两个桶编号分别为 110 和 010
- 如何根据 key 获取其应该存在的桶的编号?
用 key 值和全局深度对应的全1的掩码按位与操作获得桶编号template <typename KeyType, typename ValueType, typename KeyComparator> inline uint32_t HASH_TABLE_TYPE::KeyToDirectoryIndex(KeyType key, HashTableDirectoryPage *dir_page) { return Hash(key) & dir_page->GetGlobalDepthMask(); }
- 根据 key 获取其在桶内的值,这就是个取哈希值的过程
template <typename KeyType, typename ValueType, typename KeyComparator> bool HASH_TABLE_TYPE::GetValue(Transaction *transaction, const KeyType &key, std::vector<ValueType> *result) { // 添加读锁 table_latch_.RLock(); // 取目录页和 key 所在的桶页 auto dpg = FetchDirectoryPage(); auto bpg = FetchBucketPageByKey(key); auto flag = bpg->GetValue(key, comparator_, result); // !!! 一定不要忘了 UnPin 页面,否则 BufferPool 会因为一直加入页面而无法替换出页面,导致BufferPool 溢出 buffer_pool_manager_->UnpinPage(directory_page_id_, false, nullptr); buffer_pool_manager_->UnpinPage(KeyToPageId(key, dpg), false, nullptr); // 解锁 table_latch_.RUnlock(); return flag; }
- 关键的插入操作
template <typename KeyType, typename ValueType, typename KeyComparator> bool HASH_TABLE_TYPE::Insert(Transaction *transaction, const KeyType &key, const ValueType &value) { table_latch_.WLock(); page_id_t bucket_page_id = KeyToPageId(key, FetchDirectoryPage()); // 必须提前获取页面,不能等到分裂再获取 auto bpg = FetchBucketPageByKey(key); // 取key 所在的桶页面 bool flag = bpg->Insert(key, value, comparator_); // 记录是否插入成功 buffer_pool_manager_->UnpinPage(directory_page_id_, false, nullptr); // 释放写锁 table_latch_.WUnlock(); // 如果插入失败,需要判断是因为重复 <key, value> 键值对导致的失败还是因为桶满了导致的失败 // TODO(zhangw) 这里是否需要对桶进行枷锁操作? if (!flag) { std::vector<ValueType> result; bpg->GetValue(key, comparator_, &result); auto iter = std::find(result.begin(), result.end(), value); if (iter == result.end()) { // 如果是桶满导致的失败 flag = SplitInsert(transaction, key, value); } } // page_id_t bucket_page_id = KeyToPageId(key, dpg); // 这里 key 获取的 page_id // 可能会因为分裂而改变,因此这里是错误的 buffer_pool_manager_->UnpinPage(bucket_page_id, true, nullptr); return flag; }
- 什么时候需要分裂,什么时候需要增加全局深度?
- 当插入的桶满了就需要分裂
- 当插入的桶需要分裂且该桶的局部深度等于全局深度时则增加全局深度
- 注意全局深度增加会导致 key 到桶编号映射的改变,因此需要更新此时因为全局深度改变增加出来的那些桶的指向,保证其指向正确非常重要。
- 增加全局深度后,需要及时更新分裂桶和镜像桶的指向
- 这里比较难理解,直白点就是把由分裂前global_depth 长度的后缀分流为由 global_depth + 1 长度后缀的两部分(这两部分只有最高位不同0 or 1),然后把数据根据 global_depth + 1 位置的不同,设置其新的页面号。
// 把指向溢出桶的目录项的相应部分映射到新桶的 page_id 上去 size_t ld = dpg->GetLocalDepth(overflow_bucket_dir_idx); size_t local_depth_bits = overflow_bucket_dir_idx & ((1 << ld) - 1); // 获取overflow_bucket_idx 的最低的 local_depth 个 bit ,等价于 % (1 << ld) for (size_t i = local_depth_bits; i < dpg->Size(); i += (1 << ld)) { if (((i >> ld) & 1) != ((overflow_bucket_dir_idx >> ld) & 1)) { dpg->SetBucketPageId(i, img_page_id); } dpg->IncrLocalDepth(i); }
- Merge 操作,当删除元素后桶为空时,则指向 Merge 操作
- Merge 会把空桶和其镜像桶进行合并操作