资讯详情 从零手写关系型数据库:RMDB框架下跑通TPC-C的完整路径
📅 2026/10/12 0:18:09
简介本资源为全国大学生计算机系统能力大赛数据库管理系统赛道的参赛项目源码包面向具备一定C/C与操作系统基础、希望深入理解数据库内核的高校学生与开发者。项目基于RMDB框架实现了一套完整的关系型数据库管理系统支持TPC-C基准测试负载涵盖存储引擎、查询优化器、事务管理等核心模块可用于课程设计、竞赛备赛与内核原理学习。压缩包共442个文件约2.43MB以121个h头文件与102个cc源文件为主体辅以47个Python脚本、34个cpp文件及30个md说明文档另有cmake、bazel构建配置与csv测试数据便于编译运行与模块化阅读。目前已有72人学习下载。通过该资源可获取完整的赛题实现方案、存储引擎与查询优化器的代码组织方式、TPC-C负载的适配思路以及构建与测试脚本适合对照源码梳理数据库内核的执行流程与设计取舍。1. 从零手写关系型数据库RMDB 框架下跑通 TPC-C 的真实路径很多人第一次看到“全国大学生计算机系统能力大赛数据库管理系统赛道”这个标题第一反应是“这不就是个课程设计吗”。但真正把 RMDB 框架拉下来、编译通过、跑起 TPC-C 负载之后你会发现这其实是一次对数据库内核的完整解剖——存储引擎、查询优化器、事务管理、并发控制一个都跑不掉。RMDB 是一个教学用的关系型数据库框架它把数据库内核拆成了可替换的模块让你在已有骨架上填充核心逻辑。TPC-C 则是衡量 OLTP 数据库性能的工业级基准模拟的是批发商订单处理场景包含新订单、支付、订单状态查询、发货、库存查询五类事务。这篇文章面向的是准备参赛或想通过 RMDB 理解数据库内核的开发者我会按“先跑通、再优化、后避坑”的顺序把存储引擎和查询优化器这两个最核心的模块拆开讲让你少走弯路。2. RMDB 框架拆解存储引擎与查询优化器到底要写什么2.1 RMDB 的模块划分与数据流走向RMDB 框架通常把数据库内核分成几个层次最底层是存储引擎负责页面管理、记录组织、索引结构中间是事务与并发控制层上层是 SQL 解析、查询优化与执行引擎。数据从 SQL 语句进来经过词法语法分析生成抽象语法树再由查询优化器生成执行计划最后调用执行器通过存储引擎读写磁盘上的页面。理解这条数据流是动手的前提因为你在存储引擎里做的每一个决策——比如页面大小、记录布局、索引类型——都会直接影响查询优化器能选出的计划质量。常见做法是先把存储引擎的页面管理跑通确保能正确读写一个页面再实现记录管理让页面能存多行记录然后接入索引模块通常是 B 树。查询优化器部分则依赖存储引擎提供的统计信息比如表的行数、列的基数、索引的高度来做基于代价的选择。如果存储引擎的统计信息不准优化器就会选错索引甚至全表扫描TPC-C 的吞吐量会断崖式下跌。2.2 存储引擎的最小实现页面、记录与 B 树索引存储引擎的第一件事是定义页面结构。RMDB 框架一般会提供页面管理的接口你需要实现页面的分配、回收、读写。页面大小常见是 4KB 或 8KB和操作系统页对齐能减少额外开销。记录在页面内可以用槽页结构页头维护槽位数组记录从页尾向前增长这样删除记录只需要标记槽位无效不需要移动数据。B 树索引是存储引擎的核心。你需要实现插入、删除、查找、范围扫描。插入时要处理节点分裂删除时要处理节点合并或重分配。一个容易翻车的地方是并发控制如果多个线程同时插入同一个节点没有锁保护会导致索引结构损坏。RMDB 框架可能已经提供了锁管理器你需要在对节点修改前加写锁读取时加读锁。下面是一个 B 树节点插入的伪代码示例展示分裂逻辑的关键步骤// B树节点插入node为当前节点key为待插入键value为记录ID bool BPlusTree::Insert(Node *node, const Key key, const Value value) { if (node-IsLeaf()) { // 叶子节点直接插入保持键有序 int pos node-LowerBound(key); node-InsertAt(pos, key, value); // 如果超出最大容量触发分裂 if (node-Size() max_leaf_size_) { Node *new_node SplitLeaf(node); // 将新节点的第一个键插入父节点 InsertIntoParent(node, new_node-KeyAt(0), new_node); } return true; } else { // 内部节点找到子节点继续递归 int pos node-LowerBound(key); Node *child node-ChildAt(pos); return Insert(child, key, value); } }这段代码的逻辑是叶子节点插入后如果超过容量就分裂成两个节点然后把新节点的第一个键提升到父节点。参数max_leaf_size_决定了叶子节点的最大键值对数量通常根据页面大小和键值对大小计算得出。如果键是变长的需要更复杂的槽页管理。内部节点分裂类似但提升的是中间键而不是第一个键。分裂操作必须保证原子性否则并发插入会看到不一致的树结构。2.3 查询优化器的代价模型与计划选择查询优化器要做的是把逻辑计划转成物理计划并选择代价最小的那个。代价通常包括 I/O 代价和 CPU 代价。I/O 代价主要看需要读取多少页面CPU 代价看需要处理多少记录。对于 TPC-C 这种 OLTP 负载点查和短范围查居多索引扫描的代价远低于全表扫描所以优化器要能正确识别等值条件和范围条件选择对应的索引。一个常见的代价模型是全表扫描代价 表页面数 × 页面读取代价索引扫描代价 索引高度 × 索引页面读取代价 匹配记录数 × 记录处理代价。如果匹配记录数超过表的 10% 到 20%全表扫描可能更优因为索引扫描需要回表。优化器需要根据统计信息估算匹配记录数统计信息可以来自直方图或简单的均匀假设。在 RMDB 框架里你可能需要实现一个基于规则或基于代价的优化器。基于规则的优化器简单但容易选错计划基于代价的优化器更准但需要统计信息支持。建议先实现基于代价的框架统计信息可以先用简单估算后续再完善。2.4 事务管理与 TPC-C 事务的对应关系TPC-C 的五类事务对事务管理的要求不同。新订单事务需要插入订单、订单行更新库存涉及多个表的写操作必须保证原子性。支付事务更新仓库、区域、客户的余额也是多表写。订单状态查询是只读事务但需要读多个表。发货事务更新订单行和库存。库存查询是只读。RMDB 框架通常提供事务开始、提交、回滚的接口你需要实现日志和恢复机制来保证持久性。并发控制可以用两阶段锁或 MVCC。两阶段锁实现简单但容易死锁MVCC 读不阻塞写但需要版本管理。对于 TPC-C两阶段锁的锁粒度如果太粗比如表锁并发度会很低行锁或页面锁更合适。死锁检测可以用超时或等待图。3. 在 RMDB 上跑通 TPC-C从建表到事务提交的完整操作3.1 环境准备与 RMDB 编译的最小命令先把 RMDB 框架拉到本地通常是一个 CMake 工程。你需要安装 CMake、GCC 或 Clang、以及可能的依赖库比如 readline 用于交互式 SQL 输入。编译步骤一般是# 创建构建目录避免污染源码 mkdir build cd build # 生成 MakefileDebug 模式便于调试 cmake -DCMAKE_BUILD_TYPEDebug .. # 编译-j 后跟核数以加速 make -j8编译成功后会在 build 目录下生成可执行文件比如rmdb。如果编译报错先检查依赖是否装全再看 CMakeLists.txt 里的 C 标准是否和你的编译器匹配。常见问题是 C17 特性在旧编译器上不支持升级 GCC 到 9 以上通常能解决。启动数据库./rmdb如果框架支持指定数据库文件路径可以加参数比如./rmdb mydb.db。启动后你会看到一个 SQL 提示符可以输入 SQL 语句。3.2 TPC-C 表结构在 RMDB 中的建表语句与索引设计TPC-C 有九张表仓库、区域、历史、订单、新订单、订单行、客户、库存、商品。建表时要注意主键和唯一索引的选择。比如仓库表主键是仓库 ID区域表主键是区域 ID订单表主键是订单 ID订单行表主键是订单 ID 和行号组合。索引设计直接影响查询性能新订单事务需要按仓库 ID 和区域 ID 查客户所以客户表上要有仓库 ID 和区域 ID 的联合索引。下面是一个简化的建表语句示例-- 仓库表主键 w_id CREATE TABLE warehouse ( w_id INT PRIMARY KEY, w_name VARCHAR(10), w_street_1 VARCHAR(20), w_street_2 VARCHAR(20), w_city VARCHAR(20), w_state CHAR(2), w_zip CHAR(9), w_tax DECIMAL(4,4), w_ytd DECIMAL(12,2) ); -- 客户表主键 c_id联合索引 (c_w_id, c_d_id, c_last) CREATE TABLE customer ( c_id INT, c_d_id INT, c_w_id INT, c_first VARCHAR(16), c_last VARCHAR(16), c_credit CHAR(2), c_balance DECIMAL(12,2), PRIMARY KEY (c_w_id, c_d_id, c_id) ); CREATE INDEX idx_customer_name ON customer (c_w_id, c_d_id, c_last);建表时要注意 RMDB 支持的数据类型如果框架不支持 DECIMAL可以用 DOUBLE 或 INT 代替但要注意精度。索引创建语句如果框架不支持CREATE INDEX可能需要在表定义里用UNIQUE或KEY声明。主键索引和唯一索引的区别在于主键不允许空值且只有一个唯一索引可以有多个且允许空值取决于实现。在 TPC-C 里客户表的联合主键保证了唯一性而idx_customer_name用于按姓氏查询客户这是订单状态查询事务需要的。3.3 加载 TPC-C 数据用脚本生成与批量插入TPC-C 官方提供数据生成工具通常用 C 或 Java 写。你可以用工具生成 CSV 文件然后通过 RMDB 的批量插入接口加载。如果 RMDB 不支持LOAD DATA可以写一个 Python 脚本生成 SQL 插入语句然后通过管道传给 RMDB。# 生成仓库数据的 SQL 插入语句 import random def gen_warehouse(w_id): w_name fw{w_id} w_tax round(random.uniform(0, 0.2), 4) w_ytd 300000.00 return fINSERT INTO warehouse VALUES ({w_id}, {w_name}, street1, street2, city, ST, 123456789, {w_tax}, {w_ytd}); # 生成 10 个仓库的数据 with open(warehouse.sql, w) as f: for i in range(1, 11): f.write(gen_warehouse(i) \n)生成后可以用./rmdb warehouse.sql的方式批量执行。注意批量插入时事务不要太大否则日志和锁的开销会很高。可以每 1000 条提交一次。如果 RMDB 支持事务用BEGIN; ... COMMIT;包裹。3.4 跑 TPC-C 事务五个事务的 SQL 实现与参数绑定TPC-C 的五个事务需要按规范实现但你可以先用简化版验证功能。新订单事务的 SQL 大致是-- 新订单事务插入订单、订单行更新库存 BEGIN; INSERT INTO orders VALUES (o_id, d_id, w_id, c_id, current_timestamp, 1, 0); INSERT INTO order_line VALUES (o_id, d_id, w_id, ol_number, i_id, supply_w_id, quantity, 0, delivery_time); UPDATE stock SET s_quantity s_quantity - quantity WHERE s_w_id w_id AND s_i_id i_id; COMMIT;支付事务更新客户余额和仓库销售额BEGIN; UPDATE warehouse SET w_ytd w_ytd amount WHERE w_id w_id; UPDATE district SET d_ytd d_ytd amount WHERE d_w_id w_id AND d_id d_id; UPDATE customer SET c_balance c_balance - amount WHERE c_w_id w_id AND c_d_id d_id AND c_id c_id; COMMIT;订单状态查询是只读的按客户姓氏查最近订单SELECT c_balance, c_first, c_last FROM customer WHERE c_w_id w_id AND c_d_id d_id AND c_last last_name ORDER BY c_first;发货事务更新订单行和库存库存查询读库存表。参数绑定可以用 RMDB 的预处理语句如果不支持就在应用层拼接 SQL但要注意转义防止注入。4. 避坑与排查RMDB 开发中那些让人半夜惊醒的问题4.1 页面分裂导致索引损坏现象、原因与解决现象插入大量数据后按索引查询偶尔返回错误结果或者数据库崩溃后重启索引不可用。原因B 树节点分裂时没有正确更新父节点指针或者并发插入时两个线程同时分裂同一个节点导致树结构不一致。解决在分裂操作前对节点加写锁分裂完成后释放父节点更新也要加锁。如果是单线程测试没问题多线程才出现基本可以定位到并发问题。可以用 Valgrind 或 ThreadSanitizer 检测数据竞争。4.2 查询优化器选错索引统计信息不准的连锁反应现象TPC-C 新订单事务的响应时间突然变长查看执行计划发现走了全表扫描而不是索引扫描。原因优化器依赖的统计信息过期比如表行数还是旧值导致代价估算错误。解决在数据加载后手动执行ANALYZE TABLE更新统计信息或者让优化器在每次查询时采样。如果 RMDB 不支持自动统计可以在代码里定期更新。另一个原因是索引列的类型和查询条件类型不匹配比如索引是 INT查询条件是字符串导致索引失效。4.3 事务死锁与锁等待超时TPC-C 高并发下的典型故障现象并发跑 TPC-C 时部分事务报死锁错误或等待超时。原因两阶段锁的加锁顺序不一致比如事务 A 先锁订单表再锁库存表事务 B 先锁库存表再锁订单表形成循环等待。解决统一加锁顺序所有事务都按相同的表顺序加锁。如果 RMDB 支持死锁检测可以配置检测周期如果不支持用锁等待超时回滚。TPC-C 的新订单事务和支付事务都会更新库存和客户加锁顺序要一致。4.4 日志与恢复崩溃后数据不一致的排查思路现象数据库异常退出后重启部分已提交事务的数据丢失或者未提交事务的数据被写入。原因日志刷盘策略不对比如提交时没有强制刷日志或者恢复时重做和撤销的顺序错误。解决确保事务提交前日志先写入磁盘可以用fsync。恢复时先重做已提交事务再撤销未提交事务。如果 RMDB 框架提供了日志模块检查是否正确调用了log_manager-flush()。可以用 kill -9 模拟崩溃然后重启验证数据一致性。4.5 内存泄漏与性能衰减长时间跑 TPC-C 后的资源问题现象跑 TPC-C 几个小时后内存占用持续上升吞吐量下降。原因页面缓存没有淘汰策略或者查询执行器分配的内存没有释放。解决实现 LRU 或 Clock 淘汰算法限制页面缓存大小。查询执行器里用智能指针管理内存避免裸指针。可以用valgrind --leak-checkfull检查泄漏点。如果是 B 树节点分裂后旧节点没有释放也会导致泄漏。5. 进阶技巧用执行计划分析和参数调优把 TPC-C 吞吐量再提一档5.1 用 EXPLAIN 验证查询优化器的选择RMDB 如果支持EXPLAIN一定要用它来验证优化器的计划。比如新订单事务里按仓库 ID 和商品 ID 查库存理想计划是索引扫描。如果EXPLAIN显示全表扫描就要检查索引是否存在、统计信息是否更新。你可以手动构造不同选择率的查询观察优化器是否在索引扫描和全表扫描之间正确切换。一个实用技巧是在代码里加日志打印每个查询的代价估算值和实际执行时间对比后调整代价模型的参数。5.2 调整代价模型参数让优化器更懂 TPC-C代价模型里的参数比如页面读取代价、CPU 处理代价、索引高度需要根据实际硬件调整。如果磁盘是 SSD页面读取代价可以设低一些如果 CPU 快CPU 代价可以设低。你可以跑一组基准查询测量实际 I/O 时间和 CPU 时间然后反推参数。TPC-C 的点查多索引扫描的代价权重应该高于全表扫描。如果优化器总是选全表扫描可能是索引扫描的代价被高估了比如索引高度估算过大。5.3 页面大小与缓存策略的权衡页面大小影响 I/O 效率和缓存命中率。4KB 页面适合随机读写8KB 或 16KB 适合顺序扫描。TPC-C 是随机读写为主4KB 通常够用。缓存策略方面LRU 实现简单但可能被全表扫描污染Clock 算法开销小。你可以实现一个自适应替换缓存把索引页面和热点数据页面优先保留。如果 RMDB 框架允许把日志缓冲区和数据缓冲区分开避免日志刷盘影响数据页面。5.4 并发度与锁粒度的调优实验锁粒度越细并发度越高但锁管理开销越大。TPC-C 里客户表和库存表的更新频繁行锁比页面锁更合适。你可以做一组实验分别用表锁、页面锁、行锁跑 TPC-C记录吞吐量和死锁率。通常行锁吞吐量最高但死锁检测开销也大。如果 RMDB 支持 MVCC读事务不加锁写事务加行锁并发度会更好。调优时注意观察 CPU 利用率和磁盘 I/O 队列长度找到瓶颈再调整。5.5 一个具体技巧用批量提交降低日志开销TPC-C 的每个事务都提交会导致日志频繁刷盘I/O 成为瓶颈。一个技巧是把多个事务合并成一个大事务提交比如每 10 个新订单事务提交一次。但这样会降低持久性崩溃时可能丢失最近几个事务。折中方案是用组提交多个事务的日志一起刷盘只调用一次fsync。你可以在日志管理器里实现一个组提交队列当队列长度达到阈值或超时时间到统一刷盘。这个技巧能显著提升吞吐量但要注意事务的原子性不能破坏。我自己在调 RMDB 的 TPC-C 时最深的教训是不要一上来就优化查询优化器先把存储引擎的页面管理和 B 树并发控制做扎实。存储引擎不稳优化器再聪明也是空中楼阁。另外每次改完代码跑一遍 TPC-C 的完整负载用perf或gprof看热点函数比凭感觉猜瓶颈靠谱得多。希望帮到你。本文还有配套的精品资源点击获取