数据库管理系统设计赛:从零手写存储引擎与TPC-C实战 简介这份资源是2024年全国大学生计算机系统能力大赛数据库管理系统设计赛第三名的完整参赛源码与配套说明面向计算机相关专业学生及数据库开发学习者可用于研读赛题实现思路、提升系统级开发能力。压缩包共411个文件约1.38MB以C核心源码为主包含148个h头文件、102个cc与40个cpp实现文件另有38个Python脚本、30个md说明文档及若干txt、yml、cmake、bazel构建配置还附带gtest、gmock等测试框架代码覆盖底层架构、存储结构、索引策略、查询处理与事务管理等关键模块。目前已有122人学习。通过源码可完整还原一支获奖队伍的设计方案与工程组织方式配套文档则梳理了模块划分、关键技术选型及问题解决过程便于对照理解数据库内核实现细节适合作为课程设计、竞赛备赛与数据库原理实践的参考素材请仅用于学习交流。1. 数据库管理系统设计赛从零手写一个能跑 TPC-C 的存储引擎去年带学生打这个比赛初赛提交前三天我们的系统在 TPC-C 压测下突然出现数据页校验和不匹配回滚日志重放直接崩掉。排查到凌晨才发现是缓冲池淘汰策略在脏页写回时没有正确加闩锁两个线程同时修改了同一个页的 LSN。这个坑让我意识到数据库管理系统设计赛考察的不是你会不会用 MySQL而是你能不能从零实现一个具备事务、并发控制、恢复能力的存储引擎。这个赛题的核心就是让你手写一个单机关系型数据库支持 SQL 解析、执行、索引、事务和崩溃恢复。适合已经学过操作系统、数据结构想通过一个硬核项目把理论串起来的同学。如果你正在准备参赛或者想理解数据库底层下面这套从存储层到查询层的实现路径可以直接复现。2. 存储引擎与缓冲池页式管理为什么是第一个要啃的硬骨头数据库管理系统设计赛的评测通常分两阶段初赛跑功能测试复赛跑 TPC-C 或 TPC-H 性能。无论哪个阶段存储引擎都是地基。很多队伍一上来就写 SQL 解析器结果发现数据落不了盘、事务回滚不了最后推倒重来。我一般建议先把页式存储和缓冲池做扎实再往上叠。2.1 页格式设计与磁盘管理器的最小实现页是数据库和磁盘交互的最小单位。常见做法是固定 4KB 或 8KB 一页页头存页号、LSN、校验和、空闲空间指针页体存槽位数组和实际记录。下面是一个简化的页头结构定义用 C 写但思路对任何语言都通用。// page.h 页头结构固定 4096 字节 struct PageHeader { uint32_t page_id; // 页号全局唯一 uint32_t lsn; // 最近一次修改的日志序列号 uint16_t slot_count; // 当前槽位数 uint16_t free_space_ptr; // 空闲空间起始偏移 uint32_t checksum; // 页校验和用于检测静默损坏 uint8_t flags; // 脏页标记、页类型等 char reserved[19]; // 对齐到 32 字节 }; // 磁盘管理器负责页的读写和文件扩展 class DiskManager { public: // 从指定页号读取一页返回是否成功 bool ReadPage(uint32_t page_id, char* page_data); // 将一页写回磁盘调用者需保证 page_data 是 4096 字节 bool WritePage(uint32_t page_id, const char* page_data); // 分配新页返回页号 uint32_t AllocatePage(); private: int fd_; // 数据库文件描述符 std::atomicuint32_t next_page_id_{0}; };逻辑说明页头固定 32 字节剩余 4064 字节用于存记录。lsn字段在恢复阶段用来判断页是否已经应用了某条日志。checksum用 CRC32 计算每次写回前更新读取时校验。DiskManager只负责裸页读写不关心页内内容。参数上页大小选 4KB 是因为大多数评测环境的磁盘块大小是 4KB对齐后读写效率最高。如果比赛允许配置8KB 页能减少 I/O 次数但内存占用翻倍初赛功能测试用 4KB 足够。2.2 缓冲池淘汰策略LRU-K 与脏页写回的配合缓冲池是内存和磁盘之间的缓存。朴素 LRU 在数据库场景下有个致命问题全表扫描会把热页全部挤出去。常见改进是 LRU-K记录每个页最近 K 次访问时间淘汰第 K 次访问最久远的页。下面是一个简化实现框架。// buffer_pool.h 缓冲池核心结构 class BufferPoolManager { public: BufferPoolManager(size_t pool_size, DiskManager* disk_manager); // 获取页若不在内存则从磁盘加载必要时淘汰 Page* FetchPage(uint32_t page_id); // 取消固定允许被淘汰 bool UnpinPage(uint32_t page_id, bool is_dirty); // 强制将脏页写回磁盘 bool FlushPage(uint32_t page_id); private: std::vectorFrame frames_; // 帧数组每个帧存一页 std::unordered_mapuint32_t, size_t page_table_; // 页号到帧号映射 std::listsize_t lru_list_; // LRU 链表 std::mutex latch_; // 全局闩锁保护元数据 };逻辑说明FetchPage先查page_table_命中则更新 LRU 位置并增加引用计数未命中则从空闲帧或 LRU 链表尾部选一个牺牲页若牺牲页是脏页先写回。UnpinPage减少引用计数当计数为 0 时放回 LRU 链表。参数上pool_size建议设为 1024 到 4096 页即 4MB 到 16MB太小会导致频繁换入换出太大在评测机上可能触发内存限制。脏页写回策略我一般用后台线程定期刷配合 WAL 的强制刷盘时机避免事务提交时集中写盘造成毛刺。注意缓冲池的闩锁粒度很关键。全局一把大锁实现简单但并发差复赛性能测试会吃亏。可以按页号哈希分桶每个桶一把锁降低冲突。3. 事务与并发控制2PL 和 MVCC 到底选哪个事务是数据库管理系统设计赛区分度最高的部分。初赛通常要求支持基本的 ACID复赛会压测并发事务的吞吐。选型上两阶段锁2PL实现简单但读写互相阻塞MVCC 读不阻塞写但版本链管理和垃圾回收复杂。我的建议是如果队伍人手充足且目标复赛名次直接上 MVCC如果只求初赛通过2PL 加严格死锁预防足够。3.1 基于 2PL 的锁管理器与死锁检测2PL 要求事务在增长阶段获取所有锁在收缩阶段释放。严格 2PL 则要求所有锁在事务提交时才释放能避免级联回滚。锁管理器需要维护锁表记录每个资源上的共享锁和排他锁持有者。// lock_manager.h 锁管理器接口 class LockManager { public: // 请求锁txn_id 事务号rid 资源标识lock_type 锁类型 bool LockShared(Transaction* txn, const RID rid); bool LockExclusive(Transaction* txn, const RID rid); // 释放事务持有的所有锁 bool Unlock(Transaction* txn); private: std::unordered_mapRID, LockRequestQueue lock_table_; std::mutex latch_; // 死锁检测构建等待图找环 bool HasCycle(); };逻辑说明LockShared先检查是否有其他事务持有排他锁若有则加入等待队列并触发死锁检测。LockExclusive类似但要求没有任何其他锁。死锁检测用等待图事务是节点等待关系是边用 DFS 找环发现环则回滚代价最小的事务。参数上锁粒度选行级还是页级行级并发高但锁表大页级实现简单但容易冲突。比赛数据量不大时页级锁够用能省不少内存。3.2 MVCC 版本链与快照隔离的实现要点MVCC 的核心是每行数据有多个版本每个版本带创建事务号和删除事务号。读操作根据快照决定可见版本写操作创建新版本。下面是一个版本链节点的定义。// mvcc.h 版本链节点 struct VersionNode { TransactionId txn_id; // 创建该版本的事务 TransactionId delete_txn; // 删除该版本的事务未删除为 INVALID char* data; // 实际数据 VersionNode* next; // 指向更旧的版本 }; // 可见性判断快照隔离下读事务只能看到在它开始前已提交的版本 bool IsVisible(const VersionNode* node, TransactionId read_txn) { // 创建事务已提交且删除事务未提交或不存在 return node-txn_id read_txn (node-delete_txn INVALID_TXN || node-delete_txn read_txn); }逻辑说明每个事务开始时获取一个递增的事务号作为快照。读操作遍历版本链找到第一个可见版本。写操作先找到当前可见版本在其前面插入新版本并标记旧版本的delete_txn为当前事务号。垃圾回收需要后台线程清理不再被任何活跃事务看到的旧版本。参数上事务号用 64 位整数避免回绕。版本链长度要控制长事务会阻止垃圾回收可以设置事务超时强制回滚。提示MVCC 和 2PL 可以结合使用比如 MySQL 的 InnoDB 就是 MVCC 读加 2PL 写。比赛里如果时间紧先实现 MVCC 读加简单写锁能覆盖大部分测试用例。4. 日志与崩溃恢复ARIES 算法怎么裁剪才能跑通崩溃恢复是很多队伍的翻车重灾区。比赛评测通常会模拟断电在随机时刻杀掉进程然后重启检查数据是否一致。ARIES 是工业级恢复算法但完整实现工作量巨大。我的经验是裁剪成三阶段分析、重做、撤销只保留核心逻辑。4.1 WAL 日志格式与强制刷盘时机WAL 要求所有修改先写日志再写数据页。日志记录包含 LSN、事务号、页号、操作类型、前后镜像。下面是一个日志记录的结构。// log_record.h 日志记录 struct LogRecord { uint32_t lsn; // 日志序列号全局递增 uint32_t txn_id; // 事务号 uint32_t page_id; // 涉及的页号 LogType type; // INSERT/UPDATE/DELETE/COMMIT/ABORT uint32_t prev_lsn; // 同一事务上一条日志的 LSN char before_image[256]; // 前镜像用于撤销 char after_image[256]; // 后镜像用于重做 };逻辑说明事务每次修改前先写日志到日志缓冲区事务提交时强制将日志刷盘。prev_lsn把同一事务的日志串成链表撤销时从后往前遍历。参数上日志缓冲区大小建议 64KB 到 256KB太小会导致频繁写盘太大在崩溃时丢失更多日志。刷盘用fsync保证持久性但fsync开销大可以组提交多个事务的日志攒一批一起刷。4.2 恢复三阶段分析、重做、撤销的代码骨架恢复从最近一次检查点开始。分析阶段扫描日志确定崩溃时活跃的事务和脏页表。重做阶段从检查点开始对所有日志包括已中止事务的重放保证已提交事务的修改落盘。撤销阶段回滚未提交事务。// recovery.cpp 恢复主流程 void RecoveryManager::Recover() { // 1. 分析阶段从检查点扫描到日志末尾 auto checkpoint FindLastCheckpoint(); AnalyzePhase(checkpoint); // 2. 重做阶段从检查点开始重放所有日志 for (uint32_t lsn checkpoint; lsn max_lsn_; lsn) { LogRecord* rec log_manager_-GetLog(lsn); if (rec-type LogType::UPDATE) { // 若页的 page_lsn 小于当前 LSN则重做 Page* page buffer_pool_-FetchPage(rec-page_id); if (page-GetLSN() rec-lsn) { ApplyAfterImage(page, rec); page-SetLSN(rec-lsn); } } } // 3. 撤销阶段回滚所有未提交事务 for (auto txn : active_txns_) { RollbackTransaction(txn); } }逻辑说明分析阶段重建事务表和脏页表事务表记录每个事务的状态和最后一条 LSN。重做阶段用页上的page_lsn做幂等判断避免重复应用。撤销阶段从活跃事务的最后一条日志开始用prev_lsn往前遍历对每条 UPDATE 日志应用前镜像。参数上检查点间隔建议每 1000 条日志或每 10 秒做一次太频繁影响性能太少恢复时间长。注意重做阶段必须对所有日志重放包括已中止事务的日志因为中止事务的修改可能已经写到了数据页上。撤销阶段再把这些修改回滚。这个顺序不能反。5. 避坑与排查那些让系统在评测机上直接崩掉的细节比赛提交后最怕的是本地跑得好好的评测机上直接段错误。下面这几条是我和周围队伍踩过的真实坑每条都按现象、原因、解决来写。5.1 页校验和不匹配导致恢复失败现象重启后恢复阶段报校验和错误系统拒绝启动。原因脏页写回时没有更新校验和或者多线程同时写同一页导致内容交错。解决在WritePage之前统一计算并写入校验和缓冲池的FlushPage加页级闩锁确保同一页不会被两个线程同时写。5.2 事务提交后数据丢失现象TPC-C 压测中已提交的事务在崩溃后查不到。原因WAL 的fsync没有在提交前调用或者日志缓冲区在刷盘前被覆盖。解决事务提交路径上强制调用log_manager_-Flush()并且日志缓冲区用环形队列刷盘指针追上写入指针时阻塞写入。5.3 死锁检测误杀导致事务频繁回滚现象并发测试中事务回滚率异常高吞吐上不去。原因死锁检测过于激进把等待时间较长但不会成环的事务也回滚了。解决等待图只在检测到环时才回滚并且选择回滚代价最小的事务修改页数最少。可以设置等待超时作为兜底但超时时间要大于正常锁等待的 99 分位。5.4 索引并发插入导致 B 树结构损坏现象多线程插入后索引查询返回错误结果或死循环。原因B 树节点分裂时没有正确加闩锁两个线程同时分裂同一个节点。解决实现蟹行协议插入时从根节点开始加写闩锁向下遍历时对子节点加闩锁后再释放父节点闩锁。如果实现复杂可以先对整棵树加一把大锁初赛够用复赛再优化。5.5 内存泄漏导致长时间压测 OOM现象压测跑 30 分钟后进程被系统杀掉。原因每次FetchPage后没有正确UnpinPage帧引用计数永远不归零缓冲池无法淘汰。解决用 RAII 封装页的获取和释放确保异常路径也能解锁。在测试里加一个断言检查所有帧的引用计数在事务结束后归零。6. 从能跑到跑得快几个让评测分数翻倍的调优技巧初赛通过后复赛拼的是性能。同样的功能调优前后 TPC-C 的 tpmC 可能差三到五倍。下面这几个技巧是我在实际比赛中验证有效的。第一个是日志组提交。事务提交时不要每个都fsync而是攒一批比如 10 个事务或 5 毫秒超时一起刷盘。这样fsync次数减少一个数量级提交延迟从毫秒级降到微秒级。实现上用一个后台刷盘线程提交线程把日志写入缓冲区后等待刷盘完成的通知。// group_commit.cpp 组提交核心逻辑 void LogManager::GroupCommit() { std::unique_lockstd::mutex lock(latch_); // 将当前缓冲区日志标记为待刷盘 uint32_t flush_lsn next_lsn_ - 1; // 通知刷盘线程 flush_cv_.notify_one(); // 等待刷盘完成 while (persisted_lsn_ flush_lsn) { persist_cv_.wait(lock); } }逻辑说明flush_lsn是本次要刷盘的最大 LSN刷盘线程完成后更新persisted_lsn_并唤醒所有等待的提交线程。参数上批量大小和超时时间要权衡批量太大延迟高太小吞吐低。我一般设批量 32 个事务或超时 2 毫秒先到先触发。第二个是缓冲池预取。全表扫描时顺序预取后续页到缓冲池减少 I/O 等待。可以用一个后台线程检测到连续页号访问时提前加载。第三个是索引覆盖扫描。如果查询只需要索引列直接返回索引中的值不回表。这需要在执行器里判断查询列是否都在索引中。第四个是连接算法选择。小表驱动大表用索引嵌套循环大表连接大表用哈希连接。比赛数据量不大时哈希连接往往更快因为避免了随机 I/O。实现一个简单的哈希连接把较小表建哈希表扫描较大表探测。最后一个技巧是编译优化。用-O2或-O3开启-marchnative让编译器针对评测机 CPU 生成指令。但要注意评测机 CPU 可能和本地不同-marchnative有风险稳妥用-O2。另外把热点函数标记inline减少函数调用开销。这些调优不需要全做根据评测反馈挑收益最大的两三个。我自己的习惯是先用性能分析工具找到瓶颈再针对性优化避免盲目改代码引入新 bug。希望帮到你。本文还有配套的精品资源点击获取