CMU15-445Project整理(2021Fall)

发布时间 2023-07-05 09:51:44作者: 别杀那头猪

本文为CMU15-445(2021Fall)的lab记录。

推荐博客 : https://blog.csdn.net/twentyonepilots/article/details/120868216, 逻辑写得比较清楚

CMU-15445官方网页 https://15445.courses.cs.cmu.edu/fall2021/assignments.html

Project #1 - Buffer Pool

TASK #1 - LRU剔除策略

可以先把146. LRU 缓存做一做,涉及的底层数据结构是一样的,同样都使用了 链表和哈希表,逻辑上可能还比这个lab难一点

lab的逻辑没有课上讲的复杂,可以不使用timestamp

主要完成的是lru_repalcer.cpp这个文件,主要数据结构:

std::list<frame_id_t> list_; // 存储空闲的frame_id
std::unordered_map<frame_id_t, std::list<frame_id_t>::iterator> map_; // 用来加速查找的过程

lru replacer 管理buffer pool中的空闲frame_id, 这些frame_id刚入list中管理,前面是新加入的,后面则是旧的。当需要牺牲一个页面时,从旧的那一端取。

lru replacer管理的是空闲frame的id?

是的,可以往下看,lrt replacer是与buffer pool manager类一起使用的,buffer pool manager被pin住的frame是不能被刷盘的,而那些没有被pin的frame就交给lru replacer管理,这些没有被pin的frame可以被刷盘然后存放新的磁盘数据。由于磁盘速度与内存速度相差过大,因此我们的目标是尽可能将数据多保存在内存一会,但是内存的容量是有限的,必须对保存的内容进行选择,尽可能将热点数据保存在内存中。而lru 剔除策略就很好地满足了这个要求,当buffer pool的容量不够时,将最近最少使用的prame剔除即可。

TASK #2 - Buffer Pool Management Instance

这个project的难点可能是对page_id 、frame_id 和page_table的理解。

首先page_id是对 磁盘 上的页的编号,frame_id 是对buffer_pool中页面的编号, 由于buffer_pool需要从磁盘上不断取出、刷新页面,所以buffer_pool中的页面对应的磁盘上的具体页面是时刻变化的,所以我们需要page_table 映射这两者的关系。

  /** Page table for keeping track of buffer pool pages. */
  std::unordered_map<page_id_t, frame_id_t> page_table_;

image-20220911150300302

上图是一个关于page在内存和磁盘中如何被管理的逻辑视图。

  • bufferpool在初始化时会在内存中开辟一块内存,用来存放从磁盘上取得的page, 图示的pool_size假定为4,似乎很小,但是很多测试用例就是这个大小,所以之后的两个实验中不要忘记 unpin 操作!

     pages_ = new Page[pool_size_];  // 这里就是bufferpool中存放数据页的地方
    
  • 使用disk_manager将磁盘上指定的page取出来并存放到 pages_数组对应的位置,并修改page_table

    ....
    // 比如将磁盘上page_id  = 2的那个页读取并存储到内存page_数组中,其frame_id = 1
    disk_manager_->ReadPage(page_id = 2, pages_[1]->GetData());
    ...
    page_table[1] = 2; // 然后修改page_table的映射关系即可
    
  • lru_repalcer 只管理没有被pin住的页面的frame_id

关于LRU的优化

看面经有很多关于LRU的优化问题,所以我去看了一些资料然后整理整理。

参考资料:

传统LRU的问题

Sequential flooding : 如果数据库执行一个顺序扫描操作,会把读到的所有页都加入lru的新端而将其他页面victim掉,而这些被牺牲的页却可能是热点数据,在lru链表中“最近使用的页”可能是我们最不需要的页面。(想想我们执行顺序扫描,将满足条件的记录读出来,那些不满足条件的页面虽然在内存中但是已经没有用了)

image-20220911153604098

MySQL的优化方案

mysql将lru链表分为 young和old两个区域,young区域存储热数据(使用频率高),old区域存储冷数据, 默认的比例是old区域占全链表的3/8.

image-20220911154914849

假设我们执行sql语句 : select colA from tableA where colA = something

首先扫描进内存的页被放入old端,表示这是冷数据,当我们对old区域的数据再次访问的频率达到一定程度时,会将它加入 young 区域的头部。那么从old转入young的门槛是什么?

首先考虑访问次数,如果我们将访问old区域页面第二次的页面加入young区域怎么样? 答案是不可行,因为mysql在读取一条记录是,就算是访问了一次页面。显然一个页面的记录有很多,如果我们将访问的次数作为门槛,还是达不到保持young区域为热点数据的目的。mysql的做法是: 在对于某个处于old区域的页面进行第一次扫描时,记录这个访问时间,如果后续访问时间与第一次访问的时间在某个时间间隔内,就不会将它加入young的头部。这个时间间隔默认为1s。当然如果没有对处于old区域的页面进行后续访问,该页面同样不同移动入young区域。

Project #2 - Extendible Hash Index

很难很多坑,难度是Project1的三倍不止。前期和gradescope斗智斗勇差点放弃,幸好有大佬搞来了测试代码下来能够在本地测试,否则给我两个月都难写出来。

Extendible Hash Index 原理

首先要搞明白的就是它的工作原理,否则寸步难行。

参考资料 :

上面两个资料已经将基本的东西讲得很清楚了,先看第一个搞清楚术语。

