学生团队如何用C++17实现TPC-C达标的真实数据库内核

发布时间:2026/10/9 21:11:44
学生团队如何用C++17实现TPC-C达标的真实数据库内核 简介本资源是全国大学生计算机系统能力大赛数据库管理系统赛道的参赛项目实现面向系统能力培养方向的高校本科生与研究生聚焦数据库内核开发实践解决从零构建支持工业级负载TPC-C的关系型数据库管理系统这一高阶工程问题。压缩包共442个文件以121个C/C头文件h/cc/cpp/hpp和102个C源文件构成核心内核代码主体辅以47个Python脚本用于测试、生成或工具链、30个Markdown文档含设计说明与实验记录、27个文本日志及5个PDF技术资料整体2.43MB结构清晰覆盖存储引擎、查询优化器、事务管理等关键模块。已有72人学习下载读者可直接获取完整可编译的RMDB框架扩展代码、TPC-C负载集成方案、Bazel/CMake双构建支持、词法分析器lex.yy.c等底层实现细节以及配套的单元测试gtest/gmock与性能验证路径是深入理解DBMS内核原理与工程落地的优质实操范例。1. 为什么一个学生团队能从零写出支持 TPC-C 的数据库内核这不是 Demo是真跑通事务、锁、B树和查询计划的 RMDB 实战你可能刚在 GitHub 上刷到某个“数据库课程设计”仓库点开发现只有 parser interpreter连 WAL 都没影也可能见过标榜“自研存储引擎”的项目一查 commit 记录全是 copy-paste 的 LSM-TREE 教程代码。但这次标题里写的不是“模拟”“教学版”或“简化实现”——它明确指向「全国大学生计算机系统能力大赛数据库管理系统赛道」的参赛项目而该赛道历年评审标准中TPC-C 负载通过率、ACID 事务可见性验证、B树并发插入稳定性、查询优化器对 JOIN 顺序的实际剪枝效果全都是硬性扣分项。这意味着它必须在 2000 行以内完成 Buffer Pool 管理器的 LRU-K 替换逻辑在无外部库依赖下实现可序列化快照隔离SSI的冲突检测环路且所有模块要能被 TPC-C 的 10 个并发 warehouse worker 同时压测 30 分钟不 panic。这不是玩具是学生用 C17 原生 pthread 写出来的、能进 Linux perf 火焰图看热点、能被 gdb 断在 lock_manager.cpp 第 217 行 debug 的真实数据库内核。适合正在啃《Database Internals》第 4 章却卡在“怎么把 textbook pseudocode 变成可调试的指针操作”的人也适合想确认“本科毕设真能触达数据库内核哪一层”的导师——本文就带你拆开这个 zip 包看清楚 B树分裂时如何原子更新父节点指针、查询优化器怎么用代价模型拒绝一个看似合理的 nested-loop join、以及为什么 TPC-C 新订单事务里那行SELECT ... FOR UPDATE必须触发意向排他锁IX升级。2. 从 RMDB 框架起步为什么选它而不是手写 Makefile SQLite 源码改造RMDBRelational Model Database不是 Apache 或 CNCF 孵化项目而是某高校系统能力实训课沉淀出的教学框架其设计哲学非常务实不封装底层细节只提供内存/磁盘抽象层契约强制你亲手填满 buffer pool、page layout、log record 结构体。它不像 DuckDB 那样自带向量化执行器也不像 PostgreSQL 那样有 50 万行启动代码它的核心头文件 rmdb.h 仅定义了 7 个纯虚接口class DiskManager { public: virtual void WritePage(page_id_t page_id, const char *data) 0; virtual void ReadPage(page_id_t page_id, char *data) 0; // ... 其余 5 个方法 };提示RMDB 的“框架”二字容易误导——它不提供现成 B树只规定Page必须含page_id_t和lsn_t字段不内置事务管理器但要求每个Transaction对象必须实现GetTransactionId()和GetIsolationLevel()。所有“智能”都得你写框架只校验契约。2.1 初始化 RMDB 运行时三步绑定硬件资源与内存策略参赛项目第一步不是写 SQL 解析而是让 RMDB 知道“你的机器长什么样”。这三步缺一不可漏掉任意一步都会在后续 TPC-C 测试中出现 page fault 或 buffer pool thrashing# 步骤 1预分配固定大小的磁盘映像非稀疏文件TPC-C 要求随机 IO dd if/dev/zero ofdb_disk.img bs4096 count65536 # 256MB 固定大小 # 步骤 2配置 Buffer Pool 大小关键TPC-C warehouse10 时至少需 8192 pages echo buffer_pool_size: 8192 config.yaml # 步骤 3指定日志路径WAL 必须落盘禁用 mmap echo log_dir: ./wal_logs config.yaml逻辑说明db_disk.img必须用dd全量写零而非truncate因为 RMDB 的DiskManager默认使用pread/pwrite直接寻址稀疏文件会导致ReadPage(1024)返回全零页而非报错buffer_pool_size不是越大越好实测当设为 16384 时TPC-C 中payment事务因 page pinning 过多引发 LRU-K 颠簸QPS 反降 12%log_dir必须是独立目录RMDB 的LogManager会在此创建0000000000000001.log等序列文件若与数据文件混放fsync()时会因 ext4 journal 争抢导致 WAL 延迟超 200ms触发事务超时。2.2 替换默认 DiskManager用 mmap 实现零拷贝读写但 TPC-C 下要禁用RMDB 默认DiskManager是朴素的open()/read()/write()但参赛项目做了关键优化对db_disk.img使用mmap(MAP_SHARED)使ReadPage变成指针偏移// mmap_disk_manager.cpp class MMapDiskManager : public DiskManager { private: int fd_; char *mapped_addr_; size_t file_size_; public: void ReadPage(page_id_t page_id, char *out) override { // 直接 memcpy无系统调用开销 memcpy(out, mapped_addr_ page_id * PAGE_SIZE, PAGE_SIZE); } // WritePage 同理但注意TPC-C 测试机禁用此优化 };参数说明PAGE_SIZE固定为 4096这是 RMDB 强制契约不可修改MAP_SHARED保证其他进程如 WAL flusher可见变更但 TPC-C 基准测试明确要求 WAL 必须fsync()落盘而mmap的msync()在高并发下性能抖动极大实测导致new_order事务平均延迟从 8ms 升至 47ms。因此项目最终方案是数据页用mmapWAL 日志页强制pwrite fsync—— 这就是为什么config.yaml里log_dir和数据文件必须分离。3. 存储引擎落地B树索引如何扛住 TPC-C 的 1000 QPS 随机插入TPC-C 的warehouse表主键是w_id但最热访问是district表的(d_w_id, d_id)联合索引——因为每个new_order事务都要SELECT d_tax FROM district WHERE d_w_id? AND d_id?。这意味着 B树必须支持① 高并发插入10 warehouse × 3 district 30 个叶子页同时被写② 范围扫描district表按d_w_id分区查询需跨多个叶子页③ 原子分裂避免split_parent时父节点被其他线程读到半截状态。3.1 B树 Page Layout 设计为什么用变长 key 而非固定 8 字节RMDB 要求所有Page继承Page基类但具体布局由你定义。参赛项目放弃传统“slot array record offset”结构改用紧凑的变长记录布局// b_plus_tree_page.h struct BPlusTreePage { page_id_t parent_page_id_; // 8 bytes bool is_leaf_; // 1 byte uint16_t key_count_; // 2 bytes uint16_t free_space_offset_; // 2 bytes指向空闲区起始 char data_[0]; // 动态区域[key_len][key_data][value_len][value_data]... };逻辑说明free_space_offset_是关键它让Insert无需移动已有记录只需在末尾追加新 record 并更新该字段key_len和value_len各占 2 字节支持最大 64KB 的 keyTPC-C 中c_last字符串最长 16 字节绰绰有余放弃 slot array 是因为 TPC-C 的customer表c_last字段存在大量重复值如 “SMITH” 出现 200 次变长布局可节省 30% 空间使单页容纳更多 key减少树高。3.2 并发控制读写锁粒度为何精确到 page_id 而非 tableRMDB 提供LockManager接口但锁粒度由你决定。项目采用page-level locking而非 row-level原因直击 TPC-C 痛点锁粒度TPC-Cnew_order事务锁持有时间10 warehouse 下死锁率buffer pool 命中率table-level120ms全表扫描stock8.2%41%row-level18ms只锁 1 行stock0.3%67%page-level22ms锁 1 页stock含 10 行0.7%89%注意page-level 锁不是妥协而是权衡。stock表每页存 10 行s_i_id递增new_order事务查s_i_id时天然聚集在同一页面page 锁既避免 row 锁的元数据开销又比 table 锁更精准。实现上LockManager的LockShared(page_id)会先检查page_id是否在shared_locks_map 中若无则pthread_rwlock_rdlock(rwlock_[page_id % 1024])用 1024 个分段读写锁降低争抢成功后将page_id插入shared_locks_并标记事务 ID。4. 查询优化器实战为什么 TPC-C 的order-lineJOIN 必须用 hash join 而非 nested loopTPC-C 的new_order事务包含一条关键 SQLSELECT ol_i_id, ol_supply_w_id, ol_quantity FROM order_line WHERE ol_w_id ? AND ol_d_id ? AND ol_o_id ?;表面看是单表查询但order_line表在ol_w_id, ol_d_id, ol_o_id上有复合索引而ol_o_id是递增主键——这意味着WHERE ol_w_id1 AND ol_d_id2 AND ol_o_id BETWEEN 1000 AND 1010会触发索引范围扫描。但优化器真正的挑战在payment事务SELECT c_first, c_middle, c_last, c_balance FROM customer JOIN district ON c_d_id d_id AND c_w_id d_w_id WHERE d_id ? AND d_w_id ?;这是一个两表 JOINdistrict表仅 10 行10 districts per warehousecustomer表每 warehouse 3000 行。优化器必须决策用nested_loop_join对district每行查customer索引还是hash_join建district小表 hash 表4.1 代价模型三参数决定 JOIN 算法选择项目实现的CostModel只依赖三个可观测指标参数获取方式TPC-C 典型值对 JOIN 选择的影响table_scan_costSELECT COUNT(*) FROM table× 0.01msdistrict: 0.01mscustomer: 30msnested_loop总成本 district_rows × table_scan_costindex_seek_costBtree::Search(key)平均耗时customer主键索引0.05msnested_loop总成本 district_rows × index_seek_costhash_build_coststd::unordered_map::insert10 行耗时district: 0.002mshash_join总成本 hash_build_cost customer_rows × 0.001ms计算过程nested_loop成本 10 × 0.05ms 0.5mshash_join成本 0.002ms 3000 × 0.001ms 3.002ms→ 选nested_loop错实际测试中hash_join更快因为index_seek_cost在高并发下飙升至 0.12msB树 latch 争抢此时nested_loop成本 1.2ms仍高于hash_join。所以代价模型必须动态采样不能静态配置。4.2 动态采样机制每 100 次查询重估一次index_seek_cost// cost_model.cpp void CostModel::UpdateIndexSeekCost() { static std::chrono::steady_clock::time_point last_update; auto now std::chrono::steady_clock::now(); if (now - last_update std::chrono::milliseconds(100)) return; // 在低峰期无其他事务执行 100 次索引查找取平均 auto start std::chrono::high_resolution_clock::now(); for (int i 0; i 100; i) { tree_-Search(RandomKey()); // 随机 key 避免 cache 影响 } auto end std::chrono::high_resolution_clock::now(); index_seek_cost_ std::chrono::duration_caststd::chrono::nanoseconds(end - start).count() / 100; last_update now; }参数说明RandomKey()生成d_w_id1~10, d_id1~10范围内的随机组合确保采样覆盖热点页std::chrono::high_resolution_clock比gettimeofday精度高 100 倍避免index_seek_cost_误判采样间隔 100ms 是经验值太短则频繁采样拖慢主线程太长则无法响应 latch 争抢突增。5. TPC-C 基准测试接入如何让自研 DB 通过tpcc_start的 15 项一致性校验tpcc_start不是简单压测工具它是 TPC 官方认证的校验器。参赛项目必须让tpcc_start -h localhost -P 8080 -d tpcc_db -w 10 -c 10 -r 30 -l 120成功运行并输出RESULT: SUCCESS。这要求所有 5 类事务new_order,payment,order_status,delivery,stock_level的 SQL 必须被正确解析为 ASTSELECT ... FOR UPDATE必须触发行级锁且UPDATE stock SET s_quantity ? WHERE s_i_id ?必须在锁持有期间执行new_order事务中INSERT INTO order_line后立即SELECT SUM(ol_amount)必须看到自己插入的数据可重复读隔离级别。5.1 事务隔离级别实现为什么用 MVCC 而非锁表RMDB 要求实现IsolationLevel枚举项目选择REPEATABLE_READ对应 PostgreSQL 的 RR非 MySQL 的幻读 RR。核心是VersionedPage// versioned_page.h struct VersionedPage { txn_id_t min_active_txn_; // 该页最早可见的事务 ID txn_id_t max_committed_txn_; // 该页最新已提交事务 ID // data_ 区域每条 record 附加txn_id_t create_txn_, txn_id_t delete_txn_ };当Transaction::ExecuteSelect()扫描order_line页时若record.create_txn_ min_active_txn_跳过未提交若record.delete_txn_ current_txn_id_ record.delete_txn_ ! INVALID_TXN_ID跳过已被删否则返回record。提示min_active_txn_不是全局变量而是每次BeginTransaction()时从TransactionManager获取当前最小活跃事务 ID避免 snapshot 无限膨胀。5.2 TPC-C 校验失败的三大血泪坑避坑专章现象 1tpcc_start报错ERROR: payment transaction failed: expected c_balance100.00, got 99.99原因payment事务中UPDATE customer SET c_balance c_balance ?使用了float计算IEEE 754 精度丢失。解决所有货币字段强制用int64_t存分如$100.00 → 10000运算用整数加减显示时/100.0。现象 2tpcc_start -l 120运行 2 分钟后卡死gdb显示线程阻塞在BPlusTree::SplitLeaf()原因分裂时先申请新页AllocatePage()再加锁parent-AcquireLatch()但AllocatePage()可能因 buffer pool 满而等待LRUKReplacer::Evict()而Evict()又需获取所有 page latch形成循环等待。解决分裂前预检 buffer pool 空闲页数若 10则主动Evict()5 页再申请新页——把死锁转为可控的等待。现象 3stock_level事务SELECT COUNT(*) FROM stock WHERE s_w_id ? AND s_quantity ?返回结果波动原因COUNT(*)未加锁MVCC snapshot 读取时s_quantity被其他new_order事务并发更新导致同一事务内两次SELECT结果不同违反可重复读。解决stock_level事务显式SELECT ... FOR SHARE触发shared_lock_使new_order的UPDATE必须等待。6. 内核调试技巧用 perf eBPF 定位 TPC-C 下的隐性瓶颈当tpcc_start -w 10 -c 10的 QPS 卡在 850 上不去top显示 CPU 利用率仅 60%说明存在隐性等待。这时不能只看代码要用系统级工具挖根因。6.1 用 perf record 捕获 page fault 热点# 在 db 进程运行时采集 30 秒 perf record -e page-faults -p $(pgrep mydb) -g -- sleep 30 perf script | grep -A 20 BPlusTree::Insert典型输出mydb 12345 [001] 12345.678901: page-faults: 0x7f8b12345000 mydbBPlusTree::Insert0x2a mydbBufferPoolManager::FetchPage0x4c mydbDiskManager::ReadPage0x1d这说明Insert时频繁触发缺页中断根源在BufferPoolManager::FetchPage未命中——进而检查LRUKReplacer的evictable_标记是否被错误置 false如 page 被 pin 但未 unpin。6.2 用 bpftrace 监控锁等待时长# 监控所有 pthread_mutex_lock 调用耗时 1ms 的事件 bpftrace -e kprobe:pthread_mutex_lock { start[tid] nsecs; } kretprobe:pthread_mutex_lock /start[tid]/ { $delta nsecs - start[tid]; if ($delta 1000000) { printf(mutex lock wait %d ms, pid %d\n, $delta/1000000, pid); print(ustack); } delete(start[tid]); }当输出中频繁出现BPlusTree::SplitInternal调用栈说明内部节点分裂时父节点 latch 争抢严重——此时应检查SplitInternal是否在持有子节点 latch 时又尝试获取父节点 latch典型的锁顺序错误。6.3 最后一道防线在 WAL 写入处埋点验证事务持久性TPC-C 最终校验要求new_order事务提交后断电不丢数据。项目在LogManager::AppendLogRecord()中加入原子计数器// log_manager.cpp std::atomicuint64_t wal_bytes_written_{0}; void LogManager::AppendLogRecord(const LogRecord record) { // ... 写入 log 文件 wal_bytes_written_.fetch_add(record.size(), std::memory_order_relaxed); // 强制刷盘 fsync(log_fd_); }然后用watch -n 1 cat /proc/$(pgrep mydb)/io | grep write_bytes对比wal_bytes_written_值若两者差值持续 1MB说明fsync()被阻塞——大概率是磁盘 I/O 饱和或 ext4 journal 争抢需调整log_dir到独立 SSD 分区。我带过的几届参赛队最后卡在 TPC-C 的几乎全是 WAL 刷盘问题有人用 HDD 跑测试fsync()平均 15ms直接拖垮 QPS有人把log_dir和db_disk.img放同一目录ext4 journal 和 WAL 争抢 block group 锁。这些坑没法靠读论文避开只能在perf火焰图里、在bpftrace输出里、在/proc/pid/io的数字里亲手摸出来。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询