CMU 15-445 Fall 2021 总结

1,114 阅读24分钟

Project # 1 - Buffer Pool

CMU 15-445 的配套项目 BusTub 是一个面向磁盘的 DBMS,所以首先要做的事就是写一个缓存池来管理从磁盘上拉取的页因为 DBMS 是不能直接在磁盘上的进行修改的,需要通过缓存池。

缓存池负责将磁盘上的页放入内存或反之。缓存池可以让 DBMS 感觉可用内存比实际的内存要大(有点像虚拟内存的概念)。

本次项目有三个任务,分别是:

  1. LRU Replacement Policy:实现 LRU 替换机制。
  2. Buffer Pool Manager Instance:实现缓存池管理器的实例。
  3. Parallel Buffer Pool Manager:实现并行的缓存池管理器。

项目说明书链接:15445.courses.cs.cmu.edu/fall2021/pr…

TASK #1 - LRU REPLACEMENT POLICY

任务

实现一个 LRUReplacer 来进行页的调度,需要实现以下方法:

  • Victim(frame_id_t):* 将最近最少使用的页的帧号从 Replacer 中移除并返回。
  • Pin(frame_id_t): 将指定页的帧从 Replacer 中移除。
  • Unpin(frame_id_t): 将指定页的帧添加到 Replacer 中,BufferPoolManager 在调用该方法时,的 pin_count 必须是 0,即当前没有线程在使用这个页。
  • Size(): 返回 Replacer 的长度。

概念

首先要区分帧号和页号的概念,页号指的是磁盘中页存储位置,而帧号是该页的拷贝在缓存池中的位置。 所以上述方法实际上操作的都是帧号,BufferPoolManagerInstance 在这些方法返回帧号后,再根据帧号,在缓存池的 pages_ 数组中将真正的页取出来。

然后就是 pin 和 unpin 的概念,pin 了某个帧指的是当前有线程在使用这个帧中的页,每有一个线程使用了该页,就会将其 pin_count 加 1,所以 Pin 函数的作用是将其从 Replacer 中移除,这样当有其他线程调用 Victim 函数时,这个被 Pin 的页就不会被替换算法踢出内存。unpin 就是相反的,当线程使用完了该页时,就会将 pin_count 减 1,当 pin_count 变成 0 时,就调用 Unpin 函数将其重新放进 Replacer 中,等待被替换出去。

实现

LRU 的实现有一个很常见的想法就是使用一个队列来实现,最近刚用过的帧会被插到队尾,那么队首就是最近最少使用的帧,所以 Victim 函数就直接将队首元素出队即可,Unpin 函数也只需要将 unpin 的那个帧插入队尾即可。

但问题在于 Pin 函数,因为如果要将一个元素直接移除出队列的话,都需要遍历一遍整个队列,这样就造成了性能的损失。所以我们可以使用一个 unordered_map 来存储每个元素在队列中的位置,C++ 的话可以存储该元素的迭代器,那么就可以直接通过 erase 函数来将该元素移除。

std::list<frame_id_t> frame_id_list_;
std::unordered_map<frame_id_t, std::list<frame_id_t>::iterator> location_map_;

其他需要注意的点就是尽量使用 count 函数代替 find 函数,用 emplace_back 函数取代 push_back函数,可以提高性能。还有就是移除的时候需要判断一下 Replacer 是不是已经空了。

Task #2 - BUFFER POOL MANAGER INSTANCE

任务:

  • FetchPgImp(page_id): 根据 page_id 从磁盘中拉取页并放入缓存池中。
  • UnpinPgImp(page_id, is_dirty): 将指定页的 pin_count 减 1,如果 pin_count 等于 0 了就调用 Unpin 函数将这个帧放入 Replacer 中。
  • FlushPgImp(page_id): 将对应的页刷入磁盘中。
  • NewPgImp(page_id): 创建一个新的页。
  • DeletePgImp(page_id): 删除一个页。
  • FlushAllPagesImpl(): 将所有页刷入磁盘中。

概念

操作磁盘的 DiskManager 此次实验已经提供了,不需要自己实现。关于 Page 对象的介绍一定要仔细看项目说明里的内容。

系统中的所有内存页面都由 Page 对象表示。 BufferPoolManagerInstance 不需要了解这些页面的内容。 但是对于系统开发人员来说,重要的是要了解 Page 对象只是缓冲池中的内存容器,因此并不特定于唯一页面。 也就是说,每个 Page 对象都包含一块内存,DiskManager 只是使用它作为一个内存中的位置来复制它从磁盘读取的物理页面的内容。 BufferPoolManagerInstance 将重用相同的 Page 对象来存储数据,因为它来回移动到磁盘,即如果该 Page 写入了或重新拉取了都有可能是不同的内容。 这意味着在系统的整个生命周期中,同一个 Page 对象可能包含不同的物理页面。 Page 对象的标识符 (page_id) 跟踪它包含的物理页面; 如果 Page 对象不包含物理页面,则其 page_id 必须设置为 INVALID_PAGE_ID。