还有几个点可能要略作补充 :

  1. 资料中 directory 会保存一个指针指向bucket,但这只是逻辑上的概念,帮助我们理解它的概念。阅读lab的源码,可以知道direcotry保存的是各个bucket_page的page_id而不是bucket_page的指针, 必须使用buffer_pool_manager获取在磁盘\内存中的对应页面。

    image-20220815165649570

  2. 无论是bucket的分裂或者合并都需要用到GetSpiltImageIndex()方法, 这个方法的不同实现可能导致之后hashtable实现的逻辑不同。贴一下我的实现 :

    uint32_t HashTableDirectoryPage::GetSplitImageIndex(uint32_t bucket_idx) {
      uint32_t mid = (1 << global_depth_) / 2;
      if (bucket_idx < mid) {
        return mid + bucket_idx;
      }
      return bucket_idx - mid;
    }
    
    

    思路 : 逻辑上将整个bucket 分为两半, 先判断bucket_idx在哪一半, 然后返回另一半的对应偏移的值就是镜像index。对照下图看,无论是 ld = 1还是 ld = 2的bucket, 应该都是没有问题的:

    image-20220815162255384

  3. merge的逻辑可能不是很清楚,这个先看看官网的提示,然后又参考了其他的博客。首先注意官方代码注释提醒了不要递归地合并(分裂的逻辑却是要递归的);触发merge的条件是当delete一个key后,如果对应的bucket为空则进行merge,在merge好一个bucekt后,应该扫描directory中的所有bucket看看是否能够再次合并空bucket。下面图解这种情况:

    某一时刻,我们有下面的逻辑示意图, 即 page_id 为5 和8 的page为空,其他页都不为空。

    image-20220815170300082

    下一时刻,上层调用remove删去page_id为7的最后一条记录,7为空页,此时发生merge, 删除页面7,调整对应指针到splitimage,即 目录中0号元素所指的bucket。

    image-20220815170719376

    此时满足diretory shrink的条件:

    image-20220815171141050

    shrink后,进行额外的可能的merge操作 扫描整个directory发现page5也为空,故与page8合并

    image-20220815171821256

    等到最后全部删除时:

    image-20220815172053728

    应该满足 gd <= 1条件, 如果没有进行额外的merge操作,最后会违反这个条件导致不通过

    image-20220815172654144

TASK #1 - Page Layouts

HASH TABLE DIRECTORY PAGE

先实现目录页,它主要有4个成员属性

  • page_id : 目录页的pageid
  • global_depth : 整个hash表的全局深度
  • local_depth: 这是一个长度为512的char数组, 记录每个bucket的局部深度
  • bucket_page_id : 这是一个长度为512的int32数组,记录对应的bucket页的page_id, 也就是图示中看到的一个个箭头

512是hash表bucket页面数量的最大值,可能不会用到这么多,我们可以用 global_depth算出hash表的实际bucket数量 = 2 ^ global_depth

其他没啥好注意的,上面说的 GetSplitImage 方法想明白了就行

HASH TABLE BUCKET PAGE

这是存放键值对的页面。而且我们要支持存储相同的key不同value的键值对,这对之后的修改逻辑有很大影响。

image-20220902162953576

三个主要成员属性,

char occupied_[(BUCKET_ARRAY_SIZE - 1) / 8 + 1]; // 元素被插入时设置为1,被移除时不会设置为0, 可以用来加速某些方法的执行
char readable_[(BUCKET_ARRAY_SIZE - 1) / 8 + 1]; // 元素被插入和移除时都做相应修改
MappingType array_[0];// Do not add any members below array_, as they will overlap.

使用位图管理bucket页的键值对的占用,occupied 数组表示某个位置是否被占用过(曾今占用现在被移除,或者现在还在占用中),readale 数组表示某个位置现在是否存在有效数据。

  • Insert : 扫描readable数组,取出可读数据比较键值对和将要插入的键值对是否都相同,如果相同说明插入失败。 可以使用occupied数组加速扫描过程,因为当一个位置没有occupied时,那么说明它之后的所有位置都不会被占用,过去没有被占用,现在也没有被占用。找到位置后则设置相应的readable 、和occupied的位置为1,最后执行插入。

    必须扫描完整的readable数组得到插入的位置。本人之前以为readable数组和occupied数组一样是"连续的", 所以当扫描到一个readable数组的为为0时,就以为整个bucket的键值对已经扫描完了就执行了插入。但其实readable数组是不连续的因为有移除操作,比如此时readable数组是这样的 [1 1 1 1 0 0], 我们移除一个元素 [1 0 1 1 0], 我们再插入一个元素发现第二个位置有空位,但此时不能断定插入成功,我们需要扫描后续的所有可读数据进行判重, 如果不重复则说明插入成功,最后我们再次得到[1 1 1 1 0]

  • Remove : 没啥好说,扫描readable数组比较键值对是否时要删除的,然后只需要设置 readable数组对应位位0即可,不修改occupied数组。同样可以用occupied数组加速这个过程。

  • Search: 简单,同样可用occupied数组加速

整个页面存储多少的键值对是通过宏运算确定好的。

(BUCKET_ARRAY_SIZE - 1) / 8 + 1 这个计算公式已经根据 Key 和 Value的实际类型计算出了 一个bucket页面最多存储多少个键值对,是一个不会造成内存越界的最大值。所以我们不能在头文件中添加其他的类成员属性 , 因为这可能造成数据的 “overlap”

TASK #2 - 实现hash表

这应该是整个 Lab 中最烦最劝退的任务。

我们要实现 extendible_hash_table.cpp和相应头文件的代码, 三个主要的public 方法为 , searchinsertremove

难点在两个个private 方法: SplitInsert---可能由insert触发(bucket满)、 Merge---可能由remove触发(bucket空)

search(getvalue)的逻辑很简单就是先计算key的hash值,根据global_depth计算到一个bucket_index, 也就是说key被散列到了这个bucekt_index对应的bucket页面中,我们已经在第一个task中已经实现了如何在单个bucket页中查找key,我们使用对应方法在这个bucket中查找即可。麻烦的是下面insert 和 merge的逻辑。

insert

如果一个Bucket的空位数目足够,那么insert的逻辑和search几乎一样简单,但是一个bucket满时,我们就要进行页分裂。对应单独的一个方法 SplitInsert,我们可以在 insert 逻辑中判断插入是否成功, 如果因为bucket满而插入不成功(也有可能时key-value重复而不成功),则调用SplitInsert进行分裂插入。

SplitInsert是一个递归方法,老师有对该函数写了下面得注释 --- Performs insertion with an optional bucket splitting. If the page is still full after the split, then recursively split. This is exceedingly rare, but possible. 函数声明如下

bool HASH_TABLE_TYPE::SplitInsert(Transaction *transaction, const KeyType &key, const ValueType &value)

大致的逻辑为 :

  • hash 传入参数key, 计算这个键应该被hash到哪一个bucket。然后判断这个bucket是否满,如果不满(这就是递归终止条件)则对bucket执行正常插入然后直接返回。

  • bucket已满则要分裂bucket。 根据该buckut页面的local_depth 是否等于 global_depth有两种做法

    • 如果local_depth == global_depth, 那么目录页面的容量得首先增加一倍,然后分裂bucket, "分裂"指的是用bmp新建一个Page当作Bucket,将它的page_id放入目录页 中保存bucket pageId的数组中,当然得是在对应的bucketIdx中
    • 如果local_depth < global_depth, 那么仅分裂bucket即可
  • bucket分裂后应该更新目录页中对应的local_depths 、 page_ids 、 global_depth, 使它们可以通过完整性检查:

    /**
       * VerifyIntegrity
       *
       * Verify the following invariants:
       * (1) All LD <= GD.
       * (2) Each bucket has precisely 2^(GD - LD) pointers pointing to it.
       * (3) The LD is the same at each index with the same bucket_page_id
       */
    
  • 分裂bucket后,将原页面的键值对rehash到两个bucket中,这两个bucket互为镜像。

    • rehash过程先检测每个key是否hash到原bucket中, 如果是就不用动这个key-value对;如果不是则在这个原bucket中删除这个对,然后将这个键值对添加到image bucket中
  • 完成分裂后,递归调用 splitinsert , 而且在这之前不要忘记unpin

