资讯动态

数据库管理系统设计赛源码拆解:B+树、MVCC与WAL工程实践

发布时间:2026/10/9 21:42:31 来源:尧图企业网站定制
简介这份资源是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_outputall --cache_test_resultsno参数说明--test_outputall会打印测试进程的标准输出数据库测试里通常有大量日志页分配、锁等待、日志刷盘默认只在失败时显示--cache_test_resultsno强制重新跑避免 Bazel 缓存让你误以为改动生效了。我一般还会加--test_arg--gtest_filterPageTest.*来只跑页管理相关的用例减少等待时间。提示如果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_guardstd::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参数通常 k2记录每个页最近两次访问的时间戳。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(current_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_outputall --test_arg--gtest_filterRecoveryTest.*参数说明--gtest_filterRecoveryTest.*只跑恢复相关用例。如果测试失败先看日志里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::vectorstd::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 跑通最后才读核心模块。这份季军代码的价值不在名次而在它把数据库内核的每个环节都落到了可编译、可测试的工程结构里。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