每个 Page 对象还维护一个计数器,用于 "pinned" 该页面的线程数。BufferPoolManagerInstance 不允许释放pinned 的页面。 每个 Page 对象还跟踪它是否是 dirty 状态。如果一个页被修改了,那应该将其设置为 dirty 状态。 BufferPoolManagerInstance 必须将脏页的内容写回磁盘,然后才能重用该对象。

实现

这几个函数实现起来还是相对简单的,基本上都给出了详细的步骤,并且可以结合头文件中的注释来写,这里只记录一下碰到的一些坑。

  1. FlushPgImp 函数中,不要去做多余的判断,因为文档注释中已经明确指出只有在页表中找不到该页的时候才返回 false,否则返回 true。 并且不管这个页是否是脏页都将其写回磁盘,否则有些测试是无法通过的。
  2. NewPgImp 函数和 FetchPgImp 函数中,需要判断缓存池是否已满和缓存池中的页是否都是 pinned 状态:If all the pages in the buffer pool are pinned, return nullptr。但很容易由于这句话而去循环遍历所有的页来判断,其实并不需要,我的做法是判断空闲列表是否为空且 Replacer 的长度是否为 0。如果空闲列表不为空,那肯定是可以拉取页的;如果空闲列表为空,但是 Replacer 的长度不为 0,就代表可以将 Replacer 中标记的页替换出去,仍旧可以拉取页。if (free_list_.empty() && replacer_->Size() == 0) { return nullptr; }
  3. 使用 NewPgImp 函数创建新页时,一定要先确定是可以创建时再调用 AllocatePage 函数。
  1. UnpinPgImp 函数中,不能直接将 is_dirty 参数设置到属性中,需要判断一下 is_dirty 是否为 true。如果参数是 false,而实际上该页是脏页,那么就可能会丢失数据。
  2. 使用std::lock_guard<std::mutex> guard(latch_)来给函数上锁,在 return 的时候会自动释放锁。

TASK #3 - PARALLEL BUFFER POOL MANAGER

任务

  • ParallelBufferPoolManager(num_instances, pool_size, disk_manager, log_manager)
  • ~ParallelBufferPoolManager()
  • GetPoolSize()
  • GetBufferPoolManager(page_id): 根据 page_id 获取对应的 BufferPoolManagerInstance
  • FetchPgImp(page_id)
  • UnpinPgImp(page_id, is_dirty)
  • FlushPgImp(page_id)
  • NewPgImp(page_id)
  • DeletePgImp(page_id)
  • FlushAllPagesImpl()

概念

单个 BufferPoolManagerInstance 需要使用锁以保证线程安全,在线程多的情况下会造成大量的竞争,所以该任务的意图就是使用多个 BufferPoolManagerInstance 来解决大量竞争的问题。

实现

Task 3 的实现就比较简单了,基本上方法就是两行代码,首先使用 GetBufferPoolManager 获取 BufferPoolManagerInstance,再调用对应方法即可。可以使用一个数组来存储这些 BufferPoolManagerInstance,在构造函数中对其进行初始化。参数中的 num_instancesBufferPoolManagerInstance 的数量,instance_index 是每个 BufferPoolManagerInstance 对应的索引,初始化的时候要注意一下不要写反。

唯一一个不是两行搞定的就是 NewPgImp 函数,该函数的时候使用 round robin 的方式从一个起始位置(一开始设置为 0 )开始遍历,直到成功返回 page 或者索引又回到了起始位置返回 nullptr,遍历结束后将起始索引顺序移动一位(需要用模运算,因为相当于在一个环中移动)。

Page *ParallelBufferPoolManager::NewPgImp(page_id_t *page_id) {
  std::lock_guard<std::mutex> guard(latch_); // 只在该函数中加了锁
  size_t index = next_index_;
  Page *page;
  do {
    page = buffer_pool_manager_instances_[index]->NewPage(page_id);
    if (page != nullptr) {
      break;
    }
    index = (index + 1) % num_instances_;
  } while (index != next_index_);
  next_index_ = (next_index_ + 1) % num_instances_;
  return page;
}

总结

Project 1 虽然比较简单,但通过本次项目对缓存池有了一个更深的理解,代码提交之后 leaderboard 排到 80 多名,等再学一段时间 C++ 尝试优化一下,现在这门语言对我的心智负担太重了。往后有时间的话也可以看一下 DiskManager 等具体是怎么实现的。

Project # 2 - Extendible Hash Index