伪代码如下:

bool HASH_TABLE_TYPE::Insert(Transaction *transaction, const KeyType &key, const ValueType &value) {
    bucket = hash_key_to_bucket(key);
    insert_succeed = bucket->insert(key, value);
    if (!insert_succeed && bucket is full) {
        insert_succeed = SplitInsert(key,value);
    }
    return insert_succeed;
}

bool HASH_TABLE_TYPE::SplitInsert(Transaction *transaction, const KeyType &key, const ValueType &value) {
    bucket = hash_key_to_bucket(key);
    // recursion termination condition
    if (bucket is not full) {
        // just apply insert in the bucket 
        bucket.insert(key, value);
        return true;
    }
    
    // bucket is full , we need split it
    // gd : global_depth of directory; 
    // ld : local_depth of the bucket
    if (gd == ld) {
        // if gd == ld ,expand directory
        ExpensionDirectory();
    }
    // if gd > ld, only need to split bucket
    image_bucket = buffer_pool_manager->NewPage();
    ajdust ld 、 gd、 page_id to verify invariants
	RehashKeyValue(bucket, image_bucket);
    // must upin pages, otherwise there is no space for bmp to get pages from disk or create pages
    upin pages just used
    // recursive call
    return SplitInsert(key, value)
}

remove

和search是差不多的逻辑,在对某个bucket成功remove后, 判断这个bucket是否为空,如果为空则调用merge。 由于merge不是递归函数,但是有可能还会有额外的合并,所以我们定义一个ExtraMerge方法进行额外地合并。 官方注释提示我们可以在以下几个条件满足时可以跳过合并的步骤,:

  * There are three conditions under which we skip the merge:
   * 1. The bucket is no longer empty.
   * 2. The bucket has local depth 0.
   * 3. The bucket's local depth doesn't match its split image's local depth.
   * Note: we do not merge recursively.

直接上伪代码吧 :

bool HASH_TABLE_TYPE::Remove(Transaction *transaction, const KeyType &key, const ValueType &value) {
    bucket = hash_key_to_bucket(key);
    remove_succeed = bucket->remove(key, value);
    if (remove_succeed && bucket is empty) {
        Merge(key,value);
        ExtarMerge();
    }
}

void HASH_TABLE_TYPE::Merge(Transaction *transaction, const KeyType &key, const ValueType &value) {
    empty_bucket = hash_key_to_bucket_using_bmp(key);
    image_bucket = GetImageBucket(empty_bucket);
    // judge if we actually need merge 
    if ( ld_of_empty_bucket > 0 && ld_of_empty_bucket == ld_of_image_bucket &&
  		bucket_empty_page_id != bucket_image_page_id &&
      	empty_bucket->IsEmpty()) {
        // don't forget to unpin the empty page, otherwise we cannot delete this page due to the restrction of bmp
        bmp_unpin(empty_bucket);
        bmp_delete(empty_bucket);
        
        ajdust ld 、 gd、 page_id to verify invariants;
        
        if (all lds < gd) {
            ShrinkDirectory(); // we can just decrement gd by 1, since the actual size of deretory is calculated by gd
        }
    }
    unpin all pages   
}

void HASH_TABLE_TYPE::ExtraMerge() {
    for (every bucket in deretory) {
        if (bucket is empty) {
            just apply the merge process like  Merge() does
        }
    }
    unpin_pages
}

TASK #3 - Concurrency Control

这一个任务是让我们保证哈希表的线程安全性。在extendible_hash_table.h 中已经定义了一个表锁 :

// extendible_hash_table.h 
...
ReaderWriterLatch table_latch_;

这个锁用来对整张表进行加锁。那么为了提高并发度,我们需要降低锁的粒度,还有一个在 Page.h中

// Page.h
...
ReaderWriterLatch rwlatch_;
/** Acquire the page write latch. */
inline void WLatch() { rwlatch_.WLock(); }
/** Release the page write latch. */
inline void WUnlatch() { rwlatch_.WUnlock(); }
/** Acquire the page read latch. */
inline void RLatch() { rwlatch_.RLock(); }
/** Release the page read latch. */
inline void RUnlatch() { rwlatch_.RUnlock(); }

每个Bucket都有一把读写锁,在哪里呢?明明在BucketPage中没有读写锁啊?在在Page类中!注意Bucket page 类不是 page的一个子类,我们只是通过reinterpret_cast将page.data_解释为了BucketPage而已,所以要使用Page中的锁,我们需要重新reinterpret回去,可以这样写:

  reinterpret_cast<Page *>(bucket_page)->WLatch();
	// modify bucket
  reinterpret_cast<Page *>(bucket_page)->WUnlatch();

  reinterpret_cast<Page *>(bucket_page)->RLatch();
	// read bucket
  reinterpret_cast<Page *>(bucket_page)->RUnlatch();

为什么不用dynamic cast呢,因为Page 和 BucketPage根本没有继承关系,编译器会报错,这属于C++语法的部分,请见C++语法那部分

'bustub::HashTableBucketPage<int, int, bustub::IntComparator>' is not polymorphic

我们的BucketPage 是由Page的data_ 属性 重新解释为 BucketPage得来的:

template <typename KeyType, typename ValueType, typename KeyComparator>
HASH_TABLE_BUCKET_TYPE *HASH_TABLE_TYPE::FetchBucketPage(page_id_t bucket_page_id) {
   // 这里转换的是 char* -> HASH_TABLE_BUCKET_TYPE *, 没有继承关系,所以用 reinterpret_cast
  auto bucket_page =
      reinterpret_cast<HASH_TABLE_BUCKET_TYPE *>(buffer_pool_manager_->FetchPage(bucket_page_id)->GetData());
  return bucket_page;
}

