ARTICLE DETAIL

资讯详情

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

从RMDB到TPC-C:数据库内核存储引擎与事务实现指南

从RMDB到TPC-C:数据库内核存储引擎与事务实现指南 简介面向全国大学生计算机系统能力大赛数据库管理系统赛道的参赛资源基于RMDB框架实现了一套可运行的关系型数据库管理系统并通过TPC-C基准测试验证其事务处理能力。资源覆盖存储引擎、查询优化器等数据库内核核心模块适合学习数据库实现原理或备赛的开发者深度研读。压缩包共442个文件以C/C源码为主121个h、102个cc、34个cpp并包含Python工具脚本、Markdown文档、CMake构建配置及数据样例整体仅2.43MB目录结构清晰。目前已有72人学习。通过源码与配套文档可完整了解从SQL解析、执行计划生成到存储与并发控制的工程实现也可复用其构建脚本与实验数据是理解关系型数据库内部机制的高价值参考资料。1. 从 RMDB 框架到能跑 TPC-C 的数据库内核这条路有多长全国大学生计算机系统能力大赛数据库管理系统赛道里RMDB 框架是起点也是分水岭它已经替你做完了词法语法分析、Catalog 元数据管理、执行器骨架但存储引擎、查询优化器、事务与日志恢复这些真正算“数据库内核”的部分全部要你自己写。比赛验收时你要交出的是一套能支撑 TPC-C 基准测试负载的完整关系型数据库管理系统稳定扛住几十路并发事务断电重启还不能丢已提交的数据。我见过不少队伍在这条路上翻车而且翻法高度一致——前两个月在调 parser 和打印执行计划等意识到存储、索引、事务全部要自己实现时只剩两周TPC-C 一上并发就死锁一断电压测就回滚丢数据。这篇文章按我从 RMDB 出发到跑通 TPC-C 的真实推进顺序展开先划清框架边界再攻克存储引擎接着接查询优化器和事务最后是配置参数与避坑记录。适合正在备赛的学生队、想补数据库内核基础但不想从头写 SQL 解析的开发者以及负责评估这个技术方向值不值得投入的架构师。2. 吃透 RMDB 框架的边界现成接口与必须自己写的内核模块2.1 先盘点你手里有什么Parser、Catalog 和执行器骨架拿到 RMDB 工程后先别急着写代码花一个晚上把框架的代码结构读一遍。框架通常已经包含一套基于 flex/bison 生成的词法语法分析器能把select * from orders where o_id 123解析成语法树节点还带一个 Catalog维护了表结构、列类型、主键、索引元信息。这套东西是比赛给的“半成品”不是你的工作重点但你后面写优化器要从语法树节点里取谓词和投影列写执行器要从 Catalog 里查表结构所以必须读明白。执行器骨架一般只实现了最朴素的 SequentialScan 和打印结果的算子够跑通一条最简单的 select但远远不够扛 TPC-C。先把官方示例跑起来确认框架在你机器上能编译、能启动、能用自带 shell 执行建表和查询。这一步解决的问题和数据库内核无关纯粹是环境问题越早解决越好。提示不要在 CMake 版本上死磕3.16 以上基本都能编过真正卡编译的十有八九是 flex/bison 没装或者系统里缺了 libreadline 这类依赖。先把依赖列齐再谈内核实现。2.2 必须自己补的四个模块存储、索引、事务与日志和比赛标题直接对应的是下面四块硬骨头存储引擎Record Manager 负责数据页管理、记录插入删除和表扫描在此基础上还要实现 B 树索引向上层提供点查和范围查的能力。查询优化器至少做到谓词下推、索引选择、JOIN 顺序选择。注意RMDB 执行器骨架默认不做事每个算子按语法树原始顺序执行没有代价估算。TPC-C 的 new order 事务要对 item 表做主键点查对 order_line 批量插入如果执行器次次全表扫tpmC 连三位数都上不去。所以优化器不是加分项是及格项。事务与并发控制锁管理器表锁或行锁、死锁检测、隔离级别控制。TPC-C 压测是几十路并发会话同时跑没有锁管理器数据竞争和死锁会直接把进程卡死。日志与恢复WAL 日志、redo/undo、checkpoint。比赛压测最后通常会有一步是杀掉进程再重启验证已提交事务不丢、未提交事务回滚这就是在考日志恢复。2.3 最小可运行环境构建命令与首个查询把框架跑起来这件事我的经验是先把依赖装全再用 Release 模式编译。赛事机的 CPU 不会太差Debug 构建里的大量断言和符号检查会让 TPC-C 性能直接掉一半以上所以编译类型这一步不能省。# 我一般先把依赖装全再进 CMake 配置 sudo apt-get install -y cmake build-essential flex bison libreadline-dev mkdir -p build cd build cmake .. -DCMAKE_BUILD_TYPERelease make -j$(nproc) # 启动服务端进程端口按框架脚本的默认值来 ./bin/rmdb --port 9999 # 另开一个终端用自带 shell 连上来执行 SQL ./bin/rmdb-cli --port 9999参数说明-DCMAKE_BUILD_TYPERelease是最关键的开关去掉这一项同样的代码在并发压测下的表现可能差一个数量级make -j$(nproc)只是把编译并行度拉满不影响的产物。启动成功之后先建一张带主键的表插入两条记录再查出来如果 insert 能返回记录位置、select 能通过 SequentialScan 读回数据说明存储层到执行器的闭环已经建立接下来可以往深处填东西了。2.4 模块间接口怎么咬合框架内部最常见的组织方式是把存储层抽象成两类迭代器表迭代器支持 Begin、End、Read索引迭代器支持按键 Seek 后逐个 Next。执行器算子只依赖这两个抽象接口扫描算子从表迭代器取记录索引查找算子从索引迭代器取记录位置再回表读数据。这样的好处是查询优化器可以在计划生成阶段自由地换访问路径——同一张表用在 filter 里的谓词有索引就走 IndexScan没有就走 SeqScan上层算子不用改动。并发控制和存储接口是分开的。锁管理器不直接碰数据页它在扫描算子拿到记录之前先申请锁在修改算子写入记录之前申请写锁锁的释放统一推迟到事务提交或回滚时。为什么这样设计因为 TPC-C 里跨表的复杂事务很多如果每条 SQL 执行完就释放锁其他事务会读到中间状态隔离级别就名存实亡了。3. 存储引擎实现数据页布局、B 树与日志落盘的先后顺序3.1 Record Manager 的数据页布局Slot 与自由空间的管理存储引擎是一切的地基。我最先写的是 Record Manager它的核心职责是把记录塞进定长数据页并且支持按位置快速读取、删除、更新。RMDB 里每张表会被划分成多个 4KB 的数据页页头和页体各占一头页头存页号、槽位数量、空闲空间起点页体尾部是记录区。插入记录时从页尾往前分配空间槽位数组从页头往后增长两段在中间相遇说明页满了。这个布局的好处是删除记录不用搬数据。删一条记录只需把槽位标记成 tombstone记录本体留在原地等后续插入的新记录从尾部覆盖它。这个设计在事务场景下特别有用——回滚时只要把 tombstone 恢复成正常槽位数据就回来了代价极小。// 在一个数据页内插入一条记录返回物理位置 RID // page_data: 4KB 页面内存hdr 指向页头slots 指向槽位数组 static bool InsertRecord(char *page_data, const char *rec, uint32_t len, RID *rid) { PageHeader *hdr GetHeader(page_data); uint32_t rec_off hdr-free_start - len; // 记录从页尾向前分配 if (rec_off hdr-slot_end sizeof(Slot)) // 槽位区和记录区相遇页满 return false; memcpy(page_data rec_off, rec, len); // 记录本体写入页尾区域 Slot *slot GetSlotAt(page_data, hdr-slot_count); slot-offset rec_off; slot-length len; slot-valid true; hdr-slot_count 1; hdr-free_start rec_off; // 更新自由空间指针 *rid RID{hdr-page_id, hdr-slot_count - 1}; // RID 页号 槽位号 return true; }逻辑说明这里最关键的设计是记录从尾部往前写、槽位从头部往后长。这样删除记录不会产生碎片新插入的记录天然复用尾部空间。参数上页大小固定 4KB页头和槽位数组都走内存偏移访问不搞堆上的复杂结构。RID 由页号和槽位号组成索引叶子节点里存的就是这个 RID执行器拿到 RID 后通过FetchPage(page_id)进缓冲池再按 slot 偏移读数据。3.2 B 树索引从叶节点分裂到键值回传接着做 B 树。TPC-C 里 warehouse、district、customer 这些表的主键点查频率非常高没有索引就只能全表扫这是性能灾难。B 树我建议按自底向上的顺序实现先写叶节点的插入和分裂再写内节点的键值路由最后把根节点串起来。// B 树点查从根页下钻到叶节点在叶节点内二分找键 std::optionalRID BPlusTree::Find(const KeyType key) const { PageID page_id meta_page_-root_page_id; while (page_id ! INVALID_PAGE_ID) { auto *node buffer_pool_-FetchPage(page_id); if (node-IsLeaf()) { LeafNode *leaf static_castLeafNode *(node); int pos leaf-LowerBound(key); // 返回第一个 key 的槽位 if (pos leaf-GetEntryCount() leaf-GetKeyAt(pos) key) return leaf-GetRidAt(pos); return std::nullopt; } InternalNode *inner static_castInternalNode *(node); page_id inner-NextChild(key); // 内节点根据键值选子树 buffer_pool_-UnpinPage(page_id); // 下钻前释放父页 } return std::nullopt; }逻辑说明查找路径就是内存里最有代表性的“从根到叶”下钻内节点用二分或者顺序比较找到第一个大于等于 key 的子指针叶节点再用二分定位记录位置。参数上叶节点和内节点的最大键数建议设成满足一个数据页能装下的值比如 4KB 页装 100 个键值对扇出太大会浪费页空间扇出太小树变深查找 IO 次数变多。分裂时注意把分裂键回传给父节点这一层最容易出错的是父节点已满导致的分裂级联递归向上分裂时要先处理父节点再处理当前节点。3.3 WAL 日志与刷盘顺序先写日志再改数据页索引和记录管理器都做完事务系统就可以落盘了。如果不做日志进程一崩缓冲池里没刷盘的已提交事务全部丢失这在 TPC-C 验收时是致命的。我采用的方案是标准的 WAL任何修改数据页的操作先写日志再改页面。日志里要记录事务 ID、页面 ID、操作类型插入/删除/更新、旧值和新值这样崩溃恢复时可以用 redo 重放已提交事务用 undo 回滚未提交事务。刷盘的顺序比刷盘本身更重要我踩过一次坑。正确顺序是先把这个事务所有数据页修改的日志强制落盘fsync再把修改后的数据页刷到磁盘最后写一条 commit 日志并落盘。如果顺序反过来先刷了数据页没刷日志崩溃恢复时根本不知道这个页面的修改属于哪个事务既没法 redo 也没法 undo数据就处于未知状态。步骤操作内容崩溃后果顺序错时1写数据页修改日志并 fsync放弃本步则事务丢失无法恢复2修改缓冲池中的内存数据页此时崩溃不影响下一步会重放3数据页刷盘若 1 未完成就执行页面改动成了孤儿4写 commit 日志并 fsync响应客户端前的最后一道闸TPC-C 里每个 new order 事务要改 warehouse、district、stock、order、order_line 等多张表日志量很大。常见的优化是组提交事务提交时不立刻 fsync而是等一小段时间把多个事务的 commit 日志攒成一批一起落盘大幅降低 fsync 次数。组提交的窗口时间一般设 1 到 5 毫秒设太大会增加单事务延迟设太小又起不到批处理效果这是后续调 tpmC 时要反复试的参数。4. 查询优化器与事务TPC-C 负载里最吃性能的两条链路4.1 统计信息与代价估算优化器的最小闭环查询优化器听上去玄学但在 RMDB 这种教学框架里你不需要做基于成本模型的复杂 CBC只需做到“能比较两条访问路径哪个便宜”就够了。前提是你得有统计信息每张表的行数、页数每个索引列的不同值数量。这些信息建表时就能采集也可以在框架的 analyze 操作里主动更新。有了统计信息优化器就能对每个单表扫描算子做路径选择。我实现的最简版本是两个代价函数一个是全表扫描代价按页数乘 IO 代价加行数乘 CPU 代价一个是索引扫描代价按索引树的高度乘 IO 代价再乘上预估命中的行数。// 简化版代价估算对比全表扫描和主键索引扫描 double SeqScanCost(const TableMeta tbl) { return tbl.page_count * IO_COST_PER_PAGE tbl.row_count * CPU_COST_PER_ROW; } double IndexScanCost(const TableMeta tbl, int match_rows) { int tree_height 3; // 4KB 页、百级扇出下树高约 3 层 return tree_height * IO_COST_PER_PAGE match_rows * CPU_COST_PER_ROW; // 命中行数越大回表代价越高 }参数说明match_rows来自过滤谓词的选择率估算主键等值条件下就是 1范围条件下按索引列的不同值数量估算。IO 代价和 CPU 代价的比值我的经验是设成 10:1 左右太偏磁盘会让优化器在内存里也习惯性选索引太偏 CPU 会让该走索引的查询走了全表。实际运行时可以在执行计划里打印两个 cost 值对着日志观察选对没有。4.2 锁管理器实现二阶段锁与隔离级别事务系统里最常写崩的是锁管理器。我的做法是维护一张锁表key 是表 ID 加记录 RIDvalue 是锁类型和持有者事务列表。加锁时按二阶段锁协议事务进入提交前只加锁、不释放锁提交或回滚时统一释放全部锁。TPC-C 默认场景是读多写多单条订单记录被两个事务同时修改的概率不低所以隔离级别我直接用 READ_COMMITTED不提供可重复读否则间隙锁和范围锁会把并发度拖垮。死锁检测我倾向于用等待图。每次加锁失败进入等待时把“事务 A 等待事务 B”的边记下来后台线程周期性地在图里找环找到就牺牲一个事务让它回滚。周期不能设太长500 毫秒以上死锁事务会积压也不能设太短否则正常的长事务很容易被误杀。我调过最合适的区间是 100 到 300 毫秒扫一次等待图。4.3 执行器算子怎么消费优化结果优化器选完路径执行器要能落地。最朴素的做法是火山模型每个算子实现一个Next()父算子拉数据子算子吐数据。TPC-C 负载里有一类典型问题select c_discount from customer where c_w_id? and c_d_id? and c_id?这类三字段等值查询如果 customer 表上有联合索引优化器应该选择 IndexScan否则就是全表扫几十万行。还有一个高频优化点是谓词下推——把 where 里的过滤条件从聚合节点或连接节点往下压到扫描节点让记录在数据页级别就被筛掉而不是全量数据传到上层算子后再 filter。我给执行器加过一层很简单的“执行计划日志”每个算子执行完打印行数和耗时。这个日志在跑 TPC-C 时几乎是必备的因为 tpmC 上不去时第一件事就是看哪个算子吐出来的行数异常偏大这比瞎猜参数高效得多。5. TPC-C 跑分避坑记录必调参数与 5 个常见翻车点5.1 TPC-C 模型9 张表、5 类事务负载到底在考什么TPC-C 是一个在线交易处理基准模拟批发商的订单处理业务固定 9 张表warehouse、district、customer、history、stock、item、orders、new_order、order_line。它有 5 类事务最核心的是 new order事务内要做主键点查、库存扣减、订单明细插入其次是 payment、order status、delivery、stock level。压测结果以 tpmC 为单位也就是每分钟完成的新订单事务数。表结构的关键参数要记住item 表固定 10 万条每建一个 warehouse 就要给 stock 表插 10 万条、给 district 插 10 条、给 customer 插 3 万条左右。所以 warehouse 数量就是你的数据规模扩缩开关压测时用--warehouses 10和--warehouses 100的数据量差了一个数量级缓冲池不够时性能会断崖式下跌。5.2 配置清单缓冲池、并发终端数与日志策略比赛框架通常会把压测工具和数据库进程分开跑数据库端能调的就那么几个参数我把它们列成一个清单每个参数给一个可复用的起点值配置项起点值调优方向调小/调大的后果缓冲池页数总内存的 1/4 折算成页数对 warehouse 数做线性配比太小则磁盘 IO 剧增tpmC 腰斩并发终端数CPU 核数的 2 倍逐步递增观察 tpmC 拐点超过拐点后锁竞争加剧锁超时时间500ms死锁频繁时下调太短误杀长事务太长死锁堆积日志组提交窗口2ms延迟和吞吐之间取平衡太小 fsync 变多太大单事务延迟升高页大小4KB不改与索引扇出联动改大会影响整棵 B 树缓冲池是最值得砸内存的地方。TPC-C 的访问有很强的局部性最近几条订单的数据集中在最新页上缓冲池够大时这些页几乎全在内存里磁盘只在 checkpoint 时动一下。并发终端数不用盲目加大超过 CPU 核数两倍后锁等待和上下文切换的开销会盖过并发收益。5.3 避坑记录5 个我亲眼见过的翻车点翻车点一B 树并发操作时断言崩溃日志里全是“parent page not found”。原因是我在分裂时只把新键写进父节点没处理父节点已满的级联分裂导致某个键在树里出现两次。解决把节点分裂改写成递归从叶向根处理父节点分裂时返回分裂键给祖父节点所有路径必须重新下钻验证一遍。翻车点二进程 kill -9 后重启已提交事务丢了一半。原因是我把数据页刷盘放在了 commit 日志之前崩溃恢复时页面改动成了无主数据。解决严格执行 WAL 顺序先把事务日志 fsync 到磁盘再刷数据页最后写 commit 记录。翻车点三TPC-C 并发一上来tpmC 从几百直接掉到几十。查执行计划发现全部走了全表扫描因为建表后统计信息是空的优化器不知道表有多大只能选顺序扫。解决每次初始化数据后强制跑一轮 analyze给每张表采集行数和页数。翻车点四死锁检测永远不触发系统完全 hang 住。原因是我把死锁检测周期设成了 5 秒一轮而压测里的长事务一个周期内根本走不完等待图里的环一直没被扫到。解决把检测周期调到 200ms并对超过 2 秒的锁等待强制回滚。翻车点五日志组提交窗口设成 10ms 后单事务延迟暴涨压测工具大量超时。原因很简单这个参数开太大导致每次提交都在等窗口。解决压到 2ms 再观察实际 1ms 到 3ms 之间通常能兼顾吞吐和延迟。5.4 验证方法压测结果怎么读才靠谱TPC-C 压测脚本跑完除了看 tpmC 总值建议把单事务延迟分布也打出来。如果 new order 平均延迟 50ms但 p99 是 800ms说明系统在大多数时候是空的但存在周期性抖动——这通常指向日志 fsync 或者 checkpoint 的集中 IO。延迟分布比总吞吐更能暴露瓶颈位置别只盯一个数字。6. 把 tpmC 再往上推一截执行计划验证与并发瓶颈定位6.1 先看执行计划再调参数很多人一上来就调缓冲池大小这是错误的顺序。我在跑出第一版能通过的 tpmC 之后做的第一件事是把每条 TPC-C 事务的 SQL 都打开执行计划日志逐个算子看输出的行数。有个典型例子new order 事务里对 stock 表的更新如果执行计划走的是 SeqScan即使缓冲池再大也要扫 10 万行才能改一条记录这个算子会占掉整个事务 60% 以上的 CPU。把它换成索引扫描后同样的硬件配置 tpmC 直接翻倍。6.2 瓶颈定位速查表现象瓶颈位置验证手段常用解法CPU 打满但 tpmC 不高执行计划里有全表扫描看算子输出的行数加索引或修正统计信息磁盘 IO 持续高缓冲池太小看缓冲池命中率加大缓冲池或减少 warehouse 数锁等待占比高锁粒度太大看事务等待锁的时间表锁改行锁缩小临界区延迟周期性飙高日志刷盘集中对比 fsync 时间点开组提交调小窗口死锁频繁回滚锁顺序不一致看回滚事务占比统一表访问顺序缩短持锁时间最后一章想分享一个习惯我每次改完一个模块不做全量 TPC-C 压测而是先跑一个 5 分钟的短负载只看两个指标——事务回滚率和锁等待时间。这两个指标不变差再继续往下调一旦变差立刻回退到上一版代码看 diff。数据库内核的问题是互相耦合的日志优化可能让锁等待变长缓冲池加大可能暴露死锁检测太慢所有改动都要能回溯。有一次我把组提交窗口从 2ms 改到 5mstpmC 涨了 8%正当我高兴时发现锁等待时间翻了一倍——长事务持锁时间变长后续事务排队更久。这个教训让我养成了“一次只改一个参数、改完立刻看三个指标”的习惯希望帮到你。本文还有配套的精品资源点击获取
返回列表