☰
CMU 15-445 数据库系统实验:缓冲池、B+树、MVCC与WAL恢复的C++实现
2026/9/26 14:36:05 网站建设 项目流程

简介:这份资源是CMU 15-445数据库系统课程的实验代码与学习笔记合集,面向希望深入理解数据库底层实现的高校学生、后端工程师与数据库爱好者。内容覆盖缓冲池管理器、B树索引、并发控制、记录恢复机制等核心模块,并配有C11编程实践、课程视频总结与实验指导建议,适合在完成课程作业或自研存储引擎时对照参考。压缩包共121个文件,以55个C++头文件与46个cpp源文件为主体,辅以6个md笔记、5个txt说明及少量C、cc、png与docx文档,整体约2.64MB,目录结构便于按实验模块检索。已有66人学习下载。读者可从中获得缓冲池替换策略、B+树增删查改、锁管理器与日志恢复等关键实现思路,并借助笔记与指导建议梳理实验流程、排查常见错误,为构建高性能数据库系统打下基础。

1. CMU 15-445 到底在练什么:从缓冲池到恢复的四道硬关卡

如果你写过 CRUD,却说不清一条SELECT在磁盘和内存之间到底走了几步,那 CMU 15-445 这门数据库系统课的实验会把你按在地上摩擦。它不教你写 SQL,而是让你用 C++11 从零实现一个能跑并发事务的存储引擎:缓冲池管理器负责把页在磁盘和内存之间倒腾,B+ 树索引负责让点查和范围查都落在 O(log n),并发控制负责让多个事务同时跑还不互相踩,记录恢复机制负责在系统崩溃后把数据捞回来。这四个模块串起来,就是数据库系统概论里那些 ER 图例题和 MVCC 多版本并发控制概念真正落地的地方。适合已经会 C++、想深入数据库系统原理的人,也适合正在做数据库系统实验一却卡在 LRU-K 替换策略上的同学。下面按我实际复现的顺序,把每一步的命令、参数和翻车点讲清楚。

2. 缓冲池管理器:LRU-K 替换策略与页锁的落地实现

缓冲池管理器是整个存储引擎的内存门面。磁盘上的页要读进内存才能被上层访问,但内存有限,必须有一套替换策略决定谁被踢出去。15-445 要求实现 LRU-K,而不是简单的 LRU。原因很直接:LRU 只看最近一次访问,一次全表扫描就能把热页全部冲掉;LRU-K 看最近 K 次访问的时间间隔,把「偶尔被扫到一次」的页和「反复被点查」的页区分开。K 一般取 2,也就是 LRU-2,这是工业界和课程都常用的默认值。

2.1 为什么是 LRU-K 而不是 LRU:替换策略的选型理由

假设一个页被访问了两次,第一次在 t=1,第二次在 t=100;另一个页在 t=99 和 t=101 各被访问一次。LRU 会认为第二个页更热,因为它最近被访问过。但 LRU-K 计算的是第 K 次访问与第 K-1 次访问之间的间隔:第一个页间隔 99,第二个页间隔 2。间隔越小说明访问越密集,越应该留在内存。LRU-2 的淘汰优先级是:先淘汰访问次数不足 K 次的页,再淘汰 K 次访问间隔最大的页。这个策略能有效抵抗顺序扫描污染,代价是需要为每个页维护一个访问历史队列。

实现上,每个 frame 需要记录一个std::deque<size_t>或者固定大小的环形缓冲区来存最近 K 次访问的时间戳。当访问次数不足 K 时,页处于「冷区」,按 FIFO 淘汰;当访问次数达到 K 后,页进入「热区」,按第 K 次访问的时间戳排序淘汰。这里有个容易忽略的点:时间戳不需要真实时钟,用一个全局递增的计数器就行,每次访问加一,避免系统调用开销。

2.2 用 C++11 实现 LRU-K 替换器的核心代码

下面是我在buffer_pool_manager.cpp里实现的LRUKReplacer核心逻辑,省略了头文件声明,只保留关键路径。

