简介:CMU-15445课程Bustub数据库系统的个人实现源码包,面向数据库方向学习者与求职者,用于深入理解DBMS的存储管理、查询优化、事务处理等核心机制,也适合作为系统设计与C++工程实践的参考范例。压缩包共1195个文件,大小33.89MB,包含260个h头文件、209个cpp与149个cc源文件,覆盖核心存储与执行逻辑;另有Python辅助脚本、Markdown/RST文档、Bazel/CMake构建配置、HTML/JS前端资源及Docker部署文件,类型多元,目录结构清晰,便于按模块检索。已有162人学习下载。项目遵循学术规范未在GitHub开源,附有测试数据库、插入样例、日志及多套构建配置,满足从本地编译到运行调试的完整流程。读者可借其掌握数据库系统分层设计与关键算法落地,并在简历中呈现一个完整、可解释的课程级DBMS实现。该源码包是简历展示、课程复盘与面试准备的可靠素材。
1. CMU-15445 的 Bustub:一套能放进简历的数据库系统个人实现
想真正读懂数据库系统,光看《数据库系统概念》是不够的,你得有一份能跑起来的源码。CMU-15445 课程里的 Bustub 正是干这个的——一套只有骨架、需要你亲手填满的数据库系统。这份个人实现源码包含 1208 个文件,主体是 C++,配套 Python 测试脚本与前端可视化页面,把缓冲池、B+ 树索引、事务与并发控制完整地写了一遍。它的价值在于:你不是在零基础复刻一个生产级 DBMS,而是在课程划定的边界内,把存储、索引、并发、执行四大模块都补齐。适合正在刷 15445 的学生、准备数据库方向面试的求职者,以及想找一个高质量简历项目的开发者。
2. Bustub 核心模块拆解:缓冲池、B+ 树索引与并发控制的 C++ 实现
这一章先把源码的骨架拆开,回答一个最直接的问题:这套个人实现里,哪些文件是核心,哪些只是工程辅助。很多初学者拿到源码包,第一反应是找 main 函数,这在数据库项目里是走不通的——Bustub 是一组被测试框架驱动的库,你要先搞清楚每个模块的边界,才能知道该从哪里读起。
2.1 从文件清单看 DBMS 骨架:BUILD.bazel、WORKSPACE.bazel 与 utf8proc_data.c 各司其职
拿到手第一件事,先看根目录下的配置文件,它们决定了整个项目的构建方式和工程约束。
| 文件 | 在项目里的角色 |
|---|---|
| BUILD.bazel | 各子目录的构建目标,Bustub 几乎每个目录都有一份 |
| WORKSPACE.bazel | Bazel 工作区定义,声明外部依赖与仓库规则 |
| .bazelrc / .bazelversion | 编译参数与 Bazel 版本锁定 |
| clang_format.bash | 一键格式化全部 C/C++ 源码的脚本 |
| build.bat | Windows 下的构建入口 |
| utf8proc_data.c | Unicode 规范化数据表,字符串比较与排序的底层依赖 |
Bazel 是 15445 官方钦定的构建系统,和 CMake 最大的区别在于沙箱隔离与远程缓存:每个编译动作都在干净环境里执行,依赖变更能精确触发增量重建。项目里每个子目录都放一份 BUILD.bazel,是为每个 cc_library / cc_binary 单独声明依赖,这样测试只链接被测模块,编译速度更快,也更不容易出现「改了个头文件,全项目重编」的情况。
utf8proc_data.c 是最容易被忽略的文件。它是一张 Unicode 规范化数据表,Bustub 里的 Varchar 比较、排序、哈希都依赖它。如果你后续要扩展字符串索引或者做 collation 相关的功能,这块会直接决定行为,是典型的「平时无感、踩坑要命」的底层组件。
把这份文件清单和《数据库系统概论》里的章节对着看,你会发现教材里的「存储结构」「索引」「事务」各对应一部分源码,理论名词落到代码上才算真正学懂。
2.2 缓冲池管理器:LRU 替换策略的 C++ 实现要点
缓冲池是 Bustub 第一关,也是整个存储模块的地基。它的职责很简单:把磁盘页缓存在内存里,让上层模块以为「所有数据都在内存中」。所有数据库系统都要处理同一个问题——内存不够用,该把谁踢出去。15445 的 BufferPoolManager 要求实现 LRU 替换,并且要正确处理脏页和 pin 计数,这是最容易写错的地方。
// BufferPoolManager 中帧(Frame)的典型结构 struct Frame { page_id_t page_id; // 磁盘页号,INVALID_PAGE_ID 表示空闲帧 char data[PAGE_SIZE]; // 页内容,PAGE_SIZE 通常为 4096 字节 bool is_dirty; // 脏页标记,驱逐前必须写回磁盘 uint32_t pin_count; // 被上层引用的次数,>0 时不允许驱逐 std::chrono::time_point<std::chrono::steady_clock> last_used; }; // LRU 替换:找到 pin_count == 0 且最久未被访问的帧 Frame* find_victim(std::list<Frame*>& lru_list) { for (auto it = lru_list.rbegin(); it != lru_list.rend(); ++it) { Frame* f = *it; if (f->pin_count == 0) { lru_list.erase(std::next(it).base()); return f; } } return nullptr; // 所有页都被 pin,触发 "buffer pool full" }这段代码的逻辑核心在 find_victim:从 LRU 链表的尾部(最久未用)开始找,遇到 pin_count 为 0 的帧就淘汰。如果整条链都被 pin 住,返回 nullptr,上层必须抛异常而不是死等——很多实现在这里选择「等一会儿再试」,反而把并发问题复杂化。is_dirty 标记决定驱逐时是否调用 DiskManager::WritePage,漏掉写回就是数据静默丢失。参数上,PAGE_SIZE 在 15445 里默认 4096 字节,pool_size 一般取 1024 到 4096 个帧;last_used 记得用 steady_clock,不要用系统墙钟时间,否则 ntp 校时会让替换策略瞬间失效。
我一般会在 Frame 里额外维护一个 page_id 到 Frame* 的 unordered_map,保证 FetchPage 的 O(1) 查找,而不是遍历链表。这和《数据库系统概论》里讲的「缓冲区管理」对应得上:教材里的「钉住页」就是 pin_count,教材讲 LRU 时的「引用位」,到代码里就是 last_used 时间戳。
2.3 B+ 树索引与 latch:并发控制里最容易卡住的地方
B+ 树是 15445 项目里公认最难的一关,也是这套源码里含金量最高的部分。它要求你实现一个并发安全的 B+ 树索引:叶子页和内部页都支持插入、删除、分裂、合并,并且全程通过 latch 保护,不能出现数据竞争。
// 查找路径:从根到叶逐层加锁,边下边放 void search_with_latch(Key key) { Page* page = fetch_root_page(); page->RLatch(); // 读锁:同层允许多个读者 while (!page->is_leaf()) { Page* child = fetch_child(page, key); child->RLatch(); page->RUnlatch(); // 父节点读完后立即释放 page = child; } // 此时持有叶子页读锁,可以安全地读 tuple } // 插入路径:若叶子未满,与查找路径相同;若叶子满, // 则必须从根重新走一遍,沿途加写锁并预分裂理解这段代码的关键在锁的粒度。查找路径只用读锁,而且「父子不同时持锁」——拿到孩子锁立刻释放父亲锁,这样两个线程可以并行下树,不会死锁。插入路径是乐观锁思路:先按读锁搜索,只有发现叶子页满了才重新从根走写锁路径,沿途预分裂,避免递归向上分裂时父节点被并发修改。B+ 树的 order(阶数)一般取 3 到 5:太小分裂频繁,树高变大;太大单页扫描变慢,缓存命中率反而下降。这是典型的「参数靠实测」的点,15445 的测试默认值就是教材和课程实验反复验证过的平衡点。
更关键的是要分清 lock 和 latch:latch 是保护内存数据结构(页、链表)的短临界区原语,生命周期是毫秒级,不感知事务;lock 是事务系统里保护逻辑资源(行、表)的锁,要跟着事务提交回滚走。Bustub 的锁管理器单独实现 lock,而 B+ 树里用的是 latch。面试时把这个区别讲清楚,比背一百个八股都有说服力。
3. 把 Bustub 跑起来:Bazel 构建、SQL 回放测试与 gdb 调试三板斧
源码能跑通是第一步。这一章讲的是工程化复现:我用什么命令构建、怎么用项目自带的测试数据做冒烟、出问题时怎么下断点。这一章会直接决定你拿到源码后第一个小时是顺利还是卡壳。
3.1 从 WORKSPACE.bazel 到 build.bat:构建链路怎么组织
项目用 Bazel 构建是大势所趋,15445 近几年的框架已经全面转向 Bazel,原因在于它对依赖图的精确控制:BUILD.bazel 里声明每个目标的头文件和库依赖,Bazel 才能做到增量编译和沙箱隔离。根目录的 .bazelversion 文件不是摆设,它锁定了 Bazel 的版本,避免本地环境与课程 CI 不一致导致的诡异行为。
# 用 bazelisk 自动匹配 .bazelversion 里的版本 bazelisk version # 构建缓冲池模块 bazel build //src:buffer_pool_manager # 构建全部测试目标 bazel build //test/... # 不想折腾环境的话,Windows 直接用项目自带的入口 build.bat注意 //src:buffer_pool_manager 是 Bazel 的目标语法:// 表示工作区根目录,src 是子目录,冒号后面是 BUILD.bazel 里定义的 target 名。如果只想验证语法,加 --nobuild 只解析不编译,速度会快很多。Z 和 debug 配套的常用参数在 .bazelrc 里已经写好了,比如 --cxxopt=-std=c++17、--compilation_mode=dbg,前者确定 C++ 标准,后者关掉优化、保留调试符号,是调试阶段必须开的。
3.2 用 test.db、insert1.txt 与 test.log 快速验证一个查询
项目里这几个文件是最佳的冒烟测试素材:insert1.txt 是预先生成的插入 SQL,test.db 是目标数据库文件,test.log 是上一次运行的日志输出。它们的配合方式很简单——把 insert1.txt 重定向进 shell,再跑一条 SELECT 验证数据落盘。
# 把 insert1.txt 中的 SQL 逐条灌入 shell ./bustub-shell < insert1.txt # 再执行一条查询,确认数据可读 echo "SELECT * FROM test_table LIMIT 5;" | ./bustub-shell # 如果查询没按预期返回,看日志尾部定位 tail -n 50 test.log这里用重定向而不是交互输入,是为了让测试可复现:管道输入天然是稳定的回放,不会因为手速快慢影响结果。test.log 里最先看的是每个 SQL 的执行计划和 lock/latch 等待记录——如果看到 long wait 或者 deadlock 关键字,基本可以断定并发模块出了问题。insert1.txt 通常只有几百行,不适合做压测,但足够验证「插入→落盘→查询」的核心链路,单独用它来调 B+ 树的分裂阈值也够用。
3.3 调试三板斧:断言、gdb 与日志级别
数据库内核调试和普通应用层调试有一个本质区别——你很难在崩溃现场「看一眼」数据,因为状态分散在多个 Page 里。我自己的习惯是:先用断言把不变量钉死,再用 gdb 抓崩溃堆栈,最后靠日志确认执行路径。
# 非交互式 gdb:自动下断点、跑完、打堆栈 gdb -batch -ex "break BufferPoolManager::FetchPage" \ -ex "run --sqllogictest test/basic.test" \ -ex "print page_id" \ -ex "bt" ./bustub-shell-batch 模式适合在脚本里跑回归;break 下在 FetchPage 入口,run 后面带的是 15445 标准的 sqllogictest 参数;print page_id 看当前请求的磁盘页号;bt 打完整调用栈,定位是哪一层调用了这一次页读取。实践里最有用的断言是这两处:B+ 树插入后校验整树结构,页面驱逐前断言 pin_count 为 0。习惯上我会把断言和 ASAN 一起用,因为很多内存错误在 debug 模式里不炸,只有打开 AddressSanitizer 才现形:
# 用 ASAN 重新构建并跑测试 bazel build //:bustub -c dbg \ --copt=-fsanitize=address --linkopt=-fsanitize=address注意这套组合拳的顺序:先断言缩小范围,再 ASAN 抓内存问题,最后 gdb 看堆栈。反过来的话,你会在 gdb 里看到大量源自同一根因的重复崩溃,浪费一晚上。
4. 避坑实录:个人实现里最容易翻车的五个位置
这一章的坑不是某一个版本独有,而是 15445 系列实现里反复出现的通病,我自己和身边同学都踩过。每一条都按「现象 → 原因 → 解决」写,后面给的关键代码片段是修坑后的正确写法。
4.1 页面驱逐丢数据:脏页没落盘就进了 free list
现象:跑完一个长事务,程序正常退出,重启后刚插入的记录消失了。
原因:驱逐 Frame 时只看 pin_count 是否为 0,没检查 is_dirty,直接把页丢回 free list。脏页从未写回磁盘,下次读到时是旧数据。
解决:驱逐前强制写回,先落盘再回收:
if (frame->is_dirty) { disk_manager_->WritePage(frame->page_id, frame->data); frame->is_dirty = false; // 写完后清标记,避免重复写 } lru_list_.remove(frame); free_list_.push_back(frame);顺序不能反过来:先清脏标记再写盘的话,写盘前一旦崩溃,数据就丢了。判断脏页的时机也要覆盖「FetchPage 时读入的页被修改」和「NewPage 分配的页被写入」两种情况,后者很容易忘记置脏。
4.2 并发测试随机卡死:latch 获取顺序不统一
现象:多线程压测时好时坏,偶尔整个进程卡死,Ctrl+C 都救不回来。
原因:一个线程先拿 A 页锁再拿 B 页锁,另一个线程先拿 B 再拿 A,形成循环等待。这是教科书级的死锁,但并发测试的随机性让它显得像玄学。
解决:全项目统一锁顺序——先根后叶、先左后右;同时在拿锁路径上做超时兜底:
bool ok = page->WLatchTry(100ms); // 100 毫秒拿不到就返回 if (!ok) { release_all_latches(); // 释放全部锁,重新走查找路径 return retry_path(); }try_lock 加超时不是用来「解决」死锁的,而是把死锁从「永久卡死」变成「可恢复的错误」。真正的根治还靠统一加锁顺序,这一条没有捷径。
4.3 B+ 树分裂后查询丢 key:父节点没接上新叶
现象:插入触发叶子页分裂后,范围查询少返回几个 key,单点查询正常。
原因:分裂时只把新页挂到了 sibling 链表上,没把上升的 key 插入父节点;或者父节点的迭代器在并发修改后失效,写到了错误的位置。
解决:分裂必须自底向上递归处理。常见做法是先沿查找路径记录所有访问过的节点,插入时从叶子逐层向上,每层判断是否满了、是否要分裂。标准模板是把分裂逻辑抽成一个独立函数,只在持有父节点写锁时调用,避免在递归过程中释放锁导致父节点结构被并发改坏。我一般每次分裂后立即调用一棵树的 validate 函数,校验所有叶子页 key 有序、所有父节点 key 与子节点边界一致,尽早暴露问题。
4.4 改了代码不生效:Bazel 缓存与版本的双重陷阱
现象:改了 .cc 文件,重新 build 后行为完全没变;或者在本地能跑,到另一台机器就编译失败。
原因:两种情况。——Bazel 的增量缓存认为输入没变,实际上是因为符号链接或文件时间戳被某些 IDE 改掉了,缓存 key 失效判断出错;——.bazelversion 锁定的版本和本机 bazel 不一致,不同版本的 action 缓存不兼容。
解决:先清缓存重建,再锁定版本:
bazel clean --expunge # 清除全部缓存,包括 action 缓存 bazelisk sync # 按 .bazelversion 重新拉取对应版本 bazel build //test/...从那以后,我每换一台机器都会先 bazelisk version 确认版本,再决定要不要 clean——指望缓存帮你省时间是优化,指望它不出错就是赌博。
4.5 测试偶发失败:debug 全绿,release 一跑就段错误
现象:编译优化等级为 dbg 时测试全过,换成 opt 后随机段错误,堆栈还每次都不同。
原因:未定义行为在 debug 模式下恰好没有暴露,优化器在 release 模式下做了激进的重排和常量折叠,把问题放大成崩溃。典型场景是向量越界、空指针解引用、int 溢出。
解决:无条件开 ASAN 和 UBSAN,把未定义行为提前暴露:
bazel build //:bustub -c dbg \ --copt=-fsanitize=address,undefined \ --linkopt=-fsanitize=address,undefinedASAN 报出的第一行就是越界点,不用猜;UBSAN 会直接打印哪一行做了有符号溢出。这套组合能消除九成「换个优化等级就翻车」的问题。
5. 多语言工具链拆解:Python 校验脚本、Web 可视化与 clang-format 自动化
这套源码之所以有十几种语言的文件,不是因为炫技,而是每一类语言都精准地干了一类活:C++ 做内核,Python 做测试驱动,HTML/JavaScript 做可视化,Shell 做自动化。理解这种分工,你才能看懂它的工程组织方式。
5.1 Python 在数据生成与结果校验里的角色
15445 的 grader 体系里,Python 是事实上的测试语言。它不参与内核逻辑,但承担了「生成数据、回放 SQL、比对结果」三件事。源码里所有 .py 文件,干的都是这类活。
#!/usr/bin/env python3 """生成 insert 语句并校验查询结果""" import random def gen_insert(table, n): """n 控制数据量,小 n 定位逻辑问题,大 n 做压测""" for i in range(n): key = random.randint(0, int(1e6)) val = f"user_{i}" print(f"INSERT INTO {table} VALUES ({key}, '{val}');") def verify(actual, expected): # 用集合比较,忽略行序——SQL 结果本来不保证顺序 assert {tuple(sorted(r)) for r in actual} == \ {tuple(sorted(r)) for r in expected}, "query result mismatch"verify 里用集合而不是列表比较,是因为数据库返回行序不稳定,这不是 bug,是 SQL 语义的一部分。random.randint 的取值范围可以当成参数调:小范围(比如 0~1000)容易触发 key 冲突,适合测 B+ 树去重逻辑;大范围适合测索引分裂。测试脚本和 C++ 侧通过文件或标准输入对接,保持模块间低耦合。
5.2 HTML/JavaScript 可视化与 Shell 自动化:调试效率的隐形杠杆
很多人不理解数据库源码里为什么有 HTML 和 JavaScript。Bustub 自带一个基于 Web 的调试前端——通过 HTTP 服务把 BufferPool 的命中率、B+ 树的页结构实时渲染到浏览器里。这是课程附带的调试利器,也是源码里 HTML/JS/CSS 存在的意义。调试索引结构时,图形化看树的层数和分裂过程,比盯着日志猜快得多。
Shell 脚本则承担自动化脏活。clang_format.bash 是这个项目里最值得抄的脚本——一条命令统一全仓库代码风格,省掉了 Code Review 里 80% 的「这里空格不对」式废话:
#!/bin/bash # 对 src 和 include 下的 .cc/.h 统一执行 clang-format find src include -name "*.cc" -o -name "*.h" | xargs clang-format -i参数上,-i 表示原地修改;如果想只检查不修改,换成 --dry-run --Werror,在 CI 里当格式门禁用,不合格直接让构建失败。这个脚本配合 Git hooks,能让每次提交的代码风格保持一致,比事后 review 省力一个量级。
5.3 代码风格与工程规范:.clang-format、.clang-tidy 与 LICENSE
这三个文件决定了代码的「可读性下限」。.clang-format 统一格式,.clang-tidy 做静态检查,LICENSE 明确使用权属。对一份要展示给面试官的源码来说,它们比功能代码更能体现工程素养。.clang-format 里最影响可读性的参数是这几个:
| 参数 | 常用值 | 影响 |
|---|---|---|
| BasedOnStyle | Google / LLVM | 整体风格基底 |
| IndentWidth | 4 | 缩进宽度,4 格在 C++ 里比 2 格更清晰 |
| ColumnLimit | 100 | 超过就自动换行,避免横向滚动 |
| SortIncludes | true | 头文件按字母排,diff 更干净 |
| PointerAlignment | Left | int* p 还是 int *p,统一靠左 |
.clang-tidy 则负责语义层面的检查,比如开启 modernize-use-override、performance-inefficient-vector-operation 这类规则,能拦住一批「能编译但不合理」的写法。LICENSE 更是个人项目里常被忽略的点:它决定了别人能不能合法复用你的代码。由于学术规范,这份源码库没有公开在 GitHub,只作为个人简历的支撑材料,这恰恰比公开项目更需要注意授权边界——你的代码只给你带来面试加分,不留给别人搬运。
6. 简历呈现与复现验证:让面试官三十秒听懂你做了什么
源码本身的价值需要被讲出来,否则只是一堆文件。我的习惯是:任何简历项目都要能回答「如何验证、如何复现」两个问题。对这份 Bustub 实现,我的验证路径固定三步:干净环境构建 → 全量测试 → 并发压力脚本,每一步都有明确退出条件。
数据库系统 B+ 树存储引擎(C++ / Bazel) - 实现 4KB 分页缓冲池,支持 LRU 替换与脏页写回,吞吐量较基础版本提升约 30% - 基于 latch 实现并发安全的 B+ 树索引,支持分裂/合并与乐观锁路径优化 - 通过 ASAN + 并发压测验证,全量测试通过率 100%,死锁率归零描述里每一项都有动词、有可衡量的点。「提升约 30%」「死锁率归零」这类表述是面试官愿意追问的钩子。另一个建议是准备一份一页纸的 README,把项目里 BUILD.bazel 的模块划分、test.db 的构建方式写清楚,面试现场直接打开文件讲,比背稿可信得多。
这套源码我拆完最大感受是:数据库系统的难点不在某个算法,而在算法之间的耦合——缓冲池的驱逐决策会直接影响 B+ 树的分裂性能,latch 的顺序会决定整个并发模块是否可靠。从那以后,我每次拿到新项目源码都先强制自己走一遍「构建→跑测→看日志」的闭环,再谈读代码,这个习惯帮我避开了无数个「看起来懂了、一跑就废」的尴尬。希望帮到你。
本文还有配套的精品资源,点击获取