bustub源码解读(存储)

Project-任务解读

本人比较喜欢Andy Pavlo的DJ,而且2024春的15445还没结课,所以学习的课程是2022秋的15445,课程首页地址如下:

https://15445.courses.cs.cmu.edu/fall2022/

课程的具体安排如下:

https://15445.courses.cs.cmu.edu/fall2022/schedule.html

主要代码任务如下:

Lecture #05: Storage Models & Compression 的 Buffer Pool Manager、
Lecture #10: Sorting & Aggregations Algorithms 的 B+Tree Index、
Lecture #14: Query Planning & Optimization 的 Query Execution、
Lecture #20: Database Recovery 的 Concurrency Control。

本来想做2022秋的作业的,但是读着源码发现预留的TODO和课程要求的作业需求不一样(拉代码的时候还以为这个课程不会有作业改动),所以这里只能解读2024春的15445的课程作业了,课程首页地址如下:

https://15445.courses.cs.cmu.edu/spring2024/

到写这篇博客为止,已经发布的作业如下:

Buffer Pool Manager
Storage & Indexes
Hash Index
Query Execution

15-445课程安排
选择读bustub的源码是因为本人最近在学习数据库内核并同时在温习C++,找了一下开源的数据库项目,是C++的、代码量不大的、star比较多的,好像也没有什么合适的。又同时看到了15445这个课有配套开源项目而且语言是C++,试读了一下难度还不高,所以就拿它开刀了。

Buffer Pool Manager

作业要求地址如下:

https://15445.courses.cs.cmu.edu/spring2024/project1/#lru-k-replacer

主要有四个任务:
 详细任务

存储-Storage

磁盘-disk

磁盘管理器-disk_manager

disk_manager-属性
从属性开始阅读,可以看到disk_manager主要的属性是两个IO流,分别负责Log文件和数据file文件的读写。使用log_name_和file_name_存储当前读取文件的名称,并通过num_flushes_记录流的刷新次数,num_writes记录流的写入次数。为了保障多线程使用同一个IO流时的安全,这里还使用db_io_latch_锁来实现多线程,使用flush_log_f来接收异步操作的返回结果。
读完了属性,下面从两个函数入手解读disk_manager的主要责任。
 构造函数
首先是构造函数,形参为一个string类型的引用,即要打开的文件名。打开同名的log文件。获取读写锁,这里的锁使用的是scoped_lock互斥锁,它需要一次性获取全部的锁才会启动,并将自动在多线程中该段函数运行结束时释放全部锁,以避免死锁。打开数据库文件,将写入buffer置空。
 从外存中读一页
接着来读一下ReadPage函数,如上图,这个函数负责通过页号取外存中的Page数据,并写入提供的page_data指针处,也就是写入内存。首先同样获取互斥锁,信号量为db_io_latch_。通过page_id和页面大小计算出偏移量。如果数据库文件不包含该page,则报错,否则从db_io流中跳过偏移量个字节并读取一页数据到page_data。如果不满一页,将page_data中缺少的部分置为0。
写log
最后要读的是写日志文件的函数WriteLog。函数接受两个参数:log_data,表示要写入的日志数据的指针,以及 size,表示要写入的日志数据的大小。assert(log_data != buffer_used); 语句用于确保 log_data 不是之前用过的缓冲区。如果 log_data 等于之前使用过的缓冲区,则会触发断言失败。如果 size 为0,表示日志缓冲区为空,此时函数会立即返回,不会执行后续的写入操作。flush_log_ 被设置为 true,表示需要刷新日志。如果 flush_log_f_ 不为 nullptr,则表示正在进行非阻塞刷新。此时会等待最多10秒,确保之前的非阻塞刷新操作已经完成。num_flushes_ 被递增,表示已经进行了一次刷新操作。接着进行顺序写入操作,将日志数据写入磁盘文件。如果在写入过程中出现了 I/O 错误,会记录调试信息,并立即返回,不会执行后续的操作。最后,调用 log_io_.flush() 强制刷新日志数据到磁盘文件,以确保磁盘文件与缓冲区同步。
TODO:这个方法的目的应该依次执行flush写入操作,不允许同一个文件的并行写入,必须等到完成某线程flush操作后才会允许下一个写入操作执行。但是我却没有找到异步结果flush_log_f_的修改代码,怀疑这里并没有实现。

磁盘-内存管理器-disk_manager_memory

DiskManagerMemory在内存上复制了DiskManager 的功能,并没有实际对外存上的文件进行读取写入,而是模拟了这个过程。DiskManagerMemory主要用于磁盘读写任务调度的性能测试。
 disk_manager_memory属性