// 每个 frame 的访问记录 struct FrameInfo { std::deque<size_t> access_history; // 最近 K 次访问时间戳 bool is_evictable{false}; // 是否可被淘汰 }; class LRUKReplacer { public: explicit LRUKReplacer(size_t num_frames, size_t k) : k_(k) { frames_.resize(num_frames); } // 记录一次访问,返回被淘汰的 frame_id(如果有) void RecordAccess(frame_id_t fid) { auto &info = frames_[fid]; info.access_history.push_back(global_ts_++); if (info.access_history.size() > k_) { info.access_history.pop_front(); // 只保留最近 K 次 } // 更新淘汰候选集:访问次数达到 K 的进入热区 if (info.access_history.size() == k_) { hot_set_.insert(fid); } } // 淘汰一个页:优先淘汰冷区,再淘汰热区间隔最大的 bool Evict(frame_id_t *frame_id) { // 先找冷区中 is_evictable 的页,FIFO 顺序 for (auto it = cold_list_.begin(); it != cold_list_.end(); ++it) { if (frames_[*it].is_evictable) { *frame_id = *it; cold_list_.erase(it); frames_[*it].access_history.clear(); return true; } } // 冷区没有,从热区找间隔最大的 size_t max_gap = 0; frame_id_t victim = INVALID_FRAME; for (auto fid : hot_set_) { if (!frames_[fid].is_evictable) continue; auto &h = frames_[fid].access_history; size_t gap = h.back() - h.front(); // 第 K 次与第 1 次的间隔 if (gap >= max_gap) { max_gap = gap; victim = fid; } } if (victim != INVALID_FRAME) { *frame_id = victim; hot_set_.erase(victim); frames_[victim].access_history.clear(); return true; } return false; } private: size_t k_; size_t global_ts_{0}; std::vector<FrameInfo> frames_; std::list<frame_id_t> cold_list_; // 访问次数不足 K 的页 std::set<frame_id_t> hot_set_; // 访问次数达到 K 的页 };

这段代码的关键参数是k_,构造时传入,课程默认用 2。global_ts_是单调递增计数器,保证时间戳不重复。cold_list_用std::list是为了 O(1) 删除,hot_set_用std::set是为了遍历时稳定。淘汰逻辑先扫冷区再扫热区,冷区按插入顺序淘汰,热区按间隔最大淘汰。注意is_evictable标志:被上层 pin 住的页不能淘汰,Unpin时才置为 true。

2.3 页锁与并发安全:什么时候加 latch,什么时候加 lock

缓冲池管理器本身要被多个线程并发访问,所以每个 frame 需要一个std::mutex或者std::shared_mutex。读页时加共享锁,修改页时加排他锁。但这里有个血泪经验:不要在持有 frame latch 的时候去调用磁盘 IO,否则整个缓冲池会被一个慢磁盘拖死。常见做法是先把页读进一个临时缓冲区,释放 latch,再拷贝到 frame 里。另外,Page对象里的pin_count_和is_dirty_必须用原子变量或者受同一个 latch 保护,否则并发Unpin会导致计数错乱。我一般会在FetchPage里先查页表,命中就RecordAccess并pin_count_++,未命中就选一个 victim,如果 victim 是脏页先写回磁盘,再读新页。整个过程用std::scoped_lock锁住页表,但磁盘 IO 放在锁外。

3. B+ 树索引:从页分裂到并发 Crabbing 的完整路径

B+ 树是数据库索引的默认答案,15-445 要求实现支持点查、范围查和迭代器的 B+ 树,并且要能并发访问。课程里 B+ 树的每个节点就是一个页,内部节点存 key 和子页指针,叶子节点存 key 和记录 ID(RID)。和教科书不同的是,这里的 B+ 树要处理页分裂、页合并,还要用 crabbing 协议保证并发安全。

3.1 B+ 树节点布局与插入分裂的边界条件

一个 B+ 树节点页的大小是固定的,比如 4KB。内部节点的结构是[header][key0][page_id0][key1][page_id1]...,叶子节点是[header][key0][rid0][key1][rid1]...。插入时,先找到目标叶子节点,如果叶子没满就直接插入;如果满了,就分裂成两个节点,把中间 key 推到父节点。这里最容易翻车的是分裂时的 key 分配:假设叶子节点有 n 个 key,分裂后左节点保留前 n/2 个,右节点保留剩下的,中间那个 key 是复制到父节点还是移动?对于叶子节点,父节点里的 key 是右节点的最小 key,所以是复制;对于内部节点,父节点里的 key 是移动,原节点不再保留。这个区别如果搞反,范围查会丢数据。

