☰
数据库内核必修课:缓冲池、B+树与WAL恢复实战解析
2026/10/9 23:12:38 网站建设 项目流程

简介:CMU-15-445 数据库系统课程配套实验资料包,适合数据库学习者与从业者深入理解系统实现。内容覆盖缓冲池管理器、B树索引、并发控制、记录恢复机制等核心模块,并含C11编程示例、数据库理论实践要点、课程视频总结与实验指导建议。包内共121个文件,以 C/C++ 头文件与源文件(55个h、46个cpp)为主,辅以6个md笔记、5个txt说明、3张png图示及docx补充文档,整体压缩包仅2.64MB,结构清晰便于查阅。源码对应课程实验项目,笔记侧重关键机制拆解与实现细节,可帮助降低学习门槛。已有66人学习浏览。通过学习可获得可运行的实验代码、模块解析笔记与常见实验排错思路,能帮助读者快速上手缓冲池与B树相关实验,并为并发控制与故障恢复等进阶内容打下扎实基础。

1. 数据库系统课程(15-445)到底在练什么:一道题讲透内核必修的五个模块

很多开发者在聊到数据库时,都能把 B+ 树、事务隔离级别、WAL 这些词挂在嘴边,可真到要自己写一版缓冲池替换策略或者日志恢复流程,往往连第一步代码都落不下去。编号 15-445 的这门数据库系统课程,一直坚持把数据库理论实践绑在一起:缓冲池管理器、B+ 树索引、并发控制、记录恢复机制四个大实验串下来,再配合课程视频总结和实验指导建议,恰好能补上这块“会说不会做”的缺口。这篇文章不是把课件重新给你翻译一遍,而是按我实际做完这些实验的顺序,把每个模块的设计思路、关键参数和最容易翻车的地方讲清楚。适合准备认真啃数据库内核、愿意花几周时间泡在实验代码里的人。

2. 缓冲池管理器:先动手做对的第一步

2.1 为什么缓冲池是这节实验的第一道门槛

数据库的性能上限往往不是 CPU,而是磁盘随机 IO。一个页面在内存里命中和落盘重新读一次,代价差两三个数量级,所以所有数据库都要有一个页缓存层。缓冲池管理器的职责就是:执行引擎要某个 page_id 时,先查内存缓存;没有就从磁盘读进来;内存不够就按替换策略淘汰旧页,旧页如果是脏的还要先写回磁盘。看起来就是几行逻辑,但它被索引、表扫描、执行器所有模块依赖,任何 pin 计数或者脏页标记的疏漏,都会在后面的实验里放大成数据错乱。

课程一般会提供磁盘读写和 Page 对象骨架,由你自己实现 BufferPoolManager 的核心逻辑。动手之前,先把两个词分清楚:Page 是数据本体,Frame 是缓存里的槽位;BufferPoolManager 维护 page_id 到 Frame 的映射,以及一组可用的 Frame。我实现时选了三个数据结构:unordered_map存 page_id 到 Frame 的映射,list维护 LRU 顺序,free_list维护没装载任何页的空槽。职责划得越单纯,后面往上面加并发保护就越容易。

2.2 Frame 与 Page:先从数据结构上拧清关系

下面是一个最小可用的 Frame 设计。注意 Frame 不是 Page,Frame 是缓存槽,包含页数据、引用计数和脏位。