可以看到disk_manager_memory主要的属性都是信号量,包括:latency_processor_mutex_、每个Page都拥有的shared_mutex,一个独占信号量mutex_。此外,recent_access_用于记录最近4次访问的不同的页号,data_为实际Page数据。
结合几个函数理解一下,首先是ProcessLatency函数,这个函数的主要功能是模拟页面访问的延迟。
ProcessLatency
uint64_t sleep_micro_sec = 1000;:初始化一个变量 sleep_micro_sec,表示要休眠的微秒数,默认为1000微秒(即1毫秒),用于模拟随机访问的延迟。if (latency_simulator_enabled_) { … }:检查延迟模拟器是否已启用。如果启用了延迟模拟器,则执行下面的延迟计算和休眠操作;否则,直接返回,不进行延迟模拟。std::unique_lockstd::mutex lck(latency_processor_mutex_);:创建一个独占的互斥锁 lck,用于保护共享资源 recent_access_ 的并发访问。这里 latency_processor_mutex_ 是一个成员变量,表示一个互斥锁。for (auto &recent_page_id : recent_access_) { … }:遍历 recent_access_ 中的所有页面ID,这里假设 recent_access_ 是一个存储最近访问页面ID的容器。在循环中,根据访问模式和页面ID的关系,更新 sleep_micro_sec 的值:如果页面ID与最近访问的页面ID位于同一个"块"(block),则将 sleep_micro_sec 设置为100微秒(0.1毫秒);如果页面ID与最近访问的页面ID相邻(顺序访问),则将 sleep_micro_sec 设置为100微秒(0.1毫秒)。退出循环后,释放latency_processor_mutex_ 互斥锁。最后根据计算出的延迟时间 sleep_micro_sec,使当前线程休眠对应的时常。
PostProcessLatency
PostProcessLatency这个函数和上面的模拟延迟组对使用,用于记录和更新最近访问的页号,是上面模拟延迟的数据来源。
读取一页
最后通过一个ReadPage函数来整体捋顺一下。首先根据page_id参数调用模拟延迟函数,根据是否最近访问过来模拟取页的延迟时间。上独占锁mutex_,用于标志开始读取。接着如果是第一次读取,记录线程ID。接下来从data_中读取对应page_id的page的数据,同时通过对page的信号量加共享锁shared_mutex来保障多个线程能共享同一互斥的所有权(shared_mutex 通常用于多个读线程能同时访问同一资源而不导致数据竞争,但只有一个写线程能访问的情形)。之后释放独占信号量mutex_,启动其它线程的读/写函数。最后将页大小的page的数据copy到page_data指针所指内存处,记录这次读访问的块号。
可以看到读函数主要是两层锁(写函数的结构也同样如此)。一层锁保障data_在多线程时不会出现读写冲突的问题,而第二层Page块内容锁则避免了多个线程对page的内容进行修改。

磁盘调度-disk_scheduler

磁盘任务调度是disk模块的核心。任何对磁盘的读写都要经历提出任务->调度器管理任务->任务异步执行->获取执行结果的流程。
首先要阅读的是任务的定义。任务定义如下:
DiskRequest
阅读DiskRequest结构体可以理解,任务分为读、写两种。数据在磁盘的位置通过page_id来定位,具体数据通过*data指针指向的内存存储。此外,callback_用于存储任务执行的结果,比如:读操作完成,写操作失败。
PS:这部分的调度算法需要手动实现,但似乎在课程任务中并没有提到这部分的使用(因为我读的是2022年的Project晕,在2024年的课程作业中任务调度已经被单独提出作为任务了)。
调度任务源码
任务描述
上面是调度任务的头文件和具体的任务描述。这个任务很简单,但实现起来也有多种思路。最简单的思路就是按照提交任务的顺序依次执行。但是这可能会造成堵塞和冷访问的问题。比如上层提出5个对同一个page的读取需求,接着又提交一个对另一个page的读取需求,顺序执行可能会造成对另一个page的读取需求迟迟不能满足。由于我们已经知道了任务序列,这与操作系统的预测式页调度是有区别的,我们可以根据执行队列的操作类型和顺序做适当优化,从而尽可能提高IO的效率。比如操作队列如下(二元组第一个元素标识读写类型,第二个元素标识页号):(Read,1)->(Read,2)->(Read,1)->(Read,2)->(Read,1)->(Read,2)->(Read,3)。由于1、2页的反复读请求中没有任何写操作,所以我们是不是可以通过一个二级缓存队列来存储1、2页的数据而不用每次都去磁盘中通过IO读取数据呢。上面的是一个简单的例子,具体优化思路可能要根据测试用例或者实际场景的特点单独设计(上面这种极端的反复读取操作序列可能是BUFFER替换策略较差导致的,这里就不深追究产生的原因了)。

