简介:这份资源是全国大学生计算机系统能力大赛数据库管理系统赛道的参赛项目源码,面向具备一定C++与操作系统基础、希望深入理解数据库内核的高校学生与开发者。项目基于RMDB框架实现了一套完整的关系型数据库管理系统,支持TPC-C基准测试负载,覆盖存储引擎、查询优化器、事务管理等核心模块,可用于课程设计、竞赛备赛或数据库原理的动手实践。压缩包共442个文件,约2.43MB,以121个C++头文件、102个C++源文件为主,另有47个Python脚本、34个C++实现文件及30份Markdown文档,并包含CMake与Bazel构建配置、测试用例和说明文档,便于编译运行与二次开发。目前已有72人学习下载。通过研读源码与文档,读者可掌握存储引擎的数据组织与索引结构、查询优化器的执行计划生成思路,以及TPC-C事务负载的并发处理方式,为数据库系统研发打下实践基础。
1. 从零手写数据库内核:RMDB 参赛项目到底在考什么
很多人第一次看到“全国大学生计算机系统能力大赛数据库管理系统赛道”这个标题,第一反应是“不就是写个 SQL 解析器吗”。但真正上手 RMDB 框架之后你会发现,SQL 解析只是最表层的入口,真正吃时间的是存储引擎的页管理、查询优化器的代价估算、以及事务并发控制里那些看不见的锁等待。TPC-C 基准测试负载会把这些模块全部串起来跑,任何一个环节有短板,整体吞吐量立刻掉一个数量级。
这个赛道的核心任务是:基于 RMDB 框架,补全一个能跑通 TPC-C 负载的关系型数据库管理系统。你需要实现存储引擎、查询优化器、事务管理、日志恢复等数据库内核核心功能。适合已经学过数据库原理、但没真正写过数据库内核的同学,也适合想通过一个完整项目理解“一条 SQL 从输入到落盘到底经历了什么”的工程师。下面按实际开发顺序,把每个模块的落地路径拆开讲。
2. 存储引擎:从页式文件到记录管理的落地路径
2.1 为什么先做存储引擎而不是解析器
RMDB 框架已经给了 SQL 解析器的骨架,你真正要补的是解析之后的事情。一条SELECT * FROM warehouse WHERE w_id = 1进来,解析器产出语法树,但语法树不会自己变成磁盘上的字节。存储引擎负责的是:记录怎么组织成页、页怎么落到文件、文件怎么被缓冲池管理。
常见做法是采用页式存储,每页固定大小(比如 4KB 或 8KB),页内用槽目录(slot directory)管理变长记录。RMDB 框架里通常已经定义了Page类和BufferPoolManager的接口,你要做的是实现记录在页内的插入、删除、更新,以及页在磁盘和内存之间的换入换出。
这里有一个容易翻车的地方:很多同学一上来就写 B+ 树索引,结果发现记录本身都还没法正确读写。正确的顺序是先让记录能存进去、能读出来,再在记录之上建索引。
2.2 记录在页内的组织方式与槽目录实现
页内记录管理最常用的方案是槽目录。页头维护一个槽数组,每个槽记录对应记录在页内的偏移量和长度。插入时找一个空闲槽,把记录写到页的空闲区域;删除时把槽标记为已删除,但不立即移动记录;更新时如果新记录长度不超过旧记录,原地覆盖,否则删旧插新。
下面是一个简化的槽目录页结构定义,用 Python 描述逻辑,实际 RMDB 里通常是 C++:
# 页头结构:记录槽数量、空闲空间指针、槽数组 class PageHeader: def __init__(self): self.slot_count = 0 # 当前槽数量 self.free_space_ptr = PAGE_SIZE # 空闲区域起始偏移,从页尾向前增长 self.slots = [] # 每个槽: (offset, length, is_deleted) # 插入记录:从空闲区域分配空间,在槽数组追加槽 def insert_record(page, record_bytes): need = len(record_bytes) # 检查空闲空间是否足够(槽数组和空闲区域不能重叠) if page.header.free_space_ptr - (len(page.header.slots) + 1) * SLOT_SIZE < need: return -1 # 页满 page.header.free_space_ptr -= need offset = page.header.free_space_ptr page.data[offset:offset+need] = record_bytes page.header.slots.append((offset, need, False)) page.header.slot_count += 1 return page.header.slot_count - 1 # 返回槽号作为记录 ID这段代码的关键参数是PAGE_SIZE和SLOT_SIZE。PAGE_SIZE一般取 4096 或 8192,要和文件系统的块大小对齐。SLOT_SIZE取决于你怎么编码偏移量和长度,通常 4 字节偏移 + 4 字节长度 + 1 字节删除标记。注意free_space_ptr是从页尾向前增长的,槽数组是从页头向后增长的,两者相遇就表示页满。这个设计的好处是删除记录时不需要移动其他记录,只需要改槽的删除标记。
2.3 缓冲池管理器:页的换入换出与淘汰策略
缓冲池管理器负责把磁盘上的页缓存到内存里。RMDB 框架通常会提供一个BufferPoolManager的骨架,你需要实现FetchPage、UnpinPage、FlushPage等接口。核心数据结构是一个页表(page table)和一个淘汰器(replacer)。
常见做法是用 LRU 或 Clock 淘汰策略。LRU 实现简单,但在全表扫描场景下容易把热页挤出去。Clock 策略给每个页一个引用位,淘汰时扫描引用位为 0 的页,适合数据库这种访问模式。
// Clock 淘汰器核心逻辑(C++ 伪代码) Page* ClockReplacer::Victim() { while (true) { auto& frame = frames_[clock_hand_]; if (frame.ref_bit) { frame.ref_bit = false; // 第二次机会 clock_hand_ = (clock_hand_ + 1) % frames_.size(); } else { return &frame; // 选中淘汰 } } }参数说明:ref_bit在每次FetchPage时置为 true,表示该页被访问过。clock_hand_是扫描指针,循环扫描所有帧。这个策略的优点是实现简单,且不会像 LRU 那样在顺序扫描时频繁淘汰热页。注意在UnpinPage时如果pin_count降到 0,要把页加入淘汰器的候选集合。
2.4 磁盘管理器与文件格式
磁盘管理器负责在文件和页之间做读写。RMDB 框架一般会提供一个DiskManager接口,你需要实现ReadPage和WritePage。文件格式通常是:文件头 + 连续的页。文件头记录页数量、页大小等元信息。
// 磁盘管理器读写页的核心逻辑 void DiskManager::WritePage(page_id_t page_id, const char* data) { off_t offset = page_id * PAGE_SIZE + FILE_HEADER_SIZE; lseek(fd_, offset, SEEK_SET); write(fd_, data, PAGE_SIZE); } void DiskManager::ReadPage(page_id_t page_id, char* data) { off_t offset = page_id * PAGE_SIZE + FILE_HEADER_SIZE; lseek(fd_, offset, SEEK_SET); read(fd_, data, PAGE_SIZE); }这里的关键参数是FILE_HEADER_SIZE,通常取 4096 字节,用来存页数量、空闲页链表头等。注意lseek和read/write的返回值要检查,磁盘满或文件损坏时这些调用会失败。建议在WritePage之后调用fsync确保数据落盘,否则系统崩溃时可能丢页。
3. 查询优化器:从语法树到执行计划的代价估算
3.1 为什么规则优化不够,必须上代价模型
很多同学第一次做优化器,会写一堆规则:比如“选择下推”“投影下推”“连接顺序调整”。这些规则优化(rule-based optimization)能解决一部分问题,但面对 TPC-C 里多表连接的复杂查询,规则优化往往给出次优计划。比如SELECT * FROM orders, order_line WHERE o_id = ol_o_id AND o_w_id = ol_w_id,规则优化可能先做笛卡尔积再过滤,而代价优化会先做连接再过滤。
代价优化(cost-based optimization)的核心是:为每个可能的执行计划估算代价,选代价最小的。代价通常用 I/O 次数和 CPU 时间加权表示。RMDB 框架里一般会提供一个Optimizer的骨架,你需要实现计划枚举和代价估算。
3.2 统计信息收集:直方图与选择率估算
代价估算依赖统计信息。最常见的是列直方图(histogram)和不同值数量(NDV, number of distinct values)。选择率(selectivity)估算的准确度直接决定计划好坏。
-- 统计信息收集的 SQL 形式(RMDB 内部通常用系统表存储) -- 对 warehouse 表的 w_id 列收集直方图 ANALYZE TABLE warehouse COMPUTE STATISTICS FOR COLUMNS w_id;实际实现时,你需要在ANALYZE命令触发时扫描表,统计每列的 NDV、最大值、最小值、以及等宽直方图。等宽直方图把值域分成 N 个桶,每个桶记录落入的记录数。选择率估算时,等值查询用1/NDV,范围查询用直方图桶的累积比例。
参数说明:直方图桶数一般取 100 到 256。桶数太少,选择率估算不准;桶数太多,统计信息占用空间大且收集慢。TPC-C 的表数据量不大,100 个桶足够。
3.3 连接顺序枚举与代价公式
连接顺序枚举是优化器最耗时的部分。表数量少时可以用动态规划(DP),表数量多时用贪心或遗传算法。RMDB 参赛项目的 TPC-C 查询一般不超过 5 张表,DP 足够。
代价公式通常写成:
Cost = (N_page_read * w_io) + (N_tuple_processed * w_cpu)其中N_page_read是预估的磁盘页读取次数,N_tuple_processed是预估的元组处理数量,w_io和w_cpu是权重,一般w_io远大于w_cpu(比如 100:1),因为磁盘 I/O 比 CPU 慢几个数量级。
# 简化的连接代价估算 def estimate_join_cost(left_card, right_card, left_pages, right_pages, join_type): if join_type == "NLJ": # 嵌套循环连接 return left_pages + left_card * right_pages elif join_type == "HJ": # 哈希连接 return left_pages + right_pages + left_card + right_card elif join_type == "SMJ": # 排序归并连接 return left_pages + right_pages + left_card * math.log2(left_card) + right_card * math.log2(right_card)这段代码里left_card和right_card是左右子计划的输出基数(cardinality),left_pages和right_pages是页数。注意哈希连接在内存足够时代价最低,但如果内存不够需要落盘,代价会急剧上升。实际实现时要考虑可用内存大小。
3.4 计划树生成与执行器对接
优化器输出的是一个计划树(plan tree),每个节点是一个物理算子:SeqScan、IndexScan、NestedLoopJoin、HashJoin、Sort、Aggregate等。执行器从根节点开始,递归调用子节点的Next()方法拉取元组。
// 执行器接口(火山模型) class Executor { public: virtual void Init() = 0; virtual bool Next(Tuple* tuple) = 0; // 返回 false 表示没有更多元组 virtual void Close() = 0; };火山模型的好处是算子之间解耦,每个算子只关心自己的逻辑。缺点是Next()调用开销大,TPC-C 高并发时可能成为瓶颈。常见优化是批量返回元组(batch processing),一次Next()返回一批而不是一个。
4. 事务管理与 TPC-C 负载:并发控制和日志恢复的实战
4.1 TPC-C 负载特征与事务边界
TPC-C 模拟的是一个批发商的订单处理系统,包含 9 张表:warehouse、district、customer、history、new_order、orders、order_line、item、stock。核心事务类型有 5 种:NewOrder、Payment、OrderStatus、Delivery、StockLevel。其中 NewOrder 和 Payment 占绝大多数,且两者都会更新多个表。
TPC-C 对事务的要求是 ACID:原子性、一致性、隔离性、持久性。这意味着你需要实现事务管理器、锁管理器、日志管理器。RMDB 框架通常会提供事务和锁的接口,你需要补全具体逻辑。
4.2 两阶段锁与死锁检测
最常用的隔离级别是可串行化(serializable),通过两阶段锁(2PL)实现。事务在访问记录前加锁,锁分为共享锁(S)和排他锁(X)。两阶段锁要求事务在释放任何锁之后不能再申请新锁。
// 锁管理器核心接口 class LockManager { public: bool LockShared(Transaction* txn, const RID& rid); bool LockExclusive(Transaction* txn, const RID& rid); bool Unlock(Transaction* txn, const RID& rid); };死锁检测用等待图(wait-for graph)。每个事务是一个节点,如果事务 A 等待事务 B 持有的锁,就加一条 A→B 的边。定期检测图中是否有环,有环就选一个事务回滚。
参数说明:死锁检测周期一般取 1 秒。太频繁浪费 CPU,太稀疏死锁事务等待时间长。回滚代价最小的策略是选持有锁最少的事务回滚。
4.3 WAL 日志与崩溃恢复
Write-Ahead Logging(WAL)是保证持久性的核心。规则是:任何页的修改在落盘之前,对应的日志记录必须先落盘。日志记录包含事务 ID、页 ID、偏移量、旧值、新值。
// WAL 日志记录格式 struct LogRecord { lsn_t lsn; // 日志序列号 txn_id_t txn_id; // 事务 ID page_id_t page_id; // 页 ID uint32_t offset; // 页内偏移 uint32_t length; // 数据长度 char old_value[length]; char new_value[length]; };恢复时用 ARIES 算法:分析阶段确定崩溃时的活跃事务和脏页,重做阶段从检查点开始重放所有日志(包括未提交事务),撤销阶段回滚未提交事务。注意重做时要判断页上的 LSN 是否小于日志的 LSN,只有小于才重做,避免重复应用。
4.4 TPC-C 跑通的最小验证流程
跑 TPC-C 之前,先确保单事务能正确执行。建议按以下顺序验证:
- 加载初始数据:
./tpcc_load -w 1(1 个仓库) - 跑单事务:
./tpcc_start -w 1 -c 1 -t 10(1 个并发,跑 10 秒) - 检查数据一致性:对比事务前后的账户余额、库存数量
- 逐步增加并发:
-c 4、-c 8、-c 16 - 观察吞吐量(tpmC)和延迟(90th percentile)
如果第 2 步就失败,先查事务回滚逻辑;如果第 4 步吞吐量不涨反降,查锁竞争和缓冲池命中率。
5. 避坑与排查:RMDB 开发中最容易翻车的 5 个点
5.1 页内记录更新后槽目录不一致
现象:更新一条记录后,再查询同一条记录返回乱码或旧值。
原因:更新时新记录长度超过旧记录,代码删旧插新,但槽目录里旧槽的偏移量没更新,或者新槽的删除标记没清。
解决:更新逻辑统一走“删旧插新”,删旧时把槽标记为已删除,插新时追加新槽并返回新槽号。所有上层调用必须用新槽号,不能缓存旧槽号。
5.2 缓冲池淘汰了还被引用的页
现象:查询结果随机出错,或者程序崩溃在memcpy。
原因:UnpinPage时pin_count没减到 0 就加入淘汰器,导致页被换出后还有代码在读写。
解决:FetchPage时pin_count++,UnpinPage时pin_count--,只有pin_count == 0才加入淘汰器。调试时可以在淘汰器里加断言,检查pin_count是否为 0。
5.3 优化器选了索引但执行器没实现
现象:优化器输出IndexScan,但执行器报“未实现”或直接崩溃。
原因:优化器枚举计划时没有检查执行器是否支持该算子,或者索引本身还没建好。
解决:优化器枚举计划前先检查系统目录里该索引是否存在,执行器里对不支持的算子返回错误而不是崩溃。建议先实现SeqScan和NestedLoopJoin,跑通后再加IndexScan和HashJoin。
5.4 事务回滚后锁没释放
现象:并发跑 TPC-C 时,某些事务一直等待,吞吐量掉到接近零。
原因:事务回滚时只撤销了数据修改,没有释放持有的锁。
解决:事务回滚逻辑里,先释放所有锁,再撤销数据修改。锁管理器里维护每个事务的锁列表,回滚时遍历列表逐个释放。
5.5 WAL 日志落盘顺序错误
现象:系统崩溃后重启,部分已提交事务的数据丢失。
原因:数据页先落盘,日志后落盘,崩溃时日志还没写进去。
解决:严格遵循 WAL 规则:日志记录先落盘,数据页后落盘。在FlushPage之前,先确保该页对应的所有日志记录已经fsync。可以在日志管理器里维护一个全局 LSN,每次写日志更新,刷页时检查页的page_lsn是否小于全局 LSN。
6. 进阶技巧:用 EXPLAIN 和性能计数器定位瓶颈
跑通 TPC-C 只是第一步,真正拉开差距的是性能调优。我一般会先加一个EXPLAIN命令,输出优化器选择的计划树,然后对照执行器的实际行为找差异。
-- 查看 NewOrder 事务中某个查询的计划 EXPLAIN SELECT * FROM stock WHERE s_i_id = 123 AND s_w_id = 1;输出示例:
| 算子 | 表 | 访问方式 | 预估代价 | 预估基数 |
|---|---|---|---|---|
| IndexScan | stock | 索引 (s_w_id, s_i_id) | 12.5 | 1 |
| Filter | - | s_i_id = 123 | 1.2 | 1 |
如果EXPLAIN显示SeqScan但实际有索引,说明优化器没选对。检查统计信息是否收集、代价公式权重是否合理。如果计划正确但执行慢,加性能计数器:缓冲池命中率、锁等待时间、日志刷盘次数。
// 性能计数器示例 struct PerfCounters { std::atomic<uint64_t> buffer_hit{0}; std::atomic<uint64_t> buffer_miss{0}; std::atomic<uint64_t> lock_wait_ns{0}; std::atomic<uint64_t> log_flush_count{0}; };缓冲池命中率低于 90% 就加大缓冲池页数;锁等待时间占比高就检查锁粒度是否太粗;日志刷盘次数太多就批量提交。我自己的习惯是每次改完优化器或执行器,先跑 10 秒 TPC-C 看 tpmC 变化,再跑 60 秒看稳定性。不要一次改多个模块,否则出了问题不知道是哪个改动的锅。
希望帮到你。
本文还有配套的精品资源,点击获取