struct Frame { Page page; // 页数据本体,包含 data_ 数组和 page_id_ size_t pin_count; // 被多少个执行模块引用,0 表示可淘汰 bool is_dirty; // 内存数据比磁盘新,淘汰时要写回 std::list<Frame *>::iterator lru_pos; // 在 LRU 列表中的位置,O(1) 摘除 };

lru_pos是最容易省掉但最不该省的一个字段。如果命中一个页之后,要在std::list里从头找到这个 Frame 再移动,复杂度就成了 O(n),高压力下整个实验都会被拖慢。std::list的迭代器在元素被摘下再重新插入后不会失效,所以可以安全地把迭代器存在 Frame 里。用 C++11 提供的std::list、unordered_map、智能指针来组织这些代码,比手动管理原生数组舒服得多,也符合这节实验标题里强调的 C++ 编程要求。

pin_count是整个模块里最重要的状态。FetchPage 命中时 pin_count 要加一;pin_count 大于 0 的帧,即使排到 LRU 尾部也不能淘汰,否则正在使用的页会被换出,留下一个悬空指针。许多并发测试的偶发崩溃,最后追下去都是这里没做保护。

2.3 把 LRU 替换在实验代码里跑通:Fetch 与 Unpin 的实现要点

FetchPage 的完整流程可以分为命中、找 victim、换入三步。

Page *BufferPoolManager::FetchPage(page_id_t page_id) { // 1. 查映射表:命中则增加 pin 计数,并移动到 LRU 最近使用端 auto it = page_table_.find(page_id); if (it != page_table_.end()) { Frame *frame = it->second; frame->pin_count++; lru_list_.erase(frame->lru_pos); lru_list_.push_front(frame); frame->lru_pos = lru_list_.begin(); return &frame->page; } // 2. 未命中:优先拿空闲槽位,否则淘汰一个 pin_count == 0 的旧页 Frame *victim = nullptr; if (!free_list_.empty()) { victim = free_list_.front(); free_list_.pop_front(); } else { auto iter = lru_list_.end(); while (iter != lru_list_.begin()) { --iter; if ((*iter)->pin_count == 0) break; } if (iter == lru_list_.begin() && (*iter)->pin_count != 0) return nullptr; // 所有帧都被 pin,淘汰失败 victim = *iter; lru_list_.erase(iter); } // 3. 脏页先写回,再清空并装载新页 if (victim->is_dirty) disk_manager_->WritePage(victim->page.page_id_, victim->page.data_); victim->page.page_id_ = page_id; victim->page.data_.clear(); disk_manager_->ReadPage(page_id, victim->page.data_); victim->pin_count = 1; victim->is_dirty = false; page_table_[page_id] = victim; lru_list_.push_front(victim); victim->lru_pos = lru_list_.begin(); return &victim->page; }

这段代码里最关键的是“找 victim 时从尾部向前找第一个pin_count == 0的帧”,而不是直接取lru_list_.back()。单线程小数据量测试里二者差别不大,一旦并发测试里出现一个长时间被 pin 的页,直接取尾部就会把它提前淘汰,错误数据随后会从磁盘被重新读回,表现是测试跑着跑着开始报错或读出任意的旧值。另一个细节是 FetchPage 返回后 pin_count 已经从 0 变 1,调用方用完必须调 UnpinPage 释放,否则这个 Frame 永远无法被替换,空闲列表最终耗尽。

UnpinPage 的签名通常是bool UnpinPage(page_id_t page_id, bool is_dirty),这里有一个高频误用:调用方每次传入“这一次操作是否改了页”,而脏位是累积状态。页之前已经是脏的,就算这次 unpin 传 false,也不应该把脏位清掉。正确写法是frame->is_dirty |= is_dirty。漏掉这一步,唯一脏页在淘汰时不写回,所有修改都会静默丢失。

注意:FetchPage 命中时移动 LRU 位置,要在修改 pin_count 之后再做,避免把 pin_count 为 0 的页误放到“最近使用”端头部而影响淘汰顺序。

2.4 容易被忽略的边界参数:NewPage 与 DeletePage 的约定

NewPage 需要分配一个新的 page_id 并返回一个可写的页。常见做法是维护一个从 0 递增的分配器,先拿新 id,再从 free_list 取一个 Frame 装载。这里最容易犯的错是分配器 id 和实际装载不同步:分配器发出 id 后,如果 BufferPoolManager 没能成功找到 Frame,下次再发就会跳过这个 id,页号出现空洞,扫描逻辑全部错乱。所以我的习惯是分配器只负责发号,页面真正装载成功后才把 page_id 写进 Page 对象。

DeletePage 的语义是彻底删除。操作前先查映射表,没有这个 id 就直接返回 true;有的话再看 pin_count,大于 0 说明还有模块在用,必须返回 false。删除时要同时从映射表和 LRU 列表摘除,把 Frame 放回free_list_。只摘映射表不摘 LRU 列表,后面 LRU 遍历会访问到已删除帧的迭代器,ASan 立刻报 use-after-free。

这里给你一个实验指导建议:在写 B+ 树之前,专门花一个晚上把缓冲池这些边界情况用单测打满。B+ 树的每个节点都是一个页,分裂、合并、删除会大量调用 NewPage、DeletePage、UnpinPage。缓冲池漏 pin 或者脏页写回出错,到索引阶段都会以“读错 key”的形式暴露,到那时再回头排查,成本是现在的三倍以上。

3. B+ 树索引:从一次插入开始搞懂分裂与合并

3.1 为什么 B+ 树能统治关系型数据库的索引层

B+ 树能长期占据关系型数据库索引的主流位置,核心原因是它在磁盘页上的扇出足够大。一棵三层的 B+ 树可以容纳千万级 key,意味着大多数查询只需三次磁盘 IO:根一次、内部节点一次、叶子节点一次。相比红黑树或者跳表,B+ 树把相邻 key 都放在同一个叶子页里,范围扫描只需要沿叶子链表顺序读页,不用反复回溯父节点,这个特性在数据库工作负载里极其重要。

B+ 树与普通 B 树的区别也很好记:B 树的内部节点也存数据,B+ 树的数据全部在叶子节点,内部节点只存 key 和指向子节点的指针。这样做的好处是内部节点可以做得更“扁”,树高更稳定;叶子节点之间通过链表串起来,范围查询就是连续读。索引页的读写完全依赖缓冲池,节点本身就是一个 Page,所以你在索引代码里看到的每一个“节点指针”,本质上都是 page_id。

节点大小的参数是理解 B+ 树代码的钥匙。一个节点通常有 max_size 和 min_size 两个值:max_size 表示最多能放多少个 key,min_size 表示删除后不能低于多少 key(非根节点一般是半满,即 max_size / 2)。分裂和合并的触发条件全部基于这两个值,写错一位就全盘乱掉。

节点类型存的内容子节点指针典型特点
内部节点排序好的 keykey 数量 + 1 个页号只做路由,不存数据
叶子节点排序好的 key 和 valuenext 指针指向下一个叶子页真正承载数据,供扫描

3.2 插入路径上的分裂与父指针更新

插入操作先要从根一路向下走到正确叶子页,然后把 key 写到叶子节点。如果叶子节点满了,就要分裂:把后半段 key/value 挪到新节点,再把新节点的最小 key 提给父节点。父节点如果也满了,继续向上分裂,直到根。根分裂时新建一个根,树高加一。

分裂逻辑里最容易写错的点是“哪个 key 上提”。以每个节点最多 4 个 key 为例,插入后节点有 5 个 key,此时应该把中间位置的 key 上提到父节点,而不是把新插入的 key 上提。下面是叶子节点分裂的核心片段。

void SplitLeafNode(LeafNode *old_node, LeafNode *new_node) { int total = old_node->GetSize(); // 插入后的 key 总数 int mid = total / 2; // 后半段的起点 new_node->SetSize(0); // 把第 mid 个到最后一个 key/value 移动到新节点 for (int i = mid; i < total; i++) { new_node->Append(old_node->GetKey(i), old_node->GetValue(i)); } new_node->SetNext(old_node->GetNext()); old_node->SetNext(new_node); old_node->SetSize(mid); // 上提新节点的第一个 key,并插入父节点 InsertIntoParent(old_node, new_node, new_node->GetFirstKey()); }

InsertIntoParent要在父节点里找到 old_node 对应的指针位置,把 new_node 的页号和上提 key 插到后面。这里有个参数陷阱:内部节点的 key 数量加一才等于子节点指针数量,所以插入到父节点时,key 数组和 child 数组的下标要错开处理。很多实现在这里把下标算错,后果是树结构里出现两个一样的 key,查询时路由到错误的子树。

避免这个坑的方法是在实现里维护不变式断言:叶子节点 size 永远在 [min_size, max_size] 之间,内部节点的 key 数量恒等于 child 指针数减一。每次分裂、合并后都跑一遍断言,能把大部分错误拦在测试之前。

3.3 删除路径上的合并、重分布与下界

删除比插入更麻烦,因为删除后节点可能低于 min_size。低于下界时有两种补救:先从相邻兄弟借一个元素,这叫重分布;借不到就与兄弟合并。合并之后父节点少了一个 child,要继续向上检查父节点是否也低于下界,一路递归到根。根节点如果只有一个 child,就把根替换成这个 child,树高减一。

void CoalesceOrRedistribute(Node *node) { if (node->IsRoot()) { if (node->GetSize() == 0) { // 根只有一个子节点:让子节点成为新根,释放旧根页面 SetRoot(node->GetChild(0)); DeleteNodeFromBufferPool(node->GetPageId()); } return; } Node *parent = node->GetParent(); int idx = parent->GetChildIndex(node); // 优先尝试从右边兄弟借 Node *sibling = parent->GetChild(idx + 1); if (sibling && sibling->GetSize() > sibling->GetMinSize()) { node->Append(sibling->GetFirstKey(), sibling->GetFirstValue()); sibling->RemoveFirst(); parent->UpdateKeyAt(idx + 1, sibling->GetFirstKey()); } else { // 借不到就合并到左兄弟或右兄弟,然后递归检查父节点 Merge(node, sibling); CoalesceOrRedistribute(parent); } }

这里涉及两个参数细节。第一个是 min_size 的算法必须是“临界值一致”:如果 min_size 定义为 max_size / 2,那么合并后两个节点总数如果小于等于 max_size 才能合并,否则应该走重分布。第二个是重分布借元素时,父节点里作为分界线的 key 必须同步更新,否则后续查找会走进错误分支。删除后不断言、不更新父 key,几乎是 B+ 树实验里最隐蔽的翻车点。

我的实验指导建议是:先把插入逻辑写到一键跑通 tens of thousands 的随机插入,再开始写删除;删除逻辑要用“先插入到很大,再删除到很小,再插回来”的压测,专治分裂后合并又分裂的振荡问题。这类问题看起来很像玄学,其实都是 min_size 与合并条件不一致导致的。

3.4 把索引和缓冲池拼起来:先无并发跑逻辑,再加锁

这一节实验最稳的推进顺序是:先不处理并发,用单线程把查找、插入、删除全部跑通,然后再给每个节点加 latch。很多人一开始就想着读写锁、死锁避免,结果索引逻辑本身有 bug,并发排查根本查不过来。先把单线程下的 B+ 树打磨到稳定通过基础测试,再考虑并发,能让调试难度降低一个量级。

在无并发阶段就要把所有页面访问统一走缓冲池接口:获取节点页、读数据、修改后标记脏、unpin。绝对不要绕过缓冲池直接对 Page 指针做裸读写。节点页被淘汰后,索引代码持有的指针会悬空,这类问题在无并发阶段不出现,一开多线程就频繁崩溃。

4. 并发控制与记录恢复机制:让数据库在故障后还能说清楚账

4.1 锁管理器:从表锁到行锁的粒度选择

并发控制的第一个重头戏是锁管理器。课程实验里通常要求实现 LockShared、LockExclusive、Unlock 三个接口,并维护每个锁的等待队列。锁粒度先做表锁再做到行锁:表锁实现简单但并发度低,行锁能提高并发度但要处理更多锁冲突。实现核心是一个从锁目标到等待队列的映射。

struct LockRequest { txn_id_t txn_id; LockMode mode; // SHARED / EXCLUSIVE bool granted; // 是否已经获得锁 }; struct LockQueue { std::list<LockRequest> requests; }; // 表级锁表:table_oid_t -> 等待队列 std::unordered_map<table_oid_t, LockQueue> table_lock_table_; // 行级锁表:(table_oid_t, row_oid_t) -> 等待队列 std::unordered_map<std::pair<table_oid_t, row_oid_t>, LockQueue> row_lock_table_;

当一个事务请求锁时,先检查同一目标上有没有不兼容的已授权锁。例如已经有事务持有独占锁,后来的共享锁请求必须加入等待队列,排在前面等待队列中的请求一旦被授权,后面的请求即使模式兼容也要按顺序授让,否则会插队导致不公平。锁升级时还需要处理一个边界:同一个事务已经持有共享锁,再请求同一目标的独占锁,如果队列中间夹着别的等待者,直接升级会绕过它们形成活锁。

这里要特别强调封锁协议:二阶段锁要求事务先增长后收缩,即获得锁的阶段不能释放锁,释放锁之后不能再获得新锁。实验里很多死锁测试“偶发超时”,本质是事务在释放锁之后又重新请求锁,破坏了二阶段性质,导致测试框架模拟的调度进入不可预期状态。

4.2 死锁检测与隔离级别的实现边界

死锁检测是并发实验里最有含金量的部分。常见做法是维护一个 wait-for 图,节点是事务,有向边表示“事务 A 正在等待事务 B 持有的锁”,然后周期性对图做 DFS,发现环就中止其中一个事务。下面是一个伪代码骨架,展示了检测的基本结构。

// 构建 wait-for 图:只关心正在等待锁且尚未获得锁的事务 std::unordered_map<txn_id_t, std::unordered_set<txn_id_t>> wait_for; for (auto &[lock_target, queue] : all_lock_tables_) { for (auto &req : queue.requests) { if (req.granted) continue; // 已拿到锁的不参与等待 for (auto &holder : queue.requests) { if (!holder.granted) continue; if (holder.txn_id != req.txn_id) wait_for[req.txn_id].insert(holder.txn_id); } } } // 对每个事务做 DFS,检测环;环中找到 victim 事务并 abort for (auto &[txn, edges] : wait_for) { if (DfsFindCycle(txn, wait_for, visited, stack, &victim)) txn_manager_->Abort(victim); }

检测到环后,选哪个事务作为牺牲者直接影响测试成绩。最笨但稳定的策略是选 txn_id 最小(或者说最早启动)的事务;更好的策略是先比较持有锁数量,再比较启动时间。持有锁多的事务 abort 代价更高,所以应该优先中止持有锁少的新事务。这里有一个常见的反直觉点:不能每次都 abort 遍历时最先碰到的那个事务,否则一个连接如果总在环的起点位置,会被反复 abort,表现为某个线程永远无法完成。

隔离级别的实现边界也需要想清楚。READ_COMMITTED 下,事务读完数据后共享锁可以立即释放;REPEATABLE_READ 下,共享锁要一直持有到事务结束。如果在实现 READ COMMITTED 时错误地把锁保持到事务提交,测试框架会检测到过度的锁阻塞,并发度大幅下降。相反,如果在 REPEATABLE READ 下提前释放共享锁,会出现不可重复读,测试结果直接不通过。

4.3 WAL 与恢复机制:日志先落盘,数据后写页

记录恢复机制依赖的核心理念是 Write-Ahead Logging,也就是先写日志,再改数据页。这样做的目的是崩溃恢复时有据可依:日志里记录了每个事务对页面的修改内容,恢复过程可以用日志重放,也可以用日志回滚。课程实验里日志记录通常包含这些字段:事务 id、LSN(日志序号)、prev_lsn、日志类型、page_id,以及修改前后的数据。

struct LogRecord { lsn_t lsn; txn_id_t txn_id; lsn_t prev_lsn; // 同一事务上一条日志的 LSN,用于回滚 LogType type; // BEGIN / INSERT / UPDATE / COMMIT / ABORT page_id_t page_id; std::vector<char> old_data; // undo 需要 std::vector<char> new_data; // redo 需要 };

恢复流程分两步。redo 阶段从最后一个检查点开始,重放所有已提交事务的日志;undo 阶段对崩溃时未提交的事务,沿着 prev_lsn 链回滚。判断一条日志属于哪个事务,需要维护一个已提交事务集合。这里最容易被忽略的是:redo 不能无条件重放每条日志。如果一个脏页在崩溃前已经落盘,且它的 Page LSN 大于等于日志的 LSN,说明这条日志已经生效,再重放就会覆盖更新数据。所以恢复代码要先比较日志 LSN 与页面 LSN,只有日志更新的才重放。

注意:提交日志必须真正刷到磁盘后才能向客户端返回提交成功。只要日志还在内存缓冲区,崩溃后它就不存在,这个事务等于白做。

4.4 实验指导建议:并发与恢复一起做时怎么安排顺序

这门课的并发控制与记录恢复两个实验在顺序上连着做,但建议把精力按 6:4 分配。锁管理器完成且稳定后,立刻开始日志模块;日志模块最难的部分不是 redo/undo 算法,而是和缓冲池的配合:刷脏页、页 LSN 更新、检查点记录,这三个点耦合在一起,任何一个先后顺序错位都会带来数据丢失。

建议用一个自制的崩溃注入脚本做验证:随机在某个日志刷盘点直接终止进程,重启后检查数据是否一致。这类脚本能成倍提高对恢复机制的理解,而且远比只跑官方测试更能暴露问题。后面第五部分我会把最常见的几个崩溃现场展开讲。

5. 常见问题排查:缓冲池、B+树、并发恢复最容易翻车的五个现场

5.1 缓冲池测试偶发崩溃:LRU 把带 pin 的页淘汰了

现象:并发测试跑到几百毫秒后偶发崩溃,单线程重跑完全正常,ASan 偶尔报出悬空指针访问。

原因:LRU 替换时直接取列表尾部的 Frame,没有检查 pin_count。并发场景下,一个事务还在读的页因为排到了尾部被误淘汰,另一个线程再访问这个页时拿到的是从磁盘重新读出来的旧副本,数据立刻不一致。

解决:淘汰逻辑必须从 LRU 尾部开始向前扫描,找到第一个pin_count == 0的帧;如果全部被 pin,返回失败而不是强行淘汰。同时检查 UnpinPage 是不是真的把所有使用方都释放了。漏一次 unpin,表现就是“空闲页越来越少”,最终在 unexpected 位置崩溃。

5.2 B+ 树越界写入:满页判断与分裂时机差了一位

现象:插入测试报错 “page size exceeded”,或者某个 key 永远查不到。

原因:分裂条件判断写成了if (node->size == max_size),但实际是在插入之后判断,此时节点已经能容纳 max_size + 1 个 key。插入后直接塞进节点再触发分裂,节点数组溢出;或者分裂时把中间 key 留在旧节点里,导致旧节点超过上限。

解决:在插入到节点之前判断“如果这个节点再插入一个 key 就会超过 max_size”,超过就先分裂再插入。分裂时把中位 key 上提父节点,保证旧节点和新节点都在合法范围内。实现后一定要加不变式断言,在每次插入、删除结束时检查所有节点的 size 范围,否则这种“差一位”的 bug 会非常隐蔽。

5.3 并发测试偶发超时:死锁检测里等待方向搞反了

现象:某个并发场景下测试偶发超时,手动重跑又通过,看起来像玄学。

原因:wait-for 图建的边方向反了,实际是“A 等待 B”,图里却记成“B 等待 A”。DFS 找不到环,或者找出来的环根本没有语义;更常见的是等待队列把已经 granted 的请求也参与了等待关系,导致一个事务自己等自己,形成假死锁,永远 abort 同一个事务。

解决:构建边时只从“未获得锁的请求”出发,指向“持有锁的请求”,即等待者指向持有者。DFS 前先输出一张 wait-for 文本图,用几个手工场景核对方向。victim 选择先按持有锁数量升序,再按 txn_id 升序,保证死锁发生时总能稳定中止同一个代价最小的事务。

5.4 恢复测试丢数据:LSN 分配与刷盘时机没对上

现象:模拟崩溃后重启,某个已提交事务的插入数据消失,log 扫描却能看到 COMMIT 记录。

原因:提交日志没有真正刷盘就返回成功,或者页面写盘后 Page LSN 没更新,导致 redo 阶段认为日志已失效并跳过。另一个常见来源是日志的 LSN 不是从同一个分配器发出的:页面 LSN 用了自己的计数器,日志 LSN 用了另一个,二者对不上,重放时判断逻辑彻底失效。

解决:所有 LSN 统一由日志管理器分配,页面写入时必须从日志记录中拷贝 LSN 到 Page 的字段。提交路径上,COMMIT 日志写入后必须调用强制刷盘,确认磁盘落定才返回。自己写一个崩溃注入脚本:在 COMMIT 刷盘前、刷盘后各打断一次,验证两种场景都能恢复出正确数据。

5.5 ASan 爆 use-after-free:Page 生命周期被两个模块同时管理

现象:ASan 报 heap-use-after-free,调用栈指向 B+ 树节点访问。

原因:B+ 树删除节点时直接 delete 了 Page,缓冲池里还有这个 page_id 的映射;或者反过来,缓冲池淘汰了一个还在被索引使用的页。两套代码对“页归谁管”没有达成一致。

解决:约定统一由 BufferPoolManager 管理页生命周期。索引侧需要删除页时调用 DeletePage,由它负责从映射和 LRU 列表摘除,再放回 free_list;索引侧永远不要直接 delete Page 对象,也不要在 unpin 之后继续持有 Page 指针。想让某个页淘汰,正确做法是把 pin_count 减到 0 并交由 LRU 替换策略去处理。

6. 把实验台改造成调试工具:页面 dump、不变式断言与固定随机种子

6.1 给索引加一个页面 dump 函数

写 B+ 树时,一个能按层打印所有节点 key 的 dump 函数比任何日志都管用。我的做法是从根节点开始,对内部节点递归访问每个子节点,对叶子节点输出全部 key/value。打印时带上 page_id 和每个节点的 size,方便直接对应到缓冲池状态。

void DebugDump(const Page *page) { auto *node = reinterpret_cast<const BPlusTreePage *>(page->GetData()); // 内部节点递归打印,叶子节点打印 key 列表 if (!node->IsLeaf()) { for (int i = 0; i < node->GetSize(); i++) DebugDump(node->GetChildPage(i)); } else { for (int i = 0; i < node->GetSize(); i++) std::cerr << node->GetKey(i) << " "; std::cerr << std::endl; } }

配合不变式断言一起用:assert(node->GetSize() >= node->GetMinSize()),在每个 public 操作结束时跑一遍,能在测试崩溃前先把结构错误暴露出来。这种断言在 release 模式下会被编译掉,只在 debug 模式生效,所以不会影响性能测试结果。

6.2 用“先退回单线程”的方式定位并发问题

并发 bug 最可怕的不是难修,而是难复现。我的习惯是先把并发测试改回单线程,确定纯逻辑正确;然后固定随机种子,让插入删除序列完全相同;最后再开两个线程,并把死锁检测周期调短,方便观察等待图。定位等待问题时,用 GDB 的thread apply all bt看每个线程卡在哪个锁上,比反复打日志高效得多。

我自己在这个实验阶段踩过最狠的一次,是第四模块里以为 redo 流程顺畅就提交了,结果崩溃点正好卡在“日志未刷盘 + 脏页已写盘”的间隙,恢复后整个事务的数据全部丢失。从那以后我给自己定了规矩:任何改完核心代码的版本,先跑 ASan 加固定随机种子的回归,再跑官方测试。这套习惯一直沿用到现在,数据库内核这种“错一步就让你查一整天”的黑匣子,只有靠复现和断言才能撬开。希望帮到你。

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

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

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

立即咨询