写回缓存-write_back_cache

这是disk目录下的最后一个文件了,由于写操作的消耗较大,所以bustub单独为写操作设计了一个缓存机制,从而加速写操作的并行性。
注解中也解释了写回缓存的作用,如下:
写回缓存注解
属性
上图是写回缓存类的内部属性,*write_back_pages_是缓存区,在构造函数中被声明为8页大小。free_slot_bitmap_是unsigned char类型,长度为8位,如下图所示。
uint8_t
Add函数通过检测写入缓存区是否已满来判断是否继续执行,如果IsFull函数返回true,即free_slot_bitmap_为11111111(1代表这块缓冲区已经满了,0标识未占用)。同样的,FindFreeSlot函数通过循环右移1位运算,找到最右侧的一个0,把0的位置返回。

 free_slot_bit_map

通过上面的方法,Add函数找到一个空的缓冲区,将page数据copy进去,将slot_map置为1,返回块数据在缓冲区的指针。
构造函数和ADD操作

页-Page

Page-存储的基本单位(示例标准页)

页(Page)是数据库系统内的基本存储单位。 Page 为主存中保存的实际数据页提供了包装器。页面还包含缓冲池管理器使用的簿记信息,例如: pin 计数、脏标志、页面 id 等。
TODO:还没读到Page类具体的使用,等读到buffer时候在这里补充一下。
基本Page的属性定义如下图,其中:char *data_是Page中存储的数据,page_id是这页page的ID,pin_count用于存储取页次数,is_dirty用于判断该页是不是脏页,rwkatch_为读写锁。
 Page-属性
这里定义的Page,同样也是BufferPool管理器与外存交换的基础单位,上面的属性将为Buffer管理(如:页的替换策略)提供信息。
与BufferPool的关系
Page类提供了一系列的工具函数,如下图:
Page函数
其中有三个Get函数,四个负责加和开读写锁的函数,以及两个LSN读写函数,这里着重讲解一下LSN。
LSN定义
LSN的定义如上图,Log Sequence Number的类型是int32_t,32位、4字节长,具有自动递增、全局唯一特性,每一个LSN代表着系统内部产生的一个物理事务,比如写事务。
读LSN的函数调用逻辑图
上面的读SLN的方法,意味着每个Page都会存储在其上运行的最新的事务的LSN码,也就是从Page页起始地址+4字节偏移量的位置处,存储着一个4字节长度的本页最新的SLN码。
虽然Page如此定义,但是经过我查询全部引用后,并没有发现在任何地方使用到了这个SLN码。

TablePage-存储Table数据的Page

bustub在注解中直接给出了这类page的数据组织的格式,如下:
数据Page组织方式
TablePage采用page-slot(不清楚这个存储方式的同学要再去听听课)的方式存储Tuple的数据。在Page的头(Header)部分存储三个定长字段:下一页的id(通过直接存储指针连接下一Page)、本页中的Tuple数量(避免额外扫描操作而可以直接获取数量,减少开销)、本页中已经删除的Tuple数量。同时,通过在Header中将每个Tuple的offset和size存储,以此实现定位每个Tuple。offset是元组相对Page起始地址的偏移量、size是Tuple的长度。
TablePage的属性定义源码解读如下:page_start_定义了一个0长度char数组,用于定位本页数据的起始位置,接着定义了上图中的三个属性(NextPageId…),紧接着定义了tuple_info(即offset、size、TupleMeta)。
 TablePage的属性
这里的实现和设计有些出入,设计上TupleMeta是与TupleData作为一个整体构成一个Tuple的,但是由于元数据TupleMeta中包含了(创建时间、是否删除),其定义如下图。但是因为TupleMeta为定长(时间戳定长、是否删除标记位定长),因此实现上bustub将其和offset、size作为一个三元组共同存储。
 TupleMeta的结构
只看定义有点晕,下面结合一个GetTuple方法解读:
 Table-Page GetTuple方法
