CMU_15-445_PROJECT#0&1
写在前面
正如CMU 15-445的主讲教授所说,这门课并不会教你如何使用数据库去构建应用程序或网站,也不是教你如何去部署和管理数据库的课,这门课的核心课程是有关数据库管理系统的设计与实现。
PROJECT #0 - C++ PRIMER
这个热身实验只是让我们把项目文件下载到本地,配置好相关的git,检验一下自己的C++能力,因此对其简要说明。
在这个项目中,您将实现三个类模板:Matrix
、RowMatrix
和RowMatrixOperations
。这些矩阵是简单的二维矩阵,必须支持加法、矩阵乘法和简化的通用矩阵乘法(GEMM) 运算(我们只要修改一个文件:p0_starter.h)。
这个就不多说了。
PROJECT #1 - BUFFER POOL
以前我从来没思考过buffer和cache的区别,为什么叫buffer pool而不叫cache pool,在做这个实验时,突然想到这个问题了,现记录如下(与实验无太大关系):
- buffer与cache操作的对象不一样
- buffer(缓冲)是为了提高内存和硬盘之间的数据交互的速度而设计的。
- cache(缓存)是为了提高CPU和内存之间的数据交互速度设计的,也就是平常见到的一级缓存、二级缓存等。
- CPU在执行程序所用的指令和读取数据都是针对内存的,也就是从内存中取得的。由于内存读写速度慢,为了提高cpu和内存之间数据交换的速度,在cpu和内存之间增加了cache,它的速度比内存快。
- 缓冲是根据磁盘的读写设计的,把分散的写操作集中进行,减少磁盘碎片和磁盘的反复寻道,从而提高系统性能。
- 或者可以这样说,buffer是系统两端处理速度平衡时使用的,而cache是系统两端不匹配时的一种折衷策略。我们在组原中学习过TLB(translation lookaside buffer),这玩意名字虽然叫buffer,但其实是一种cache。
所以说,这里之所以叫buffer,那是因为它直接和磁盘进行数据交互。
在Free命令中显示的buffer和cache,它们都是占用内存:
buffer : 作为buffer cache的内存,是块设备的读写缓冲区,更靠近存储设备,或者直接就是disk的缓冲区。
cache: 是内存的缓冲区 。
补充,linux中buffer cache和page cache的概念和二者的区别:
Page cache在linux读写文件时,它用于缓存文件的逻辑内容,从而加快对磁盘上映像和数据的访问。具体说是加速对文件内容的访问,buffer cache缓存文件的具体内容——物理磁盘上的磁盘块,这是加速对磁盘的访问。
简单说来,page cache用来缓存文件数据,buffer cache用来缓存磁盘数据。在有文件系统的情况下,对文件操作,那么数据会缓存到page cache,如果直接采用dd等工具对磁盘进行读写,那么数据会缓存到buffer cache。
由于我对文件系统所知甚少,后面再继续补充。
在传统的数据库管理系统中,数据的存储介质是磁盘,文件是以page的形式存储在磁盘上的。对于结构化数据来说,一条记录会被保存在磁盘上的某个数据块中。在对某条记录进行处理时,可以通过代表该记录地址的page_id从磁盘上获取该记录,随后系统会把存储有该条记录的数据块从磁盘读到缓冲区,缓冲区分为多个frame,每个frame可以保存一个磁盘块,再从缓冲区将该条记录读到线程或事务的工作区进行处理,处理结束后将更新的记录写回缓冲区中的数据块,再由数据库管理系统将修改过的数据块写回到磁盘上。
在buffer pool中,有一个又一个的chunk块,我们称之为frame,我们可以将page放置在其中,一个frame对应一个page,这就是很简单的一对一拷贝,page可以以它们想要的任何顺序放置在frame中,如下图所示:
在此之上,我们需要一个额外的indirection层,如果我想要某个特定的page,通过这个indirection层我就知道它在哪个frame中——也就是pagetable,pagetable就是一个hash表,它用来跟踪内存中有哪些page,如果我们想找一个特定的page,通过page表和page id,我们就可以知道这个page在哪个frame中。数据库系统必须维护一些额外的元数据,以此来跟踪当前buffer池中发生了什么。下面是对这些元数据的详解:
dirty_page——这个数据告诉我们当我们从磁盘中读取到这个page后,这个page是否被修改过。
pin_count——引用计数,它用来跟踪想要使用该page的当前线程数量或者是正在查询该page的数量,这意味着我们并不想该page写出到磁盘上,因为我可能回去对它进行更新。从该page被放入内存到我对它进行更新这段时间内,我不想让该page被移除或者是交换回磁盘,这也将阻止我们去移除那些还未被安全写回磁盘的page。这也是我们后面的Pin函数想要表达的意思。
基本上来说,就是管理我们自己的内存,但我们也要去跟踪事务或查询是如何修改page的,在我们想做任何事之前,我们必须保护pagetable免受其他人污染或者是被其他人覆盖里面的东西。因此我们会用到Latches,下面是Locks和Latches的区别。
在数据库的世界中,lock是某种更高级的逻辑原语,它会去保护数据库中的逻辑内容,例如,tuple、表、以及数据库,事物会在运行的时候去持有这个lock,这就意味着可以有多个查询,数据库系统可以为我们提供这些东西,它能将这些暴露给我们这些开发人员。
Latch是一种底层保护原语,我们使用它来保护数据库系统内部的关键部分,比如,保护数据结构和保护内存区域,我们在执行操作的期间内,会持有这些Latch,用来保护某些东西,比如说,如果我去更新我的pagetable,我会在我要去修改的地方加上一个latch,修改完后,我会将它释放。
后面教授就讲得很绕了,甚至spin mutex都讲出来了,我先去查查资料,后面填坑。
另一个我们需要去区分的就是page目录和pagetable,二者之间的区别我在MIT6.S081 labs的博客里面也说过,我们还是来听听Andy教授怎么说:
page目录的作用是用来找到page在我们数据库文件中的位置,比如我们想要找到page_no_1,它会告诉我们要找的那个page在这些数据库文件中的什么地方,page中的offset值有哪些,所以我们将page目录写在磁盘上,如果系统崩溃了,恢复后我们想知道该在哪里可以找到我么拥有的page,就可以访问page目录找到。
pagetable是内存中的映射,它将page_id映射到它们在buffer池中frame的位置。
当我们想将一个page放到内存中,但是内存中没有位置了应该怎么办呢?在替换策略中,我们最关心的就是正确性,如果说某个数据我们并没有真正地使用完,那么我们不想将它写出或移除,我们要确保我们所移除的page是在未来不太会被用到的那些page。
总而言之,为什么要使用buffer pool manager呢?
- 如果没有缓冲池,那么数据都位于磁盘上,每次访问数据都要将其从磁盘读取到内存,下一次访问相同的数据时,还需要从磁盘读取,非常耗时。
- 现实中内存是有限的,如果我们在内存中缓存了page 1,2,3,已经存满内存了现在想访问page4,就要用到buffer pool manager,通过LRU机制进行替换。
CLOCK算法(了解)
clock算法会给每个page设置一个标志位,唯一需要跟踪的就是每个page的标志位(referrence bit),它会告诉你自上次检查过该page之后,这个page是否被访问了。我们用环形队列来组织page,就像是一个clock,用一个能够旋转的指针去检查标志位是被设置为1还是0,如果是0,我们就可以知道从上次检查过之后该page就没有被访问过,因此我们就可以将该page从环形buffer中移除。
我们将为BusTub DBMS构建一个面向磁盘的存储管理器。这样的存储管理器假定数据库的主要存储位置在磁盘上,project_1的任务是在存储管理器中实现缓冲池,缓冲池负责将物理页面从主存移动到磁盘,值的注意的是,DBMS需要支持大于系统可用内存量的数据库;多个线程将同时访问内部数据结构,因此我们需要确保他们的关键部分受到锁存器的保护。
我们可以维护一个单独的数据结构,比如队列,它根据page的时间戳进行排序,在某个时刻,有人对一个page进行读和写,我们只需要把该page从队列中拿出来,然后处理完放到队列的末尾,因为队列是先进先出的。
一句话,实验一的目标便在于主动管理磁盘中的页在内存中的缓存,从而最小化磁盘访问次数、最大化相关数据连续。
我们需要在存储管理器中实现以下组件:
- LRU更换策略
- 缓冲池管理器实例
- 并行缓冲池管理器
LRU更换策略
LRU(Least recently used,最近最少使用)算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。也就是说我们这里定义一个lru_list
,我们把pin_count为0的frame_id存放在lru_list,然后每次按照顺序出队(victim),如果在队列中的时候,这个frame_id被访问了,那么我们就从队列中删除它,直到它的pin_count再次为0,再次将它入队,循环往复。这样就达到了LRU的机制。
我们先来分析一下include/buffer/replacer.h
中定义的父类Replacer。Replacer 是一个跟踪页面使用情况的抽象类。我们要实现以下方法:
Victim(frame_id_t*):Replacer跟踪与所有元素相比最近访问次数最少的对象并删除,将其删除页号存储在输出参数中,并返回 True。如果 Replacer 为空则返回 False。
Pin(frame_id_t):在将page固定到BufferPoolManager中的frame后,应调用此方法。它应该从LRUReplacer中删除包含固定page的frame。
Unpin(frame_id_t):当页面的引用计数变为 0 时,应该调用此方法。这个方法应该将未包含固定 page 的 frame 添加到 LRUReplacer,表示可能会被替换掉。(注意,需要判断是否超出了内存大小,如果超过了,则删除较新的页面,然后再添加。)
Size():此方法返回当前在LRUReplacer中的页面数。
在LRUReplacer中我们要实现一个队列std::list<frame_id_t> lru_list_;
,这个队列用来维护page写入frame。我们可以将LRUReplacer当作是一个替换器,在初始状态下,LRUReplacer应当被初始化为不包含任何frames。当page加入到buffer pool的某个frame后,调用Pin删除在LRU队列中的frameid,表示该frame已经不应该成为victim;Unpin则将未固定page的frame加入到LRU队列,表示该frame使用情况比较差(页面的引用计数变为0);victim方法则是将LRU队列中最后一名frame_id弹出(在另外的对应的函数中会删除对应页面)。LRUReplacer的最大页数与缓冲池的大小相同(这样做是为了管理缓冲池中的所有frames),因此我们在LRUReplacer的构造函数中将capacity的大小设置为参数num_pages,如下所示:
1 |
|
当我们将page加入到BufferPoolManager中的frame后,需要从LRUReplacer中删除相应的frame。Pin函数如下所示:
1 |
|
值得注意的是,我们在对LRUReplacer进行操作时应该上锁,以避免内存中特定的数据结构不会因为并发访问而导致错误。其实我们在每个函数前面用unique_lock是最稳妥的,既不会死锁,也保证安全。
当页面的引用计数为0时,应该调用Unpin,这个方法应该将未固定 page 的 frame 添加到 LRUReplacer,表示有被victim的风险。代码如下所示:
1 |
|
Replacer跟踪与所有元素相比最近访问次数最少的对象并删除,将其删除页号存储在输出参数中,并返回 True。如果 Replacer 为空则返回 False。代码如下:
1 |
|
缓冲池管理器实例
接下来,需要在系统中实现缓冲池管理器实例,该BufferPoolManagerInstance负责从DiskManager中读取数据库页并将它们存储在内存中。
我们来看看该类的数据结构:
pages_
,保存在buffer pool中的page数组;disk_manager
,磁盘管理器;log_manager
page_table_
,页表,保存了page_id到frame_id的映射;replacer
,查找未固定的页面进行替换;free_list_
,没有被page填充的frame list。
刚看到这个任务的时候无从下手,还是一个函数一个函数分析。
FlushPgImp
函数,我们要将在内存中的page内容刷新到磁盘。题外话:由刷盘想到的——面试官问我,刷盘时机怎么选择,就比如微博的热榜tag数据,本来这个tag不是热数据,但是这个tag数据可能激增,激增之后刷盘策略要怎么改变来适应,热度又会慢慢降低,这个时候又怎么改变刷盘策略,主要是刷盘时机。
1 |
|
FlushAllPgsImp
函数,刷新所有页面到磁盘。
1 |
|
NewPgImp
函数:在缓冲池创建一个新页面。如果
- 确保你调用
allocatepage
函数; - 如果所有在buffer pool的page都pinned,那么返回nullptr;
- 从空闲列表或替换器中选择一个受害者页面 P。 始终首先从空闲列表中选择。
1
2
3
4
5
6
7
8page_id_t BufferPoolManagerInstance::AllocatePage() {
const page_id_t next_page_id = next_page_id_;
//一旦拿出一个next_page_id,就加上num_instances,到下一个next_page_id
next_page_id_ += num_instances_;
ValidatePageId(next_page_id);
return next_page_id;
}那么我们看具体的代码实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43Page *BufferPoolManagerInstance::NewPgImp(page_id_t *page_id) {
// 0. Make sure you call AllocatePage!
// 1. If all the pages in the buffer pool are pinned, return nullptr.
// 2. Pick a victim page P from either the free list or the replacer. Always pick from the free list first.
// 3. Update P's metadata, zero out memory and add P to the page table.
// 4. Set the page ID output parameter. Return a pointer to P.
std::unique_lock<std::mutex> lck(latch_);
bool all_pinned = true;
for (size_t i = 0; i < pool_size_; ++i) {
if (pages_[i].pin_count_ <= 0) {
all_pinned = false;
break;
}
}
if (all_pinned) {
return nullptr;
}
frame_id_t frame_id=-1;
if(!free_list_.empty()){
if(!replacer_->Victim(&frame_id)){
return nullptr;
}
}else{
frame_id=free_list_.front();
free_list_.pop_front();
}
*page_id=AllocatePage();//
Page *page = &pages_[frame_id];//我们找到pages序列中对应的要被替换的一帧
//disk page->pool page
if (page->IsDirty()) {//如果这一页数据是dirty,先把这一页数据写回去
disk_manager_->WritePage(page->page_id_, page->data_);
page->is_dirty_ = false;
}
page_table_.erase(page->page_id_);
if (*page_id != INVALID_PAGE_ID) {
page_table_.emplace(*page_id, frame_id);
}
page->ResetMemory();
page->page_id_ = *page_id;
replacer_->Pin(frame_id);
page->pin_count_=1;
return page;
}- 确保你调用
FetchPgImp
函数,从缓冲池中获取请求的页面,我们可以从官方注释中具体了解这个方法要实现的任务:- 在页表中寻找请求页面P;
- 如果页面P存在,调用Pin方法后返回;
- 如果页面P不存在,则从空闲列表或替换其中查找替换页R;
- 将脏页写回磁盘;
- 从页表中删除R,插入P;
- 更新P的元数据,从磁盘读入页面内容,返回一个指向P的指针。
- 在页表中寻找请求页面P;
代码如下,具体解析在注释中:
1 |
|
UnpinPgImp函数
:这个函数就是完成对该页面操作之后,进行Unpin操作。- 如果一开始
pin_couter==0
,直接返回。 - 如果这个页面的
pin_couter>0
,我们直接返回。 - 如果这个页面的
pin_couter==0
,我们需要给它加入到LRU_Replacer中。因为没有人引用它,所以它可以成为被替换的候选人。
- 如果一开始
其代码如下:
1 |
|
DeletePgImp
函数:从缓冲池删除一个页面,如果页面存在但无法删除,则返回 false,如果页面不存在或删除成功,则返回 true。- Make sure you call DeallocatePage!
- Search the page table for the requested page (P).
- If P des not exist, return true.
- If P exists, but has a non-zero pin-count, return fale. Someone is using the page.
- Otherwise, P can be deleted. Remove P from the page table, reset its metadata and return it to the free list.
代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29bool BufferPoolManagerInstance::DeletePgImp(page_id_t page_id) {
// 0. Make sure you call DeallocatePage!
// 1. Search the page table for the requested page (P).
// 1. If P does not exist, return true.
// 2. If P exists, but has a non-zero pin-count, return false. Someone is using the page.
// 3. Otherwise, P can be deleted. Remove P from the page table, reset its metadata and return it to the free list.
std::unique_lock<std::mutex> lck(latch_);
auto iter = page_table_.find(page_id);
if(iter==page_table_.end()){
return true;
}
if(pages_[iter->second].pin_count_>0){
return false;
}
DeallocatePage(page_id);
frame_id_t frame_id=page_table_[page_id];
auto page=&pages_[frame_id];
if(page->IsDirty()){
disk_manager_->WritePage(page->page_id_,page->data_);
page->is_dirty_=false;
}
page->ResetMemory();
page->page_id_=INVALID_PAGE_ID;
free_list_.push_back(frame_id);
page_table_.erase(page_id);
return true;
}
并行缓冲池管理器
正如上个任务中看到的,每一个缓冲池都有一个锁来保证线程安全。这可能导致性能问题,因为每个线程在与缓冲池交互时都会争夺该锁。一个可行的解决方案是同时维护多个缓冲池,每个缓冲池都有独立的锁。
ParallelBufferPoolManager是一个包含多个bufferpoolmanagerinstance的类。对于每个操作,其都会选择一个实例并调用该实例。
我们使用给定的page_id来确实使用哪个特定的实例。如果我们有多个实例,则需要某种方法将page_id映射到[0, num_instances)中。对于本项目,我们将使用模运算,即page_id mod num_instances来将给定的page_id映射到正确的范围。
当ParallelBufferPoolManager初始化的时候,他的起始索引应该是0。此后每创建一个新的页面,都应从起始索引开始尝试每个实例直到某个成功。然后将起始指数加1。
确保当你创建单独的BufferPoolManagerInstances时,你调用的构造函数应使用uint32_t num_instances和uint32_t instance_index,来保证页面id被正确地创建。
上面是机器翻译的,我来总结一下:也就是说,现在我们可以在系统中搞多个缓冲池,每个缓冲池都有自己的锁,这样来实现并行。那么如何确定我们要查找的page位于哪个缓冲池呢,我们可以根据某种映射方式,也就是说,我们可以用page_id%num_instances来确定page_id是位于哪一个缓冲池实例。
我们还是先来看看ParallelBufferPoolManager
类中的数据结构,如下所示:
size_t pool_size_
:缓冲池的页数uint32_t num_instances_ = 1
:实例的数量uint32_t instance_index_ = 0
:实例的索引
有了上面的讲解,那么下面两个函数的实现就很容易了:
1 |
|
对于缓冲区实例的操作也很简单了,我们只需要在获得page_id参数之后用GetBufferPoolManager
,来知道是在哪一个实例当中,就可以了,代码如下所示:
1 |
|
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!