项目 2 是实现一个可拓展的哈希索引,它包含一个 directory_page,其中存储了指向 bucket_page 的指针,而数据就存储在 bucket_page 中。这些 page 都会存储在之前写的缓存池中。

该哈希索引要支持满/空桶的拆分/合并,并且也要支持目录的拓展和收缩(Extendible)。

本次项目有三个任务,分别是:

  • Page Layouts
  • Extendible Hashing Implementation
  • Concurrency Control

Extendible Hash Index

要完成这个项目首先一定要把 Extendible Hash Index 的流程弄清楚,否则写起来会缺胳膊少腿。可以阅读这篇文章了解先算法流程:

www.geeksforgeeks.org/extendible-…

,后文的内容默认读者都是已经看过这篇教程,这篇图文教程写的非常清楚,但是并没有写如何收缩,剩下的细节在后面实现中提到。

TASK #1 - PAGE LAYOUTS

这个任务就是要实现 directory_page 和 bucket_page,分别来存储哈希表的目录和数据。

要通过 Task #1 ,两种页都只需要实现部分方法即可:

  • Bucket Page: - Insert - Remove - IsOccupied - IsReadable - KeyAt - ValueAt
  • Directory Page: - GetGlobalDepth - IncrGlobalDepth - SetLocalDepth - SetBucketPageId - GetBucketPageId

但为了看起来更有整体性,所以在写的时候就将整个 Extendible Hash Index 所需要的东西全部写上了。

HASH TABLE DIRECTORY PAGE

概念

GlobalDepth:

当 Extendible Hash Index 进行 GetValue、Insert、Remove 时,GlobalDepth 用于确定这个 key 对应的 directory_index 在哪,具体来说就是取这个 key 经过哈希之后的 低 GlobalDepth 位来确定 directory_index。比如当前要插入的 key 哈希之后得到的结果是 00110,GlobalDepth 是 3,那么取低 3 位也就是 110,那么 directory_index 就等于 6。但项目中给出的公式是:DirectoryIndex = Hash(key) & GLOBAL_DEPTH_MASK, GLOBAL_DEPTH_MASK 得求出来,其实它就等于 GloablDepth 这么多个 1,所以 GetGlobalDepthMask 函数就很好实现了:

return (1 << global_depth_) - 1; // 得到 GlobalDepth 这么多个 1.

LocalDepth:

LocalDepth 并不与 directory_index 相关,而是与 bucket 相关。我们会根据LocalDepth 和 GlobalDepth 的关系来决定当发生 bucket_page 溢出,或者 bucket_page 为空时需要干的事情(具体要到 Extendible Hash Table 的实现)。GetLocalDepthMask 的实现方式跟 GetGlobalDepthMask 一样。

SplitImageIndex:

这个指的是 bucket_page 分裂之后,它分裂出来的那个 bucket_page 的 directory_index,只要将当前 bucket的 directory_index 中 LocalDepth 对应的那个位取反即可。比如说 directory_index 是 001,LocalDepth 为 2,那么它的 split_image_index 就是 011,可以通过异或运算来实现:

return bucket_idx ^ (1 << (local_depth - 1));

实现

Mask 和 SplitImageIndex 如何求上面已经写了,还剩下就是 IncrGlobalDepth、DecrGlobalDepth、Size

IncrGlobalDepth: 当 GlobalDepth 增加时,就是目录需要扩容了,GlobalDepth 增加 1,目录的容量就增加一倍,所以实现这个函数时,我们要把目录扩容前的 bucket 分布情况拷贝一份到扩容后的位置,也就是此时一个 bucket_page 是有多指针指向它的。注意此时数据还并未进行迁移,因为新的 bucket_page 还没插进来,只是先把目录拓展。

DecrGlobalDepth: 当 GlobalDepth 减少时,就是目录需要缩小了,GlobalDepth 减少 1,目录的容量就减少一倍,但注意只有当所有的 LocalDepth 都小于 GlobalDepth 时,才能减少 GlobalDepth,如果能够减少,我们只需要 global_depth_-- 即可,因为 GlobalDepth 减少了,后面的无用位置其实已经访问不到了。

Size: 返回当前目录的长度,GlobalDepth 有多少位,目录长度就是 2 的多少次方,即 1<<global_depth_。

HASH TABLE BUCKET PAGE

概念

实现 bucket_page 同样也需要用到很多位运算,因为 occupied_ 数组和 readable_ 数组都是 char 类型的数组,而一个 char 是一个字节,即 8 比特,所以可以标识 bucket_page 中八个位置的情况,我们要用 bucket_index 在 char 数组中找到那个对应的比特。

occupied_ 数组的含义:如果 array 的第 i 个索引曾经有被占用过,那 occupied_ 数组的第 i 个比特位就是1。注意:一旦设置为 1,那么这个位置之后就不会再变成 0 了,除非 bucket_page 内容被清空。