接下来记录一下我的加锁策略。

  • Search(GetValue) : 为table上读锁,在bucket上也使用读锁
  • Insert : 由于此方法不会修改目录页但是修改bucket页,所以table使用读锁,bucket使用写锁
  • SplitInsert : 由于此方法会修改目录页也修改bucket页,所以table肯定是用写锁的, 而bucket我们可以不加锁,因为其他方法肯定会先对table加锁,无论使用写锁还是读锁锁都于本方法(SplitInsert方法)使用的写锁互斥,所以我们不用在本方法中对bucket再加锁。
  • Remove : 和Insert的策略一样
  • MergeExtraMerge 和SpiltInsert一样,只需要为table加写锁,不用管bucket

注意Insert 和 Remove方法中都要调用其他方法, 比如Insert方法会调用SplitInset,这是要注意解锁操作与调用顺序,否则会造成死锁。拿Insert举例来说,考虑加锁的逻辑后如下 :

bool HASH_TABLE_TYPE::Insert(Transaction *transaction, const KeyType &key, const ValueType &value) {
   // lock table
   table->ReadLatch.Lock();
   
   bucket = hash_key_to_bucket(key);
   // lcok the bucket
   bucket->WriteLatch.Lock();
   insert_succeed = bucket->insert(key, value);
   bucket->WriteLatch.UnLock();
   
   // must unlock table, before we call splitinsert
   table->ReadLatch.UnLock();
   
   if (!insert_succeed && bucket is full) {
       insert_succeed = SplitInsert(key,value);
   }
   return insert_succeed;
}

bool HASH_TABLE_TYPE::SplitInsert(Transaction *transaction, const KeyType &key, const ValueType &value) {
   // lock table, because we use write lock of table so we don't care about bucket
   table->WriteLatch.Lock();
   bucket = hash_key_to_bucket_using_bmp(key);
   // recursion termination condition
   if (bucket is not full) {
       // just apply insert in the bucket 
       bucket.insert(key, value);
       // unlock before return !
       table->WriteLatch.UnLock();
       return true;
   }
   
   // bucket is full , we need split it
   // gd : global_depth of directory; 
   // ld : local_depth of the bucket
   if (gd == ld) {
       ExpensionDirectory();
       CopyPageId();
   }
   // now we need split bucket
   image_bucket = buffer_pool_manager->NewPage();
   ajdust ld 、 gd、 page_id to verify invariants
   RehashKeyValue(bucket, image_bucket);
   // must upin pages, otherwise there is no space for bmp to get pages from disk or create pages
   upin pages just used
       
   // unlock table before recursive call
   table->WriteLatch.UnLock();    
   // recursive call
   return SplitInsert(key, value)
}

Task3其实没有什么难度, 注意加锁解锁的调用顺序就行了,Debug过程中如果是死锁错误,那应该是好调的; 如果在加锁的时候出现SEGV错误,可能不是加锁策略的问题,而是没有正确地执行 unpin 操作!

建议完成了hashtable的主要功能后再考虑线程安全, gradescope上的线程安全测试用例是完全独立的。

DEBUG

说一下我碰到的几个比较烦但是只要稍加注意就能避免的BUG。

  1. 不要忘记 unping 不使用的page。 gradescope的测试代码的bufferpool大小小得可怜,有些只有4个page的容量,所以一定要及时unpin避免bufferpool爆满而不能再获取页面

  2. 注意使用bmp的unpin方法的flag参数是true,还是flase。如果是true说明我们在本线程中修改过这个页面,所以bufferpool会用这个页面刷新磁盘上对应的页。如果搞错了逻辑,特别是该true的时候却设置为false,那不用说多线程场景了,就是单线程场景也很有可能会出现SEGV(使用未定义的内存)错误,因为bufferpool会将修改过的页面淘汰但是却没有刷新硬盘,等下次从硬盘上取数据就不符合预期了。

  3. 如果注释了一大片代码后,也请 make format一下。是的注释也有代码风格问题 : )。

  4. bucket page的方法需要操作readable数组,请记住它是不连续的,详见上文的分析。

  5. 对于有加锁的代码块,一定在return前释放锁,很容易出现在一个if分支里return但是忘记解锁的情况,然后就导致死锁而超时。建议使用lock_guard。

  6. 数组越界错误。这本来是很容易诊断的bug,但是gradescope可能会超时而不报memory_test方面的错误,导致debug的方向不对。

  7. 搞清楚到底是上读锁还是写锁

  8. C++语法:

    1. 使用初始化列表进行初始化时,其初始化顺序已被成员定义的顺序定死,一定多加注意放置一些隐晦的BUG。如果两个成员变量相互依赖,一定要按照顺序初始化!

    2. C++初学者注意引用,不要对着拷贝一顿修改,然后debug的时候很奇怪地发现原数据结构没有被修改!

      std::unordered_map<string, vector<int>> umap;
      // ... // 
      auto vec = umap["copy"]; 
      vec.push_back(1);  // 这是对着一个拷贝做修改
      
      auto& vec = umap["reference"];
      vec.push_back(1);  // 这才会在原数据结构上修改
      

建议:

不要做过早的优化,否则如果出现了一个Bug,很难定位。

建议是先完成单线程版本的,然后再完成多线程版本的,最后再考虑某些优化,比如读写锁的选择bufferpool managerd的交互优化(flag是true还是false)等。

补充

动态hash的优点?

https://loonytek.com/2016/05/17/extendible-hashing/

https://eng.libretexts.org/Bookshelves/Computer_Science/Databases_and_Data_Structures/Book%3A_Data_Structures_(Wikibook)/09%3A_Hash_Tables/9.07%3A_Other_hash_table_algorithms

image-20220914210203752

mysql的inodb中有hash索引吗?

mysql的inodb中存在hash索引,但是不能由用户指定使用hash索引取代默认的B+树索引。mysql的hash索引是一个内存数据结构,当系统检测到在内存建立hash索引进行快速查询能够提高性能时,会自动创建hash索引。

参考资料:

  1. MySQL :: MySQL 8.0 Reference Manual :: 15.5.3 Adaptive Hash Index

PROJECT #3 - Query Execution

本实验难点在源码阅读理解上,没啥好说的只能一个一个地点进去看。

依然还是推荐这篇博客的内容熟悉相关api。

首先要对火山模型有个总览:sql语句会被拆分成一个树形图,其中的每个节点就是一个executor,这个实验就是要实现各个executor。而将整个sql语句拆分成多个executor的步骤则没有让我们实现。

image-20230324171014205

TASK #1 - EXECUTORS

完成9个 executor类的编写

SeqScan

