
简介这份资源是2024年全国大学生计算机系统能力大赛数据库管理系统设计赛第三名的完整参赛源码与配套说明面向计算机相关专业学生及数据库开发学习者可用于研读赛题实现思路、提升系统级开发能力。压缩包共411个文件约1.38MB以C核心代码为主包含148个h头文件、102个cc与40个cpp源文件另有38个Python脚本、30个md说明文档及若干txt、yml、cmake、bazel构建配置并附带gtest/gmock测试用例覆盖底层架构、存储结构、索引策略、查询处理与事务管理等模块。目前已有122人学习。通过源码可完整还原一支获奖队伍的设计方案与工程组织方式说明文档则梳理了模块划分、关键技术选型及问题解决过程配合测试代码便于理解验证路径适合作为课程设计、竞赛备赛与数据库内核学习的参考范例请仅用于学习交流不得商用。1. 数据库管理系统设计赛从赛题到可运行源码一条能复现的路径全国大学生计算机系统能力大赛的数据库管理系统设计赛近两年在高校圈里的热度肉眼可见地涨。它不像算法竞赛那样只拼一道题的解法而是要求你在几个月里从零搭出一个能跑 SQL、能存数据、能过事务的数据库内核。第三名这个成绩意味着方案在功能完整度、性能指标和工程规范上都过了硬门槛。很多人拿到一份参赛源码第一反应是“能不能直接跑起来”第二反应是“我照着做能不能也拿奖”。这篇笔记就按这个思路走先讲清赛题到底考什么再拆源码结构然后落到编译、跑测、调参、排错的具体操作最后给几个能拉开差距的进阶技巧。适合正在备赛的本科生、想补数据库内核实践的研究生以及需要一套可参考实现来带课设的开发者。2. 赛题拆解与源码结构先搞懂评分表再动手2.1 赛题到底在考什么从 SQL 解析到事务隔离数据库管理系统设计赛的赛题通常围绕一个核心目标实现一个支持基本 SQL 子集、具备存储引擎和事务管理能力的关系型数据库。评分维度一般分四块——功能正确性、性能指标、代码规范、答辩表现。功能正确性占大头要求你能正确执行CREATE TABLE、INSERT、SELECT、UPDATE、DELETE以及带WHERE、JOIN、ORDER BY、GROUP BY的查询。性能指标则看吞吐和延迟比如在给定数据集上跑完一组查询的耗时。事务部分通常要求支持BEGIN、COMMIT、ROLLBACK并保证一定级别的隔离性。很多队伍翻车不是因为功能没写完而是因为对评分表的理解有偏差。比如有的年份要求支持NULL值的三值逻辑有的年份要求索引必须能加速等值查询和范围查询。这些细节在赛题文档里写得清楚但容易被忽略。我一般会先把评分表打印出来逐条打勾确保每个得分点都有对应的代码路径。2.2 源码目录怎么读五个核心模块的职责划分拿到一份参赛源码不要急着编译。先看目录结构通常能看出作者的架构思路。一个典型的数据库内核源码会包含这几个目录src/ ├── parser/ # SQL 词法、语法分析生成 AST ├── planner/ # 逻辑计划、物理计划选择执行策略 ├── executor/ # 算子实现扫描、过滤、投影、连接、聚合 ├── storage/ # 页管理、缓冲池、B 树索引、记录序列化 ├── transaction/ # 事务管理、锁、日志、恢复 └── common/ # 工具类、配置、错误码parser负责把 SQL 文本变成抽象语法树planner把 AST 转成可执行的计划树executor按计划树逐算子执行storage管磁盘和内存的数据组织transaction保证并发和崩溃恢复。读源码时建议从parser的入口函数开始跟着一条SELECT语句走完全流程这样能快速建立全局观。2.3 编译与依赖用 CMake 在本地跑通最小构建大多数参赛源码用 C 编写构建系统以 CMake 为主。先确认本地环境有g或clang、cmake、make以及可能的第三方库如readline、gtest。下面是一套通用的构建命令# 进入源码根目录 cd db-contest-src # 创建构建目录保持源码树干净 mkdir -p build cd build # 生成 MakefileCMAKE_BUILD_TYPE 控制优化级别 cmake .. -DCMAKE_BUILD_TYPERelease -DENABLE_TESTON # 并行编译-j 后面跟 CPU 核心数 make -j$(nproc) # 编译完成后可执行文件通常在 build/bin 下 ls bin/CMAKE_BUILD_TYPERelease会开启-O2优化对性能测试很关键ENABLE_TESTON会编译单元测试方便验证各模块。如果编译报错找不到头文件先检查CMakeLists.txt里的include_directories是否指向了正确的第三方库路径。常见坑是readline没装开发包Ubuntu 下用apt install libreadline-dev解决。2.4 跑通第一个测试从建表到查询的完整链路编译成功后先别急着跑性能测试。用一个小数据集验证基本链路-- 启动数据库服务端或直接进入交互式客户端 CREATE TABLE student ( id INT PRIMARY KEY, name VARCHAR(32), score INT ); INSERT INTO student VALUES (1, A, 90); INSERT INTO student VALUES (2, B, 85); INSERT INTO student VALUES (3, C, 92); SELECT name, score FROM student WHERE score 88 ORDER BY score DESC;如果这条查询能返回正确结果说明解析、计划、执行、存储四个模块基本打通。如果报错按错误信息定位语法错误看parser类型不匹配看executor的表达式求值数据不对看storage的序列化。这一步跑通后再跑官方提供的测试集逐条对比预期输出。3. 存储引擎与索引实现B 树、缓冲池与页管理的落地细节3.1 页式存储为什么 4KB 页大小是常见起点数据库存储引擎通常以页为单位管理磁盘和内存。页大小选 4KB 还是 8KB直接影响 I/O 次数和内存占用。4KB 是操作系统页大小的常见值能减少一次额外的内存拷贝8KB 则能在一页里放更多记录降低树高。参赛源码里常见做法是定义一个Page类包含页头页号、页类型、空闲空间指针和数据区。// page.h 简化示例 constexpr size_t PAGE_SIZE 4096; struct PageHeader { page_id_t page_id; // 页号 uint16_t page_type; // 数据页、索引页、溢出页 uint16_t slot_count; // 槽位数 uint16_t free_space; // 空闲空间偏移 lsn_t lsn; // 日志序列号用于恢复 }; class Page { public: char data[PAGE_SIZE]; PageHeader* header() { return reinterpret_castPageHeader*(data); } // 插入记录、删除记录、整理碎片等接口 };页头里的lsn是事务恢复的关键每次修改页都要更新。slot_count和free_space配合使用实现变长记录的槽式管理。注意页内碎片整理删除记录后不要立即搬移数据而是标记槽位为空等空闲空间不足时再整理避免频繁内存移动。3.2 缓冲池LRU-K 替换策略与脏页刷盘时机缓冲池是内存和磁盘之间的缓存层核心问题是“内存不够时淘汰哪一页”。朴素 LRU 在数据库场景下容易被全表扫描污染把热页挤出去。常见改进是 LRU-K记录每页最近 K 次访问时间淘汰时优先选访问次数少的。参赛源码里如果实现了 LRU-K性能测试通常能拉开差距。// 缓冲池淘汰逻辑伪代码 Page* BufferPool::fetch_page(page_id_t pid) { if (page_table_.count(pid)) { // 命中更新访问历史 access_history_[pid].push_back(now()); return pages_[pid]; } // 未命中需要从磁盘读入 if (free_list_.empty()) { // 淘汰一页优先选访问次数少于 K 的再按最久未访问排序 page_id_t victim select_victim(); if (is_dirty(victim)) { flush_page(victim); // 脏页先写回磁盘 } evict(victim); } // 分配新页从磁盘读取 Page* p allocate_page(); read_from_disk(pid, p); page_table_[pid] p; return p; }脏页刷盘时机很关键太频繁会拖慢事务太懒则崩溃恢复时间长。常见策略是后台线程定期刷加上事务提交时强制刷日志WAL。注意flush_page要保证先写日志再写数据页否则崩溃后无法恢复。3.3 B 树索引插入分裂与范围扫描的边界处理B 树是数据库索引的标配。参赛源码里通常要求支持等值查询和范围查询。实现难点在插入分裂和删除合并。插入时如果叶子节点满了要分裂成两个节点并把中间键上推到父节点父节点满了继续分裂直到根节点。删除时如果节点利用率低于阈值要考虑合并或重分配。// B 树插入分裂的核心逻辑 void BPlusTree::insert_in_leaf(LeafNode* leaf, key_t key, value_t value) { if (leaf-is_full()) { // 分裂叶子节点 LeafNode* new_leaf allocate_leaf(); int split_pos leaf-size() / 2; // 把后半部分记录移到新节点 for (int i split_pos; i leaf-size(); i) { new_leaf-insert(leaf-key_at(i), leaf-value_at(i)); } leaf-truncate(split_pos); // 更新链表指针保证范围扫描能跨节点 new_leaf-next leaf-next; leaf-next new_leaf; // 把新节点的第一个键上推到父节点 key_t up_key new_leaf-key_at(0); insert_in_parent(leaf, up_key, new_leaf); } leaf-insert(key, value); }范围扫描时从起始键所在的叶子节点开始沿着next指针遍历到终止键。注意边界如果起始键不在任何记录中要找到第一个大于等于它的位置如果终止键跨页要保证链表指针正确。常见坑是分裂后忘记更新父节点指针导致后续查询走错路径。3.4 记录序列化变长字段与 NULL 值的编码方式记录在页内的存储格式直接影响读写效率。定长字段直接按偏移存放变长字段如VARCHAR通常用“长度 数据”的方式并在页头维护一个槽目录。NULL值可以用位图标记每个字段一位避免额外存储。// 记录序列化示例先写 NULL 位图再写定长字段最后写变长字段 void serialize_record(const Row row, const Schema schema, char* buf) { size_t offset 0; // NULL 位图每个字段一位 uint32_t null_bitmap 0; for (size_t i 0; i schema.columns.size(); i) { if (row.is_null(i)) null_bitmap | (1 i); } memcpy(buf offset, null_bitmap, sizeof(null_bitmap)); offset sizeof(null_bitmap); // 定长字段 for (auto col : schema.columns) { if (col.is_fixed_length() !row.is_null(col.index)) { memcpy(buf offset, row.data(col.index), col.length); offset col.length; } } // 变长字段先写长度再写数据 for (auto col : schema.columns) { if (!col.is_fixed_length() !row.is_null(col.index)) { uint16_t len row.length(col.index); memcpy(buf offset, len, sizeof(len)); offset sizeof(len); memcpy(buf offset, row.data(col.index), len); offset len; } } }反序列化时按同样顺序读取。注意字节对齐如果直接memcpy到结构体要确保编译器不会插入填充字节否则跨平台会出问题。稳妥做法是逐字段读写不依赖内存布局。4. 事务管理与并发控制从锁粒度到日志恢复的工程取舍4.1 锁粒度选择表锁、页锁还是行锁事务隔离性的基础是锁。锁粒度越细并发越高但管理开销越大。表锁实现简单但并发差行锁并发好但需要处理锁升级和死锁检测。参赛源码里常见折中方案是页锁或行锁加意向锁。意向锁用来表示“某个事务想在更细粒度上加锁”避免逐行检查。// 锁管理器简化接口 class LockManager { public: bool lock_shared(txn_id_t txn, resource_id_t res); bool lock_exclusive(txn_id_t txn, resource_id_t res); bool unlock(txn_id_t txn, resource_id_t res); private: std::unordered_mapresource_id_t, LockRequestQueue lock_table_; std::mutex latch_; // 保护锁表本身 };加锁时先检查兼容性共享锁之间兼容共享与排他不兼容排他之间不兼容。如果冲突把请求放入等待队列并检测死锁。死锁检测常用等待图环检测发现环就选一个事务回滚。4.2 隔离级别实现可重复读与读已提交的差异点可重复读RR要求同一事务内多次读同一数据结果一致读已提交RC只要求读到已提交的数据。实现差异主要在锁的持有时间RR 下读锁持有到事务结束RC 下读锁读完即释放。参赛源码里如果只实现 RC性能测试可能更好但功能测试可能丢分。// 读操作加锁策略对比 void read_record(txn_id_t txn, record_id_t rid, IsolationLevel level) { if (level IsolationLevel::REPEATABLE_READ) { lock_manager_-lock_shared(txn, rid); // 持有到事务结束 } else if (level IsolationLevel::READ_COMMITTED) { lock_manager_-lock_shared(txn, rid); // 读完立即释放下一句读可能看到不同数据 lock_manager_-unlock(txn, rid); } // 实际读取数据... }注意 RR 下如果事务要更新某行需要先加排他锁这时可能死锁。常见做法是更新前先加排他锁或者用乐观并发控制提交时冲突再回滚。4.3 WAL 日志redo 与 undo 的记录格式和恢复流程预写日志WAL是崩溃恢复的核心。修改数据前先写日志日志里包含事务 ID、页号、偏移、旧值和新值。恢复时先重放 redo 日志把已提交事务的修改应用到数据页再回滚未提交事务的 undo 日志。// 日志记录格式 struct LogRecord { lsn_t lsn; // 日志序列号 txn_id_t txn_id; page_id_t page_id; uint16_t offset; uint16_t length; char old_value[256]; // undo 用 char new_value[256]; // redo 用 LogType type; // BEGIN, COMMIT, ABORT, UPDATE, CHECKPOINT };恢复流程分三步分析阶段扫描日志确定哪些事务已提交、哪些未提交重做阶段从检查点开始重放所有 redo撤销阶段回滚未提交事务。注意检查点要定期做否则恢复时间会随日志增长而变长。4.4 死锁检测等待图环检测与超时回滚的配合死锁检测有两种常见方式等待图环检测和超时回滚。等待图维护事务之间的等待关系发现环就选一个牺牲者回滚。超时回滚则简单粗暴等待超过阈值就回滚。参赛源码里可以两者结合先超时再环检测减少误杀。// 等待图环检测伪代码 bool DeadlockDetector::detect_cycle() { std::unordered_maptxn_id_t, int visited; for (auto [txn, _] : wait_graph_) { if (dfs(txn, visited)) return true; } return false; } bool dfs(txn_id_t txn, std::unordered_maptxn_id_t, int visited) { visited[txn] 1; // 正在访问 for (txn_id_t next : wait_graph_[txn]) { if (visited[next] 1) return true; // 发现环 if (visited[next] 0 dfs(next, visited)) return true; } visited[txn] 2; // 访问完成 return false; }发现环后选择回滚代价最小的事务通常是修改数据最少或优先级最低的。回滚时按 undo 日志反向操作并释放所有锁。5. 避坑与排查第三名方案里踩过的五个真实坑5.1 编译通过但运行崩溃缓冲区溢出与未初始化指针现象编译无报错启动后执行第一条 SQL 就段错误。原因页内记录序列化时没检查边界写入超过PAGE_SIZE或者缓冲池分配页后没初始化PageHeaderfree_space是随机值。解决在序列化函数里加断言offset len PAGE_SIZE分配页后用memset清零或显式初始化页头。5.2 查询结果时对时错并发下的脏读与锁遗漏现象单线程测试全过多线程跑测试集时结果随机出错。原因读操作没加共享锁或者加锁后提前释放导致读到其他事务未提交的修改。解决检查所有读路径是否都经过锁管理器RR 级别下读锁必须持有到事务结束。用gtest写并发测试固定随机种子复现。5.3 性能测试跑不完索引没命中导致全表扫描现象功能测试通过但性能测试超时。原因查询计划没选索引或者索引条件写错导致无法使用。解决在planner里加日志打印每个查询选择的计划检查WHERE条件是否匹配索引列比如WHERE score 88应该走 B 树范围扫描而不是全表扫。如果索引没建先CREATE INDEX。5.4 崩溃恢复后数据不一致日志刷盘顺序错误现象模拟崩溃后重启部分已提交事务的数据丢失。原因先写了数据页再写日志崩溃时日志没落盘恢复时无法重做。解决严格遵循 WAL 规则事务提交时先fsync日志文件再返回成功。数据页刷盘可以延迟但日志必须先行。5.5 内存泄漏导致长跑失败缓冲池页未释放现象跑长时间测试时内存持续增长最终 OOM。原因缓冲池淘汰页时只从page_table_删除没释放实际内存或者事务回滚后没释放锁和日志资源。解决用valgrind或AddressSanitizer检查泄漏点确保每个new都有对应的delete或者用智能指针管理页对象。6. 进阶技巧从第三名到第一名的差距在哪里6.1 用执行计划缓存减少重复解析开销很多队伍每次查询都重新解析和生成计划性能测试里重复查询多这部分开销很可观。加一个计划缓存以 SQL 文本的哈希为键缓存计划树。注意参数化查询要区分常量避免缓存错计划。// 计划缓存简化实现 std::unordered_mapsize_t, PlanNode* plan_cache_; PlanNode* get_plan(const std::string sql) { size_t key std::hashstd::string{}(sql); if (plan_cache_.count(key)) { return plan_cache_[key]; } PlanNode* plan planner_-create_plan(sql); plan_cache_[key] plan; return plan; }缓存要设上限避免内存无限增长。淘汰策略可以用 LRU。6.2 向量化执行一次处理一批记录而不是一行传统执行器一次处理一行函数调用开销大。向量化执行一次处理一批如 1024 行减少虚函数调用和分支预测失败。参赛源码里如果实现简单的向量化扫描和过滤性能能提升不少。// 向量化过滤示例 void filter_batch(const std::vectorRow input, std::vectorRow output) { for (size_t i 0; i input.size(); i BATCH_SIZE) { size_t end std::min(i BATCH_SIZE, input.size()); for (size_t j i; j end; j) { if (evaluate_predicate(input[j])) { output.push_back(input[j]); } } } }注意批大小要适配 CPU 缓存太大反而降低命中率。6.3 用火焰图定位热点函数性能测试跑完后用perf或gprof生成火焰图看时间花在哪里。常见热点是锁竞争、内存分配、B 树查找。针对热点优化比如把malloc换成内存池把全局锁拆成分段锁。# 用 perf 采样 perf record -g ./build/bin/db_server perf report火焰图能直观看到调用栈比猜更靠谱。6.4 测试驱动开发先写测试再写实现备赛时间紧但测试不能省。每实现一个模块先写单元测试覆盖正常和边界情况。比如 B 树插入要测空树、单节点、分裂、多级分裂。测试用gtest组织ctest跑。TEST(BPlusTreeTest, InsertAndSplit) { BPlusTree tree; for (int i 0; i 1000; i) { tree.insert(i, i * 10); } for (int i 0; i 1000; i) { EXPECT_EQ(tree.search(i), i * 10); } }测试通过再提交避免回归。6.5 答辩准备把设计取舍讲成故事答辩不是念代码而是讲清楚为什么这么设计。比如“我们选页锁而不是行锁因为行锁管理开销在测试数据集上反而更慢”或者“我们用 LRU-K 而不是 LRU因为全表扫描会污染缓存”。每个取舍都要有数据支撑最好在性能测试里对比过。我一般会准备一页 PPT左边是方案 A右边是方案 B下面放测试数据讲的时候直接指。最后说个血泪经验备赛期间一定要用版本控制每天提交。我们有一次改崩了存储引擎靠git reset才救回来。还有性能测试前先跑一遍功能测试别为了优化把正确性丢了。希望帮到你。本文还有配套的精品资源点击获取