P2: Extensible Hash Table

本文只关于实现以及Debug思路

Task #1 - Read/Write Page Guards

在Project1的BufferPoolManager中,FetchPage和NewPage这两个API都是直接返回Page *指针。然后通过程序员手动地调用UnpinPage方法来使得页面会处于Evictable的状态,一旦程序员在使用完页面之后忘记调用UnpinPage方法,可能就会导致这个页面一直处于Unevictable的状态而一直留在BufferPool中。

为了可以避免这个问题,就采用一个数据结构将Page *指针包裹起来——XXPageGuard

class BasicPageGuard {
    BufferPoolManager *bpm_{nullptr};
    Page *page_{nullptr};
    bool is_dirty_{false};
}

通过PageGuard中的API实现:1、当Page离开其作用域,不能再被访问到的时候,PageGuard销毁时自动调用UnpinPage;2、程序员可以主动调用Drop()方法告诉BufferPoolManager,将不再使用这个页面。

🚀注意:移动构造函数和移动赋值函数的区别!

移动赋值函数,赋值等号左边的PageGuard可能已经初始化,正在guard一个page;

但是对于移动构造函数则不存在这个问题~

Task #2 - Extendible Hash Table Pages

如图所示是一个extendible hash table,它的header page的最大深度为2,directory page的最大深度为2,而且bucket pages最后存放两个entry。

Hash Table Header Page

/**
 * Header page format:
 *  ---------------------------------------------------
 * | DirectoryPageIds(2048) | MaxDepth (4) | Free(2044)
 *  ---------------------------------------------------
 */

由于最大深度是4,所以在header page中实际上最多只能存放16(1<<4)个指向directory page的page id。

Hash Table Directory Page

/**
 * Directory page format:
 *  --------------------------------------------------------------------------------------
 * | MaxDepth (4) | GlobalDepth (4) | LocalDepths (512) | BucketPageIds(2048) | Free(1528)
 *  --------------------------------------------------------------------------------------
 */
  • MaxDepth:directory page的最大深度

  • GlobalDepth:当前directory page的全局深度,使用了几个bit位

  • LocalDepths:对于其中一个bucket page,使用的bit位数

  • BucketPageIds:存放指向bucket page的page id

  • 下图展示了这些概念的含义:

Hash Table Bucket Page

/**
 * Bucket page format:
 *  ----------------------------------------------------------------------------
 * | METADATA | KEY(1) + VALUE(1) | KEY(2) + VALUE(2) | ... | KEY(n) + VALUE(n)
 *  ----------------------------------------------------------------------------
 *
 * Metadata format (size in byte, 8 bytes in total):
 *  --------------------------------
 * | CurrentSize (4) | MaxSize (4)
 *  --------------------------------
 */
  • Remove()的实现:考虑两种:1、在移除了key-value之后,做compression,使得[0, size)中始终都是有效的键值对;2、只移除键值对,不做compression,在[0, max_size_)进行Lookup和Insert(如何判断某个entry是空的?)

    • 使用的是第一种

    • 后面在优化的时候选择了第二种

Task #3 - Extendible Hashing Implementation

Extendible Hashing (Dynamic approach to DBMS) - GeeksforGeeks

需要实现三个API:

  • GetValue:只需要从HeaderPage开始向下检索即可。

  • Remove

    • Merge Bucket:如果做完Remove操作之后,Page变为空,则需要进行Merge;Merge之后还可能需要进行Directory Shrink。

    • Merge Bucket只会出现在local_depth==global_depth的bucket上

      • 这个是错误的,导致这个想法的本质原因是GetSplitImageIndex的错误实现!!!

  • Insert:需要考虑几种情况

    • 由于HashTable初始化完之后,只有一个HeaderPage,所以第一个插入的Key-Value,需要新建DirectoryPage和BucketPage。

    • 如果BucketPage满了的话,需要进行Bucket Split和Directory Expansion。

  • 注意点

    • 一次Split Bucket可能不能达到目的,Split之后,要插入的页面可能还是满的,需要while循环直到能够顺利插入

    • 注意GetValue和Remove方法中取出来的directory_page_id和bucket_page_id不一定是有效的(比如在没有插入任何Key-Value就进行GetValue或者Remove)

    • Merge和Split的一样,也不是一次就结束的,在完成一次Merge之后需要判断新的BucketPage的SplitImage是否为空(手册中已经给了提示),同样需要while循环