另一个边界是根节点分裂。根节点分裂后要创建一个新的根,树高加一。很多同学在实现时忘了更新root_page_id_,导致后续查找从旧根开始,直接段错误。我一般会在Insert返回后检查root_page_id_是否变化,如果变了就更新 header page 里的元数据。

3.2 并发 Crabbing 协议: latch 的获取与释放顺序

Crabbing 协议的核心是:查找时先锁住根节点,再锁住子节点,然后释放父节点的锁。这样任意时刻最多持有两个节点的 latch,避免死锁。插入时稍微复杂:如果子节点不会分裂,就释放父节点锁;如果可能分裂,就一路持有父节点锁直到完成分裂。判断「是否可能分裂」的方法是看子节点当前 key 数量是否等于 max_size - 1。这个预判可以减少锁持有时间。

// 查找路径上的 crabbing:先锁子,再放父 Page *FindLeaf(page_id_t root_id, const Key &key) { page_id_t cur = root_id; Page *parent = buffer_pool_->FetchPage(cur); parent->RLatch(); // 根节点加读锁 while (!parent->IsLeaf()) { page_id_t child_id = parent->InternalLookup(key); Page *child = buffer_pool_->FetchPage(child_id); child->RLatch(); parent->RUnlatch(); // 释放父节点读锁 buffer_pool_->UnpinPage(parent->GetPageId(), false); parent = child; } return parent; // 返回叶子节点,仍持有读锁 }

这段代码里,RLatch和RUnlatch是页级读写锁。查找时全部用读锁,因为不修改结构。插入时,从根开始加写锁,向下走时如果子节点安全(不会分裂)就释放祖先的写锁。注意UnpinPage的第二个参数是is_dirty,查找路径上不修改页,所以传 false。如果传 true,缓冲池会把这个页标记为脏,导致不必要的写回。

3.3 迭代器的实现与范围查的坑

B+ 树的迭代器要支持Begin()、Begin(key)、Next()。Begin(key)找到第一个大于等于 key 的叶子位置,然后Next()在当前叶子内移动,如果到叶子末尾就通过next_page_id_跳到下一个叶子。这里有个坑:叶子节点之间的链表指针必须在分裂时正确维护。分裂时,新右节点的next_page_id_指向原节点的下一个,原节点的next_page_id_指向新右节点。如果顺序搞反,范围查会死循环或者漏数据。另外,迭代器持有叶子节点的读锁,Next()跳到下一个叶子时要先锁新叶子再放旧叶子,否则中间窗口可能有其他线程修改结构。

4. 并发控制: MVCC 多版本并发控制与两阶段锁的取舍

并发控制是 15-445 最抽象的部分。课程要求实现基于两阶段锁(2PL)或者 MVCC 的事务管理器。MVCC 多版本并发控制是当前热搜里经常出现的词,它的核心思想是:读操作不阻塞写操作,写操作不阻塞读操作,每个事务看到自己开始时的快照。实现上,每个元组维护多个版本,每个版本有begin_ts和end_ts,读的时候找begin_ts <= read_ts < end_ts的版本。

4.1 事务 ID 分配与可见性判断规则

事务开始时分配一个read_ts,提交时分配commit_ts。可见性规则是:对于读事务 T,元组版本 V 可见当且仅当V.begin_ts <= T.read_ts且(V.end_ts == INF或V.end_ts > T.read_ts)。写操作会创建一个新版本,新版本的begin_ts是当前事务的commit_ts(提交时才确定),旧版本的end_ts也设为这个值。这里有个关键点:未提交事务的写版本对其他事务不可见,所以begin_ts在提交前是无效的,通常用一个事务状态表来辅助判断。

// 可见性判断:读事务 read_ts 能否看到版本 v bool IsVisible(const Version &v, timestamp_t read_ts) { if (v.begin_ts > read_ts) return false; // 版本太新 if (v.end_ts != INVALID_TS && v.end_ts <= read_ts) return false; // 版本已过期 // 如果 begin_ts 对应的事务还未提交,也不可见 if (!txn_mgr_->IsCommitted(v.begin_ts)) return false; return true; }

INVALID_TS是一个极大值,表示版本仍然有效。txn_mgr_->IsCommitted查事务状态表,只有提交了的事务产生的版本才可见。这个判断在每次读元组时都要做,所以事务状态表要用并发安全的结构,比如std::unordered_map加读写锁。

4.2 写冲突处理:先写后读还是先读后写