The SeqScanExecutor iterates over a table and return its tuples, one-at-a-time. A sequential scan is specified by a SeqScanPlanNode. The plan node specifies the table over which the scan is performed. The plan node may also contain a predicate; if a tuple does not satisfy the predicate, it is not produced by the scan.

我的做法是在.h文件中添加一个成员属性 TableIterator, 用来保存表中遍历的位置,方便再Next方法中调用。在Init方法中初始化迭代器

 tableheap_iterator_ = table_info_->table_->Begin(exec_ctx_->GetTransaction());

在Next方法中只需要 对tableheap_iterator_进行递增操作,就可以实现整张表的遍历动作。

官网提示plan也会包含一个判断条件,我们可以这样使用它,相当于 Select ... From... Where ... 中对Where语句指定的元组进行筛选:

while (tableheap_iterator_ != table_info_->table_->Bnd()) {
    auto predicate = plan_->GetPredicate();
    if (predicate != nullptr && !predicate->Evaluate(&(*tableheap_iterator_), &(table_info_->schema_)).GetAs<bool>()) {
      // 不满足predicate, 且如果隔离等级为read_commited 则立刻释放锁
      ++tableheap_iterator_;
      continue;
    }
    ...
}
...

Insert

有两种类型的插入

  • 一种称为 raw insert , 将要插入的值就存储在plan node中
  • 另一种则从子执行器中取得tuple, 将这个从子执行器得到的元组插入目标table中。 即 INSERT INTO .. SELECT ..

所以在Init 和 next中因该分两种情况, 在init中如果有子执行器,则应该先init子执行器

在插入tuple后还应该更新索引,API的使用如下:

 ...
if (insert_successed) {
      // 插入成功则更新索引
      auto indexs = exec_ctx_->GetCatalog()->GetTableIndexes(table_info_->name_);
      // 更新所有的索引
      for (auto &index_info : indexs) {
        Tuple key_tuple = tuple_to_be_inserted.KeyFromTuple(table_info_->schema_, index_info->key_schema_,
                                                           index_info->index_->GetKeyAttrs());
        index_info->index_->InsertEntry(key_tuple, rid_to_be_inserted, exec_ctx_->GetTransaction());
      }
  }

Update

update的修改对象总是来自于一个子执行器

总体逻辑和delete差不多,但是比delete还简单些

同样要更新索引,但是索引没有相应的api,所以可以先delete然后insert

Delete

和Update的逻辑相似,要删除的tuple总是来自于seqscan子执行器,将对应的rid的tuple, Mark delete 就行了

Nested Loop Join

无论课本还是课堂上的理论都是非常简单的

image-20230324171339096

但这就是理论和实践的差距,上面的伪代码没有将火山模型考虑进去,火上模型要求我们为每个excutor些一个Next方法,一次返回一个有效的tuple,并且提前return出while循环,这就可能要求我们保存左表或者右表的一些状态,我的做法是在类的成员变量中保存状态

也要注意处理输入表为空的情况!

class NestedLoopJoinExecutor : public AbstractExecutor {
  ...
  Tuple left_tuple_; // 从左表保存一个tuple,逻辑上表示一个游标
  RID left_tuple_rid_;
  bool left_table_not_empty_; // 左表是否为空
}

NestLoopJoinExecutor 有左右两个自执行器,一般都是seqscan子执行器。对每一个left_tuple都对右表中的所有tuple判断它们是否满足predicate。

另外grade_scope线上测试有IO_cost 测试,会判断你对两个表的迭代次数是否有误。比如A表10条记录,B表10条记录,那么总共的“IO次数”(应该是算的对两张表调用next总次数) = 10 * 10 = 100。所以也要注意这里的逻辑,我之前写的逻辑迭代了101次(就多了一次),没有通过。

关于NestLoopJoin的改进 : BlockNestLoopJoin算法。

Hash Join

需要构建一个哈希表,哈希表的key是左表的join对应的value,而哈希表的value是对应的tuples(放于vector)。基础数据结构选择unordered_map, 按照官网的提示编写自定义键的 hash模板类 和 == 函数,(参考)。 我们也可以使用 map,只需要重载 < 就可以了。

个人选用的是std::unordered_map, 键是一个自定义类型,值是一个Tuple数组,该数组的每个Tuple都有相同的JoinKey

// 关键类成员
std::unordered_map<JoinKey, std::vector<Tuple>> join_map_;

对于自定义类型,如果我们想要使用它,那么我们要做两件事:

  • 第一要对自定义类重载 == 运算符
  • 第二要再std的命名空间中,特化hash类模板
// hash_join_executor.h
namespace bustub {
    struct JoinKey {
      Value value_;
      bool operator==(const JoinKey &other) const { return value_.CompareEquals(other.value_) == CmpBool::CmpTrue; }
    };
}  // namespace bustub

// 注意是在std的命名空间中
namespace std {
    template <>
    struct hash<bustub::JoinKey> {
      std::size_t operator()(const bustub::JoinKey &join_key) const {
        size_t curr_hash = 0;
        if (!join_key.value_.IsNull()) {
            // 仿照SimpleAggregationHashTable 的写法提供hash函数
          curr_hash = bustub::HashUtil::CombineHashes(curr_hash, bustub::HashUtil::HashValue(&join_key.value_));
        }
        return curr_hash;
      }
    };
}  // namespace std

在init阶段,遍历左表的tuple,将每个tuple中参与join的value计算出来,将(value, tuple)放入join_map_中

image-20220910145229895

在Next阶段就拿右表的joinvalue在哈希表中寻找存放这个joinkey的Tuple数组,一个一个遍历并连接即可

HashJoin 和 Sort-Merge Join https://15445.courses.cs.cmu.edu/fall2021/notes/10-joins.pdf

  • Hash join can only be used for equal-joins on the complete join key. (哈希连接只使用与等值连接)
  • image-20220910145737992

注意这个实验的HashJoin只能进行等值连接!

Aggregation

这个与HashJoin相比反而简单,但是它的Api反倒很复杂

aggregate key 是 Sum() Count() 等函数括号里对应的那个key

group_atrs 故名思意就是group 后的哪个属性

看两个例子

  • SELECT COUNT(colB), SUM(colB), MIN(colB), MAX(colB) from test_3;

    这个sql语句没有group by,所以SimpleAggregationHashTable表中的key只有一个,即为空

    image-20220911163843494

    Init阶段对每一个chile_executor的tuple,只需要调用MakeAggregateKey 和 MakeAggregateValue先计算出 key 和value,然后再调用SimpleAggregationHashTable::InsertCombine(key,value)即可,它会根据聚合类型更新hash表中对应的value。

  • SELECT COUNT(colA), colB FROM test_1 GROUP BY colB

    这个语句对colB进行groupby,所以hash表的key是colB的值

    image-20220911164333810