Task #4 - Concurrency Control

没有什么特殊的,使用Task#1中的API——FetchPageRead和FetchPageWrtie即可。

✋为什么? 首先BufferPoolManager是线程安全的,对于public的三个API:Insert、Remove和GetValue,它们执行的过程都是依次获取HeaderPage、DirectoryPage、BucketPage,然后根据Page上的内容做后续的操作。使用FetchPageRead和FetchPageWrite就是得线程在获取到对应的Page的同时也持有Page上的读锁或者写锁,能够在线程持有的时候做到并发控制。

Debug

有三个测试一直过不去

BUG1. InsertTest3

BUG2. RecursiveMergeTest

估计是漏了一次BucketMerge

BUG3. GrowShrinkTest

原因应该是在一次SplitBucket时,BufferPoolManager的NewPage失败,返回了空指针。

Solve1. 没有理解BucketSplit和BucketMerge的真正过程

解决了前两个bug!!

💬关于GetSplitImageIndex函数,之前的理解有误!

  • 首先是对这个函数的含义理解错误:这个函数返回的是这个bucket在前面split的时候同时产生的另一个bucket的索引。所以这个函数应该是用在BucketMerge中的。

    • 之前的理解是,用于计算一个bucket在split后产生的新的bucket的索引。

  • 对bucket-spliting和bucket-merging的理解出现偏差

    • 将它们简单地理解成了两个bucket merge或者一个bucket split。但是一个bucket page是会涉及多个bucket_idx的,所以要充分考虑所有指向这个bucket page的bucket_idx。

    • 本质上,spliting和merging还是local_depth对应的lsb的收缩和扩展,考虑的应该是hash值的最低有效位,而不应该是索引。

Slove2. 没有注意BufferPool的size

🍳考虑一个情况:BufferPool的大小只有3个Page时,如果在插入时需要做BucketSplit,会发生什么?

进而需要考虑:BufferPool的大小为2(Page)时应该怎么办呢?为1呢?最小能为多少呢?

  • 分析对于每个API,[当前实现]一次最多会使用到多少个Page?

    • GetValue:最多3个——Header、Directory、Bucket各一个。

    • Insert:最多4个——Header、Directory、Bucket各一个,如果需要进行BucketSplit,在while循环中会多一个BucketPage。

    • Remove:最多3个——Header、Directory、Bucket各一个。

  • 这三个API理论上最少需要多少个Page同时在BufferPool中:

    • GetValue:最少只需要一个;可以依次取出HeaderPage、DirectoryPage、BucketPage。

    • Insert:最少需要三个;HeaderPage可以Drop掉。Split的时候需要DirectoryPage和BucketPage,以及一个新建的BucketPage

    • Remove:最少需要两个;HeaderPage可以Drop掉。Merge的时候需要DirectoryPage和BucketPage

  • 所以,BufferPool的Size最少为3(Page)时,ExtensibleHashTable才能正常工作。

BUG4. 超时

但是这个test仍然无法通过,原因是超时!!

似乎是真的太慢了?猜测应该是BucketPage里的Insert和Remove实现太慢了

改进了BucketPage存储data的方式,添加一个bitmap,bitmap[i]表示array中index为i的键值是否为空,这样最坏情况下只需要[0, max_size_]的遍历。

好消息:变快了;坏消息:还是超时?!


🙌需要明确的是:修改几千次就需要30s以上,确实是某些地方实现不恰

在本地统计了一下每insert/remove and lookup 10 keys所需要的时间,可以看到无论是insert还是remove,越往后所需要的时间越多。

完结!

经过接近一个星期的Debug,终于通过了

在重新实现了P1的BufferPoolManager之后,顺利通过了P2,在重写的时候优化了LRUKNode里对访问记录的存储:

  • 之前是:一股脑全部存进history_

  • 优化后:history_中只存放最近的十条访问记录

下面是优化前后,ExtensibleHashTable运行过程中在LRUKNode的Access中打印出来的日志:优化前,history_中要存放6000左右个访问记录,而优化后只需要10个;而且在1500次左右的Insert-and-Lookup和Remove-and-Lookup的操作中,有18000次对Node的访问操作。

原因可能是:history_中存储过多的数据,导致内存频繁换页,空间局部性丢失。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值