首先讲一下参数RID,RID是存储模块标识和索引一个元组的工具,其属性定义如下图。RID主要由:page_id和slot_num_构成,slot_num_存储的是这个元组的逻辑偏移量,也就是是Page中的第几个元组,借此去定位元组。
RID定义
通过输入的RID,可以解析得到slot_num,也就是逻辑上的偏移量。如果逻辑偏移量比这个页面中真实存储的Tuple还多,那就说明这是一个非法读,抛出异常。接着,从TupleInfo三元组序列中读取逻辑偏移量对应的TupleInfo真实数据,并将其分别存为:offset、size、meta。
 TupleInfo的定义
最后,新建Tuple对象tuple,从 page_start_ 数组的偏移位置开始的size长的内存拷贝到 tuple 对象中的 data_ 成员指向的内存地址中,并通过二元组组合返回一个Meta和Tuple的二元组。
此外,Tuple的组织格式也同样在注解中定义了,即:元数据+真实数据。
Tuple格式
设计上其主要包含两项:定长数据-size/变长数据-偏移量、数据的载荷。但是当阅读Tuple源码时,可以看到其构成为rid+data_,分别表示元组的ID、真实数据。Tuple实现
读到这里,几乎可以确定bustub的Page实现的底层存储是行存储格式。更多的函数在此不做过多赘述。感兴趣的可以自行阅读,这部分的代码位于:Identification: src/storage/page/table_page.cpp和Identification: src/include/storage/page/table_page.h。

PageGuard-TODO

完成PageGuard是BufferPoolManager任务的一个子任务。在完成之前,首先要理解PageGuard的职责和功能。
BPM-Task4
PageGuard关系
page_guard.h文件主要的功能是管理页面的访问和锁定,以确保在页面被访问时保持一致性和线程安全性。其中定义了三个PageGuard类:ReadPageGuardWritePageGuardBasicPageGuard
在初始代码中,ReadPageGuard和WritePageGuard中都定义了BasicPageGuard类型的指针,同时ReadPageGuard和WritePageGuard中的几个方法均使用了BasicPageGuard的方法,如下图:
WritePageGuard内部方法

缓存-Buffer

LRUK替换策略-lru_k_replacer

这个替换策略的实现是BPM的第一个子任务,下面为任务描述。其中强调了我们不需要实现时钟替换策略和LRU替换策略。
LRU-K任务描述
要实现功能,首先要明白Replacer和BPM之间的关系。
Replacer
在bustub中,Replacer负责管理frame,而frame其实是用于缓存页面page的“框”。为什么要定义frame,是因为页面缓存替换策略需要额外记录页面的使用信息,比如单纯的LRU可能需要记录页面的最近访问时间戳,用于计算替换优先级。上图是我绘制的Replacer、Frame、Page之间的关系,但是bustub将实际的Page与Frame之间的映射关系在BPM中通过一个map定义,Replacer中仅存储了frame的替换所需信息,如(状态、历史访问时间戳)。
通过上面的方法,BPM将页面缓存替换的任务完全下放至具体的Replacer,在尝试从磁盘中读取不在bufferpool中的Page时,首先向Replacer查询有无空或者可置换的frame,如果有,才可以申请磁盘调度(向disk_scheduler发送调度任务),异步获取数据后将page与frame关联起来。
LRUK的算法效果,引用课程任务的一句话简单概括如下:

The LRU-K algorithm evicts a frame whose backward k-distance is maximum of all frames in the replacer. Backward k-distance is computed as the difference in time between current timestamp and the timestamp of kth previous access. A frame with fewer than k historical accesses is given +inf as its backward k-distance. 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)

LRUK算法会扔掉一个页面,这个页面的后向k距离是所有页面的后向距离中最大的。后向k距离就是当前时间戳和k次之前访问的时间戳的差。对于访问次数少于k的key,给定一个+∞值为其后向k距离。如果有多个页面的后向距离都是+∞,那就选择一个时间戳最早的进行替换。这里推荐几篇LRU-K的博客:

https://juejin.cn/post/6844904129500823559
https://zhuanlan.zhihu.com/p/619198634

LRU-K算法主要解决了LRU替换算法的热点数据被批量一次读替换的问题。通过K长的历史读取时间戳,每个缓存块的读取频率可以被K-Distance表示出来,越频繁读的缓存块的K-Distance越小,就越不容易被置换。
任务

替换策略-replacer

文件位于src/include/buffer/replacer.h,为一个虚拟类。
Replacer
Replacer定义了一个Replacer替换策略应具有的基础功能。其中,Victim方法为frame换出方法的实际实现,Pin方法标识一个frame的正在使用次数,Unpin方法表示对frame的一次使用结束,Size函数返回可以被换出的frame的数量。
这个Replacer实际并没有被使用。

缓存池管理器-buffer_pool_manager

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值