Limit

没啥好说的, 用成员变量记录个数就行了

Distinct

这里只需要一个集合数据结构去重就可以了,那么是set还是unordered_set呢?可能得看个人的实现了,我是这样的:

// 同样应该提供 == 和 hash()
namespace bustub{
    struct DistinKey {
      std::vector<Value> values_;

      bool operator==(const DistinKey &other) const {
        for (uint32_t i = 0; i < other.values_.size(); i++) {
          if (values_[i].CompareEquals(other.values_[i]) != CmpBool::CmpTrue) {
              // 只要有一个 不等就false
            return false;
          }
        }
        return true;
      }
    };
}  // namespace bustub

namespace std {
template <>
struct hash<bustub::DistinKey> {
  std::size_t operator()(const bustub::DistinKey &distinct_key) const {
    size_t curr_hash = 0;
    for (const auto &value : distinct_key.values_) {
      if (!value.IsNull()) {
        curr_hash = bustub::HashUtil::CombineHashes(curr_hash, bustub::HashUtil::HashValue(&value));
      }
    }
    return curr_hash;
  }
};
}  // namespace std
...
std::unordered_set<DistinKey> set_;

我这样的选法似乎不能使用 std::set, 因为 set要我们提供 < 操作符号重载,但是我的key是 std::vector values, 它是一个"矢量",如何在一个矢量上执行 < 操作呢? bustub没有给出定义,所以不太能够写出准确的 < 定义。 但是bustub 给了我们values.CompareEquals()函数,所以我们可以对vector中的value一一判断是否相等,像上面做的那样。

Debug

  1. 有个值得注意的点是对 RID的理解, 它是对一个在表中元组的定位信息,如果一个元组不是从表中得到的(比如SecScan从表中得到的Rid是有效的),像这样

    *tuple = Tuple(values, output_shema_);
    

    直接构造一个tuple,它是没有rid的,如果执行tuple->GetRid()得到的是一个非法的RID。

    把元组插入表中,才能够得到一个有效的RID

    table_info_->table_->InsertTuple(tuple, &rid_to_be_inserted, exec_ctx_->GetTransaction());
    // rid_to_be_inserted 会被赋予正确的值
    

PROJECT #4 - Concurrency Control

本实验难度应该是第二大,不是很熟悉并发编程,所以debug起来也还是很痛苦。

TASK #1 - Lock Manager

课上的理论听得好好的,但是一到编程时间就蒙了。比如隔离级别的实现,本project不要求实现serailizable隔离级别,但尽管如此,我刚开始还是不知道要怎样具体地实现它们.

看了一些博客以及看了测试用例后渐渐摸清了, 推荐这个博客 https://blog.csdn.net/twentyonepilots/article/details/120868216, 写得比较清楚

总结以下 :

首先,各个隔离级别下可能发生的一致性问题有:

image-20220916150545603

不可重复读 和 幻读的区别?

两种情况下,都是指在两次读操作返回的结果不同。区别就在于,幻读是多出了一些不存在的记录,而不可重复读是结果集已存在的记录发生了改变。

从造成原因来看,两者的区别很大,幻读出现的原因是 我们不可能对不存在的记录加锁, 不可重复出现的原因是 读锁在commit之前被释放,而导致其他事务修改了对应的记录(这个记录是已存在的)

各个隔离级别的具体方案是:

  • isolation = READ_UNCOMMITTED : 不需要读锁, 只需要写锁
  • isolation = READ_COMMITED : 读锁,写锁都需要,但是读锁 在读完就释放, 写时上写锁,在commit时才释放写锁 (不需要2pl,看测试代码就懂了)
  • isolation = REPEATABLE_READ : 使用二阶段锁,在growing阶段只能获取锁,在shrinking阶段只能释放锁,而且所有的锁都在commit时才释放(成了 rigorous 2pl)
  • isolation = SERIALIZABLE : 本lab不要求,理论上需要 上一级别的所有要求 + index locks。因为幻读的出现的原因是我们不可能对不存在的记录加锁,所以我们只能对索引加锁了。sql语句运行过程中,如果插入一条新数据,之后势必会更新索引,如果新加入的索引在 index lock 保护的范围内,那么就回滚对记录所作的插入操作,因此index lock 起到了消除幻读的作用。

关键数据结构:

  class LockRequest {
   public:
    LockRequest(txn_id_t txn_id, LockMode lock_mode) : txn_id_(txn_id), lock_mode_(lock_mode), granted_(false) {}
    txn_id_t txn_id_;
    LockMode lock_mode_;
    bool granted_;
  };

  class LockRequestQueue {
   public:
    std::list<LockRequest> request_queue_; // 对某一RID的锁请求,全部都在这各list上等待
    std::condition_variable cv_;  // for notifying blocked transactions on this rid
    bool upgrading_ = false;      // 标记是否正在升级, while循环等待写锁释放时,upgrading为true,
                                  // 退出whilie循环后,将读锁升级并将ubgrading设置为false;
    bool has_writer_ = false;     // 标记这个等待队列中是否有读锁
    int sharing_count_ = 0;       // 获得 share lock 的事务个数
  };

...
  std::mutex latch_; // 这个mutex用来保护 lock_table_ ,在对lock_table_做修改时一定要首先获取这个互斥锁
  std::unordered_map<RID, LockRequestQueue> lock_table_; // 管理锁的table

image-20220916191446465

以RID为key为申请锁的行记录创建一个LockRequestQueue,此后对该记录的上锁请求都加入到这个请求队列中。

has_writer 和 sharing_count 是我自己添加的属性,方便在等待条件变量时判断可上锁的条件。

而且在某一时刻在ConditionalVariable循环等待的请求,当sharing_count != 0时,读请求不会进入等待循环而写请求会,因为读写互斥,而读读不互斥(这里存在一个问题,就是读者可能会饿死写者,因为得等到sharingcount = 0是,写请求才会被唤醒)。

那么RID是什么?

RID唯一地标识了某条行记录的位置信息,它由两个成员变量组成:

  • page_id, 标识这条行记录位于磁盘上的哪个页(由diskManager管理)
  • slot_num, 标识这条行记录在某个页的哪个槽

