简介:一套面向全国大学生计算机系统能力大赛数据库管理系统赛道的参赛项目资源包,基于RMDB框架开发了支持TPC-C基准测试负载的完整关系型数据库管理系统。项目覆盖数据库内核最核心的三大模块:存储引擎负责物理组织、索引与缓冲管理;查询优化器根据统计信息生成并优选执行计划;事务管理则保障并发访问下的原子性与隔离性。资源共442个文件,以C/C++源码(h、cc、cpp)及Python脚本为主,另含Markdown/TXT文档、CMake/Make构建配置、测试用例和词法分析器生成文件等,压缩包整体约2.43MB,目录按构建、文档、测试等模块组织,便于定位代码与实验记录。目前已有72人在CSDN浏览学习,适合数据库内核初学者、竞赛备赛团队及相关课程设计者参考。通过该资源既可研读存储引擎从数据页到索引结构的具体实现,也能借助TPC-C负载理解OLTP场景下的吞吐优化与一致性保障;配套的构建脚本与测试样例为动手编译、运行和二次开发提供了直接入口,是一份从原理到代码闭环的数据库系统实践素材。
1. 为什么选择数据库管理系统赛道:用 RMDB 框架拿下一个能跑 TPC-C 的内核
第一次打开大赛给的 RMDB 框架时,大部分人是懵的:仓库里躺着一张 SQL 解析器、一个空壳的存储引擎、一个只做顺序扫描的执行器,剩下的全留白。我见过不少队伍前两周都在研究 lex 和 yacc,最后却在 TPC-C 压测上翻车——真正拉开差距的不是解析器,而是存储引擎和查询优化器。这篇笔记按我实际做完一版完整 RMDB 系统的顺序来讲:怎么拆框架、怎么写存储、怎么让优化器听话、怎么把 tpmC 跑过基本线,以及那些让你熬夜到头秃的坑。给打算报数据库管理系统赛道的人一份可以照图施工的路线图。
2. RMDB 框架的模块拆解:哪部分是脚手架,哪部分决定获奖
2.1 框架通常给你的部分:网络层、解析器与执行器骨架
拿到 RMDB 框架压缩包,先别急着翻源码,我的习惯是先按“框架自带”和“需要自己写”列一份清单。大多数竞赛框架会先给你一个能编译、能启动、能执行极简单 SQL 的最小系统,剩下的模块由参赛者补全。目录结构一般是这几块:网络服务端、SQL 解析器、存储管理、B+树索引、执行器、事务日志。虽然不同年份的框架版本略有差异,但骨架基本跑不出这个圈。
| 模块 | 框架通常会提供 | 参赛者需要确认 |
|---|---|---|
| 网络层 | 端口监听、会话管理 | 是否支持并发连接 |
| SQL 解析 | lex/yacc 生成的 AST | 是否覆盖 TPC-C 全部 SQL 模板 |
| 存储 | 文件页管理、记录格式 | 缓冲池并发控制是否完善 |
| 索引 | B+树接口 | 分裂与合并的边界情况 |
| 执行器 | SeqScan 单表扫描 | 缺 join / aggregate / sort |
| 事务 | WAL 基础封装 | redo/undo 是否留空 |
这份清单看起来简单,但值得认真做。我见过好几支队伍以为框架肯定给全了,跳过确认,最后发现解析器连FOR UPDATE都不支持,或者事务接口里没有提交钩子,全盘返工。建议在项目第一天就把每个模块的 TODO 标注出来,写进 README,后续每周对照一次进度。动手改代码前,先把系统跑起来。RMDB 一般用 CMake 构建,流程是:
mkdir build && cd build cmake .. -DCMAKE_BUILD_TYPE=Release make -j$(nproc)这里最容易翻车的是依赖缺失。Ubuntu 下常见缺 flex、bison、libreadline-dev,运行sudo apt install flex bison libreadline-dev就能解决。编译过了,别急着写功能,先用客户端跑一个SELECT 1;,确认从网络端口到执行器的最小链路是通的。这一步决定了之后所有排错有没有参照物,也是每次提代码后回归测试的第一个用例。
2.2 内核的三个核心接口:TableIterator、PlanNode 与 Transaction
一个 RMDB 能不能在后续几个月里持续迭代而不翻车,取决于三个核心接口是否足够干净。第一个是表数据迭代接口 TableIterator,所有执行算子都按迭代器模型向上层吐数据:
// iterator.h class TableIterator { public: virtual bool open() = 0; // 打开迭代器,定位到首行 virtual bool next() = 0; // 推进一行,返回是否还有下一行 virtual Record &get() = 0; // 取当前行 virtual void close() = 0; // 释放资源 };这里最常见的错误是让 next() 同时承担“移动”和“取值”,导致 get() 拿到的总是旧数据或空行。正确做法是 open() 只定位,next() 只推进,get() 只拷贝当前行。每个算子持有自己的迭代器,不要图省事多个算子共享同一个遍历对象,否则换行时互相污染游标位置。像 HashJoin 这种算子,build 阶段要完整读完右表的迭代器,probe 阶段再用左表驱动,两边必须各持一个独立的迭代器。
第二个接口是执行计划节点 PlanNode:
// plan_node.h class PlanNode { public: virtual std::vector<Column> schema() const = 0; // 输出列结构 virtual std::unique_ptr<TableIterator> start() = 0; // 生成迭代器 virtual std::string explain() const = 0; // 调试输出 };查询优化器的所有输出最终都落到一棵 PlanNode 树上。注意 cost 信息不要写进 PlanNode,因为 join 重排时同一个子计划可能被复制多次,带代价信息容易造成错误传播。explain() 方法建议从第一天就实现,它打印出来的计划树能让你在调试时一眼看出优化器选错了索引。
第三个是事务接口 Transaction:
// transaction.h class Transaction { public: virtual bool commit() = 0; virtual bool abort() = 0; virtual bool is_valid() const = 0; // 事务是否仍可写 };TPC-C 每个事务都很短,但提交频率极高。如果 commit() 内部直接同步刷盘,tpmC 会低得没法看。我一般会把提交拆成三步:写 WAL → fsync → 更新内存脏页标记。真正把脏页从缓冲池落盘交给后台线程,而不是在提交路径里做。这块的取舍直接决定最后的压测分数,建议在评审时把提交路径的调用栈画出来,确认没有隐藏的磁盘 IO。
2.3 跑通最小链路:一条 SELECT 的完整生命周期
接口理清后,先不要碰 TPC-C,把一条最简单的SELECT * FROM warehouse WHERE w_id = 1;完整跑通。我习惯用 DEBUG 日志把每个阶段的入口出口打出来:
[SQL] SELECT * FROM warehouse WHERE w_id = 1; [PARSE] AST: Project(Filter(Eq(w_id, 1), Scan(warehouse))) [PLAN] Physical: IndexScan(warehouse, idx_w_id, [1]) [EXEC] TableIterator open [EXEC] row fetched: (1, "City", 100.00) [RETURN] 1 row encoded如果日志断在某一步,问题就锁定在哪一步。我见过的情况是:解析和计划都正常,但 EXEC 阶段一行也不返回,最后定位到 B+树等值查找时把 key 和整条元组做了比较,类型对不上导致全不匹配。另一种情况是客户端收到乱码,多半是行协议里列长度按字节计算,但字符串用了 GBK,统一改成 UTF-8 编码就好了。
跑通最小链路之后,再按固定顺序写内核功能:存储引擎 → 索引 → 执行器 → 优化器 → TPC-C 对接。这个顺序让每一步都有上一步的设施可以依赖,排错成本最低。很多队伍一开始就写优化器,结果没有任何执行器可以配合测试,写出来的优化器只是看起来能跑,一上真实负载就暴露问题。
3. 存储引擎落地:缓冲池、B+树与 WAL 一次写对
3.1 缓冲池:页面缓存与淘汰策略的参数选择
存储引擎是整场竞赛里最不能“先跑起来再说”的模块。数据文件格式一旦定了,页面里的 slot 布局一旦写了,后面想改就要面对文件兼容问题——你总不能正式评测时重新导入一遍数据。所以第一版就要认真选参数。
缓冲池的核心是把 page_id 映射到内存里的 frame,frame 就是一个页拷贝,所有算子读数据都经过它。最小实现长这样:
// buffer_pool.h struct PageId { int32_t file_id; int32_t page_no; bool operator==(const PageId &o) const { return file_id == o.file_id && page_no == o.page_no; } }; class BufferPool { public: Page *getPage(const PageId &pid); // 取页,未命中则从磁盘加载 void unpin(const PageId &pid); // 释放一个引用计数 Page *newPage(const PageId &pid); // 分配新页 private: std::unordered_map<PageId, Page*, PageHash> pages_; std::deque<PageId> evict_queue_; // FIFO 淘汰队列 };页大小一般用 4096 或 8192 字节。RMDB 竞赛数据集不大,我建议选 8192,单次 IO 能读出更多行,索引扇出也更高。缓冲池容量按物理内存一半以内设,别和 OS 的 page cache 互相抢。竞赛数据量通常在几百 MB 量级,缓冲池给 256MB~512MB 就足够。
这里必须注意一个内存生命周期问题:getPage 返回的 Page* 在 unpin 之前有效,但你绝不能让一个事务持有的 Page* 跨到另一个事务提交之后再用。否则脏页被回滚时,你手里那个指针引用的是被 revert 过的内存,读出来就是错的。团队协作最常遇到的翻车现场就在这里——一个线程在缓冲池里写页,另一个线程在同一页上做索引查找,不加锁或引用计数就会随机崩溃。
// 从缓冲池取页的典型用法 auto page = buffer_pool.getPage(pid); // 读/写 page->data... buffer_pool.unpin(pid); // 用完必须释放我还会额外做一层 page latch,用自旋锁保护页内并发,因为 buffer pool 的全局锁如果粒度太大,并发事务在索引分裂时会被卡住。
3.2 B+树索引:分裂必须做对,合并可以偷懒
索引层是存储引擎出 bug 最多的地方。查找逻辑简单,但插入触发分裂时,父节点指针更新错一步整棵树就废了。最小实现:
// bplus_tree.cpp bool BPlusTree::insert(uint64_t key, const RowId &rid) { Page *leaf = find_leaf(key); // 沿内部节点找到叶子页 leaf->insert_entry(key, rid); if (leaf->is_overflow()) { Page *new_leaf = split_leaf(leaf); // 分裂成左右两页 insert_to_parent(leaf, new_leaf); // 把中间键上提 } return true; }参数说明:fanout 和页大小挂钩,8KB 页、8 字节 key + 8 字节 rid 时,一个叶子页大约能装 400 条 entry,这个数字直接决定树高。三百万行的 TPC-C 数据,树高大概 4 层,查找一次要做 4 次页读,只要缓冲池命中率在 95% 以上,单次索引查找只要几十微秒。
我踩过的最深的坑是分裂时只更新了叶子链表的双向指针,没有往父节点插入中间键,结果范围查询丢了一半的行。第二个大坑是删除 key 后不做借位和合并,导致树高不降反升,本来 3 层的树长到 5 层,索引扫描慢了一倍。TPC-C 的数据模型里删除操作很少,删除合并可以先做最简版,只做标记删除也能拿大部分分。但插入分裂必须做对,并发插入时分裂没处理好会直接死锁。
分裂时注意中间键的选取。B+树的约定是中间键只保留在父节点,不能两边都放,否则等值查询会返回两条重复记录。经典做法是中间键提升到父节点,叶子节点之间通过 next_leaf 指针串起来,便于范围扫描。
// 叶子页分裂的关键步骤 void split_leaf(Page *old_leaf, Page *new_leaf) { int mid = old_leaf->entry_count / 2; // 把 [mid, count) 移动到 new_leaf for (int i = mid; i < old_leaf->entry_count; i++) { new_leaf->insert_entry(old_leaf->entry_at(i)); } old_leaf->truncate(mid); // 双向链表维护 new_leaf->next = old_leaf->next; old_leaf->next = new_leaf; // 父节点的插入由 insert_to_parent 完成 }这段逻辑面试官大概率会深挖,建议全部用模型落一遍(模拟插入 20 条记录画分裂),不要只在测试集上碰运气。
3.3 WAL 与事务恢复:redo、undo 的最小闭环
事务模块在 RMDB 里一般做成 WAL。TPC-C 每个事务很短,日志要尽量紧凑。常用做法是每行变更写一条 record:事务号、页号、偏移、旧值、新值。
// wal.h struct WALRecord { uint64_t txn_id; PageId page_id; uint16_t offset; char old_value[8]; char new_value[8]; enum Op { UPDATE, INSERT, DELETE } op; };commit 流程是:先把日志 buffer 顺序写入日志文件并 fsync,再改内存页。崩溃恢复时扫描日志:已提交事务做 redo,未提交事务做 undo。这里有一个很现实的取舍——框架自带的日志模块如果是同步逐条写,性能会非常难看。改用组提交:攒满 N 条或等待一个小时间窗再一起落盘。TPC-C 场景下,我常用 N=64 或 5ms 时间窗,tpmC 能有接近一倍的提升。
另外要注意日志文件不能无限涨。TPC-C 连续跑半小时,日志量可能上 GB。需要周期性做 checkpoint:把当前所有脏页写回,记录一个 LSN 水位线,恢复时只扫描水位线之后的日志。如果框架没给 checkpoint,就自己实现一个定时触发,否则磁盘会先于你的系统崩溃。我的做法是创建一个后台线程,每 30 秒检查一次,当未 checkpoint 的日志超过 256MB 就触发。
4. 查询优化器与执行器:让 SELECT 跑出该有的样子
4.1 逻辑计划到物理计划:AST 翻译成算子树
查询优化器是 RMDB 里面试官最看重的模块。很多队伍把它推迟到最后做,结果 TPC-C 里几条 join 查询直接全表扫描,tpmC 压线都过不去。优化器的输入是解析器产出的 AST,输出是一棵物理算子树。
逻辑改写阶段做这几件事:谓词下推、投影裁剪、join 顺序调整。谓词下推是把 WHERE 里的条件从 join 上方往下挪到表扫描层,这样扫描时就能提前过滤,减少进入 join 的行数。投影裁剪是只保留 SQL 里出现的列,减少元组宽度和内存占用。这两个改写没有风险,赛前必须做,收益非常确定。
物理计划阶段的核心是算子选择。TPC-C 的典型负载里,SELECT ... WHERE w_id = ? AND d_id = ?这类点查必须走索引扫描。我给 RMDB 写代价模型的经验公式:
# cost_estimator.py def estimate_seq_scan(rows, row_size=100, page_size=8192): return rows * row_size / page_size # 顺序扫描代价约等于页数 def estimate_index_scan(rows, selectivity): # selectivity 是谓词选择率,等值条件下 = 1 / distinct_values return 2 + rows * selectivity # 2次页读定位 + 结果页读参数说明:selectivity 要靠统计信息支撑。没有统计信息时,等值谓词默认取 1/N(N 为行数),范围谓词默认取 1/3。这个默认值很粗糙,但比没有强。TPC-C 的表结构里 warehouse 只有几十行,district 几百行,这些表做 join 时全表扫描反而更快。真正要优化的是 orders、order_line 这种千万行量级的表。
代价算完之后,还要把代价最小的物理计划真正拼出来。我的执行器构建函数大致长这样:
// optimizer.cpp std::unique_ptr<PlanNode> build_physical_plan(LogicalNode *logical) { if (auto *scan = dynamic_cast<LogicalScan*>(logical)) { // 检查是否有可用索引 auto idx = find_index(scan->table()); if (idx && scan->predicate_is_equality()) { return std::make_unique<IndexScanNode>(scan->table(), idx); } return std::make_unique<SeqScanNode>(scan->table()); } // join 等其他节点递归处理 }IndexScanNode 和 SeqScanNode 都实现 PlanNode 接口,start() 时各自创建对应的 TableIterator。这里的取舍是:等值谓词且列上有索引时,优先走索引,通常不会错。但要注意,如果该列的可选择性很差,比如性别列只有男女两个值,索引扫描反而比全表扫描慢——这就是为什么统计信息收集不是可选项。
4.2 统计信息:不收集统计信息的优化器等于盲飞
统计信息的收集在 RMDB 里一般做成一个命令,比如ANALYZE table_name。框架通常没有自动收集机制,参赛者需要在导入数据后显式调用。统计信息包括每张表的行数、每列的非重复值个数(NDV)、每列的最小最大值。
// stats.h struct ColumnStat { int64_t distinct_values; // NDV int64_t min_val; int64_t max_val; double null_ratio; };没有统计信息的优化器就是盲飞。TPC-C 最经典的翻车是:orders 表 o_id 上有索引,因为没收集统计信息,优化器认为 orders 很小,选了全表扫描,单次 New Order 事务的查询从几十毫秒涨到几百毫秒,tpmC 直线下降。所以数据加载完成后,必须对每张表执行 ANALYZE 并打印统计信息,确认 NDV 和行数符合预期,再开始压测。
还有一个细节——统计信息要持久化。有些框架的统计信息只存在内存里,重启后丢失,需要重新 ANALYZE。你可以在测试脚本里把 ANALYZE 放在数据加载之后、压测之前,不要依赖人工手动执行。
4.3 物理算子选择:嵌套循环、Hash Join 与索引扫描的边界
TPC-C 里最常见的 join 长这样:
SELECT ... FROM orders, order_line WHERE o_w_id = ? AND o_d_id = ? AND o_id = ? AND ol_w_id = o_w_id AND ol_d_id = o_d_id AND ol_o_id = o_id;orders 有索引,order_line 也有索引,正确计划是 orders 走索引定位一行,然后对 order_line 用索引做嵌套循环 join。如果优化器选了 hash join,build 阶段要把整张 order_line 建成哈希表,内存和时间都翻几倍。我给物理算子选择的判断逻辑很简单:
def choose_join_algorithm(left_rows, right_rows, left_indexed, right_indexed): if left_indexed and right_rows < 1000: return NESTED_LOOP_WITH_INDEX if right_indexed and left_rows < 1000: return NESTED_LOOP_WITH_INDEX if left_rows * right_rows < 10000: return NESTED_LOOP return HASH_JOIN这个判断在 TPC-C 数据集上基本能覆盖全部 join 场景。大表之间的 join 需要给 hash join 实现内存预算。默认给 32MB、bucket 数量 65536 是够用的,如果 build 表行数超过预算,执行器要能优雅地把中间结果溢出到磁盘,最低限度是报一个“内存不足”的错误,不要让整个进程崩掉。
执行器还有一个高频问题:多线程执行。RMDB 竞赛负载会开多个连接并发执行事务,如果执行器内部的算子是无状态的,直接用线程池分发即可。HashJoin 的 build 阶段和 probe 阶段不能跨线程共享哈希表,需要每个查询自己建表。这一点在压测多并发时尤其明显,共享同一个哈希表的并发查询会消耗完内存,然后被系统 OOM 杀掉。
5. TPC-C 压测的四个坑:锁竞争、导入耗时、计划回退与日志增长
5.1 并发压测一启动就卡死,吞吐从几百掉到几十
现象:单连接跑 TPC-C 一切正常,多连接压测一开,整个进程像冻住一样,tpmC 掉到单连接的一半都不到,日志里全是锁等待超时。
原因:绝大多数是表级锁或全局锁粒度太大。RMDB 框架模板里,很多表的读操作拿的是表级共享锁,写操作拿表级排他锁。TPC-C 的 New Order 事务会对 warehouse、district、orders、order_line 多张表做读写,表级锁互相阻塞,并发就没了。另外,如果缓冲池的全局锁在索引分裂时持有过久,所有线程都会卡在同一把锁上。
解决:把锁粒度挪到页级。实现一个 page latch 数组,每个页一把自旋锁,行级锁可以先不做。TPC-C 的锁竞争主要来自同一页上多条记录同时被更新,页级锁性能够用。还要检查缓冲池的 LRU 操作是否持锁过长,把淘汰队列的更新放到临界区外,只在 getPage 的最后一个外部引用释放时触发淘汰候选标记。
5.2 数据导入比压测还慢,两个小时都没装完
现象:TPC-C 数据加载阶段,先插 warehouse、district、customer,再插 orders、order_line,总耗时超过两小时,而官方要求通常是 10 分钟以内。
原因:批量导入是逐条执行 INSERT,每条 INSERT 都走完整的 SQL 解析、事务提交、WAL 刷盘路径。尤其 orders 和 order_line 表有多个索引,每插一行都要更新所有索引的 B+树节点,加上日志同步落盘,速度自然上不去。
解决:导入阶段单独绕过 WAL。常见做法是允许一个 bulk load 模式:直接向页里顺序追加叶子页,一次维护整棵 B+树,而不是逐行插入。同时,导入过程用大事务分批提交,比如每 5000 行一个事务,日志只 fsync 一次。代码上,通常是在存储引擎里开放一个TableLoader接口,直接写数据页并重建索引。这个功能做出来后,无论是重新导数据还是调试 bug,都能省下大把时间。
5.3 执行计划忽然回退成全表扫描,tpmC 跌破基线
现象:压测前十分钟 tpmC 稳定,之后某段日志里出现大量全表扫描的查询执行记录,整体吞吐骤降。
原因:统计信息过期。RMDB 的优化器缓存了表的行数和 NDV,如果压测过程中数据量变化明显,或者统计信息只在一开始 ANALYZE 了一次,之后某些中间表的基数失真,优化器就会选错计划。更隐蔽的原因是:统计信息按文件名缓存,但数据文件被 TPC-C 脚本重新生成后,文件名没变,缓存没有失效。
解决:把 ANALYZE 放在每次数据加载后的固定位置,并且压测脚本开始前强制清空优化器的统计缓存。我一般还会加一个“计划回退日志”:每当优化器选中的计划的估算代价比上一次高 3 倍以上,就把 SQL 和计划树写到日志文件,赛后复盘时一眼看出是哪条语句出了问题。
5.4 WAL 日志涨到磁盘撑爆,压测中途宕机
现象:TPC-C 连续运行 30 分钟,日志文件已经占满整个数据盘,数据库进程在 checkpoint 阶段因磁盘 IO 写入失败直接宕机。
原因:日志只追加不清理。框架自带的 WAL 实现很可能只在关闭时做一次 checkpoint,运行期间无限增长。TPC-C 压测的写入量很大,30 分钟产生几个 GB 的日志很正常。
解决:加一个 checkpoint 后台线程。每当未 checkpoint 的日志超过阈值,比如 256MB,就触发全量脏页写回,并记录当前 LSN 水位。恢复时只需要扫描水位之后的日志。如果你没时间做增量 checkpoint,最粗暴的兜底是定期重启进程,但这不能救正式评测。真正可靠的方案是一定要把 checkpoint 做成可复现、可验证的,每次触发时打一条日志,压测结束后检查触发次数是否符合预期。
6. tpmC 之外的进阶验证:12 个数字与一道压轴题
6.1 用 12 个数字给系统做“体检”
压测通过不代表内核没有隐患,评委最常问的一句话是:“你的系统瓶颈在哪?”。为了回答这个问题,我养成了收集一组指标的习惯,核心的 12 个如下:
| 指标 | 采集方法 | 正常范围 |
|---|---|---|
| 缓冲池命中率 | 命中次数 / 总页访问 | >95% |
| 平均页读耗时 | IO 时间总和 / 页读次数 | <1ms |
| WAL fsync 次数/秒 | 日志模块计数 | 不高于事务提交数 |
| 锁等待占比 | 等待时间 / 事务总耗时 | <5% |
| 平均事务耗时 | 总耗时 / 事务数 | <50ms |
| 索引树高 | B+树根节点 meta | 3~4 |
如果命中率低于 90%,优先调大缓冲池;如果 fsync 次数等于提交数,说明组提交没生效;如果锁等待占比超过 10%,回去查表锁。这组数字在写赛后技术报告时,也直接构成“系统性能分析”章节的图表素材,比贴一段压测截图更有说服力。
6.2 一道容易被问垮的压轴题:并发插入与索引分裂的锁策略
评委大概率会问并发写场景。RMDB 的 TPC-C 里,并发插入 order_line 很常见,如果你的 B+树叶子分裂持锁策略是“整棵树一把锁”,那么并发插入就是串行的。提前准备一个答案:分裂路径上的节点锁、叶子页的 latch、以及索引树重新平衡失败时的重试路径。能把这个链路讲清楚,比多跑 10% 的 tpmC 更能说服评委。我自己的教训是:系统压测过了,但答辩时被问住,就是因为没有准备锁这一层。做完这版 RMDB,我最大的一条后悔药是——没有早一点开始压测。很多问题是到正式压测时才暴露的,如果提前两周就开始跑,至少能多调一轮缓冲池和锁粒度。竞赛项目真正考验的其实是这一件事:在固定时间内把系统调到没有明显短板。希望这篇笔记能帮你少走几步弯路。
本文还有配套的精品资源,点击获取