MVCC 下写冲突有两种处理策略:第一种是「先写后读」,写操作直接创建新版本,如果发现另一个未提交事务已经写了同一个 key,就等待或者回滚;第二种是「先读后写」,先检查可见版本,再基于可见版本创建新版本,如果版本在检查后被修改,就重试。15-445 的 Project 4 通常要求实现第一种,配合一个锁管理器来检测写写冲突。我一般会在Update时先获取元组的写锁,然后检查是否有其他未提交事务持有该元组的写锁,如果有就阻塞。这个写锁可以用一个std::mutex加条件变量实现,也可以用更细粒度的锁表。

4.3 死锁检测与回滚:等待图与超时机制

并发控制绕不开死锁。两个事务互相等待对方持有的锁,就会永久阻塞。常见做法是维护一个等待图,事务 A 等待事务 B 就加一条 A->B 的边,如果图中出现环就回滚其中一个事务。等待图可以用std::unordered_map<txn_id_t, std::set<txn_id_t>>表示,每次加边时做一次 DFS 检测环。另一个简单做法是超时:如果一个事务等待超过一定时间(比如 50ms),就回滚它。超时机制实现简单但可能误杀,等待图更精确但开销大。我一般会先用超时兜底,再在锁管理器里加等待图检测,两者结合。

5. 记录恢复机制: WAL 日志与 ARIES 算法的简化实现

恢复机制保证数据库在崩溃后能回到一致状态。15-445 要求实现基于 WAL(Write-Ahead Logging)的恢复:任何页的修改必须先写日志再写数据页,日志按顺序落盘。恢复时重放日志,把已提交事务的修改重新应用,把未提交事务的修改撤销。ARIES 算法是工业界标准,课程里通常简化成三个步骤:分析、重做、撤销。

5.1 日志记录格式与 LSN 的分配

每条日志记录包含LSN(日志序列号)、txn_id、type(BEGIN/UPDATE/COMMIT/ABORT)、page_id、offset、before_image、after_image。LSN 全局递增,由日志管理器分配。写日志时先写进内存缓冲区,再批量刷盘。刷盘策略有两种:强制刷盘(每次提交都刷)和组提交(攒一批再刷)。课程实验一般要求强制刷盘,保证提交的事务一定持久化。

// 日志记录结构 struct LogRecord { lsn_t lsn; txn_id_t txn_id; LogType type; page_id_t page_id; uint32_t offset; std::vector<char> before_img; std::vector<char> after_img; }; // 写日志:先分配 LSN,再写缓冲区,提交时刷盘 lsn_t LogManager::AppendLog(LogRecord rec) { std::scoped_lock lock(latch_); rec.lsn = next_lsn_++; buffer_.push_back(rec); if (rec.type == LogType::COMMIT) { Flush(); // 提交时强制刷盘 } return rec.lsn; }

next_lsn_是原子递增的,buffer_是内存日志缓冲区。Flush把缓冲区写到磁盘文件,并更新persist_lsn_。注意before_img和after_img的大小要和页内记录大小一致,否则重做时会越界。

5.2 重做与撤销:从检查点恢复的完整流程

恢复时先从检查点开始。检查点记录了当前活跃事务列表和persist_lsn_。分析阶段扫描日志,重建活跃事务表和脏页表。重做阶段从检查点的persist_lsn_开始,对每条 UPDATE 日志,如果页的page_lsn小于日志的 LSN,就应用after_img。撤销阶段从日志末尾反向扫描,对未提交事务的 UPDATE 应用before_img,并写一条 CLR(补偿日志记录)。CLR 的作用是防止恢复过程中再次崩溃导致重复撤销。

这里有个容易忽略的坑:重做时必须比较页的page_lsn和日志的 LSN,如果page_lsn >= log.lsn,说明这个修改已经落盘了,跳过。否则重复应用会导致数据错乱。page_lsn存在每个页的头部,每次修改页时更新为当前日志的 LSN。

6. 避坑与排查:四个模块联调时最容易翻车的地方

6.1 缓冲池淘汰了还被引用的页

现象:程序随机崩溃,报段错误或者数据错乱。原因:UnpinPage时pin_count_减到 0,页被标记为可淘汰,但上层还持有Page*指针,另一个线程触发淘汰后这个指针就悬空了。解决:上层使用Page*期间必须保证pin_count_ > 0,用完立即Unpin。我一般会在FetchPage返回的页上强制要求调用方在同一个作用域内Unpin,用 RAII 封装一个PageGuard。

