15445 Project 1
Buffer Pool Manager
Task 1
先看官网给出的描述:
在该项目的第一部分中,您将构建一个通用哈希表,使用无序的桶来存储唯一的键/值对。您的哈希表必须支持插入/删除键/值条目的能力,而无需指定表的最大大小。您的表需要根据需要逐步增长,但您不需要将其缩小。也就是说,您不需要实现缩小或压缩哈希表的支持。您还需要支持检查哈希表中是否存在一个键,并返回其相应的值。
我们鼓励您首先手动演示一些拆分和目录增长情况的小例子,然后再开始实现。
您必须在项目源代码中的指定文件中实现您的哈希表。您只允许修改哈希表头文件(src/include/container/hash/extendible_hash_table.h
)及其相应的实现文件(src/container/hash/extendible_hash_table.cpp
)。您不需要修改任何其他文件。您不得在实现中内部使用另一个内置哈希表。您必须在ExtendibleHashTable
类中实现以下函数:
Find(K,V)
:对于给定的键K,请检查它是否存在于哈希表中。如果存在,则将其相应值的指针存储在V中并返回true。如果键不存在,则返回false。Insert(K,V)
:将键/值对插入哈希表中。如果键K已经存在,则用新值V覆盖其值并返回true。如果无法将键/值对插入桶中(因为桶已满且该键不是更新现有对),请在重试之前执行以下步骤: 如果桶的局部深度等于全局深度,则增加全局深度并将目录的大小加倍。增加桶的局部深度。拆分桶并重新分配目录指针和桶中的kv对。某些实现将在插入后如果桶变满就拆分桶。但是对于这个项目,请检测桶是否溢出并在插入之前执行拆分。Remove(K)
:对于给定的键K,请从哈希表中删除其相应的键/值对并返回true。如果键K不存在于哈希表中,则返回false。GetGlobalDepth()
:返回整个哈希表的当前全局深度。GetLocalDepth(dir_index)
:返回给定目录索引指向的桶的当前局部深度。GetNumBuckets()
:返回哈希表中分配的桶的总数。
您可以使用提供的IndexOf(K)
私有函数来计算给定键散列到的目录索引。此外,我们提供了一个Bucket嵌套类,表示可扩展哈希表中的桶。首先按照代码文档完成Bucket
类的TODO
,可以更轻松地实现ExtendibleHashTable
API。但是您可以随意编写自己的内部类/辅助函数。
您需要确保哈希表中的所有操作都是线程安全的,使用std::mutex
。您需要决定如何保护数据结构。
Extendible Hash Table
与正常的线性哈希不同,我们采取分裂bucket的方式而不是让bucket线性增长,多个哈希槽位置可以指向同一个桶链。在拆分时重新整理桶中的条目,并增加要检查的位数。具体的来说,Extendible Hash Table
的结构中会维护一个global depth
来记录哈希表中最长的位数,对每个bucket
结构会维护一个local depth
,来记录该bucket
的寻址位数,当bucket
进行分裂时,local depth
会增加,如果local depth
超过了global depth
,那么global depth
会同时加一。
我们采取自底向上的方法进行实现,先来介绍Extendible Hash Table
中Bucket
的实现:
Bucket
中维护一个(key,value)
pair的list
来存储数据,size_
来记录Bucket
中(key,value)
pair的最大个数(2^depth_
),depth_
来记录上述中记录local depth
的大小Find(k,v)
:在Bucket
中给定key寻找value的方法,该方法实现较简单,对list
中的pair进行遍历,如果寻找到相等的key
则将对应的value
返回并返回true
,若没找到则返回false
Remove(k)
:在Bucket
中给定key删除对应的(key,value)
pair,与Find
类似,直接遍历,若找到相等的key
则删除并返回true
,否则遍历完成并返回false
Insert(k,v)
:在Bucket
中插入新的(key,value)
pair,如果list
为空,则直接插入并返回true
;否则需要先遍历Bucket
寻找是否该key已存在,若已存在更新即可;若不存在则需要判断Bucket
是否已满,若未满直接插入并返回true
即可;若已满需要返回false
来通知上层需要分裂该Bucket
接下来我们介绍Extendible Hash Table
的实现:
Extendible Hash Table
维护一个Bucket
的vector
,一个控制并发操作的锁latch
,一个记录Bucket
数量的变量,记录Bucket
最大size的变量以及一个global depth
IndexOf(key)
&IndexOf(key,depth)
:这两个函数都是寻找Bucket index
的函数,与PPT中的寻址方法相同,直接匹配前depth
/global depth
的位数来寻址即可GetDepth
一类的函数(包括local depth
/global depth
等):这类函数获取depth
的值,需要注意的是,depth
的值是会因为Bucket
的分裂而动态变化的,由于我们需要考虑并发操作,所以在访问无论是global depth
还是local depth
时都需要对latch
进行上锁,再进行访问;访问local depth
时就到对应的Bucket
中读取数据即可Find(k,v)
:该函数在哈希表中寻找key对应的value,同样的,由于Bucket
中的数据在动态变化,所以在查找前需要对整个哈希表上锁,然后对key
进行对应的Bucket
编号查找,找到对应的Bucket
后调用Bucket
的Find函数进行查找即可Remove(key)
:与Find(k,v)
同理Insert(k,v)
:该函数向哈希表中插入一个(key,value)
pair,这个函数应该是哈希表中最复杂的一个函数了。首先由于我们需要对表中数据进行修改,所以需要对整个哈希表上锁;在插入之前,我们需要对Bucket
是否已满进行判断,若已满则需要进行分裂处理,(需要注意的是,在极端情况下分裂处理是需要递归进行的,因为我们分裂的方法是继续看key
的下一位bit的值,而此时有可能Bucket
中下一位bit都是相同的,这时候就需要递归进行处理,但这样的递归总有尽头,直到剩余bits的位数小于等于Bucket
中个数时,一定会出现不满的Bucket
,这时即可进行插入)- 不需要分裂(要插入的
Bucket
非满):直接调用Bucket
的插入进行插入即可 - 需要分裂(要插入的
Bucket
已满):若目标Bucket
的深度已经达到global depth
,则需要扩展整个哈希表的global depth
并扩大Bucket vector
;然后建两个新Bucket
,把原Bucket
中的元素按照key下一位bit的不同分别插入到两个Bucket
中,最后把两个Bucket
按照索引位的不同放到Bucket vector
的相应位置中
- 不需要分裂(要插入的
Task 2
先看官网给出的描述:
该组件负责跟踪缓冲池中页面的使用情况。您将在src/include/buffer/lru_k_replacer.h
中实现一个名为LRUKReplacer
的新类,以及其相应的实现文件src/buffer/lru_k_replacer.cpp
。请注意,LRUKReplacer
是一个独立的类,与其他Replacer类无关。您只需要实现LRU-K
替换策略,不需要实现LRU
或时钟替换策略,即使有相应的文件也不需要。
LRU-K
算法会淘汰最长时间未被访问的页框。后向k
距离是指当前时间戳与第k次之前访问的时间戳之间的时间差。如果一个页框历史访问次数小于k
,则将其后向k距离设为正无穷。当有多个页框的后向k距离都是正无穷时,替换器会淘汰时间戳最早的那个页框。LRUKReplacer
的最大大小与缓冲池的大小相同,因为它包含了BufferPoolManager
中所有页框的占位符。然而,在任何时刻,替换器中不是所有的页框都被视为可淘汰的。LRUKReplacer
的大小由可淘汰的页框数量表示。LRUKReplacer
被初始化为不含任何页框。只有当一个页框标记为可淘汰时,替换器的大小才会增加。
您需要实现课堂上讨论的LRU-K
策略。您需要实现以下方法:
Evict(frame_id_t*)
:从替换器中淘汰后向k距离与其他可淘汰的页框中最大的那个页框。将页框的id存储在输出参数中并返回True。如果没有可淘汰的页框,则返回False。RecordAccess(frame_id_t)
:记录给定页框id在当前时间戳下被访问。在BufferPoolManager
中固定页面后,应调用此方法。Remove(frame_id_t)
:清除与页框关联的所有访问历史记录。仅在BufferPoolManager
中删除页面时调用此方法。SetEvictable(frame_id_t, bool set_evictable)
:此方法控制页框是否可淘汰。它还控制LRUKReplacer
的大小。当页的引用计数达到0时,对应的页框被标记为可淘汰,并增加替换器的大小。Size()
:此方法返回当前LRUKReplacer
中可淘汰的页框数量。
LRU-K
算法简介:
LRU-K
算法是一种页面替换算法,它通过记录缓存数据被访问的历史来判断哪些数据最有可能被淘汰。在LRU-K
算法中,K
代表最近使用的次数。与LRU
算法不同的是,LRU-K
算法将“最近使用过1次”的判断标准扩展为“最近使用过K
次”,从而解决了LRU
算法中可能出现的“缓存污染”的问题。
LRU-K
算法需要维护一个访问历史队列,用于记录缓存数据被访问的历史。当一个数据第一次被访问时,它会被加入到访问历史队列中。如果一个数据在访问历史队列中的访问次数没有达到K
次,则按照一定规则(例如FIFO
或LRU
)淘汰。当一个数据的访问次数达到K
次后,它将被移到缓存队列中,并缓存此数据。缓存队列会按照时间排序,以便淘汰最久未使用的数据。
当需要淘汰数据时,LRU-K
算法会淘汰缓存队列中排在末尾的数据,即“倒数第K
次访问离现在最久”的数据。当一个数据再次被访问时,它会被重新排序,以确保最近使用的数据总是在缓存队列的前面。
总的来说,LRU-K
算法是一种比较复杂的页面替换算法,但它能够更准确地判断哪些数据最有可能被淘汰,从而提高了缓存系统的性能。
具体实现方法:
LRUKReplacer
维护的数据包括:一个记录每个页对应的访问次数的map
;上文中提到的一个历史队列、一个缓存队列;缓存队列和历史队列都需要的一个页号对应迭代器的map
;一个记录每个页是否可以被换出的map
;记录LRU-K
的K
值;此时包含的页数size
;一个保持安全并发操作的锁latch
(需要强调的是,LRU-K替换器本身就是一个公共资源,所以在执行任何操作时都需要加锁,下文的操作中不再赘述);一个replacer_size
Evict(frame_id_t*)
:该函数换出LRU-K
策略选出的页,若无页可选直接返回false
;在有页可选的情况下,我们优先从历史队列中挑选可被换出的页,直接从历史队列队尾回溯选择,若可换出则在历史队列中将该项移除,历史队列的map中溢出,记录访问次数设置为0,并设置不可换出并减少当前的size
,返回true
;若历史队列中无可换出的页,则遍历缓存队列(遍历方向相同),操作也相同;若均无可换出的页,则返回false
RecordAccess(frame_id_t)
:若frame_id
超过了replacer
的size
,说明是非法页框号,需要特殊处理,否则开始访问记录,先增加对应frame
的访问计数,若访问计数达到k
次则需要在缓存队列/map中删除,并添加到历史队列/map中;若访问计数大于k
次,则需要将其移到队尾;若访问计数未达到k
次,则需要移动到缓存队列的队尾SetEvictable(frame_id_t, bool set_evictable)
:该函数设置该frame
是否可以被换出,若可换出则set_evictable=true
,反之为false
,同理,若frame_id
超过了replacer
的size
,说明是非法页框号,需要特殊处理,若访问次数为0,则不需要设置;若此时状态不可换出并设置可换出,则需要增加当前的size
;若次数状态可换出且要设置不可换出,则需要减小size
;最后设置是否可换出为set_evictable
即可Remove(frame_id_t)
:若frame_id
超过了replacer
的size
,说明是非法页框号,需要特殊处理,否则开始执行删除,若访问次数为0可直接返回无需删除;若不可换出则直接抛出异常;剩下的工作就是在历史/缓存队列和map中删除对应的项,减小当前size
,初始化count和是否可换出即可Size()
:由于我们维护了当前的size
,所以直接返回即可
Task 3
先看官网给出的描述:
最后,您需要在系统中实现缓冲池管理器(BufferPoolManagerInstance
)。BufferPoolManagerInstance
负责从DiskManager
中获取数据库页面并将其存储在内存中。当需要为新页面腾出空间时,BufferPoolManagerInstance
也可以将脏页面写回磁盘,或者在明确指示时进行写回。
为确保您的实现与系统的其余部分正确配合,我们将为您提供一些已填充的函数。您也不需要实现实际读写数据到磁盘的代码(在我们的实现中称为DiskManager
),我们将为您提供该功能。
系统中的所有内存页面都由Page
对象表示。BufferPoolManagerInstance
不需要理解这些页面的内容。但是,作为系统开发人员,了解Page
对象只是缓冲池中内存的容器,因此不属于唯一的页面是很重要的。也就是说,每个Page
对象包含一块内存块,DiskManager
将使用该内存块作为从磁盘读取的物理页面内容的位置。BufferPoolManagerInstance
将重用相同的Page
对象来存储数据,因为它在磁盘和内存之间来回移动。这意味着同一个Page
对象可能在系统的整个生命周期内包含不同的物理页面。Page
对象的标识符(page_id
)跟踪它包含的物理页;如果Page
对象不包含物理页,则必须将其page_id
设置为INVALID_PAGE_ID
。
每个Page
对象还维护一个计数器,用于记录“固定”该页面的线程数。您的BufferPoolManagerInstance
不允许释放被固定的Page
。每个Page
对象还跟踪它是否是脏的。您的任务是在取消固定Page
之前记录页面是否已修改。在可以重复使用该对象之前,您的BufferPoolManagerInstance
必须将脏Page
的内容写回磁盘。
您的BufferPoolManagerInstance
实现将使用您在先前步骤中创建的ExtendibleHashTable
和LRUKReplacer
类。它将使用ExtendibleHashTable
来映射page_id
到frame_id
的表。它还将使用LRUKReplacer
来跟踪Page
对象的访问时间,以便在必须释放帧以为从磁盘复制新的物理页面时决定哪个页面被淘汰。
您需要在源文件(src/buffer/buffer_pool_manager_instance.cpp
)中实现头文件(src/include/buffer/buffer_pool_manager_instance.h
)中定义的以下函数:
FetchPgImp(page_id)
UnpinPgImp(page_id, is_dirty)
FlushPgImp(page_id)
NewPgImp(page_id)
DeletePgImp(page_id)
FlushAllPagesImpl()
对于FetchPgImp
函数,如果在空闲列表中没有可用页面且所有其他页面当前都被固定,则应返回nullptr
。FlushPgImp
应该无论页面的固定状态如何都会刷新页面。
对于UnpinPgImp
,is_dirty
参数跟踪页面在固定期间是否被修改。
AllocatePage
私有方法为BufferPoolManager
提供一个新的唯一页面标识符,当您想在NewPgImp()
中创建新页面时使用。另一方面,DeallocatePage()
方法是一个仿真在磁盘上释放页面的无操作,您应该在DeletePgImp()
实现中调用此方法。
BufferPoolManager
的具体实现方法如下:
BufferPoolManager
要维护的数据包括:一个常量缓冲池的大小;一个原子页id记录下一个页面id;一个常量记录哈希表的Bucket
大小;一个存储页面的数组;一个从物理页号映射到缓冲池帧号的可扩展哈希表;一个LRU-K
替换器;一个用来管理并发操作的锁(与Task 2相同,每个操作都在改写公共资源BufferPoolManager
的内容,所以几乎每个操作都需要加锁,后面不特殊说明的话就说明该操作是需要上锁的);一个list
记录空闲帧;一个磁盘管理器来负责将数据向磁盘出读入/写回NewPgImp(page_id)
:该函数在缓冲池中新建一个page,若空闲帧list
非空,则存在空闲帧,可以在空闲帧中存放page,从队尾取出一个空闲帧;若空闲帧list
为空,则不存在空闲帧,先判断是否所有页面均已被占用(pin count > 0
),若是则无法新建,返回false
,若不是则换出一个未被任何进程pin
过的页面(若该页为脏页则需要disk manager
将内容写回磁盘),从页表中删除原page并添加新page并将该页在BufferPoolManager
中进行初始化即可,而且需要注意的是,我们需要在此处对该帧进行RecordAccess
,因为我们对该帧进行了替换操作(无论之前是否在空闲帧list
中)FetchPgImp(page_id)
:该函数从磁盘中调入一个页号为page_id
的页,若该页未在BufferPool
中,需要找到一个帧(优先考虑空闲帧,最大化缓冲池的利用率),找到可换的帧后将页换出(若该页为脏页则需要disk manager
将内容写回磁盘),重新初始化刚刚选取的帧,调用disk manager
将该页换到帧中并设置页表项即可;若该页已在缓冲池中(注:换回页后也同样需要这个操作),该操作需要记录RecordAccess
,设置pin count
表明上级调用正在使用该page
,并将该page
返回即可UnpinPgImp(page_id, is_dirty)
:该函数取消页的pin
状态,is_dirty
参数记录该页是否被修改,首先需要确定该页是否在缓冲池中,若该页不在缓冲池中或pin count <= 0
,则无需取消pin
状态,直接返回false
即可;否则将该页维护的pin count--
即可;若pin count
变为0,则需要设定可被替换状态;若is_dirty
参数为true
,则需要改变该页是否为脏页的状态,需要注意的是,如果is_dirty
为false
则不需要执行任何操作,因为有可能其他进程修改了页面,该进程未进行改变,最后返回true
即可FlushPgImp(page_id)
:该函数的作用是将给定页号的页写回磁盘,若该页不在缓冲池中,则无需做任何操作,直接返回false
即可;若该页对应的帧号不合法也返回false
;否则为合法情况,将页写回磁盘并设置is_dirty
为false
并返回true
即可FlushAllPagesImpl()
:该函数将所有页写回内存,遍历页数组执行FlushPgImp
即可,需要注意的是这个操作无需上锁,因为每个FlushPgImp
都会上锁并进行写操作,而且按照并发执行的语义来说,写回后的页就可以为后续进程所用,无需对写回后的page进行阻塞了DeletePgImp(page_id)
:该函数删除参数页号对应的页,若不在缓存池中则无需删除,直接返回true
即可;若在缓冲池中但仍处于pin
状态,则无法删除,返回false
;若在缓冲池中但处于unpin
状态,则从页表中删除该页,执行初始化并将帧放回空闲帧list
即可
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!