readable_ 数组的含义:如果 array 的第 i 个索引当前存储了可读的值,那 readable_ 数组的第 i 个比特就是 1 。

实现

首先要实现的是 IsOccupied、SetOccupied、IsReadable、SetReadable 函数。我选择的是用除法的方式来确定索引对应的比特位:

const auto [char_pos, bit_pos] = std::div(bucket_idx, CHAR_BIT_SIZE);

char_pos 即 char 数组中的第几个字符,bit_pos 则是该字符中的第几位是我们要找的位。假设 ch 为一个字符。

判断找出来的位是否为 1:

((ch >> bit_pos) & 1) == 1;

将某一位设置为 1:

ch |= (1U << bit_pos);

将某一位设置为 0:

ch &= ~(1U << bit_pos);

接下来就可以实现 GetValue、Insert、Remove 了:

Insert:

插入是可以插入 key 重复,但不能插入 key 和 value 同时重复的值的,所以不能找到一个空位就插进去,因为后面可能还有 KV 都重复的条目,要全都检查一遍。

Remove:

找到 KV 都符合的那个条目,将 readable_ 数组的这个位置设置为 0 即可。

GetValue:

将所有符合 key 的条目都放进 result 数组中,再返回 result 是否为空即可。

Remove 和 GetValue 有个可以优化的点就是一旦到了一个位置的 occupied 为 0,那么就可以停止遍历了,因为后面已经没有数据。

TASK #2 - HASH TABLE IMPLEMENTATION

概念

现在开始可以着手实现可拓展哈希表了,我们要在刚刚之前实现的 bucket_page 和 directory_page 的基础上实现 GetValue、Insert、Remove。如果看了前文提到的博客,应该大致流程都已经了解了,本文再对具体的实现步骤和细节做一个总结。

实现

构造函数:

我在构造函数中新建了一个 directory_page 和两个 bucket_page,并将 GlobalDepth 设置为 1,再将这两个 bucket_page 的 id 放进目录中,再测试中将所有数据删除后就会恢复到现在的状态,GlobalDepth 和 LocalDepth 最小为 1。记得 pin 过的页一定要及时的 unpin。

将 新创建的 Page 对象转成 directory_page 或 bucket_page 可以用下面这种方式:

auto dir_page = reinterpret_cast<HashTableDirectoryPage *>(buffer_pool_manager_->NewPage(&this->directory_page_id_)->GetData());

在实现 FetchDirectoryPage 和 FetchBucketPage 时可以用同样的方法。

GetValue:

这个函数的实现没有什么好说,将 directory_page 和 key 所在的 bucket_page 拉出来调用查询方法即可。

Insert:

Insert 的实现细节很多,有非常多需要注意的地方,具体的实现步骤如下:

  1. 将 directory_page 和 key 所在的 bucket_page 拉取出来。
  2. 尝试进行插入,若插入成功则直接返回 true,否则进行步骤 3 。
  3. 判断 bucket_page 是否已满,如果未满还是插入失败那么只能是已经重复,返回 false,否则进行步骤 4 。
  4. 如果 bucket_page 已满,但是这时候不能直接进行分裂,因为就算是满了的情况,也有可能已经重复,如果这时候分裂就会导致插入错误(我因为这个错误卡了很久),还要再检查一遍是否重复
  5. 这个时候将刚刚用到的页 unpin,进入 split insert 阶段。
  6. 将需要分裂的 bucket_page 的 LocalDepth 加 1,判断 LocalDepth 是否大于 GlobalDepth,如果大于则将 directory 进行扩容,即调用之前实现的 IncrGlobalDepth 函数。
  7. 获取到需要分裂的 bucket_page 的 SplitImageIndex,并为这个兄弟 bucket 创建一个新的页(注意创建了页之后就不要再 Fetch 了),取出原 bucket_page 中的所有数据,再将数据重新散列到这两个页中。
  8. 这里有一个需要注意的地方,一个 bucket 分裂之后,仍然有可能有多个指针指向它,如下图所示。当我们往第五个 bucket_page 里再插入一个数据时,它进行分裂后,它自身和分裂出来的那个页仍然各自都有两个指针指向它,所以我们不止要设置 SplitImageIndex 那个位置的指向新分裂出来的页,和它同级的另一个指针也需要指向这个页,并且 LocalDepth 也要更新成一样的。

  1. 最后再次尝试插入需要插入的那个值,这里要重新通过哈希和 mask 来获取应该插入的地方
  2. 将使用到的页 unpin 并返回。

