简介:本资源是全国大学生计算机系统能力大赛数据库管理系统赛道的完整参赛项目,面向系统软件方向本科生与数据库内核学习者,聚焦关系型数据库从零实现的核心能力训练。项目基于RMDB框架构建支持TPC-C基准测试的全功能RDBMS,覆盖存储引擎、查询优化器、事务管理等内核模块,可作为数据库原理课程设计、系统能力实训及内核源码研读的高质量实践范例。压缩包共442个文件,以121个C/C++头文件(h/hpp)与102个C++源文件(cc/cpp)构成主体代码层,辅以47个Python脚本(自动化测试与工具)、30个Markdown文档(设计说明与实验记录)及5个PDF技术报告,整体仅2.43MB,结构紧凑、模块边界清晰。目前已有72人学习下载,读者可直接获取完整可编译工程、TPC-C负载集成方案、Bazel/CMake双构建支持及关键组件(如词法分析lex.yy.c、GTest单元测试用例)的详细实现逻辑,快速切入数据库系统开发实战。
1. 为什么一个学生团队敢从零手写存储引擎、优化器和事务模块?——这不是课程设计,而是用 C++ 实现可跑 TPC-C 的完整 DBMS 内核
你见过凌晨三点还在调 B+ 树页分裂逻辑的本科生吗?我带过几届某高校数据库系统能力大赛参赛队,最深的体会是:当学生真正把BufferPoolManager的 pin_count 和 latch 顺序搞明白时,ta 对 ACID 的理解,已经远超照着《数据库系统概念》划重点的研究生。这个标题不是包装话术,它指向一个真实存在的技术路径:用纯 C++(不依赖 SQLite、LevelDB 或任何嵌入式引擎)实现支持完整 SQL 解析、基于代价的查询优化、WAL 日志 + ARIES 风格崩溃恢复、多版本并发控制(MVCC)的单机关系型数据库内核,并通过 TPC-C 基准验证其 OLTP 能力。它不追求吞吐碾压 PostgreSQL,但要求每个模块——从磁盘页读写、索引结构、执行算子到锁管理——都由参赛者亲手编码、调试、压测。适合两类人:一是想撕开数据库黑匣子、拒绝“会用就行”的系统级学习者;二是准备冲击系统能力大赛数据库赛道、需要可复现、可答辩、可现场 demo 的硬核项目方案者。本文不讲理论推导,只讲怎么在 3 个月内,把src/storage/page/table_page.h从空文件变成能扛住 100 并发订单插入的生产级结构。
2. 从零构建存储引擎:B+ 树索引与 WAL 日志的协同落地
数据库内核的根基不在 SQL 解析器,而在数据如何落盘、如何被快速定位、如何在崩溃后不丢。学生项目最容易翻车的地方,就是把存储引擎当成“配角”,结果优化器再 fancy,一写入就 core dump。我们坚持一个原则:先让单线程下的 insert/select 正确跑通,再加并发,最后上 WAL。跳过这步,后面所有优化都是空中楼阁。
2.1 表页与索引页的物理布局:为什么必须手写 PageHeader?
很多团队直接套用现有内存池框架,却忽略了一个致命细节:页头(PageHeader)必须包含page_id_t、lsn_t、pin_count、is_dirty和next_free_offset这五个字段,且顺序不能错。这是后续 WAL 日志解析、缓冲区淘汰、页面重用的契约基础。例如next_free_offset决定了新记录插入位置,若未初始化或更新不同步,会导致记录覆盖、页内碎片无法回收。
// src/storage/page/page.h struct PageHeader { page_id_t page_id_; // 该页在磁盘上的唯一 ID lsn_t lsn_; // 最后一次修改该页的日志序列号(WAL 关键) int32_t pin_count_; // 当前被多少个线程/事务 pin 住(防止被换出) bool is_dirty_; // 是否被修改过,决定刷盘时机 int32_t next_free_offset_; // 下一个可用字节偏移(指向空闲区起始) };提示:
page_id_t必须是全局唯一整数(如从 0 开始递增),不能用指针地址或随机数。lsn_t在无 WAL 阶段可设为 0,但结构体必须预留,否则加 WAL 时要重构所有页结构,血泪经验。
2.2 B+ 树索引的最小可行实现:只支持点查与范围扫描的 LeafNode
学生常陷入“必须支持所有 B+ 树操作”的误区。实际参赛中,只要能正确完成SELECT * FROM orders WHERE o_w_id = ? AND o_d_id = ? AND o_id = ?的等值查找,就满足 TPC-C 的主键查询需求。因此,我们砍掉复杂的合并、借位逻辑,聚焦于LeafNode的分裂与InternalNode的键提升。
关键约束:每个叶子节点固定存 64 条记录(按 TPC-Corders表 128 字节/行估算),内部节点存 128 个键(节省空间)。分裂时,新节点取后半部分记录,父节点插入新键(右子节点最小键)和新子节点 ID。
// src/storage/index/b_plus_tree.cpp bool BPlusTree::Insert(const KeyType &key, const ValueType &value) { // 1. 从根开始查找,沿途记录 path(用于分裂后更新父节点) std::vector<Page *> path; auto leaf = FindLeaf(key, &path); // 2. 尝试插入到 leaf;若满,则分裂 if (!leaf->Insert(key, value)) { auto new_leaf = SplitLeaf(leaf); // 3. 向上回溯,将 new_leaf 的最小键和 ID 插入父节点 InsertIntoParent(path, leaf, new_leaf); } return true; } Page *BPlusTree::SplitLeaf(Page *leaf) { auto new_leaf = buffer_pool_manager_->NewPage(); // 申请新页 // 将 leaf 后半记录 memcpy 到 new_leaf int split_point = leaf->GetSize() / 2; for (int i = split_point; i < leaf->GetSize(); ++i) { new_leaf->Insert(leaf->KeyAt(i), leaf->ValueAt(i)); } leaf->SetSize(split_point); // 截断原页 return new_leaf; }逻辑说明:FindLeaf返回叶子页指针,并通过path参数返回从根到叶子的路径(含所有内部节点指针),这是分裂后向上更新父节点的唯一依据。SplitLeaf不做键复制,只移动数据,避免二次遍历。InsertIntoParent是核心难点:若父节点也满,则递归分裂,直到根节点——此时需新建根(树高+1)。
参数说明:GetSize()返回当前页有效记录数;KeyAt(i)和ValueAt(i)是安全访问接口,内部检查边界;buffer_pool_manager_->NewPage()是缓冲区管理器接口,返回可写的干净页。
3. 查询优化器落地:从语法树到物理计划的三步剪枝
TPC-C 的 SQL 模板高度结构化(9 个固定事务,SQL 模式极少),这让学生优化器不必追求通用性,而应聚焦确定性、可解释、易调试。我们放弃基于规则的复杂重写(如子查询上拉、视图合并),采用“三步剪枝法”:语法树 → 逻辑计划 → 物理计划,每步只做必要转换,且全程保留原始 SQL 注释,方便答辩时逐行溯源。
3.1 语法树到逻辑计划:只做投影下推与谓词下推
SELECT o_id, o_carrier_id FROM orders WHERE o_w_id = 1 AND o_d_id = 2这类查询,逻辑计划只需两步:
- 谓词下推:
WHERE条件直接附加到TableScan算子,而非在顶层Filter; - 投影下推:只从磁盘读取
o_id和o_carrier_id两列,跳过其他 12 列。
// src/optimizer/logical_plan_builder.cpp std::unique_ptr<LogicalOperator> BuildPlan(const ASTNode &ast) { if (ast.type == SELECT_STMT) { auto table_scan = std::make_unique<LogicalTableScan>(ast.table_name); // 谓词下推:将 WHERE 条件转为 ScanPredicate if (ast.where_condition) { table_scan->predicate_ = BuildPredicate(ast.where_condition); } // 投影下推:只请求需要的列 table_scan->output_columns_ = ast.select_columns; // 若有 GROUP BY 或 ORDER BY,才添加对应算子 if (!ast.group_by_columns.empty()) { auto group_by = std::make_unique<LogicalGroupBy>(); group_by->children_.push_back(std::move(table_scan)); return group_by; } return table_scan; } return nullptr; }逻辑说明:BuildPredicate将 AST 中的o_w_id = 1解析为ColumnRef("o_w_id") == Constant(1)结构,供后续选择索引时匹配。output_columns_直接传给TableScan,驱动存储层只读指定列(需在TablePage::GetTuple中实现列裁剪)。
3.2 逻辑计划到物理计划:基于代价的索引选择(仅主键)
TPC-C 所有表均以(w_id, d_id, id)为复合主键。因此,物理优化器只需判断 WHERE 条件是否覆盖最左前缀:
WHERE w_id = ?→ 用主键索引(范围扫描)WHERE w_id = ? AND d_id = ?→ 用主键索引(范围扫描)WHERE d_id = ?→ 全表扫描(无索引可用)
不实现二级索引、不考虑 join 顺序(TPC-C 无多表 join),极大降低复杂度。
// src/optimizer/physical_planner.cpp std::unique_ptr<PhysicalOperator> ChooseIndexScan( const std::string &table_name, const std::vector<std::pair<std::string, Value>> &predicates) { // 检查 predicates 是否包含 w_id(主键第一列) bool has_w_id = false; for (const auto &p : predicates) { if (p.first == "w_id") { has_w_id = true; break; } } if (has_w_id) { return std::make_unique<PhysicalIndexScan>(table_name, predicates); } return std::make_unique<PhysicalTableScan>(table_name); }参数说明:predicates是从BuildPredicate提取的列值对向量,格式为{"w_id", 1}, {"d_id", 2};PhysicalIndexScan内部调用BPlusTree::Search定位叶子页,再遍历匹配d_id和id;PhysicalTableScan则顺序读取所有页。
4. 事务与并发控制:MVCC + 两阶段锁(2PL)的轻量级融合实现
TPC-C 要求严格一致性(如 NewOrder 事务中库存扣减与订单插入的原子性),但学生项目无法承担 full MVCC 的垃圾回收开销。我们的解法是:用 2PL 保证写冲突串行化,用 MVCC 快照隔离读操作,二者共用同一版本链。这比纯 2PL 减少读写阻塞,比纯 MVCC 降低 GC 复杂度,是大赛场景下的黄金折中。
4.1 版本链结构:TupleHeader 里藏三个时间戳
每个元组(Tuple)头部扩展TupleHeader,存create_tx_id_、delete_tx_id_和prev_version_offset_:
// src/storage/tuple/tuple.h struct TupleHeader { txn_id_t create_tx_id_; // 创建该版本的事务 ID txn_id_t delete_tx_id_; // 删除该版本的事务 ID(0 表示未删) int32_t prev_version_offset_; // 指向前一版本在页内的偏移(负数表示无效) }; class Tuple { TupleHeader header_; char data_[]; };逻辑说明:create_tx_id_和delete_tx_id_构成可见性判断基础;prev_version_offset_形成单向链表,新版本总在旧版本之后分配空间(页内追加),避免链表断裂。事务开始时,记录当前max_committed_tx_id_作为快照;读取时,沿链表向上找第一个满足create_tx_id_ <= snapshot && (delete_tx_id_ == 0 || delete_tx_id_ > snapshot)的版本。
4.2 两阶段锁协议(2PL)的极简实现:只锁主键页
TPC-C 所有写操作必通过主键定位(如UPDATE stock SET s_quantity = ? WHERE s_w_id = ? AND s_i_id = ?),因此锁管理器只需维护page_id_t到std::shared_mutex的映射,而非行级锁。事务执行时:
- 加锁阶段:对涉及的所有主键页(通过 B+ 树查找路径获得)调用
lock_manager_->LockShared(page_id)或LockExclusive(page_id); - 解锁阶段:事务提交/回滚时,统一释放所有已持锁。
不实现意向锁(IX/SIX),因无嵌套事务;不实现死锁检测(用超时替代),因 TPC-C 场景锁持有时间极短。
// src/concurrency/lock_manager.cpp void LockManager::LockExclusive(txn_id_t txn_id, page_id_t page_id) { std::unique_lock<std::shared_mutex> lock(mutex_); auto &page_locks = page_lock_table_[page_id]; // 若已有其他事务持有 X 锁,等待 if (page_locks.exclusive_owner_ != INVALID_TXN_ID) { page_locks.waiting_x_.push(txn_id); cv_.wait(lock, [this, page_id] { return page_lock_table_[page_id].exclusive_owner_ == INVALID_TXN_ID; }); } page_locks.exclusive_owner_ = txn_id; }参数说明:page_lock_table_是std::unordered_map<page_id_t, PageLock>;PageLock包含exclusive_owner_(当前 X 锁持有者)、shared_owners_(S 锁持有者集合)、waiting_x_(等待 X 锁的事务队列);cv_是条件变量,用于等待锁释放。
5. 避坑指南:TPC-C 压测中 5 个高频翻车点与血泪修复方案
学生项目在 TPC-C 压测环节失败率超 70%,多数源于对底层机制的“想当然”。以下是我们在三届比赛中反复验证的 5 个致命坑,每条都附现场gdb截图级排查路径。
5.1 现象:TPC-Cnew_order事务在 50 并发时出现重复订单号(o_id 冲突)
原因:o_id生成未加锁,多个事务同时读取warehouse.w_ytd后自增,导致相同o_id写入。
解决:在WarehouseTable::GetNextOrderId()中,对w_id对应的仓库页加LockExclusive,读取w_ytd后立即w_ytd++并MarkDirty(),再释放锁。禁止在锁外修改。
5.2 现象:payment事务执行缓慢,perf record -e cache-misses显示 L3 缓存缺失率 > 40%
原因:B+ 树内部节点未预加载,每次查找都要buffer_pool_manager_->FetchPage(),引发大量磁盘 I/O。
解决:在BPlusTree::Search开头,对根节点及路径上所有内部节点调用buffer_pool_manager_->PinPage(),确保其驻留内存;查找结束后,对非叶子节点调用UnpinPage(false)(不刷盘)。
5.3 现象:WAL 日志写入后,进程崩溃重启,stock表部分更新丢失
原因:WAL 日志写入log_buffer_后未fsync()到磁盘,崩溃时缓冲区数据丢失。
解决:在LogManager::AppendLogRecord()末尾,当log_buffer_.size() >= LOG_BUFFER_SIZE或事务提交时,调用log_file_->Flush()(内部执行fsync(fd))。注意:LOG_BUFFER_SIZE设为 4KB,与磁盘块对齐。
5.4 现象:order_status查询返回空结果,但SELECT COUNT(*) FROM orders显示数据存在
原因:MVCC 可见性判断错误,delete_tx_id_被误设为INVALID_TXN_ID(应为 0),导致已删除版本被误读。
解决:统一约定delete_tx_id_ == 0表示“未删除”,delete_tx_id_ == INVALID_TXN_ID表示“该字段未初始化”,在Tuple::Init()中强制header_.delete_tx_id_ = 0。
5.5 现象:buffer_pool_manager_在高并发下频繁触发VictimPage(),命中率低于 30%
原因:replacer_(驱逐策略)使用 LRU,但未区分 pinned 页面,导致正被事务使用的页被错误换出。
解决:改用ClockReplacer(环形链表 + use_bit),PinPage()时置use_bit=1,UnpinPage()时仅减pin_count;VictimPage()遍历时,若use_bit==1则置 0 并跳过,否则驱逐。ClockReplacer在 100 并发下缓存命中率稳定在 85%+。
注意:所有修复必须通过
./test/buffer_pool_test和./test/transaction_test单元测试,禁止仅靠 TPC-C 压测验证。
6. TPC-C 基准验证:从生成数据到解读 3 个核心指标的实操手册
TPC-C 不是“跑个脚本看吞吐”,而是通过 5 类事务(NewOrder、Payment、OrderStatus、Delivery、StockLevel)的混合负载,验证系统在 ACID、可扩展性、稳定性三方面的硬实力。学生项目常止步于“能跑”,但大赛答辩要求你能说清:为什么我的 100 tpmC 是可靠的?瓶颈在哪?如何证明没作弊?以下是我们验证全流程。
6.1 数据生成:用官方tpcc_build工具,但必须校验 3 个关键约束
TPC-C 规范强制要求:
warehouse表:10 个仓库(w_id从 1 到 10),每个仓库w_ytd = 300000.00;district表:每个仓库 10 个分区(d_id从 1 到 10),d_ytd = 30000.00;customer表:每个分区 3000 个客户(c_id从 1 到 3000),c_balance = -10.00。
生成后,必须执行 SQL 校验:
-- 校验 warehouse 总数与 ytd SELECT COUNT(*), SUM(w_ytd) FROM warehouse; -- 应返回:10 | 3000000.00 -- 校验 district 分布 SELECT w_id, COUNT(*) FROM district GROUP BY w_id; -- 每个 w_id 应返回 10 -- 校验 customer 均匀性 SELECT d_w_id, d_id, COUNT(*) FROM customer c JOIN district d ON c.c_d_id = d.d_id AND c.c_w_id = d.d_w_id GROUP BY d_w_id, d_id; -- 每个 (d_w_id, d_id) 应返回 3000提示:
tpcc_build默认生成 1 仓数据,需加-w 10参数;若用自定义生成器,必须输出tpcc_output目录下*.tbl文件,并用LOAD DATA INFILE导入,不可用INSERT循环(太慢且易超时)。
6.2 压测执行:tpcc_start的 4 个必调参数与日志解析
我们固定使用tpcc_start(非tpcc_worker),命令如下:
./tpcc_start -h localhost -P 8080 -d tpcc_db -u root -p pass \ -w 10 -c 10 -r 10 -l 300 -f tpcc_report.log参数说明:
-w 10:10 个仓库(与数据一致);-c 10:10 个并发连接(非线程数,每个连接模拟一个终端);-r 10:预热 10 秒(跳过冷启动抖动);-l 300:持续压测 300 秒(5 分钟),足够观察稳态;-f:输出详细日志,含每秒事务计数(tpmC)和响应时间分布。
关键日志字段解析(tpcc_report.log):
| 字段 | 含义 | 合格线 | 诊断意义 |
|---|---|---|---|
tq | 新订单事务数(NewOrder) | ≥ 90% 总事务 | 主力事务,占比低说明索引失效或锁争用 |
pq | 支付事务数(Payment) | ≥ 5% 总事务 | 验证 UPDATE 性能,偏低可能 WAL 写入慢 |
90th pct latency (ms) | 90% 事务响应时间 | ≤ 100 ms | 超过则需perf top查热点函数 |
retries | 事务重试次数 | = 0 | 非零说明锁冲突严重,需检查 2PL 实现 |
6.3 指标解读:tpmC、Price/tpmC 与稳定性三角验证
大赛评分不只看峰值 tpmC,更看重三者平衡:
- tpmC(Transactions per Minute C):标准公式
tpmC = (NewOrder 成功数 × 60) / 测试秒数。我们目标:10 仓下 ≥ 800 tpmC(单机 C++ 实现合理上限); - Price/tpmC:虽不真买硬件,但需在报告中声明“假设部署于 16 核 32GB 云服务器,年成本约 XXX 元”,体现成本意识;
- 稳定性:连续 3 次 300 秒压测,tpmC 波动 < ±5%,且
90th pct latency无毛刺(用gnuplot绘制时间序列图)。
我们曾发现:某次 tpmC 达 850,但retries为 12,90th latency在 200ms 波动——经查是StockLevel事务未加锁,导致SELECT COUNT(*)与UPDATE冲突。修复后 tpmC 降至 790,但retries=0,latency=45ms,最终得分更高。
最后一句:我带过的冠军队,没有一个是在截止日前一周才开始写代码的。他们从 3 月就建好BufferPoolManager的单元测试骨架,4 月跑通单线程INSERT/SELECT,5 月加 WAL 和事务,6 月集成 TPC-C。真正的竞争力,从来不是最后炫技的 demo,而是每一天git commit -m "fix: btree split crash on full leaf"的踏实。希望帮到你。
本文还有配套的精品资源,点击获取