简介:这份资源是2024年全国大学生计算机系统能力大赛数据库管理系统设计赛第三名的完整参赛源码与配套说明,面向计算机相关专业学生及数据库开发学习者,帮助理解一个真实竞赛级DBMS从底层架构到上层功能的实现路径。压缩包共411个文件,约1.38MB,以148个C++头文件、102个cc与40个cpp源文件为核心,辅以38个Python脚本、30个Markdown文档和22个txt说明,另有cmake、bazel等构建配置及测试用例文件,覆盖存储结构、索引策略、查询处理与事务管理等模块。已有122人学习。读者可从中获取完整赛题方案、模块划分思路、关键技术选型与问题解决记录,并借助说明文档梳理设计脉络,适合作为课程实践、竞赛复盘与数据库内核学习的参考,请仅用于学习交流,不得商用。
1. 从一份季军源码说起:数据库管理系统设计赛到底在比什么
如果你正在准备全国大学生计算机系统能力大赛的数据库管理系统设计赛,或者刚拿到一份“第三名参赛源码+说明.zip”却不知道从哪下手,这篇就是替你拆包的。这份资源的核心不是几万行代码本身,而是一套完整的数据库内核实现路径:从存储引擎的页式管理、B+ 树索引的并发控制,到查询执行器的火山模型、事务的 MVCC 可见性判断,再到 WAL 日志与崩溃恢复。它适合两类人:一类是正在打比赛、需要参考架构选型和模块划分的在校选手;另一类是学过数据库理论、但没真正写过存储层和事务层的开发者,想通过一份能跑通的工程代码把“黑匣子”打开。季军意味着它在功能完整度和性能之间找到了一个可复现的平衡点,不是那种只跑通测试用例的玩具。
2. 拆包先看构建系统:BUILD.bazel 与 gtest 的工程组织
2.1 为什么这份源码用 Bazel 而不是 CMake
拿到源码第一件事不是读src/,而是看根目录的BUILD.bazel。这份工程用 Bazel 作为构建系统,和大多数教学数据库用 CMake 的习惯不同。Bazel 的优势在于增量构建和依赖隔离:数据库内核通常拆成storage、index、executor、transaction、recovery等多个 target,每个 target 的deps显式声明,改一个模块不会全量重编。对于比赛场景,这意味着你调 B+ 树分裂逻辑时,不用等整个执行器重新编译。
从你给的正文片段看,工程里混入了gmock-matchers_test.cc、gtest_unittest.cc、gtest_pred_impl_unittest.cc、gmock-spec-builders_test.cc、googletest-printers-test.cc、gtest-death-test.cc这些文件,说明第三方测试框架 googletest/gmock 是以源码形式内嵌的,而不是通过系统包管理器安装。这样做的好处是版本锁定,避免评测环境里 gtest 版本不一致导致链接错误。常见做法是把 googletest 放在third_party/或test/目录下,用cc_library包一层,再让业务测试 target 依赖它。
2.2 用 Bazel 跑通第一个测试 target
假设你已经装好 Bazel(建议 6.x 以上),进入工程根目录后,先别急着bazel build //...,那会触发全量编译。先列出所有可用的测试 target:
# 列出工程中所有 test 类型的 target bazel query 'kind("cc_test", //...)'这条命令会输出类似//test:storage_test、//test:index_test、//test:transaction_test的列表。参数说明:kind("cc_test", //...)是 Bazel 的查询语法,cc_test是规则类型,//...表示从当前包递归匹配所有子包。如果你只想看某个模块的依赖树,可以用:
# 查看 index 模块的依赖关系 bazel query 'deps(//src/index:index)' --output graph--output graph会输出 Graphviz 格式的依赖图,适合排查循环依赖。比赛代码里最容易出现的翻车点是storage和index互相依赖:索引需要读页,存储层又需要索引做页内查找。正确做法是抽一个common或page层,让两者都依赖它,而不是互相引用。
2.3 编译单个模块并跑测试
确定 target 后,编译并运行:
# 编译并运行存储引擎测试,输出详细日志 bazel test //test:storage_test --test_output=all --cache_test_results=no参数说明:--test_output=all会打印测试进程的标准输出,数据库测试里通常有大量日志(页分配、锁等待、日志刷盘),默认只在失败时显示;--cache_test_results=no强制重新跑,避免 Bazel 缓存让你误以为改动生效了。我一般还会加--test_arg=--gtest_filter=PageTest.*来只跑页管理相关的用例,减少等待时间。
提示:如果
bazel test报找不到gtest/gtest.h,检查BUILD.bazel里是否把 googletest 的cc_library加进了deps,而不是只放在srcs里。源码内嵌 gtest 时,头文件路径要用includes = ["third_party/googletest/googletest/include"]显式导出。
3. 存储引擎与索引:从页式管理到 B+ 树并发
3.1 页式存储的元数据布局与空闲页管理
数据库内核的存储层通常以固定大小的页(常见 4KB 或 8KB)为最小单位。这份季军源码的存储模块一般会包含Page、BufferPoolManager、DiskManager三个核心类。Page的头部会存page_id、pin_count、is_dirty、lsn(日志序列号)等元数据,剩余空间才是元组数据。你需要先找到page.h或storage/page.h,确认页头大小,因为这直接影响元组最大长度和槽位数组的偏移计算。
空闲页管理常见两种做法:链表法和位图法。链表法在页头存next_free_page_id,实现简单但随机分配时磁盘寻道多;位图法用一个或多个页记录所有页的占用状态,适合页数固定的场景。比赛代码为了快速通过测试,往往用链表法。你可以通过搜索free_list_或next_free_page_id定位。
3.2 B+ 树索引的插入分裂与并发控制
索引模块是比赛拉开差距的地方。B+ 树的插入需要处理节点分裂,删除需要处理合并或重分配。源码里通常有BPlusTree::Insert、Split、Coalesce等函数。关键参数是order(阶数),它决定每个节点最多存多少个键。阶数越大,树越矮,但节点内二分查找越慢。常见做法是根据页大小和键类型反推:max_keys = (page_size - header_size) / (key_size + value_size)。
并发控制方面,比赛代码可能用 latch coupling(闩锁耦合)或乐观锁。latch coupling 在下降时先锁子节点再释放父节点,避免死锁;乐观锁则先读后验证版本号。你可以在bplus_tree.cpp里搜std::lock_guard、std::shared_mutex或ReadWriteLock来判断。如果看到root_latch_和page_latch_两级锁,说明是粗粒度根锁加细粒度页锁的混合方案。
// 典型的 B+ 树插入分裂伪代码(基于源码结构还原) bool BPlusTree::Insert(const KeyType &key, const ValueType &value) { std::lock_guard<std::mutex> root_guard(root_latch_); // 根锁保护根节点切换 if (IsEmpty()) { StartNewTree(key, value); return true; } return InsertIntoLeaf(key, value); // 内部走 latch coupling }逻辑说明:根锁只在根节点为空或需要换根时持有,避免每次插入都串行化。InsertIntoLeaf内部会沿着路径对子节点加读锁或写锁,具体取决于是否可能触发分裂。参数说明:KeyType和ValueType通常是模板参数,比赛里可能是int64_t和RID(记录标识)。如果你要改阶数,改BPlusTree的模板参数或构造函数里的order即可,但注意同步修改测试里的预期树高。
3.3 缓冲池的替换策略与脏页刷盘
缓冲池管理器负责把磁盘页换入内存。常见替换策略是 LRU-K 或 Clock。源码里可能有LRUKReplacer类,带k参数(通常 k=2),记录每个页最近两次访问的时间戳。Evict时优先淘汰倒数第二次访问最久远的页。脏页淘汰前必须写回磁盘,并确保对应的 WAL 日志已经刷盘(WAL 规则:日志先于数据)。
你可以通过buffer_pool_manager.cpp里的FlushPage和FlushAllPages观察刷盘逻辑。一个容易踩的坑是:FlushPage只写磁盘不清除脏标记,导致同一页被反复写。正确做法是写完后把is_dirty置 false,但保留pin_count不变。
4. 查询执行与事务:火山模型、MVCC 与日志恢复
4.1 火山模型执行器的算子接口
查询执行器通常采用火山模型(Volcano Model),每个算子实现Init()和Next()。Next()返回一个元组或nullptr表示结束。源码里会有SeqScanExecutor、IndexScanExecutor、NestedLoopJoinExecutor、AggregationExecutor等。你需要关注ExecutorContext里带了哪些信息:BufferPoolManager、Transaction、Schema、Catalog。比赛代码为了简化,可能把谓词下推和投影都放在SeqScanExecutor里做。
// 顺序扫描算子的 Next 实现(基于常见比赛代码结构) bool SeqScanExecutor::Next(Tuple *tuple, RID *rid) { while (iter_ != table_heap_->End()) { auto current_tuple = iter_.GetTuple(); // 从表堆取元组 iter_++; // 迭代器前移 if (predicate_ == nullptr || predicate_->Evaluate(¤t_tuple, schema_).GetAsBool()) { *tuple = current_tuple; *rid = current_tuple.GetRid(); return true; } } return false; }逻辑说明:iter_是表堆迭代器,predicate_是谓词表达式。如果谓词为空或求值为真,就返回当前元组。参数说明:Tuple包含数据和元数据,RID是页号加槽号。注意Evaluate返回的是Value类型,需要.GetAsBool()转换。常见错误是忘记在Init()里重置iter_,导致第二次执行查询时直接返回空。
4.2 MVCC 可见性判断与事务隔离级别
事务模块的核心是 MVCC(多版本并发控制)。每个元组会带xmin(创建事务号)和xmax(删除事务号)。可见性判断规则:如果xmin已提交且xmax未提交或未开始,则元组可见。源码里通常有IsVisible或CheckVisibility函数。你需要找到TransactionManager和LockManager,看它支持哪几种隔离级别。比赛一般要求实现 Read Committed 或 Repeatable Read。
一个血泪经验:MVCC 的版本链如果只在内存里维护,崩溃恢复后会丢失。所以源码里通常会把旧版本也写到表堆里,用xmax标记删除,而不是直接覆盖。这样 WAL 重放时才能重建版本链。
4.3 WAL 日志格式与崩溃恢复流程
WAL 日志通常分Begin、Commit、Abort、Insert、Delete、Update等类型。每条日志有lsn、txn_id、prev_lsn。恢复分三个阶段:Analysis(扫描日志确定活跃事务和脏页)、Redo(重放所有已提交或未完成事务的操作)、Undo(回滚未提交事务)。源码里会有LogManager、LogRecovery类。
# 运行恢复测试,观察日志重放 bazel test //test:recovery_test --test_output=all --test_arg=--gtest_filter=RecoveryTest.*参数说明:--gtest_filter=RecoveryTest.*只跑恢复相关用例。如果测试失败,先看日志里Redo阶段是否跳过了某些 LSN,常见原因是page_lsn比较逻辑写反了:只有当页的page_lsn小于日志的lsn时才重放,否则跳过。
5. 避坑与排查:季军代码里也躲不过的五个问题
5.1 现象:Bazel 编译通过但测试链接报 undefined reference
原因:BUILD.bazel里cc_test的deps只写了业务库,没写 googletest 的cc_library,或者 googletest 的cc_library没有visibility = ["//visibility:public"]。解决:在测试 target 的deps里显式加//third_party/googletest:gtest_main,并确认该 target 的visibility允许当前包引用。
5.2 现象:缓冲池测试随机失败,报页号越界
原因:BufferPoolManager的pages_数组大小和pool_size_不一致,或者Evict返回的帧号没有做边界检查。解决:在FetchPage和NewPage里加断言frame_id < pool_size_,并检查replacer_的Evict是否在池满时正确返回 false。
5.3 现象:B+ 树并发插入时死锁,测试超时
原因:latch coupling 下降时,父节点锁释放顺序和子节点加锁顺序不一致,两个线程交叉持锁。解决:统一加锁顺序,始终先锁父再锁子,释放时先放子再放父。或者改用乐观锁加重试。
5.4 现象:事务回滚后数据仍在,可见性判断错误
原因:Undo 阶段只改了内存中的元组,没有写补偿日志(CLR),崩溃后再次恢复时无法回滚。解决:Undo 操作也要生成日志,并在日志里记录undo_next_lsn,形成回滚链。
5.5 现象:查询执行器返回重复元组
原因:SeqScanExecutor的迭代器在谓词过滤后没有正确前移,或者NestedLoopJoinExecutor的内层循环没有重置。解决:在Next()里确保每次循环都推进迭代器,join 的内层Init()在外层每次取新元组时重新调用。
6. 进阶验证:用 TPC-C 简化负载压一遍执行路径
跑通单元测试只是第一步。要验证这份季军源码的工程成色,我一般会自己搭一个简化版的 TPC-C 负载:五张表(仓库、 district、 customer、 orders、 order_line),用多线程跑新订单和支付事务,观察吞吐和延迟。具体做法是写一个benchmarktarget,依赖//src:db,用std::chrono计时。
// 简化压测:多线程执行新订单事务 void RunNewOrderBenchmark(int num_threads, int txn_per_thread) { std::vector<std::thread> threads; for (int i = 0; i < num_threads; i++) { threads.emplace_back([&, i]() { auto *txn = txn_mgr_->Begin(nullptr); // 开启事务 for (int j = 0; j < txn_per_thread; j++) { // 执行新订单逻辑:插入 order、order_line,更新 district executor_->ExecuteNewOrder(txn, i, j); } txn_mgr_->Commit(txn); // 提交 }); } for (auto &t : threads) t.join(); }逻辑说明:每个线程独立开启事务,循环执行新订单,最后提交。参数说明:num_threads控制并发度,txn_per_thread控制单线程事务数。你可以通过调整这两个参数观察锁竞争和日志刷盘频率。如果吞吐随线程数增加反而下降,说明锁粒度太粗或日志刷盘成了瓶颈。
验证时重点看三个指标:事务提交延迟的 P99、缓冲池命中率、WAL 日志文件增长速率。P99 突然飙升通常是锁等待;命中率低于 90% 说明缓冲池太小或替换策略有问题;日志增长过快可能是每次更新都刷盘,可以改成组提交。
从那以后我每次拿到比赛源码,都强制先跑一遍bazel query理清依赖,再挑一个最小测试 target 跑通,最后才读核心模块。这份季军代码的价值不在名次,而在它把数据库内核的每个环节都落到了可编译、可测试的工程结构里。希望帮到你。
本文还有配套的精品资源,点击获取