Remove:

  1. 将 directory_page 和 key 所在的 bucket_page 拉取出来。
  2. 尝试进行删除,如果删除失败则直接返回 false,否则将使用到的页 unpin 并进行步骤 3,调用 Merge 函数 。
  3. 再次拉取 directory_page 和 key 所在的 bucket_page,检查 bucket_page 是否为空,如果不为空则 unpin 并返回,否则进行步骤 4 。
  4. 如果该 bucket_page 的 LocalDepth 小于等于 1,或 它不等于它 SplitImage 的 LocalDepth,则 unpin 并返回,否则进行步骤 5 。
  5. 将该 bucket_page 的 SplitImage 的 LocalDepth 减 1,并将 LocalDepth 减 1 后的所有同级 bucket 的 LocalDepth 和指向的 bucket_page 都设置为 SplitImage 。 这一步跟 Insert 的步骤 8 类似。
  6. 一定要先 unpin 该 bucket_page ,然后再将这个页删除。
  7. 将使用到的页 unpin 并返回。

Extendible Hash Table 到这里就已经可以通过除了并发控制测试之外的所有测试了。

TASK #3 - CONCURRENCY CONTROL

最后我们要进行并发控制,给页上锁的方式项目说明中已经详细写到了,这里就不再多做说明,只说一下加锁的思路。

GetValue:

table_latch_ 和 bucket_page 的锁均上读锁即可。

Insert:

插入阶段,table_latch_ 只需要上读锁即可,但是对 bucket_page 要上写锁,因为此时只对一个 bucket_page 进行修改。

SplitInsert:

这个阶段因为目录也有可能被修改,所以两个都需要上写锁。

Remove:

跟 Insert 同理。

Merge:

跟 SplitInsert 同理。

总结

项目二整体还是非常难的,首先一定要认真搞清楚 Extendible Hash Table 到底是什么再来动手,否则从一开始就会理解不了。然后用到了很多位运算,如果之前没什么经验的话也需要琢磨一下。现在这个版本等整个项目写完了再考虑优化一下吧,提交上去发现发现花了三十几秒。

Project # 3 - Query Execution

项目 3 我们需要实现一些 Executor 来执行 Query Plan

  • Sequential Scan
  • Insert (Raw)
  • Insert (Select)
  • Update
  • Delete
  • Nested Loop Join
  • Hash Join
  • Aggregation
  • Limit
  • Distinct

我们会使用的 Iterator query processing model 来实现,即 Volcano 模型。在这个模型中每个 Executor 都实现了一个 Next 函数,每次执行 Next 函数时,Executor 要么返回一个元组要么返回一个表示没有更多元组的标志。

项目说明书:15445.courses.cs.cmu.edu/fall2021/pr…

TASK #1 - EXECUTORS

这是本项目的唯一一个任务,实现项目说明中指出的 Executor 即可。项目 3 相比于项目 2 的难度系数下降很多,一开始可能会由于 API 不熟悉导致无从下手,但实际上思路都是很简单的,在写每一个 Executor 之前,先把对应的 Plan 的源码看一下,碰到不懂的地方可以从测试文件看起,看如何手写一个查询计划,思路就会清晰很多。下文中会将常用的 API 简单的给出。

而且本项目中有很多工具都是已经写好的,我们只需要实现 Next 函数和 Init 函数就可以了,所以通过 Autograder 后最好还是将整个代码全部都过一遍,可以理解的更加深入。

Sequential Scan

根据 Hint,可以知道需要借助 TableIterator 来遍历一张表,查看源码可以发现 TableHeap 类中有 BeginEnd 函数可以获取对应的迭代器所以我们可以在初始化的时候将 Executor 中迭代器设置为 Begin 的位置,接着在执行 Next 函数的时候遍历整张表即可。跟表或索引有关的信息都在 ExecutorContext 中。

注意我们遍历时需要判断是否满足 Predicate 条件:

while (table_iterator_ != table_info_->table_->End() && plan_->GetPredicate() != nullptr &&
		!plan_->GetPredicate()->Evaluate(&(*table_iterator_), plan_->OutputSchema()).GetAs<bool>()) {
    table_iterator_++;
}

当满足条件的时候,要根据执行计划的 OutputSchema 来生成元组:

std::vector<Value> values;
for (const auto &column : plan_->OutputSchema()->GetColumns()) {
	values.push_back(column.GetExpr()->Evaluate(&(*table_iterator_), &table_info_->schema_));
}
*tuple = Tuple(values, plan_->OutputSchema());

Insert

这里需要实现两种 Insert:

  1. RawInsert
  2. SelectInsert

在 Insert Plan 中有一个函数 IsRawInsert 用来判断是哪种 Insert,如果是 RawInsert 表明不需要执行 Chlid Executor直接调用 TableHeap 的插入函数将查询计划中的数据插入到表中即可,而如果是 SelectInsert,那么就要先初始化 Chlid Executor,再从 Chlid Exceutor 取出元组并插入。

