多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

用MiniOB从零实现数据库内核:B+树索引与SQL执行链路实战

用MiniOB从零实现数据库内核:B+树索引与SQL执行链路实战 简介这是2023 OceanBase数据库大赛初赛的完整参赛资料包源自作者luooofan的MiniOB项目总结第46期主要面向正在备战数据库大赛的学生、数据库从业者及对数据库内核感兴趣的开发者。资源共637个文件约14.52MB以189个头文件、166个C源文件为主辅以143张结构示意图、50篇Markdown笔记、测试用例、Lua脚本与构建配置源码覆盖B树索引、表管理、表达式解析等关键模块并涉及内存管理、网络通信和磁盘I/O等工程能力训练。其中test与result文件可帮助进行输出对照与排错调试。已有160人浏览学习。通过对照总结文档、结构示意图与源码实现可以快速理解MiniOB的设计思路与代码组织方式再结合测试用例和脚本在Windows环境下进行本地编译调试从而掌握数据库内核的基础构建方法为参赛或后续深入学习打下扎实基础。1. 用 MiniOB 代码包入门数据库内核一个多月从连不上到跑通 SQL很多人把数据库内核当成黑匣子认为没个三五年碰不了源码。2023 OceanBase 数据库大赛初赛这套资源核心是一个叫 MiniOB 的教学型数据库项目代码量不大但五脏俱全B 树索引、表存储、SQL 解析、表达式计算都在里面。适合在校学生、想转基础软件方向的工程师也适合面试前突击数据库原理的人。这套资源最值钱的地方在于它把“怎么从零做一个能跑 SQL 的数据库”拆成了可上手的模块Windows 用户配好环境一样能编译调试。我当时花了一个多月从连create table都报错到能跑通带索引的select靠的就是这份代码包。2. MiniOB 代码结构与编译链路先看懂文件再动手改代码拿到压缩包别急着编译先花半小时把文件清单过一遍。初赛用到的核心代码都在根目录下文件名起得相当直白每个文件对应一个内核模块。搞清楚这些文件在一条 SQL 的执行链路里处于什么位置后面改代码才不会瞎撞。2.1 模块划分从 lex_sql 到 bplus_tree 的执行链路先看这份文件职责对照表这是我拆完代码后整理的文件模块定位在 SQL 执行链路中的角色lex_sql.cpp词法分析把 SQL 文本切成 token识别关键字、字段名、数值yacc_sql.cpp语法分析根据文法把 token 组合成语法树产出逻辑计划expression.cpp表达式计算处理 where 条件、算术运算、比较运算的求值table.cpp表与记录管理负责表的创建、记录的插入删除与扫描bplus_tree.cpp索引存储维护主键索引和二级索引的 B 树结构mysql_communicator.cpp网络通信层客户端与服务器之间的协议解析.clang-format/.clang-tidy代码规范统一代码风格初赛阶段不参与业务逻辑一条select * from t where id 5的完整路径是mysql_communicator收报文 →lex_sql切词 →yacc_sql建语法树 →expression解析条件里的比较表达式 →table定位记录 → 有索引就走bplus_tree查询。初赛题目的核心就是把这条链路里的每一环按需求补全。2.2 设计取舍为什么 MiniOB 适合用来学内核MiniOB 在设计上有意砍掉了三样东西并发事务控制、安全权限校验、复杂的查询优化器。这意味着你不会被死锁检测、撤销日志、代价估算这些进阶话题拖住所有精力都集中在“一行 SQL 如何变成磁盘上的数据操作”这条主线上。我对比过其他教学数据库MiniOB 的代码密度控制得比较好。比如table.cpp里一个插入记录的接口就只做分配记录槽位、写入数据、维护索引这三件事不会塞进几十个间接层。这种结构对新手友好第一步是能看懂第二步才是动手改。一般项目上来就是几千行的执行器抽象很容易把人劝退。2.3 Windows 环境搭建用 WSL2 CMake 把工程跑起来初赛资源里没有带编译好的二进制需要自己拉环境。Windows 用户我建议直接用 WSL2别在原生 Windows 上跟依赖库较劲。MiniOB 依赖 readline、gtest 这些 Linux 生态的库在 Windows 原生环境编译容易在链接阶段翻车。# WSL2 内执行安装基础工具链 sudo apt update sudo apt install -y g cmake make libreadline-dev libgtest-dev # 解压代码包并进入根目录 cd miniob-2023 mkdir -p build cd build # 生成 Makefile 并编译 cmake .. -DDEBUGON make -j$(nproc)这里几个参数提一下-DDEBUGON会保留调试符号后面用 gdb 跟踪 B 树分裂时必须有它make -j$(nproc)是让编译器用满所有 CPU 核心初赛代码包不算大正常人机器上几分钟就能编完。编译完会在build/bin下生成 observer 和客户端程序跑./observer -f ../etc/observer.ini就能启动服务。3. B 树索引实现思路初赛最该先啃的一环初赛评分里 B 树占了很大权重。整个大赛期间我在bplus_tree.cpp上花的时间比别的模块加起来都多。这个文件初看只有几百行但里面藏着分裂、合并、再平衡三条复杂路径任何一个边界条件没处理好索引就在某个数据量下悄悄失效。3.1 写入路径insert 的分裂时机与父节点更新B 树插入的关键不是把 key 插到叶子节点而是在节点满了之后怎么分裂、怎么把中间 key 提升到父节点。初赛代码给的骨架里插入逻辑通常长这样// bplus_tree.cpp 中 insert 的简化核心逻辑 RC insert_internal(const KeyValuePair kv) { // 1. 从根节点开始逐层向下查找叶子节点 BplusNode* leaf find_leaf_node(kv.key); // 2. 如果叶子节点还有空位直接插入并返回 if (leaf-kv_count leaf-max_size) { leaf-insert_kv_sorted(kv); return RC::SUCCESS; } // 3. 空间不足分裂叶子节点为左右两半 BplusNode* left leaf; BplusNode* right new_node(); int mid leaf-kv_count / 2; // 4. 把右半部分 kv 移动到新节点并把中间 key 上提到父节点 for (int i mid; i leaf-kv_count; i) { right-kv.push_back(leaf-kv[i]); } leaf-kv.resize(mid); // 5. 递归上提中间 key若父节点也满则继续分裂 push_up_to_parent(leaf-parent, right-kv[0].key, left, right); return RC::SUCCESS; }这段逻辑里最容易写错的是第 4 步右半部分的第一个 key 到底该留在右节点还是上提给父节点。按 B 树定义叶子节点之间通过链表相连右节点的最小 key 要同时出现在父节点里作为分隔值但右节点自身仍然保留这个 key。很多初赛选手在这里多删了一个 key结果就是范围查询丢数据。第 5 步的push_up_to_parent是个递归过程父节点满了就继续往上分裂直到根节点。根节点分裂时会生成新的根树的高度加一。我建议实现完插入后用一个单调递增的 key 序列连续插几万条然后跑范围查询验证数量是否与插入量一致这个测试能暴露九成以上的分裂错误。3.2 删除路径merge 与 borrow 的边界条件删除比插入更麻烦。B 树删除后如果节点利用率低于一半需要从左兄弟或右兄弟借一个 keyborrow或者与兄弟合并merge。初赛代码里通常会给你一个coalesce_or_redistribute的骨架里面要自己判断走哪条路// 删除后调整节点平衡的简化伪代码 void adjust_after_delete(BplusNode* node) { if (node-kv_count node-min_size) return; // 仍然达标 BplusNode* left get_left_sibling(node); BplusNode* right get_right_sibling(node); if (left left-kv_count left-min_size) { // 从左兄弟借一个 key父节点下移一个 key 到当前节点 node-kv.insert(node-kv.begin(), left-kv.back()); left-kv.pop_back(); update_parent_separator(node-parent, left, node); } else if (right right-kv_count right-min_size) { // 从右兄弟借一个 key父节点下移一个 key 到当前节点 node-kv.push_back(right-kv.front()); right-kv.erase(right-kv.begin()); update_parent_separator(node-parent, node, right); } else { // 两边都不够借只能与其中一个兄弟合并 if (left) merge_nodes(left, node); else merge_nodes(node, right); } }min_size的取值不是随便定的一般等于max_size / 2。merge_nodes合并之后要记得释放被合并节点的内存否则每删一批数据就泄漏一批节点初赛压测跑上几小时内存就爆了。3.3 用日志和断点验证索引正确性B 树是典型的“逻辑简单、细节致命”的组件。我调试时会在插入和删除的每个关键路径上打印节点状态// 调试专用输出整棵树的层级结构 void dump_tree(BplusNode* node, int depth) { std::string indent(depth * 2, ); std::cout indent Node node keys; for (auto kv : node-kv) { std::cout kv.key ; } std::cout std::endl; for (auto* child : node-children) { if (child) dump_tree(child, depth 1); } }每次插入或删除后调用dump_tree重点观察三件事第一所有叶子节点的 key 从左到右是否单调递增第二父节点的分隔 key 是否落在左右子树的 key 区间内第三删除后有没有出现空节点。这三项都正常索引大概率是对的。4. 表达式与表记录实现避开浅层的坑索引搞定之后另一个容易出问题的点是表达式计算和表记录的存储布局。这两个模块不像 B 树那样有复杂的算法但在初赛的测试用例里它们被踩到的频率一点都不低。4.1 expression.cpp 的类型系统与计算顺序初赛的 where 条件支持比较运算和逻辑运算expression.cpp 里定义了一组表达式节点比如FieldExpr字段引用、ValueExpr常量、ComparisonExpr比较还有逻辑与或非。每个表达式节点都实现一个evaluate()方法传入一条记录返回计算结果。实现evaluate时有一个细节特别容易忽略比较两个值之前要先做类型对齐。表里的age字段是 int用户在 SQL 里写where age 18解析出来的是两个不同类型的值。常见做法是在Value::compare里做隐式转换字符串能转数字就按数字比转不了就报类型错误。初赛代码骨架里通常会留一个Value类你要把compare_to方法补完整。4.2 table.cpp 的记录管理定长记录与槽位分配初赛阶段的表存储大多采用定长记录格式每一条记录占用的字节数固定字段按定义顺序密集排列。这种设计的好处是支持随机访问——知道记录号就能算出偏移量。// table.cpp 中插入记录的简化实现 RC Table::insert_record(const std::vectorValue values) { // 1. 计算这条记录在文件中的存储长度 int record_size calculate_record_size(); // 2. 从空闲列表中分配一个槽位如果没有就追加到文件末尾 int slot_id allocate_slot(); char* buf get_record_buffer(slot_id); // 3. 字段逐个序列化到 buf int offset 0; for (size_t i 0; i fields_.size(); i) { const auto field fields_[i]; values[i].serialize(buf offset, field.type()); offset field.len(); } // 4. 如果有索引把 record_id 插入索引 update_indexes(values, slot_id); return RC::SUCCESS; }这里最关键的是第 1 步calculate_record_size。要算上所有定长字段的长度再加一个固定大小的 header 用于存放记录状态是否删除、记录号等。如果你漏了一个字段的长度后面所有记录的偏移量都会错位查出来就是乱码。另一个常见问题是allocate_slot的复用逻辑。删除记录时把槽位标记为空闲下次插入优先复用这个槽位。初赛的测试用例会反复插入删除槽位分配搞不好就会越插越慢。4.3 从语法树到表达式求值select 条件的完整路径一条带条件的select语句执行路径比看起来要长yacc_sql解析出select列表、from表和where条件然后执行器遍历表的每条记录用where条件构造表达式树对记录求值为真的记录才进入结果集。一段典型的求值核心循环长这样// 执行 select 时的条件判断 for (int rid 0; rid table-record_count(); rid) { Record record; table-get_record(rid, record); // 把记录绑定到表达式上下文 ExpressionCtx ctx; bind_record(ctx, record); // 用表达式树求值结果为真才保留 Value result condition_expr-evaluate(ctx); if (result.is_true()) { output_rows.push_back(record); } }bind_record这一步要特别小心它负责把表达式里的字段名映射到记录的偏移位置。如果表有两个字段a和b而表达式里写了where b 1绑定时要按字段名找到对应的偏移不能假设字段顺序。初赛里有人图省事按字段下标绑定一旦表结构里字段顺序跟你预期不一致条件判断就全错了。5. 初赛实战避坑这些问题卡了我最久这部分是我在调试和复现过程中真正踩过的坑。每一条都按“现象 → 原因 → 解决”来描述你可以对照自己的运行环境提前排查。5.1 Windows 下编译报错找不到 readline现象在 Windows 原生终端执行make报/usr/include/readline/readline.h: No such file or directory。原因readline是 Linux 自带的命令行交互库Windows 原生环境下没有对应的头文件和链接库。这不是代码问题是环境问题。解决换用 WSL2 编译运行别在原生 Windows 折腾。如果非要原生 Windows可以用 MinGW 加第三方移植版 readline但链路复杂容易在其他依赖上继续翻车。5.2 B 树插入数据量变大后查询结果变少现象插入 1000 条之前一切正常超过某个数量后范围查询返回的记录数莫名其妙变少。原因插入触发了叶子节点分裂但分裂时父节点的分隔 key 没有正确更新。查询走索引时把一部分数据漏掉了。解决在分裂函数里加上断言左子树所有 key 小于等于父节点分隔 key右子树所有 key 大于等于分隔 key。用 1 万条递增数据做插入后再做范围查询对比结果集数量与预期值。5.3 where 条件完全不生效返回所有记录现象select * from t where id 1返回了全部记录条件像被忽略了一样。原因执行器在遍历记录时没有调用表达式求值或者表达式节点里字段绑定失败求值结果恒为真值。典型触发点是字段名大小写不匹配建表时字段叫ID条件里写的是id绑定不到对应字段。解决在bind_record里增加字段名查找失败时的日志输出并在表达式求值入口打印一条 debug 日志确认条件确实被调用。字段名校验统一走同一个大小写规则不要一半用原名一半用转换后的名字。5.4 程序跑一段时间后内存持续上涨现象压测脚本一直执行插入删除占用的内存肉眼可见地往上涨。原因B 树删除时合并了节点但被合并的节点对象没有释放或者记录删除时只修改了状态位没有释放缓冲区。解决用valgrind --leak-checkfull ./observer跑一遍删除密集用例定位泄漏点。B 树合并后必须delete被合并的节点记录删除时要复用槽位而不是重新分配。5.5 CMake 编译时提示 gtest 相关依赖缺失现象执行cmake ..时提示找不到GTestmake在测试目标处中断。原因初赛代码包的测试依赖 Google Test 框架但 WSL2 里没有安装对应开发库。解决sudo apt install -y libgtest-dev装完后要到/usr/src/googletest目录下编译安装一次或者把build/CMakeCache.txt删掉重新生成。更省事的办法是直接注释掉测试相关目标只编 observer 和客户端。6. 从初赛代码到决赛能力用一条 SQL 做全链路验证初赛阶段经常出现“单模块测没问题连起来就挂”的情况。所以我最后分享一个自己的验证习惯永远准备一条覆盖全链路的 SQL把它当成回归测试的基准。我用的基准语句如下create table t (id int primary key, name char(32), age int); insert into t values (1, alice, 20); insert into t values (2, bob, 25); insert into t values (3, carol, 30); select * from t where age 22;这条语句覆盖了建表、主键索引插入、全表扫描、表达式比较和结果输出五个阶段。每次改完任何一个模块我都先跑这条 SQL再看输出结果。如果它正常再往更复杂的方向扩展——加一条order by或加一个二级索引。为了让链路验证更充分我会写一个小脚本灌入批量数据来触发 B 树的分裂合并# 生成 10 万条插入语句并批量执行 python3 -c for i in range(100000): print(f\insert into t values ({i}, name_{i}, {i % 50});\) batch_insert.sql # 在客户端里执行批量导入 ./obclient -f batch_insert.sql灌完数据后先查总数验证有没有丢再跑一个范围查询对照耗时。B 树的层级在数据量上去后应该保持稳定如果查询时间随数据量线性上涨说明索引没生效。内存和日志的配合也很重要。我编译时坚持开-DDEBUGON然后在代码关键路径留下带前缀的日志输出——比如在 B 树分裂时打印[BPLUS] SPLIT nodexxx new_root_keyxxx在表达式求值时打印[EXPR] eval typeCOMPARE resulttrue。日志不用全留但每条都对应一个容易出错的分支点。比赛现场出问题时这些日志能帮你把问题定位到具体的函数而不是对着黑匣子猜。从那以后我每次改完任何内核模块都强制走一遍“造数 → 基准 SQL → 看日志 → 查内存”四步流程。这个习惯帮我避开了不下十次隐藏问题初赛和决赛阶段的代码稳定性都靠它托底。这套流程也分享给你希望帮到你。本文还有配套的精品资源点击获取
返回列表