接下来分别记录三个Lock 和 一个Unlock操作的大致逻辑

  • LockShared

    - 2pl检测,只在 RR 隔离级别下检查事务的状态是否为Growing
    - 为本事务对应的记录生成一个读锁请求,放到请求队列中,分两种情况
    	- locktable中原先不存在对应Rid的LockRequestQueue,那么使用emplace_back api生成新的lockrequest
    	- locktable中原先存在对应Rid的LockRequestQueue,表示已经有其他请求在该记录上执行操作
    		- 执行cv.wait()循环,等待has_writer = false
    		- 在wait循环外,表示本事务已经得到该记录写锁,那么执行一系列更新操作,(granted、sharing_count修改)
    - txn->GetSharedLockSet()->emplace(rid);
    
  • LockExclusive

    大致逻辑几乎与LockShared,但是cv.wait()等待has_writer = false && sharing_count = 0
    
  • LockUpgrade

    - 2pl检测,只在 RR 隔离级别下检查事务的状态是否为Growing
    - 如果该队列的upgrading = true,表示有一个锁正在升级,直接返回false
    - txn->GetSharedLockSet()->erase(rid); 去除事务中原来的读锁
    - 由于执行锁升级,所以在locktable中一定存在相应的lockrequest,修改它的sharing_count, graned_, lock_mode,并将upgrading 设置为true
    	- 循环cv.wait()等待has_writer = false && sharing_count = 0
    - txn->GetExclusiveLockSet()->emplace(rid); 升级完成添加获得的写锁
    
  • Unlock

    -  txn->GetSharedLockSet()->erase(rid); // 删除本事务对应rid的读锁
    -  txn->GetExclusiveLockSet()->erase(rid); // 删除本事务对应的写锁, 这两条语句不可能同时会执行有效动作,因为同一个RID的记录不可能同时被授予写锁和读锁
    -  2pl处理,只有在 repeatable 隔离级别下才将事务状态改为 Shrinking
    -  根据事务id,遍历 lockrequest_list 中的lockrequest,获得对应事务id的lockrequest
    -  按照lockrequest的模式执行不同的操作
        - 如果lock模式为读锁, 那么sharing count --, 此时再判断是否 sharing count = 0 ,如果是的话就调用cv_.notify_all(),唤醒写请求;
    	- 如果lock模式为写锁,那么将has_writer 设为false, 调用cv_.notify_all(),唤醒读或写请求;
    

以上所有操作都必须在获取latch_ 互斥锁才能进行,并且为了配合条件变量的使用,要配合unique_lock使用

std::unique_lock<std::mutex> unique_lk(latch_);
...
cv_.wait(uniq_lk);
...

从逻辑上说,lockshared 和 lockexclusive函数都应该判断是否已经对该rid进行了加锁,如果已经加锁了就返回false;对于锁升级来说,应该检查对应rid的记录是否持有读锁,如果没有则返回false。 我当初没有想到这个逻辑,但是测试代码好像不会检测,所以也过了。

LockUpgrade的真实使用场景:

一个update executor,首先他会从子节点(seqscan executor)取出对应的记录,根据隔离级别的不同,这条记录从子节点返回时,它可能被加了读锁或者没有加锁。在没有加锁的情况下,update executor 应该对该记录执行LockExclusive, 如果该记录已经加了读锁,那么应该对其执行LockUpgrade,将读锁升级为写锁。

TASK #2 - Deadlock Prevention

采用wound-wait(young wait for old)的方法预防死锁

image-20220916190929329

具体说就是当两个事务,同时对一个记录加互相排斥的锁时,应该检查哪个事务时先运行的。比如T1先运行要对A加X锁,然而检测到T2也对A加了X锁,这时判断T1和T2哪个先运行,T1检测到是自己先运行,则Abort T2,把T2的锁抢为己有。如果T1检测到是T2先运行,那么就挂入锁申请队列等待T2释放X锁。

以上讨论同样适合X锁与S锁, 因为X锁和S锁同样是互斥的。但是记住S锁互相是兼容的。

加入死锁检测的逻辑大致要该两个地方的逻辑,一个是在加入lockrequest队列时,运行wound-wait算法,另一个时当本事务被其他事务Abort时,可能正在cv.wait()循环中,所以我们在这个循环中还要检测自身事务的状态是否为Aborted,如果是则退出循环,返回false

用 LockShared 作为例子,添加死锁预防的后逻辑如下

- 2pl检测,只在 RR 隔离级别下检查事务的状态是否为Growing
- 为本事务对应的记录生成一个读锁请求,放到请求队列中,分两种情况
	- locktable中原先不存在对应Rid的LockRequestQueue,那么使用emplace_back api生成新的lockrequest
	- locktable中原先存在对应Rid的LockRequestQueue,表示已经有其他请求在该记录上执行操作
		- 执行woundwait算法,检查 LockRequestQueue中所有 `写` 请求,判断它们的txn_id是否大于本事务,如果是的话就表示这个发出这个请求的事务比本事务年轻,因此abort它; 
		- 调用一次cv.notify_all() 唤醒所有被abort的事务
		- 执行cv.wait()循环,等待has_writer = false,并且还要判断自身事务状态是否为abort,如果是就表示其他事务在执行 wound-wait算法时把本事物abort了,因此 break 退出循环
         - 检查本事务是否被abort,如果是则返回false
		- 到这里,表示本事务已经得到该记录写锁,那么执行一系列更新操作,(granted、sharing_count修改)
- txn->GetSharedLockSet()->emplace(rid);

TASK #3 - Concurrent Query Execution

修改project3中的 seq_scan delete update 执行器,使它们能够满足各种隔离级别下的特性。

  • seq_scan : 只涉及读锁,
    • 在Next方法开头判断隔离级别是否为 READ_UNCOMMITTED, 如果是就不用加读锁, 其他两个隔离条件都需要加读锁.
    • 在Next方法返回前, 判断隔离级别是否为REPEATABLE_READ, 如果不是则立刻释放读锁,如果是则不释放,由上层调用者在事务commit时一起释放
  • delete: 涉及写锁和锁升级, 首先应该意识到delete从它的子执行器(也就是scan executor)中获得tuple, 视隔离级别不同, 取得的tuple有 没有上锁或者已经上读锁的 两种可能
    • 获得子执行器传出的tuple后, 判断当前事务的隔离级别, 如果是REPEATABLE_READ, 说明该tuple已经被上了读锁,那么对该tuple执行锁升级
    • 否则对该tuple上写锁
    • 在该防范中不需要解锁, transaction manager类会在事务commit后自动释放全部的锁
  • update: 同上
  • insert : 似乎没有要求? 因为insert插入的记录是在前一刻不存在的,因此行记录锁是锁不住的。这会导致幻读的发生,但是实验并没有要求我们实现Serializable这个隔离级别,所以没有对insert executor做出具体要求,如果要实现Serializable隔离级别,则在RR隔离级别上加上index lock才行。