在表中成功插入元组后,还需要在索引中插入一个 Index Tuple:

auto indexes = exec_ctx_->GetCatalog()->GetTableIndexes(table_info_->name_);
for (const auto &index : indexes) {
	Tuple index_tuple = tuple->KeyFromTuple(table_info_->schema_, index->key_schema_, index->index_->GetKeyAttrs());
	index->index_->InsertEntry(index_tuple, *rid, exec_ctx_->GetTransaction());
}

Insert 中有一个很大的坑,就是虽然测试文件中没有写Insert Executor 不能修改 result_set,而且测试文件中 insert_plan 中的 result_set 也是 nullptr,但是在 Autograder 中有几个测试的 insert_plan 中的 result_set 并不是 nullptr,这个时候就会出现错误。所以需要一次性将所有数据插入元组再直接返回一个 False 给 Exceutor Engine,避免将数据插入到 result_set 中。

Update

Update Executor 必定会有 Chlid Executor,所以在初始化时也要将 Chlid Executor 初始化。 每从 Chlid Executor 中拿出一个元组都调用 GenerateUpdatedTuple 函数来生成更新后的元组,再使用 TableHeap 的更新函数即可。如果更新成功,要将索引中更新前的元组删除,再插入更新后的元组,所以需要记录更新前的那个元组。 Update Executor 同样也不能修改 result_set

Delete

跟 Update Executor 非常类似,每从 Chlid Executor 中拿出一个元组都调用 TableHeap 的 MarkDelete 函数(真正的删除在事务提交时执行)。再将索引中对应的元组删除即可。

Nested Loop Join

嵌套两个循环将外表的每一个元组分别跟内表中的每一个元组进行比较,如果满足条件就将其返回(我选择用一个 List 来存储结果,再依次返回)。

判断是否满足 Join 条件的方式:

plan_->Predicate()->EvaluateJoin(&left_tuple, left_executor_->GetOutputSchema(), right_tuple, right_executor_->GetOutputSchema()).GetAs<bool>();

如果满足条件,组合 Tuple 的方式:

std::vector<Value> values;
for (const auto &columns : plan_->OutputSchema()->GetColumns()) {
	values.emplace_back(columns.GetExpr()->EvaluateJoin(&left_tuple, left_executor_->GetOutputSchema(),&right_tuple, right_executor_->GetOutputSchema()));
}
result_.emplace_back(Tuple(values, plan_->OutputSchema()));

Hash Join

Hint 中让仿照 SimpleAggregationHashTable 实现一个 Hash Table,但是感觉好像没有什么必要,我使用了项目中提供的 HashUtil 加上一个 unordered_map 来完成。我们可以在初始化阶段将 HashTable 建立好。

注意,Hint 中提到我们需要处理多个元组的 key 是一样的情况,所以 HashTable 的声明应该是:std::unordered_map<hash_t, std::vector<Tuple>> hash_table_;,将外表中所有 join key 相同的元组存储到一起,在 Probe 时将这些元组统一跟内表中对应的元组进行比较,如果满足条件则拼装起来放入 result 中,方法跟 nested loop join 一样。

获取 join key 的方法:

auto left_join_key = left_expression->Evaluate(&tuple, left_child_->GetOutputSchema());
auto hash_key = HashUtil::HashValue(&left_join_key);
hash_table_[hash_key].emplace_back(tuple);

Aggregation

聚合的实现需要的东西很多,但是项目都已经给出来了,在写之前先看一下 SimpleAggregationHashTable 这个类。对于每一个元组,我们只需要调用 MakeAggregateKey、MakeAggregateValue 函数,生成 aggregate_key 和 aggregate_value 然后调用 InsertCombine 函数即可,这样聚合的结果就已经计算好了,存放在 SimpleAggregationHashTable 中,可以用它的迭代器对其中每一条结果进行操作。

对于有 Having 语句的情况,使用类似的做法判断条件:

plan_->GetHaving()->EvaluateAggregate(aht_iterator_.Key().group_bys_, aht_iterator_.Val().aggregates_).GetAs<bool>();

同样,生成元组的方式也是类似:

std::vector<Value> values;
for (const auto &column : plan_->OutputSchema()->GetColumns()) {
    values.emplace_back(
    column.GetExpr()->EvaluateAggregate(aht_iterator_.Key().group_bys_, aht_iterator_.Val().aggregates_));
}
*tuple = Tuple(values, plan_->OutputSchema());

Limit

Limit 就非常简单了,限制一下调用 Chlid_Executor 的次数就可以了。

Distinct

跟 Hash Join 类似,这里也用到了 HashUtil 加上一个 unordered_map 来实现,但现在是为了去重,所以 HashTable 中存储的东西就跟刚刚不一样了,我们将 key 相同的元组的 values 存储在其中,方便进行比较:std::unordered_map<hash_t, std::vector<std::vector<Value>>> distinct_map_{};。当从 Chlid Executor 中取出一个元组后,用 HashTable 进行去重即可,如果不是重复的就将其放入 result 中,最后依次返回。

