本系列文章将以倒序的顺序回顾自己在写Spring23的Projects时的主要思路以及遇到的问题,往期文章:
CMU15-445 Project 4 Concurrency Control个人详解 - 嘿喂GO!
CMU15-445 Project 3 Query Execution详解 - 嘿喂GO!
前言
很快啊,一下子CMU15-445系列的文章已经总结到第四篇了。在做完所有的Projects后回过头来看P1,确实能感觉到P1是最朴素的一个Project。但在当时做P1时,给我的感觉并不是最容易的。P1是Spring 2023的所有Projects中最底层的一个,所有后续的Projects的实现都以P1的内存池Buffer Pool为基础,所以请一定确保P1正确实现后再开始后续的Projects。
Project 1的内容也很直白,不像后续的Projects在开始实现前需要了解非常多的前序知识。P1要求我们实现一个支持LRU-K策略的内存管理器缓冲池Buffer Pool Manager(后文简称bpm),并将相关函数封装为Page Guard
,实现page的RAII机制。后续Projects的任何操作,包括索引、SQL执行、多线程并发等,在请求数据时都向bpm请求。可见虽然bpm很基础,但也非常非常重要。
在开始Project 1之前,建议观看课程组Lecture1~6的视频。
再次强调,不要公开自己的作业代码,尊重Mr.Pavlo与助教们的劳动成果~本文也仅会展示主要思路,不会展示个人代码(课程组Starter Code除外)。
Task 1 - LRU-K Replacement Policy
我们知道,在不使用时,数据都是存在磁盘的;而只有当需要对数据进行操作时,数据才会被拿到内存中来进行访问。具体的细节可以了解一下操作系统的虚拟内存与内存管理相关的内容。数据在磁盘中以页为单位进行存储,每一页被bpm拿到内存中后,我们称之为页框frame。需要注意的是DBMS的内存管理并不完全依赖操作系统,用课上的话来说就是“没有什么比DBMS自己更了解数据”,所以DBMS有自己的内存管理系统,在结构上bpm会占用OS的实际物理内存的一部分。
bpm能容纳的frame数量是有限的,当frame满了以后,如果要从磁盘disk中fetch一个新的页面至内存,则需要选择一个frame将其内容替换为新的page,这个选择的过程就是页面置换算法。常见的页面置换算法包括FIFO、NRU、Clock算法等,而我们Task1要实现是LRU-K算法。
LRU-K算法可以认为是LRU的进阶版:对于LRU,当我们需要置换某一个页面时,选择所有页面中上一次访问时间距离当前时刻最久的页面进行置换,LRU-K则是选择所有页面页面中过去第K次访问时间距离当前时刻最久的页面进行置换。换句话说,LRU本质上就是LRU-1算法。具体的LRU-K实现思路如下:
- 对于每一个frame,维护一个访问队列,用于保存过去k次访问的时间戳,大于k次访问时需弹出队头,即满足
q.size() <= k
。 - 对与每一个frame,如果其队列的size小于k,那么,初始化其
k-distance
为+inf
。 - 若frame的队列size == k,那么,其
k-distance
为q.front()
,即过去第K次访问的时间。 - 当需要进行页面置换时,在所有可置换的frame里,查看是否有
k-distance
为+inf
的frame。- 若有,从所有
+inf
frame中按照FIFO的策略进行置换。 - 否则,从所有frame中,选择过去第k次访问距离当前时刻最久的进行置换。可直接使用队列的队首获得。
- 若有,从所有
具体代码实现中,4.1有一点需要注意一下:无论是Handout还是Starter Code的注释,都说对于+ inf
的frame,采用LRU的策略进行置换:
When multiple frames have +inf backward k-distance, the replacer evicts the frame with the earliest overall timestamp (i.e., the frame whose least-recent recorded access is the overall least recent access, overall, out of all frames).
但实际的Test Case里要求的是使用FIFO的方式进行置换(个人认为使用LRU合理一些),所以,最终,一句话总结就是:优先使用FIFO置换不满k次访问的页面,再从访问次数大于等于k次的frame中对比过去第k次访问的时间进行置换。
我们再稍微看一下涉及到的两个类:
class LRUKNode {
private:
/** History of last seen K timestamps of this page. Least recent timestamp stored in front. */
// Remove maybe_unused if you start using them. Feel free to change the member variables as you want.
std::list<size_t> history_;
size_t k_;
frame_id_t fid_;
bool is_evictable_{false};
...
}
class LRUKReplacer {
private:
// TODO(student): implement me! You can replace these member variables as you like.
// Remove maybe_unused if you start using them.
std::unordered_map<frame_id_t, std::shared_ptr<LRUKNode> > node_store_;
size_t current_timestamp_{0};
size_t curr_size_{0};
size_t replacer_size_;
size_t k_;
}
这里LRUKNode
的history_
即为存储时间戳的队列,存储的为current_timestamp_
——我们每次access一个frame时,都让current_timestamp_
自增,并更新相应的history_
,以模拟访问时间的先后关系。根据以上分析,这里在判断置换时,无论是对于+inf
还是非+inf
的node,我们都是用其history_
的front()
进行判断即可。
此外,curr_size_
表示可置换的frame数量,即is_evictable_
为true的node数量,注意与replacer_size_
区分。RecordAccess
访问一个不存在的frame id时,需要创建新的并Insert。
Task 1只关注替换,即只关注非空frame里哪些可置换。至于在进行置换时是否还有frame为空,LRUKReplacer
不负责,这一点由Task 2的Buffer Pool调度。总体而言,Task 1只需要搞明白LRU-K算法的原理即可,实现起来并不复杂。
Task 2
Task1主要实现了页面置换的算法,但具体地从磁盘fetch页面、将dirty page写回磁盘等操作则需要bpm实现。这里区分一下frame id与page id:在磁盘中的页面我们称之为page,其page id
是递增式的(理论上只要磁盘有多大,page id就能递增到多大);而frame id则有一定的范围,最大不能超过bpm的frame size限制。因此,我在实现时使用了两个map来对frame id
与page id
进行双向映射。此外,我还增加了一个成员evictable_number_
用于表示可置换页面的数量。
Task2要求我们实现bpm诸如fetch、unpin、newPage、delete、flush等功能,概括起来就是打通内存与磁盘。这里磁盘的相关接口课程组已经为我们写好,我们只需要专注于bpm即可。在Handout里提到,bpm不关心page的内容或结构,但作为开发者,我们需要了解。(可以看一下Lecture视频)另外,建议熟悉一下bpm.h
以及page.h
文件,代码量不多,逻辑清晰,熟悉起来很快。
-
FetchPage
:FetchPage要求我们根据给定的page id,在内存中拿到对应的page。如果page在frame中,则直接拿;否则,需要从disk拿。- 首先,根据map判断是否在内存中,如果不在,且没有frame空闲、没有可置换的页面,返回null。
- 若page在内存中,更新该page的access history,更新pin count,并根据pin count前后的值更新
evictable_number_
,返回page。 - 若不在内存中,我们需要将对应的page fetch至内存中:
- 先确定取得的page放到哪个frame:若有空闲frame,则选择一个frame id用于存放;否则,通过LRU-K选择需要被置换的frame,根据
is_dirty_
判断是否需要将该frame的page在置换前写回disk。 - bookkeeping:更新map以及
evictable_number_
。 - 清空frame的page数据(
ResetMemory
),调用disk相关接口将page读取至frame。 - 调用LRU-K相关函数更新frame的属性,返回。
- 先确定取得的page放到哪个frame:若有空闲frame,则选择一个frame id用于存放;否则,通过LRU-K选择需要被置换的frame,根据
-
NewPage
:NewPage
与FetchPage
很像,不同之处在于逻辑上前者是创建一个新的page并fetch,后者则是fetch一个已有的page。主要思路为调用AllocatePage
构建一个新的page id,之后与Fetch的步骤三相同。 -
UnpinPage
:这里先写一下我对一下pin与lock的理解:一个frame被pin时,表示不可置换,但能够访问;而一个frame被lock时,不仅不可置换,同一时刻只能一个对象访问。- 首先判断需要unpin的page是否在内存中,若不在,直接返回false。
- 根据map拿到page,并查看对应的
pin_count_
是否为0。若否,返回false。 - 对
pin_count_
减一,并根据前后状态更新is_evictable_
与evictable_number_
。 - 更新page的
is_dirty_
。这一点注意要结合实际情况,不能简单地set为传入的参数,举个例子:一个事务修改了page1,之后事务Unpin page1,set此时page1的is_dirty_
为true。之后另一个事务读取page1,读取结束后Unpin,对应的dirty形参为false。思考一下,此时,能否简单粗暴地将is_dirty_
直接set为false?
余下的函数实现起来都不复杂,思路都很清晰,注意及时bookkeeping即可(FlushPage不需要)。所有的函数在实现时我都使用了unique_lock
来保证线程安全。
Task3 - Read/Write Page Guards
从22Fall的讨论区以及网上的相关讨论可以看到,在Project 2的B+树中,由于没有及时Unpin相关的page,导致出现了各种问题。这一问题也使得22Fall的Project 2难度远远大于其他的Project。为此,课程组今年在Task3中增加了一个PageGuard
的类,用于实现RAII。简单说就是实现三个PageGuard
的相关接口(构造、析构、Drop)等,并在bpm中进行封装,以便在外界通过bpm获取page时能够及时Unpin,提高bpm的存取效率以及系统性能。
BasicPageGuard
:BasicPageGuard
有三个成员:bpm、page以及一个is_dirty_
标识位,我们需要实现移动构造函数、移动赋值运算符、Drop函数以及析构函数。BasicPageGuard(BasicPageGuard &&that)
:思路很明确,参数初始化列表进行赋值就行,注意初始化后需要将that
的成员赋空。operator=(BasicPageGuard &&that)
:与移动构造相似,不同之处在于赋值前this的成员需要先赋空(实际上即是Drop的内容)。Drop()
:Unpin page并赋空。- 析构:直接调用
Drop()
。
Read/Write Guard的成员只有一个BasicPageGuard
,需要实现的函数均可调用BasicPageGuard
的实现,不同在于析构时解锁——这里的上锁我是在bpm的封装里实现的。最后注意一下空指针的处理。
后记
总体而言,Project 1的复杂度不及后面的Projects,坑也没有后面的Projects那么多。但Project 1是所有Projects中唯一给了我较大顿挫感的一个Project。现在回过头来分析一下,归根结底还是当时对整个代码没有一个全局的了解,每写一部分代码都想着赶紧结束当前的Task,想着赶进度,跟心态有很大关系。但,无论如何,写完P1后,收获还是有的。
最后,本篇文章花了将近三个小时,如果能够帮助到你,那么就是值得的~