
简介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.bazelBazel 工作区定义声明外部依赖与仓库规则.bazelrc / .bazelversion编译参数与 Bazel 版本锁定clang_format.bash一键格式化全部 C/C 源码的脚本build.batWindows 下的构建入口utf8proc_data.cUnicode 规范化数据表字符串比较与排序的底层依赖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_pointstd::chrono::steady_clock last_used; }; // LRU 替换找到 pin_count 0 且最久未被访问的帧 Frame* find_victim(std::listFrame* 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 和 latchlatch 是保护内存数据结构页、链表的短临界区原语生命周期是毫秒级不感知事务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-stdc17、--compilation_modedbg前者确定 C 标准后者关掉优化、保留调试符号是调试阶段必须开的。3.2 用 test.db、insert1.txt 与 test.log 快速验证一个查询项目里这几个文件是最佳的冒烟测试素材insert1.txt 是预先生成的插入 SQLtest.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-fsanitizeaddress --linkopt-fsanitizeaddress注意这套组合拳的顺序先断言缩小范围再 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 获取顺序不统一现象多线程压测时好时坏偶尔整个进程卡死CtrlC 都救不回来。原因一个线程先拿 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-fsanitizeaddress,undefined \ --linkopt-fsanitizeaddress,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 fuser_{i} print(fINSERT 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 mismatchverify 里用集合而不是列表比较是因为数据库返回行序不稳定这不是 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 里最影响可读性的参数是这几个参数常用值影响BasedOnStyleGoogle / LLVM整体风格基底IndentWidth4缩进宽度4 格在 C 里比 2 格更清晰ColumnLimit100超过就自动换行避免横向滚动SortIncludestrue头文件按字母排diff 更干净PointerAlignmentLeftint* 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 的顺序会决定整个并发模块是否可靠。从那以后我每次拿到新项目源码都先强制自己走一遍「构建→跑测→看日志」的闭环再谈读代码这个习惯帮我避开了无数个「看起来懂了、一跑就废」的尴尬。希望帮到你。本文还有配套的精品资源点击获取