Conclusion

在开始写 Query Execution 之前本来觉得应该非常的费时费脑,但是项目中大部分东西都已经给出,大大降低了难度。但是在自己查看 API 的过程中,发现依旧对这一块内容的理解变深了不少。在降低难度的同时还能加深理解,感谢 Andy。最后 leaderboard 还是不意外的在 100 名开外。

Project #4 - Concureency Control

项目 4 我们需要为 DBMS 实现一个锁管理器,并且利用锁管理器来实现并发的执行查询计划。锁管理器负责追踪事务涉及到的元组级的锁(行锁) ,并且基于隔离级别来授予/释放共享锁(Shared Lock)和排它锁(Exclusive Lock)

任务如下:

  1. Task #1 - Lock Manager
  2. Task #2 - DeadLock Prevention
  3. Task #3 - Concurrent Query Execution

本次项目的难度集中在 Task #1 和 Task #2,如果一些概念不清楚的话很容易到处出现问题,所以在写之前先把隔离级别、两段式锁还有 Wound-Wait 等概念理清楚再来动手写代码。并且一定一定听项目说明中说的先把 transaction.hlock_manager.h 中的 API 了解清楚。

TASK #1 - LOCK MANAGER AND TASK #2 - DEADLOCK PREVENTION

任务 1 中我们要实现一个全局的锁管理器,每次有事务要访问/修改某个元组时,我们就用锁管理器对这个元组上锁,并且锁管理器要根据事务的隔离级别来授予或者释放锁。任务 2 需要使用 WOUND-WAIT 算法实现死锁预防

在 2PL 下各个隔离级别的行为:

  • Read Uncommitted: 读取不需要获取锁,但是写需要获取排他锁,用完直接释放,不需要等待提交。
  • Read Committed:读取需要共享锁,写需要排他锁,用完直接释放,不需要等待提交。
  • Repeatable Read:读取需要共享锁,写需要排他锁,不能直接释放,必须等到事务提交或者中止时才能释放。

清楚了在 2PL 下各个隔离级别下的不同行为,再来实现 Lock Manager。

NeedWait

我实现了一个 NeedWait 函数来判断事务是否需要阻塞等待,并且使用 WOUND-WAIT 来实现死锁预防,基本的使用方式如下:

std::unique_lock<std::mutex> guard(latch_);
// ...
while (NeedWait(txn, rid, LockMode::EXCLUSIVE)) {
	lock_table_[rid].cv_.wait(guard);
    if (txn->GetState() == TransactionState::ABORTED) {
      return false;
    }
}

NeedWait 函数传入事务想要获取的锁的类型,根据锁类型来判断是否需要阻塞等待:

  1. 事务想要获取共享锁:如果等待队列中有已经授予的排他锁,那么事务阻塞等待。
  2. 事务想要获取排他锁:如果等待队列中有已经授予的锁,那么事务阻塞等待。

初步判断过后,如果不需要阻塞等待,直接能拿到锁,就返回 false,没有必要再进行死锁预防了(现在并不会产生竞态)。但是如果需要阻塞等待,这时再进行死锁预防,进行死锁预防前将返回值置为 false。

Wound-Wait 概念如下:

  1. Timestamp(T n ) < Timestamp(T k ) , then Tn forces Tk to be killed − that is Tn "wounds" Tk. T k **is restarted later with a random delay but with the same timestamp(k).
  2. Timestamp(T n ) > Timestamp(T k ) , then Tn is forced to "wait" until the resource is available.

将返回值置为 false,遍历等待队列:

  • 当遇到一个事务的 id 比自己大(这个事务比自己年轻)并且这个事务与自己冲突时,就将其强制中止,接着跳过这轮循环;
  • 当遇到一个事务的 id 比自己小并且这个事务与自己冲突时,就需要阻塞等待;
  • 遍历结束后,如果发生了中止,就进行一次通知,唤醒那些被中止的事务返回;
  • 及时清理掉等待队列中被中止的事务,否则会出现一个 younger 在等待一个被中止的 older 释放锁。

最后返回最终的判断结果。

NeedWaitUpgrade

这个函数用作 LockUpgrade,由于 LockUpgrade 是在持有共享锁的情况下升级,所以不需要初步判断是否需要等待,直接进行死锁预防即可,并且需要获取的锁类型固定是排他锁。

同样将返回值置为 false,遍历等待队列:

  • 当遇到一个事务的 id 比自己大并且这个事务与自己冲突时,就将其强制中止,接着跳过这轮循环;
  • 否则是肯定要等待的,因为要获取的是排他锁。
  • 遍历结束后,如果发生了中止,就进行一次通知,唤醒那些被中止的事务返回;
  • 及时清理掉等待队列中被中止的事务。

