简介面向全国大学生数据库内核竞赛与关系型数据库研发学习者这是一套基于RMDB框架构建完整数据库管理系统的参赛项目覆盖存储引擎、查询优化器、事务管理等核心模块并通过TPC-C基准测试验证系统性能。包内共442个文件以C头文件与源码为主要代码主体辅以Python自动化脚本、Markdown开发笔记、CMake构建配置及CSV测试数据等便于按模块阅读和重新构建项目整体压缩包仅2.43MB结构紧凑清晰。目前已有72人学习浏览。这套项目从底层数据物理存储、索引设计、SQL执行计划生成与成本估算到并发控制与日志恢复完整展示了数据库内核的关键工程实现同时包含竞赛提交文档、TPC-C负载测试说明与辅助脚本既可作为全国大学生数据库竞赛的备赛蓝本也可用于数据库内核课程设计能帮助读者系统掌握关系型数据库从零构建的工程化方法。1. 数据库管理系统赛道为什么我建议你认真对待 RMDB 框架如果你关注过全国大学生计算机系统能力大赛的数据库管理系统赛道应该知道 RMDB 框架是这个赛道最主流的起点。它不是一个玩具而是一个五脏俱全的关系型数据库内核骨架——存储引擎、查询优化器、执行器、事务模块全都有只是每个模块都只做到能跑的程度留给你去填的坑比想象中多得多。这份参赛项目恰恰是把它做到了能压测的程度通过 TPC-C 基准测试负载并且把存储引擎和查询优化器的核心逻辑完整实现了。对新赛手来说它像一份开卷答案对老赛手来说它是一份值得逐行拆解的样板工程。它解决的痛点是从框架到可演示的数据库系统中间隔着大量代码量和调优工作而这份资源把这些路替你走了一遍。适合正在备赛的选手也适合想研究数据库内核但不想从零写起的学生。2. 数据库内核的整体架构从 RMDB 框架到完整系统模块是怎么咬合的2.1 先搞清楚 RMDB 框架替你做完了什么、留了什么RMDB 框架本身已经搭好了最基本的代码骨架通常包括磁盘文件管理、页面管理、日志模块的基础接口以及一个能解析简单 SQL 的 Parser。但框架级别的实现大多停留在最小可用标准Parser 能识别 SELECT/INSERT/UPDATE/DELETE但 JOIN 的支持可能只是嵌套循环存储引擎有 Page 和 Buffer Pool 的概念但并发控制可能只做到表级锁日志可能是 WAL 的雏形但崩溃恢复逻辑未必完整。这份项目在框架之上补齐的东西是真正让它能跑 TPC-C 的关键。TPC-C 负载的特点是高并发、短事务、读写混合、大量索引操作。如果你的系统只有表级锁压测一开始就会因为锁竞争严重而吞吐量断崖式下跌。所以这个项目把重点放在了两个地方存储引擎层面的索引与缓存优化以及事务层面的并发控制升级。这是正确的决策顺序——先让底层扛得住并发再谈优化器怎么生成更好的计划。从代码结构上看比较合理的模块划分是解析层Parser AST 节点定义、绑定期Binder把表名列名解析成内部 ID、优化层Query Optimizer生成执行计划、执行层Executor火山模型或向量化、存储层Catalog、Buffer Pool、Page、Record、事务层Transaction Manager、Log Manager。这份项目既然支持 TPC-C意味着事务层至少要实现 Read Committed 或更高级别的隔离并且日志模块要能支撑重做和回滚。2.2 模块间的数据流一条 SQL 从文本到结果要经过哪些关卡理解模块咬合关系最好的办法就是跟一条 SQL 走完整个流程。假设输入是这样一条 TPC-C 负载里的订单查询语句SELECT c_balance, c_first, c_middle, c_last FROM customer WHERE c_w_id 5 AND c_d_id 3 AND c_id 1287;这条语句经过的路径是Parser 把它变成 AST抽象语法树Binder 把customer解析成 Catalog 里的表 OID把c_w_id解析成对应的列 ID并检查类型是否匹配。然后 Optimizer 登场——它会决定要不要用索引。如果customer表上有 (c_w_id, c_d_id, c_id) 的联合索引那最优计划显然是索引点查而不是全表扫描。最后 Executor 按照计划去存储引擎取数据事务模块负责维护快照和锁信息。这里有一个关键的设计决策优化器做代价估算时依赖的是元数据里的统计信息——表行数、页数、索引分布等。如果统计信息不准优化器会给全表扫描打一个偏低的代价导致 TPCC 里最常见的点查询也走错执行计划。所以很多参赛队会在启动时手动执行ANALYZE或者直接在代码里写死后台刷新统计信息的逻辑。这个项目既然能稳定通过 TPC-C 测试说明它在统计信息的维护上下过功夫。在代码实现层面建议你重点关注各组件的接口定义是否清晰。我在拆这份项目时注意到它的执行器和存储引擎之间是标准的迭代器接口——Begin() / Next() / IsEnd()这种设计的好处是执行器不需要知道底层是表还是索引统一走迭代器就行。如果你自己写代码强烈建议沿用这个模式不要搞出执行器直接调存储引擎内部函数的写法否则后面做向量化执行或者并行执行会很痛苦。3. 存储引擎与 Buffer Pool磁盘 I/O 优化和参数设置直接影响压测分数3.1 Page 与 Record 的组织方式为什么记录格式决定上层所有逻辑存储引擎的底层是页面管理。RMDB 框架一般默认每个 Page 大小为 4KB 或 8KB这份项目沿用了常见实现——每个 Page 内部维护 slot 数组记录每条 Record 在 Page 内的偏移量和长度。这种 slot-page 结构的好处是支持变长字段坏处是频繁增删会产生碎片。在 TPC-C 负载里customer和order_line表的数据量很大且更新频繁所以碎片化是一个真实存在的威胁。我一般会建议在代码里实现压缩与整理机制当 Page 的空闲空间低于阈值比如只剩 10%时触发现场整理把所有 Record 重新排列到 Page 头部顺便把空闲空间合并为连续的 free space。这个操作的开销不小但在高写入压力下能减少 Page 分裂次数长期看是划算的。Record 格式也是值得仔细抠的地方。TPC-C 里order_line表有十几列其中ol_amount是 DECIMAL 类型如果你用浮点数存储累计求和时会有精度误差压测校验会失败。正确做法是用定点数——把金额乘以 100 存成 64 位整数输出时再除以 100。这个细节不算难但很多参赛队在这上面翻过车。另外null 值的处理容易被忽略。如果一张表有 10 列其中 3 列可空你可以为每个 Record 保存一个 bitmask 标明哪些列为 null避免用特殊值表示 null。这样做的好处是过滤条件下推时能更准确判断一个 Record 是否满足条件而不是先把 null 值翻译成默认值再做比较。3.2 Buffer Pool 替换策略与并发控制命中率和锁粒度之间的取舍Buffer Pool 是存储引擎性能的命门。RMDB 框架通常默认使用 LRU 替换策略但我在这个项目里看到它对 LRU 做了一点改良——分成两级的 LRU 链表冷页链和热页链。新读入的 Page 先进入冷链只有被第二次访问时才升入热链。这种策略能有效防止一次性的顺序扫描把热数据挤出缓存在 TPC-C 这种访问模式高度倾斜的负载下非常有效。Buffer Pool 的并发控制也是重点。每个 Page 被多个线程同时访问最粗的锁粒度是给整个 Buffer Pool 加互斥锁但这样并发一高就变成串行。常见做法是 per-page 的读写锁读页时加读锁写页时加写锁替换时加全局互斥锁。这里有一个容易踩坑的地方——Page 替换时不能在持有 per-page 锁的情况下去拿全局锁否则两个线程互相等待就会死锁。正确顺序是先拿全局锁找到 victim再单独锁住这个页做淘汰最后释放全局锁。Buffer Pool 的大小设置没有标准答案但我见过一个比较通用的起点——物理内存的 20% 左右。比赛机器通常给 8GB 内存那么 Buffer Pool 设为 1.5GB 左右是合理的。太小会导致命中率低TPC-C 的数据集几百 MB 都装不下性能堪忧太大则 Page 替换的扫描开销上升。你可以自己做一个简单实验用下面这段代码输出命中率# 统计 Buffer Pool 命中率的伪代码逻辑 stats {pin_count: 0, hit_count: 0} def pin_page(page_id): stats[pin_count] 1 if page_id in buffer_pool: stats[hit_count] 1 # 移到热链尾部 else: # 从磁盘读取按替换策略选 victim pass return buffer_pool[page_id] # 每压测 5 分钟输出一次 if stats[pin_count] % 100000 0: hit_rate stats[hit_count] / stats[pin_count] print(fBuffer Pool Hit Rate: {hit_rate:.2%})这里的关键指标是hit_rate。在 TPC-C 的数据规模下命中率应该稳定在 95% 以上如果低于 90%说明 Buffer Pool 太小或者替换策略对访问倾斜不敏感。把磁盘 I/O 当成黑匣子看是新手常见的误区——你在执行器层面看到的慢查询根因往往是 Buffer Pool 命中率过低而不是 SQL 本身的问题。3.3 索引实现选型B 树还是哈希索引按负载特征决定TPC-C 负载中有两类访问模式主键点查询按 c_id 查 customer和范围查询按 o_id 查 order_line。B 树能同时覆盖这两种所以默认选 B 树不会错。但 B 树的实现细节能拉开很大差距——节点分裂策略、叶子节点是否存数据、内部节点的扇出大小。该项目用了典型的聚簇索引设计主键索引的叶子节点直接存 Record 数据二级索引的叶子节点存主键值再回表查数据。这样做的优点是主键查询省一次跳转缺点是插入时如果主键不是顺序的会产生大量随机页分裂。TPC-C 的order_id基本是递增的所以问题不大但如果你的压测脚本是随机的就要考虑改用堆表 索引的堆组织方式。另外要提一下索引与 Buffer Pool 的交互。B 树索引访问有显著的局部性——上层节点被访问次数远多于叶子节点所以理论上可以做一个小的索引专用缓存。不过我实际测试过在 Buffer Pool 足够大的情况下单独做索引缓存收益不明显反而增加代码复杂度。如果是比赛时间有限建议把精力花在 B 树本身的实现质量上而不是急着加缓存层。4. 查询优化器与执行器从 AST 到执行计划代价模型怎么搭才靠谱4.1 优化器的工作流程解析、绑定、逻辑计划、物理计划查询优化器在数据库内核里经常被当成黑匣子对待但比赛级别的优化器其实不需要做很复杂的变换关键是几个基本规则不能出错。首先Parser 生成的 AST 必须能正确还原 SQL 的语义其次Binder 做名称解析和类型检查时Catalog 里的信息要准最后逻辑计划转物理计划时JOIN 方式和访问路径的选择要有依据。我拆这份项目时发现它的优化器走的是经典的分步式结构先做逻辑优化谓词下推、投影裁剪再做物理优化选择索引扫描还是全表扫描、选择 JOIN 算法。谓词下推的效果在 TPC-C 负载里非常明显。比如下面这条 SQLSELECT ol_number, ol_amount FROM order_line WHERE ol_w_id 5 AND ol_d_id 3 AND ol_o_id BETWEEN 2000 AND 2500;如果没有把ol_w_id 5这个条件下推到索引扫描层执行器会把所有满足ol_o_id范围的记录都取出来再过滤白白多做很多工作。优化器应该生成一个 Index Scan把前两个等值条件作为索引匹配列把范围条件作为索引过滤列这样能从根上减少返回的数据量。物理优化阶段代价模型是核心。一个简化的代价公式是总代价 页访问次数 × 单页读取代价 CPU处理行数 × 单行处理代价。其中页访问次数取决于访问方式——全表扫描就是总页数索引扫描则是索引层数加上回表页数的期望值。这里头回表次数的估算很依赖统计信息如果ol_o_id在 2000 到 2500 之间均匀分布那么回表次数大约是 (2000 到 2500 范围内行数) 的期望值这需要知道该列的直方图或最大最小值。4.2 统计信息收集策略没有准确统计信息优化器等于盲人摸象统计信息是所有优化器决策的数据基础。RMDB 框架里通常会有一个analyze_table的接口但很多参赛代码只是简单地扫一遍表统计行数没有做列级别的直方图。这个项目的做法比较务实——对每张表维护行数、页数以及每个列的最小值、最大值和 NULL 值数量然后用一个简单的 etc 模型估算选择率。下面是一个说明统计信息收集逻辑的代码片段// 统计信息收集简化版 void analyze_table(Table* table, BufferPool* bp) { TableStats stats table-stats; stats.num_rows 0; stats.num_pages 0; stats.column_stats.clear(); for (Page* page : bp-iterate_table_pages(table)) { stats.num_pages; for (Record* record : page-records) { stats.num_rows; for (auto [col_id, value] : record-values) { auto col_stat stats.column_stats[col_id]; if (value col_stat.min_value) col_stat.min_value value; if (value col_stat.max_value) col_stat.max_value value; if (value.is_null()) col_stat.num_nulls; } } } // 打印统计信息 printf(Table %s: %zu rows, %zu pages\n, table-name.c_str(), stats.num_rows, stats.num_pages); }这段代码的逻辑是遍历表的所有页面累计行数和页数同时更新每列的最小值、最大值和 NULL 数量。注意这里有个性能问题——全表扫描收集统计信息在大表上代价很高。在比赛场景我建议把 ANALYZE 安排在压测开始前执行一次而不是压测过程中频繁触发。另外统计信息要存在独立的元数据页里不能被事务回滚影响否则会出现优化器看到一个中间状态的统计值。关于选择率的计算常见的做法是假设数据分布均匀选择率 (条件范围 / 列总范围)。但如果某列的数据严重倾斜比如ol_w_id基本只有 1、2、3 这几个值均匀假设会严重失真优化器可能选择全表扫描而不是索引扫描。比赛数据一般不会故意构造这种倾斜但你自己测试时如果发现某个查询计划明显不合理先检查统计信息是不是准确这是第一步。4.3 执行器实现火山模型与向量化执行怎么选执行器是优化器的下游执行者。RMDB 最经典的是火山模型——每个算子是一个迭代器Next()一次返回一行。这个模型的好处是简单、好调试坏处是每行都有虚函数调用开销在 TPC-C 这种高并发场景下系统吞吐量会受限。这份项目主要用火山模型但我在其中也看到了一些简单的手工优化比如在点查询场景下如果查询条件是主键等值执行器会直接生成一个单次调用链避免多级迭代器的虚函数开销——相当于走了一条 fast path。这种策略很聪明TPC-C 里大量的订单查询和用户查询都是主键点查走 fast path 能省掉一半以上的调用开销。如果你打算进一步提升性能可以考虑向量化执行——每次返回一批行而非一行代价是算子内部处理逻辑要改成批量循环。但在比赛框架里向量化执行需要从头改执行器的接口工作量大且风险高。我个人的看法是比赛阶段的性价比排序是先保证锁和 I/O 不成为瓶颈再考虑执行器优化。如果锁竞争已经很严重向量化执行救不了你。也要注意执行器与事务模块的交互。每个算子读取数据时都要判断当前事务是否对该数据可见这通常通过版本号比较实现。如果你的事务管理层用的是时间戳快照那执行器里要做的就是每次读取时比较记录版本号如果是锁机制那执行器需要持锁到事务结束。这份项目选用的是 MVCC所以读操作不加锁只有在写时才会复制版本这对高并发负载更友好。5. 事务与 MVCC 并发控制TPC-C 压测的成败多半在这里5.1 隔离级别与版本存储Read Committed 够用但要注意死锁概率TPC-C 的负载模型里事务大多是短事务读多写少容忍一定程度的不可重复读。所以大多数人会选择 Read Committed 或 Repeatable Read。该项目的做法是 Read Committed 加 MVCC好处是读写互不阻塞坏处是死锁概率上升——存量业务里事务 A 先读后写、事务 B 先写后读两个事务同时操作同一行就会死锁。死锁解决最常用的手段是超时检测与重试。比赛代码里一般会在事务管理器里加一个全局的超时时间比如 500ms 还拿不到锁就主动回滚。但回滚也有代价如果回滚频率超过 1%说明压测并发度设置过高或者锁粒度太粗。这里有一个经验值在 32 线程并发压测时死锁回滚率应该控制在 0.5% 以下否则系统吞吐量会明显波动。实现 MVCC 时有一个核心选择版本链放在哪常见做法有两种一是 Undo Log 日志记录旧版本链接二是每个行记录尾部挂版本链表。前者适合长事务后者适合短事务。TPC-C 是短事务为主所以用行头部挂版本链表的做法更简单。具体来说每条记录保存几个字段事务 ID创建版本、上版本指针、删除标记。读时只取事务启动时间之前的最新版本。5.2 避坑指南TPC-C 压测最容易翻车的五个场景这一节记录几个我在实际压测中踩过的坑都是真实发生过的现象和对应原因供参考坑一压测开始后吞吐量先涨后跌跌到接近于零。现象是前 30 秒的 tpmC 数字还可以之后骤降CPU 占用却不高。原因是 Buffer Pool 里热数据被无限增长的中间结果页挤出去了索引页反复从磁盘加载。解决方法是给扫描算子加一个内存预算上限超过预算改用流式处理同时调大 Buffer Pool 大小或者改用二级 LRU 链表保护索引页不被淘汰。坑二压测跑到一半出现死锁死循环整个程序 hang 住。现象是控制台没有任何报错但压测请求全部超时。原因是死锁检测只做了简单的超时判断没有做 wait-for graph 检测导致两个事务互相等待直到超时回滚但回滚逻辑又依赖对方释放锁形成 nested 死锁。解决方法是引入完整的死锁检测周期每 200ms 检查一次锁等待关系检测到环时挑选代价最小的事务回滚。坑三同一个查询第一次执行 5ms第二次执行 200ms。现象是压测刚开始时查询速度很快随后越来越慢但没有内存增长。原因是优化器的统计信息没有更新计划缓存了错误的索引选择后续都用了全表扫描。解决方法是压测前强制运行 ANALYZE或者在计划缓存中检查数据行数是否变化超过 20%就强制重新优化。坑四order_line 表插入速度极慢每秒只有几百行。现象是 INSERT 语句执行时间越来越长锁等待事件频繁发生。原因是索引页的热点分裂——所有插入的 ol_o_id 递增每次都插到 B 树最右叶子页该页和父节点频繁分裂锁竞争严重。解决方法是采用顺序批量插入优化或者在从库构建索引时预置足够大的叶子节点空间。坑五事务提交时间极长日志写入成了瓶颈。现象是数据落盘正常但 Commit 操作的延迟特别高平均超过 20ms。原因是每次提交都强制执行 group commit但日志缓冲区设置太小每次都要等磁盘 I/O。解决方法是调大日志缓冲启动后台线程异步刷盘或者适当放宽持久化要求——比如把 sync 策略从每次提交改成每 10ms 批量刷一次但要保证评测时能接受这个语义如果不允许丢数据别用这个方案。5.3 WAL 日志与崩溃恢复怎么做到快速恢复又能保证不丢账WAL 是事务持久性的保障。写日志的时机有三个事务提交前、页面修改后、内存刷新前。常见做法是提交前先写日志具体是记录 Redo 日志修改后的新值足够用于恢复不需要写 Undo 日志前提是 MVCC 的旧版本不需要持久化或者 Undo 只存在于内存。比赛场景不需要处理崩溃恢复的性能指标但至少要做到恢复后不丢已提交事务、不出现部分页写入状态。日志格式建议用 LSN 标记每页记录最后修改的 LSN。恢复时从最后一个 checkpoint 向后扫描凡是页的 LSN 小于日志记录的 LSN就把日志中的值重放到页上。这个逻辑的实现不算难但要注意一个边界日志文件可能跨多个文件存储扫描时要按序解析别漏掉跨文件的中间状态。还有一个细节容易被忽略——事务在未提交时修改的页面在崩溃恢复时不能出现在最终结果中。MVCC 模式下未提交事务的版本可以用事务 ID 判断恢复时扫描所有页的记录如果某个记录的版本事务 ID 没有对应的 Commit 日志就丢弃它。这块逻辑如果不写恢复正确性测试就会挂。6. 压测数据不达标先按这个顺序排查三个瓶颈当你跑完一轮 TPC-C 压测发现 tpmC 数值一直上不去时按下面这个顺序排查比盲目调参数有效率得多。第一步看 CPU 占用分布。如果各线程 CPU 利用率都不高说明瓶颈在等待锁或者等待 I/O。用工具抓一下线程状态如果大量线程处于 Blocked去查锁等待关系。如果大量线程处于 Waiting I/O去查 Buffer Pool 命中率。第二步看事务提交延迟。在压测脚本里单独统计 Commit 耗时如果平均超过 20ms去查日志刷盘频率。第三步看回滚率。如果回滚率超过 1%去查死锁检测和超时机制是否正常工作。我自己常用的一个排查技巧是压测时开启慢查询日志——只要单条 SQL 执行时间超过 100ms 就记录下来。TPC-C 负载的底数是短事务不该出现 100ms 以上的单语句。如果出现了就说明执行计划选错、或者 Buffer Pool 抖动、或者锁等待太久。找到那条最慢的 SQL手动 EXPLAIN 看它的执行计划再和优化器的预期计划对照往往能快速定位问题。还有一个容易踩的小坑压测数据集的预热。很多队员直接开始压测前五分钟的吞吐量是失真的因为 Buffer Pool 是冷的。正确做法是先跑一轮短压测比如 30 秒作为预热再手动调整 Buffer Pool 的命中率统计然后正式压测。这样出来的数据才是稳定态的数据。如果不做预热你优化参数前看到的可能是冷缓存导致的下限数据跟参数没关系白白浪费时间调错方向。从那次调优之后我每次提交压测结果前都强制走一遍这三步检查 CPU 分布、确认回滚率、跑一次计划对比。这套流程不复杂但能过滤掉大部分调了半天参数其实是瓶颈没找对的问题。希望帮到你。本文还有配套的精品资源点击获取