6.2 B+ 树分裂后父节点 key 没更新

现象:点查能找到数据,但范围查漏掉一部分。原因:叶子分裂后,父节点里的分隔 key 还是旧值,导致查找时路由到错误的叶子。解决:分裂后必须把右节点的最小 key 插入父节点,如果父节点也满了就继续向上分裂。检查方法是写一个单元测试,插入 1000 个随机 key,然后范围查验证返回数量。

6.3 MVCC 读到了未提交的版本

现象:事务 A 未提交,事务 B 却读到了 A 的修改。原因:可见性判断里漏了事务状态检查,只比较了begin_ts和read_ts。解决:在IsVisible里加txn_mgr_->IsCommitted(v.begin_ts)判断,并且事务状态表要在提交时原子更新。测试时可以用两个线程,一个写一个读,读线程 sleep 一段时间再读,验证读不到未提交数据。

6.4 WAL 日志刷盘顺序错误

现象:系统崩溃后恢复,已提交的事务丢失。原因:数据页先于日志落盘,崩溃时日志还没写,恢复时找不到对应记录。解决:严格保证Flush日志在写数据页之前。可以在BufferPoolManager::FlushPage里先调用log_mgr_->Flush(),再写磁盘。另一个检查点是commit时必须强制刷日志,不能只写缓冲区。

6.5 并发插入导致 B+ 树结构损坏

现象:多线程同时插入时,树结构出现环或者节点丢失。原因:crabbing 协议里释放父节点锁的时机不对,两个线程同时分裂同一个节点。解决:插入时对可能分裂的节点持有写锁直到分裂完成,并且用std::mutex保护根节点 ID 的更新。测试时开 8 个线程各插入 1000 个 key,最后中序遍历验证有序性。

7. 用 Google Test 做模块级验证:从单元测试到压力测试

15-445 的代码量很大,四个模块联调时靠打印日志排查效率极低。我习惯用 Google Test 给每个模块写独立的单元测试,再写一个集成测试跑并发压力。下面是我常用的测试骨架。

// 缓冲池 LRU-K 淘汰顺序测试 TEST(LRUKReplacerTest, EvictOrder) { LRUKReplacer replacer(3, 2); replacer.RecordAccess(1); replacer.RecordAccess(2); replacer.RecordAccess(1); // frame 1 达到 K=2,进入热区 replacer.RecordAccess(3); replacer.SetEvictable(1, true); replacer.SetEvictable(2, true); replacer.SetEvictable(3, true); frame_id_t victim; // 冷区先淘汰:frame 2 和 3 访问次数不足 2 ASSERT_TRUE(replacer.Evict(&victim)); ASSERT_EQ(victim, 2); // FIFO 顺序,2 先于 3 ASSERT_TRUE(replacer.Evict(&victim)); ASSERT_EQ(victim, 3); ASSERT_TRUE(replacer.Evict(&victim)); ASSERT_EQ(victim, 1); // 最后才是热区 }

这个测试验证了淘汰优先级:冷区按 FIFO,热区最后淘汰。参数k=2是构造时传入的,SetEvictable模拟上层Unpin。跑通这个测试,缓冲池的替换逻辑基本就稳了。

对于 B+ 树,我会写一个随机插入和删除的测试,插入 10000 个随机 key,然后逐个点查验证存在,再范围查验证数量。对于 MVCC,写两个线程,一个不断更新同一个 key,另一个不断读,验证读到的版本号单调不减。对于恢复,模拟崩溃:写一批日志,不刷数据页,然后调用恢复流程,验证已提交事务的数据都在。

压力测试用std::thread开 8 个线程,每个线程跑 1000 次随机操作,最后检查数据一致性。如果出现死锁,用gdbattach 上去看各个线程的调用栈,重点看锁的持有顺序。我一般会在锁管理器里加一个DLOG记录每次加锁和解锁,崩溃后看日志就能定位到哪个事务没释放锁。

最后说一个我踩过的坑:Google Test 默认不检测内存泄漏,缓冲池的Page对象如果忘记delete,跑久了内存会爆。我习惯在测试里加--gtest_also_run_disabled_tests和 AddressSanitizer,编译时加-fsanitize=address,这样悬空指针和泄漏都能在测试阶段暴露。这套流程跑下来,四个模块的联调时间能从几天压缩到几个小时。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询