ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

RMDB 数据库内核实战:从存储引擎到 TPC-C 优化全攻略

RMDB 数据库内核实战:从存储引擎到 TPC-C 优化全攻略 简介一套面向全国大学生计算机系统能力大赛数据库管理系统赛道的参赛项目资源包基于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_TYPERelease 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::vectorColumn schema() const 0; // 输出列结构 virtual std::unique_ptrTableIterator 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 映射到内存里的 frameframe 就是一个页拷贝所有算子读数据都经过它。最小实现长这样// 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_mapPageId, Page*, PageHash pages_; std::dequePageId evict_queue_; // FIFO 淘汰队列 };页大小一般用 4096 或 8192 字节。RMDB 竞赛数据集不大我建议选 8192单次 IO 能读出更多行索引扇出也更高。缓冲池容量按物理内存一半以内设别和 OS 的 page cache 互相抢。竞赛数据量通常在几百 MB 量级缓冲池给 256MB512MB 就足够。这里必须注意一个内存生命周期问题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 场景下我常用 N64 或 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_size100, page_size8192): return rows * row_size / page_size # 顺序扫描代价约等于页数 def estimate_index_scan(rows, selectivity): # selectivity 是谓词选择率等值条件下 1 / distinct_values return 2 rows * selectivity # 2次页读定位 结果页读参数说明selectivity 要靠统计信息支撑。没有统计信息时等值谓词默认取 1/NN 为行数范围谓词默认取 1/3。这个默认值很粗糙但比没有强。TPC-C 的表结构里 warehouse 只有几十行district 几百行这些表做 join 时全表扫描反而更快。真正要优化的是 orders、order_line 这种千万行量级的表。代价算完之后还要把代价最小的物理计划真正拼出来。我的执行器构建函数大致长这样// optimizer.cpp std::unique_ptrPlanNode build_physical_plan(LogicalNode *logical) { if (auto *scan dynamic_castLogicalScan*(logical)) { // 检查是否有可用索引 auto idx find_index(scan-table()); if (idx scan-predicate_is_equality()) { return std::make_uniqueIndexScanNode(scan-table(), idx); } return std::make_uniqueSeqScanNode(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 joinbuild 阶段要把整张 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 时间总和 / 页读次数1msWAL fsync 次数/秒日志模块计数不高于事务提交数锁等待占比等待时间 / 事务总耗时5%平均事务耗时总耗时 / 事务数50ms索引树高B树根节点 meta34如果命中率低于 90%优先调大缓冲池如果 fsync 次数等于提交数说明组提交没生效如果锁等待占比超过 10%回去查表锁。这组数字在写赛后技术报告时也直接构成“系统性能分析”章节的图表素材比贴一段压测截图更有说服力。6.2 一道容易被问垮的压轴题并发插入与索引分裂的锁策略评委大概率会问并发写场景。RMDB 的 TPC-C 里并发插入 order_line 很常见如果你的 B树叶子分裂持锁策略是“整棵树一把锁”那么并发插入就是串行的。提前准备一个答案分裂路径上的节点锁、叶子页的 latch、以及索引树重新平衡失败时的重试路径。能把这个链路讲清楚比多跑 10% 的 tpmC 更能说服评委。我自己的教训是系统压测过了但答辩时被问住就是因为没有准备锁这一层。做完这版 RMDB我最大的一条后悔药是——没有早一点开始压测。很多问题是到正式压测时才暴露的如果提前两周就开始跑至少能多调一轮缓冲池和锁粒度。竞赛项目真正考验的其实是这一件事在固定时间内把系统调到没有明显短板。希望这篇笔记能帮你少走几步弯路。本文还有配套的精品资源点击获取
返回列表