最后返回最终的判断结果。

LockShared

这个函数用于获取某个元组的共享锁,调用该函数的线程会阻塞到成功获取锁或者该事务中止。在尝试获取锁之前,先根据 2PL 和 事务的隔离级别,来提前做一些判断:

  1. 如果该事务已经中断,直接返回 false。
  2. 如果该事务处于 Shrinking 阶段,就意味着不能再获取锁了,直接中止事务,并抛出异常。
  3. 如果该事务的隔离级别是 Read UnCommitted,就意味着该事务进行读的时候不需要获取锁,直接中止事务,并抛出异常。
  4. 如果事务已经获取了共享锁,那么就直接返回 true。

然后新建一个 LockRequest,并将其插入到对应的等待队列中去,开始调用 NeedWait 进行判断,如果该事务在等待时中止了,就返回 false。当成功获取了共享锁时,把该线程在等待队列中的 LockRequest 的 granted 属性设置为 true,该事务进入 Growing 阶段,将获取到的共享锁放入事务的 SharedLockSet 中并返回 true。

LockExclusive

该函数的实现跟 LockShared 非常类似,需要注意的就是即使事务的隔离级别是 Read Uncommitted 它也是要获取排他锁来进行写操作的,这时候不能直接中止。

LockUpgrade

之前对锁升级的概念存在误区,写成了先将共享锁释放掉,再去尝试获取排他锁,后来在一篇帖子中看到了这样一段话:

Upgrade: A S(A) can be upgraded to X(A) if Ti is the only transaction holding the S-lock on element A.

www.geeksforgeeks.org/lock-based-…

即一个元组的共享锁在只有一个事务持有时,就可以将其升级成排他锁,所以锁升级是在该事务持有共享锁的情况下进行的, 否则锁升级好像就没有什么意义了,就是直接获取排他锁。

同样,在等待升级锁之前,也可以先进行一系列判断:

  1. 如果事务已经中断、事务没有持有该元组的共享锁或已经有事务(lock_table_[rid].upgrading_ != INVALID_TXN_ID)在等待升级时直接返回 false。
  2. 如果事务已经持有排他锁则直接返回 true。
  3. 如果事务处在 Shrinking 阶段,直接中断事务并抛出异常。

接下来调用 NeedWaitUpgrade 去尝试升级锁。如果升级成功,把该线程在等待队列中的 LockRequest 的 lock_mode_ 属性设置为排他锁,并将该等待队列的 upgrading_ 设置为 INVALID_TXN_ID,然后把排他锁放进 ExclusiveLockSet 中,将之前的共享锁从 SharedLockSet 中移除,并返回 true。

Unlock

  1. 先判断事务有没有占用该元组的锁,如果没有直接返回。
  2. 开始遍历等待队列,如果找到了自己,就将对应的 LockRequest 移除,并发起一次通知。
  3. 如果没有找到直接返回 false。
  4. 如果事务的隔离级别是 Repeatable Read,并且处于 Growing 阶段,那么事务此时进入 Shrinking 阶段。
  5. 将锁从事务的 LockSet 中移除。

TASK #3 - CONCURRENT QUERY EXECUTION

任务 3 要将我们实现的锁管理器应用起来,去修改以下 Executor:

SeqScanExecutor

在所确定访问的元组后,即遍历到了那个位置的时候(这个时候 rid 才能确定,函数参数是用作返回值的,遍历时还是空值)去获取共享锁。在将参数的值设置好后再将锁释放(Read Uncommitted、Read Committed 隔离级别下直接释放,如果是 Repeatable Read 则不需要自己编写代码)。

InsertExecutor

在所确定访问的元组后获取排他锁(如果已有排他锁就不需要获取,如果已有共享锁就进行升级,否则再获取排他锁)。更新完索引还要往 index_write_set_ 中插入一条记录,注意 tuple 字段填的是插入表的那个元组。最后释放锁。

UpdateExecutor

总体跟 InsertExecutor 类似,但是 UpdateExecutor 在将元组更新后,还需要插入一条记录到 table_write_set_ 中,tuple 字段是更新前的元组。并且再往 index_write_set_ 中插入记录时,还需要将记录的 old_tuple_ 字段也附上,即更新前的元组。最后释放锁。

DeleteExecutor

跟 InsertExecutor 几乎完全一致。

Conclusion

四个项目终于都完成了,虽然 Project 4 实现起来没有 Project 2 这么困难,但是感觉理解的并没有那么清晰,还需要阅读更多的资料再结合项目代码好好读一遍,而且这次提交了连 leaderboard 都没有,还有很大的优化空间。