15445 数据库系统(24Fall实验)
目前进度,P0 ,P1 完成
可能不会包含详细的代码逻辑,只是对错误和易错的地方进行总结,对在探索过程中获得的经验进行分享。
Prepare
Lab 源码仓库
GitHub - cmu-db/bustub: The BusTub Relational Database Management System (Educational)
你可以从 releases 下载对应的源码
课程首页
CMU 15-445/645 :: Intro to Database Systems (Fall 2024)
前置课程总结请看之前文章。
环境配置略,由于之前有现成的几乎没有配置()
clangd+lldb/gdb+clang vscode 一套反正是。
上班用的 clion 也很爽(现在还免费了)。

碎碎念
- 不能理解的时候可以根据 test 样例来模拟
- discord 有很多有用的讨论(百思不得其解的时候来看看吧
- 建议关键路径打些日志,不过日志有时候也会导致错误
- 提交的时候需要运行
python3 gradescope_sign.py来签名
P1 Buffer Pool Manager
可以参考的文章
# CMU Fall 2024 15-445 P1&P2[优化P2]
T1
1. 访问次数达 K 次的帧
- 驱逐策略:根据后向 k 距离(Backward K-Distance)决定,即当前时间戳与第 K 次历史访问时间戳的差值,选择后向 k 距离最大的帧进行驱逐。这类似于传统 LRU 的变种,但关注的是第 K 次访问的时间间隔,而非最近一次访问。
- 示例:若某帧的访问历史为时间戳
[t1, t2, t3](K=3),当前时间为t4,则后向 k 距离为t4 - t1。距离越大,越优先被驱逐。
2. 访问次数未达 K 次的帧
- 驱逐策略:==FIFO ==(LRU 在测试过程中会有问题)
- 示例:若两个帧的访问次数均不足 K 次,且访问时间分别为
t1和t2(t1 < t2),则优先驱逐时间戳为t1的帧。
一个测试样例,搬运工。
TEST(LRUKReplacerTest, SecondSimpleTest) {
bustub::LRUKReplacer lru_replacer(7, 3);
// [4, 3, 2, 1]
lru_replacer.RecordAccess(1);
lru_replacer.RecordAccess(2);
// 3的最早访问时间
lru_replacer.RecordAccess(3);
// 4的最早访问时间
lru_replacer.RecordAccess(4);
// [4, 1, 2, 3]
lru_replacer.RecordAccess(1);
lru_replacer.RecordAccess(2);
lru_replacer.RecordAccess(3);
// [4, 3, 1, 2]
lru_replacer.RecordAccess(1);
lru_replacer.RecordAccess(2);
// 把1,2,3,4都标记为Evictable
lru_replacer.SetEvictable(1, true);
lru_replacer.SetEvictable(2, true);
lru_replacer.SetEvictable(3, true);
lru_replacer.SetEvictable(4, true);
// 此时4被访问过1次,3被访问过2次, 4和3的访问次数都小于k
// 但是3的第一次访问时间早于4, 所以被淘汰的应该是3,而不是4
auto frame_id = lru_replacer.Evict();
ASSERT_EQ(3, *frame_id);
}
注意不要修改 DiskRequst 结构…不然测试会 failed。
还想异步刷盘来着
T2
构造函数初始一个线程StartWorkerThread 根据Channel(线程安全的消息队列)取出DiskRequest,根据消息类型调用DiskManager 进行读写
T3
坑多到无力吐槽,理解能力太差,有些我一时真没理解它要做什么…
第一次提交,只有惨淡的47.0 分…
笔者实习多是追求高性能的简单并发,上锁的机会比较少,追求无锁更多…
菜就多练()
错误
部分错误是跟着其他错误一块出现的,只解释最后一次出现的解决方案
LRUKReplacerTest.Evict (0/4)
访问次数未达 K 次的帧 使用 FIFO
LRUKReplacerTest.ConcurrencyTest (0/5)
忘记上锁
BufferPoolManagerTest.PagePinHardTest (0/4)
BufferPoolManagerTest.DeletePageTest (0/4)
忘记replacer_->Remove…
BufferPoolManagerTest.IsDirtyTest (0/4)
WritePage Drop无需Flush,无需设置Dirty 为true
BufferPoolManagerTest.ContentionTest (0/2)
打日志导致并发错误?
BufferPoolManagerTest.EvictableTest (0/3)
BufferPoolManagerTest.ConcurrentWriterTest (0/5)
死锁
Program exited with -1 in XXXs (timed out after XX secs, force kill)
统一归类
BufferPoolManagerTest.ConcurrentReaderWriterTest (0/6)
Frame 驱逐。
BufferPoolManagerTest.StaircaseTest (0/4)
1、判断过时,导致同时进入多个线程。
2、FetchPage不能加锁?
思考
和 23Fall 的区别在于需要 bpm_latch + frame_latch 结合使用。
对于上锁简单的总结:
- 死锁避免:
- 死锁的四个必要条件:互斥、不可抢占,占有且等待和循环等待。前两个破坏起来比较麻烦,而后两个就相对简单。
- 如果一时间只占用一个锁,不会死锁,我们更应该思考是否正确。
- 两个线程,一个持有 A 想要 B,一个持有 B 想要 A 的情况是典型的顺序问题。
- 有时候上多个锁是必须的。
- 多次上锁,多次解锁请使用递归锁。在这个 Task 我建议是 bpm 使用递归锁,因为线上测试样例你永远无法预测…避免使用递归锁请给每个函数创建一个上锁和无锁版本。
- 正确性:
- 对于可能不止一个线程读写的数据我们需要加锁保护。
- 对于不同的两个锁切换过程中,考虑数据是否发生变化。例如判断条件是否过时,被驱逐,被改变都会导致错误。对于这种情况,我们可以延长大锁的时候,获取两个锁。也可以在第二个锁的期间再进行一次判断。
- 当然也可以通过逻辑的方式来避免,例如一个锁的时候设置不能驱逐等
- 思考不同锁的“作用”,做到清晰的上锁。
- 建议除了官方给的函数,自己的辅助函数。尽量避免内部上锁,而让锁在上层控制。
来自蠢猪的例子:
auto BufferPoolManager::CheckedWritePage(page_id_t page_id, AccessType access_type) -> std::optional<WritePageGuard> {
auto frame_opt = GetFrameByPageId(page_id);
if (!frame_opt.has_value()) {
frame_opt = ShouldLoadPage(page_id);
if (!frame_opt.has_value()) {
return std::nullopt;
}
}
return WritePageGuard(page_id, frame_opt.value(), replacer_, bpm_latch_);
}
这里有两个错误。
1、通过 GetFrameByPageId 判断有无,如果无会 ShouldLoadPage,包含了两个操作,找到新的 Frame(驱逐或找到空闲),以及从磁盘加载。但是可能其他线程重复进入这块语句。(正常情况下是我从磁盘加载后再次判断就已经存在那个 Page 了),也就是这条语句我必须视为一个原子的,后面的操作影响到了我前面的判断。
2、在获取或加载 Frame 后,我的 Frame 可能被驱逐,我加锁的 Frame 对应的 Page 已经不是原本的那个(ntr)
对于 1 上 bpm_latch,
对于 2 提前 replacer_->SetEvictable(frame->frame_id_, false)
然后部分驱逐后不影响操作的可以直接 frame->page_id_ != page_id 结束
tips
- PageGuard is_valid_是在右值移动过程使用的,防止锁的一些问题,比如解锁两次
- GetDataMut 需要SetDirty = true
- disk_scheduler_->Schedule is_write_=false 的时候上的是独占锁…有点容易出错,其他时候读锁就可以

浙公网安备 33010602011771号