Debug

花费时间最长的是关于Reapeatable Read相关的测试

测试代码会这样测试你是否正确实现了Reapeatable Read隔离级别:

  • 开启两个thread,记为thread1,thread2
  • thread1 执行两次Selcet操作,两次查询的数据的RID相同
  • thread2 执行一次Update操作,修改的数据与thread1的数据的RID相同
  • 测试代码使用sleep操作保证调度顺序保证为:
    • thread1第一次select -> thread2 update数据 -> thread1第二次select
  • 如果正确实现了Repeatable隔离级别,那么thread1的第二次select结果与第一次的结果相同
  • 这里的关键就是使得thread2在执行update时被阻塞
  • UpdateExecutor有一个seqscan子Executor,子执行器已经对数据上了读锁,且由于隔离级别为Reapeatable Read,因此子执行器上的读锁不会归还直到事务结束。这时主执行器,在对记录进行update时就要使用LockUpdate方法了。
  • LockUpdate方法一定要确保在这种调度顺序下,thread2会在条件变量上等待,等待该RID的读锁和写锁数量为0。

具体如何调试?
说来惭愧,本人不是很会用GDB,且这个实验的并发读不高,所以一直使用printf和LOG_DEBUG在调式。主要观察thread2 的update方法是否在thread1 commit事务前结束执行。如果是的话,说明哪里有问题,那就打印更加详细的LOG,把等待队列的has_writer、readcount、是否陷入等待、是否执行唤醒操作、wound-wait算法是否正确abort了新事务等等,都打印出来,一个一个排查。

易错: 当一个事务被wakeup时,并不代表它获取了锁,有可能它作为一个老事务被新事物abort了,因此一定要检查这种情况!

其他

读写锁

这个项目的读写锁是老师自己实现好的,感觉可以摸索摸索

与操作系统导论那本书的P272相比,bustub的读写锁实现有一个mutex和两个条件变量,多出了一个条件变量,一把mutex锁用来保护整个读写锁,reader和writer分别在各自的条件变量上等待。以及最重要的是,在RLOCK方法中判断了读者数量是否到达了最大数量,如果读者达到了最大数量,那么将不会获取锁,这就预防了写者被饿死。

// Identification: src/include/common/rwlatch.h
namespace bustub {
/**
 * Reader-Writer latch backed by std::mutex.
 */
class ReaderWriterLatch {
  using mutex_t = std::mutex;
  using cond_t = std::condition_variable;
  static const uint32_t MAX_READERS = UINT_MAX;

 public:
  ReaderWriterLatch() = default;
  ~ReaderWriterLatch() { std::lock_guard<mutex_t> guard(mutex_); }
  DISALLOW_COPY(ReaderWriterLatch); // 不允许赋值操作
  /**
   * Acquire a write latch.
   */
  void WLock() {
    std::unique_lock<mutex_t> latch(mutex_);
    while (writer_entered_) {
      reader_.wait(latch);
    }
    writer_entered_ = true;
    while (reader_count_ > 0) {
      writer_.wait(latch);
    }
  }

  /**
   * Release a write latch.
   */
  void WUnlock() {
    std::lock_guard<mutex_t> guard(mutex_);
    writer_entered_ = false;
    reader_.notify_all();
  }

  /**
   * Acquire a read latch.
   */
  void RLock() {
    std::unique_lock<mutex_t> latch(mutex_);
    while (writer_entered_ || reader_count_ == MAX_READERS) {
      reader_.wait(latch);
    }
    reader_count_++;
  }

  /**
   * Release a read latch.
   */
  void RUnlock() {
    std::lock_guard<mutex_t> guard(mutex_);
    reader_count_--;
    if (writer_entered_) {
      if (reader_count_ == 0) {
        writer_.notify_one();
      }
    } else {
      if (reader_count_ == MAX_READERS - 1) {
        reader_.notify_one();
      }
    }
  }

 private:
  mutex_t mutex_;
  cond_t writer_;
  cond_t reader_;
  uint32_t reader_count_{0};
  bool writer_entered_{false};
};

}  // namespace bustub

工厂模式

发现了一个简单的工厂模式的应用

bool Execute(const AbstractPlanNode *plan, std::vector<Tuple> *result_set, Transaction *txn,
               ExecutorContext *exec_ctx) {
    // Construct and executor for the plan
    auto executor = ExecutorFactory::CreateExecutor(exec_ctx, plan);

    // Prepare the root executor
    executor->Init();
	// ...... 下略
}

CreateExecutor方法如下所示:

  • 返回值是一个使用unique_ptr管理的AbstractExecutor指针
  • 方法内根据paln的种类创建对应的具体的Executor
  • 各个具体的Executor则继承AbstractExector
std::unique_ptr<AbstractExecutor> ExecutorFactory::CreateExecutor(ExecutorContext *exec_ctx,
                                                                  const AbstractPlanNode *plan) {
  switch (plan->GetType()) {
    // Create a new sequential scan executor
    case PlanType::SeqScan: {
      return std::make_unique<SeqScanExecutor>(exec_ctx, dynamic_cast<const SeqScanPlanNode *>(plan));
    }

    // Create a new index scan executor
    case PlanType::IndexScan: {
      return std::make_unique<IndexScanExecutor>(exec_ctx, dynamic_cast<const IndexScanPlanNode *>(plan));
    }

    // Create a new insert executor
    case PlanType::Insert: {
      auto insert_plan = dynamic_cast<const InsertPlanNode *>(plan);
      auto child_executor =
          insert_plan->IsRawInsert() ? nullptr : ExecutorFactory::CreateExecutor(exec_ctx, insert_plan->GetChildPlan());
      return std::make_unique<InsertExecutor>(exec_ctx, insert_plan, std::move(child_executor));
    }

    // Create a new update executor
    // ... 下略
  }
}
class SeqScanExecutor : public AbstractExecutor {
 public: 
 // 下略
}

class IndexScanExecutor : public AbstractExecutor {
 public: 
 // 下略
}

class InsertExecutor : public AbstractExecutor {
 public: 
 // 下略
}
// ... 等等