资讯详情

从零拆解MiniOB:数据库内核中的SQL解析与B+树实现

📅 2026/9/13 16:31:51 | 华诺云谱 👁 阅读
从零拆解MiniOB:数据库内核中的SQL解析与B+树实现
简介基于C实现的MiniOB数据库管理系统由OceanBase与华中科技大学联合打造是面向在校学生和数据库初学者的入门级内核实践项目通过简化并发等复杂特性围绕数据库管理、表维护、索引检索等核心模块展开帮助学习者快速理解数据库的模块构成与工作原理。压缩包共含352个文件以118个h头文件、101个cpp源文件为主体覆盖bplus_tree、table、disk_buffer_pool、execute_stage等关键实现另有58张原理示意图、19篇markdown说明、若干测试用例与编译配置整体仅3.14MB便于下载阅读和定位查找。目前已有117人学习使用适合结合课程或自学逐步拆解数据库设计也可作为课程项目或入门研究的重要参考。研读该源码可以掌握索引结构、缓冲池、执行器等组件之间的协作关系并通过自带测试理解建表、增删改查、条件扫描等功能的验证方法为设计高效SQL或进一步研究数据库内核打下坚实基础。1. MiniOB一个能跑通SQL的C数据库内核适合谁拆第一次在GitHub上看到MiniOB的时候很多人的第一反应是“又一个人教你造轮子的项目”。但你真把这套源码拉下来、编译跑通、再把建表、插入、索引查询一步步打通之后会发现它和那些只演示API的demo完全不是一个量级。MiniOB是OceanBase与华中科技大学联合开发的教学数据库内核代码里既能看到lex/yacc生成的词法语法分析器也能看到B树、缓冲池、执行器这些数据库核心模块的真实实现。和MySQL那种动辄百万行的工程不同MiniOB把并发、事务、安全这些复杂度砍掉了保留下来的恰好是“一条SQL从文本到执行结果”的完整骨血。适合拆它的人有两类一类是正在做数据库内核实验、需要完成课程设计的学生你可以在它上面改索引策略、写新的执行算子另一类是工作多年的后端或C工程师想快速找回对存储引擎和查询处理链路的手感。这篇博客我会按“解析链路 → 存储引擎 → 编译与排错 → 动手改造”的顺序把这套源码的关键文件和可复现的调试方法讲清楚。2. 从lex/yacc到执行器MiniOB的SQL解析与处理链路2.1 lex.yy.c与yacc_sql.tab.c词法与语法如何配合MiniOB的解析器没有手写而是用了flex和bison这两个经典工具。源码包里的yacc_sql.tab.c是bison生成的语法分析器lex.yy.c是flex生成的词法分析器。两者通过全局函数yyparse()和yylex()相互驱动语法分析器需要读下一个token时调用yylex()词法分析器从输入缓冲区里识别出关键字、标识符、数字、运算符把token编号返回给语法分析器语法分析器根据文法规则决定是否归约。你如果打开yacc_sql.tab.c看会发现里面充斥着YYDEBUG、yyr1、yyr2这类bison运行时需要的表。很多初学者一头扎进这两个生成文件里这是效率最低的读法。正确的方式是找到对应的.y和.l源文件在源文件里看规则。/* 源文件里如yacc_sql.y常见的规则片段 */ sql_stmt_list: sql_stmt { $$ $1; } | sql_stmt_list ; sql_stmt { ... } ; sql_stmt: create_table_stmt | drop_table_stmt | select_stmt | insert_stmt | delete_stmt | update_stmt ;这段规则定义了SQL语句的“语法骨架”。$$表示当前规则归约后的结果$1、$3分别引用规则的第一个、第三个成分。这样做的好处是把“语法是否合法”和“如何执行”解耦bison只负责判断输入是否符合文法真正构造查询对象、生成执行动作的代码写在每个规则对应的C动作块里。MiniOB里这些动作块会new出CreateTable、Insert等结构体最终连同原始SQL一起丢给执行阶段。2.2 execute_stage从语法树到执行计划的边界execute_stage.cpp是MiniOB里最重要的转接层。它拿到解析后的ParsedSqlResult遍历每一条SQL语句按语句类型分发到do_create_table、do_insert、do_select等处理方法。这里有一个容易忽略的设计execute_stage并不直接操作文件而是把写操作交给Table对象把读取交给TableScanner自己只负责调度。从底层视角看这就是一个极简版的“火山模型”。我在拆代码时最喜欢看的是do_select它把SelectStmt里的表和条件解析出来构造扫描区间然后调用table-scan_record遍历记录再逐条过滤。由于MiniOB最初设计的教学定位它没有做查询优化没有代价估算select的join也只是简单的循环嵌套。这个简化反而让“执行计划”这个概念变得非常直观——你对着代码一行行看就能明白你在MySQL里写的where条件最终是怎么变成一个个内存判断的。2.3 一个最小的SQL执行链路示例要验证MiniOB的解析链路是否真的通不需要写代码直接用它自带的命令行客户端就行。编译完成之后进入bin目录启动后执行下面这几条命令# 启动MiniOB客户端默认连接本机的observer ./obclient # 在客户端里执行 create table t(id int, name char(32)); insert into t values (1, miniob); select * from t;执行select时我会在gdb里给execute_stage.cpp的do_select下断点观察传入的SelectStmt。这里有几个值得打印的结构结构体作用关键字段SQLStageEvent串起整个处理流程的事件对象sql_原始SQL、session_会话SelectStmt解析后的select语句表示relations_表集合、conditions_过滤条件TableScanRecord单条记录的扫描结果data_记录数据、record_rid_位置我一般会打印conditions_的长度和每个条件比较符。如果where id1解析出来的条件数不是1那说明语法规则或代码生成阶段出了问题需要回过去查yacc_sql.y里condition相关的规则。整体来说MiniOB的解析链路非常“听话”只要flex/bison的生成步骤没出错问题几乎都出在动作代码构造查询结构体时对$1、$3取用不当。3. 存储引擎核心bplus_tree、disk_buffer_pool与table模块3.1 B树索引的插入、删除与查找实现要点bplus_tree.cpp是整个MiniOB源码包里最值得反复读的文件之一。它是教材上B树算法的工业平替版没有那么多模板泛型但分裂、合并、借位这些关键操作一个不少。MiniOB的索引设计是“索引文件即数据文件”一个索引对应磁盘上的一个文件文件头记录根节点页号、内部节点和叶子节点的数量等元数据。查找操作从根页开始逐层二分查找key走到叶子节点后线性扫描。MiniOB的叶子节点不像InnoDB那样用双向链表串起来而是每个叶子页里维护了next_page指针范围扫描时沿着next_page走即可。下面这个伪代码反映了我通常会关注的核心逻辑// 基于常见B树实现思路对应bplus_tree.cpp的查找路径 int BplusTree::find(Attrbuf key, RecordId *rid) { PageNum page_no root_page_; while (page_no ! INVALID_PAGE_NUM) { BplusTreePage *page get_page(page_no); if (page-is_leaf()) { int idx page-find_key(key); if (idx 0) { *rid *page-get_record_at(idx); } unget_page(page_no); return idx 0 ? 0 : -1; } else { // 内部节点根据key定位子页 page_no page-find_child(key); unget_page(page_no); } } return -1; }这里的find_key是页内搜索如果页内key是有序排好的用二分查找复杂度是log2(页内key数)。find_child返回的是下一个页号。注意MiniOB页大小默认是8KB具体值由宏定义决定一个叶子页能装多少条索引取决于索引key长度和记录指针大小。看这个文件时建议把SPLIT宏和MERGE宏的触发条件对比着看分裂是插入时发现页满就做合并是删除时发现页太“瘦”才做。MiniOB合并阈值通常设置为半满和经典算法一致。3.2 disk_buffer_pool的帧管理与替换策略disk_buffer_pool.cpp做的事情是“缓存磁盘页”。MiniOB的缓冲池实现比InnoDB的LRU简单得多但也足以教学它有一个固定大小的页数组每个页对应一个帧frame帧上有pin计数。当某个页被读取时先查缓冲池命中则pin计数加1不命中则淘汰一个干净页或脏页从磁盘读入。这里面最容易踩坑的是“脏页回写”。如果你在update一条记录之后不等待脏页落盘就直接调用close可能会丢失数据。MiniOB提供了同步落盘的方式但一般不会在事务里自动触发。我拆代码时习惯在disk_buffer_pool.cpp的flush_page函数里加打印看它是在什么时候把页写到磁盘的// disk_buffer_pool.cpp 中常见的关键逻辑 bool DiskBufferPool::flush_page(PageNum page_num) { Frame *frame get_loaded_page(page_num); if (frame nullptr) { return false; } // 将frame中的data_写回文件 lseek(fd_, frame-page_no_ * PAGE_SIZE, SEEK_SET); write(fd_, frame-data_, PAGE_SIZE); frame-is_dirty_ false; // 回写后清除脏标记 return true; }注意这里lseek用的偏移是page_no_ * PAGE_SIZE也就是说页号和文件偏移是线性对应的。如果磁盘文件上页大小和内存页大小不一致会出现“读出的数据后半段是乱的”这种诡异问题。MiniOB为了让实验者少折腾把磁盘页大小和内存页大小统一了。如果你在自己的改造中调整过缓冲池页大小记得同时检查文件初始化那段代码里的页大小参数。3.3 table.cpp表记录与索引的联动table.cpp把“文件系统中的一个文件”抽象成“一张表”。表文件的第0页是表元数据记录字段个数、每个字段的类型和长度第1页开始才是实际记录。创建表时MiniOB会同时初始化表文件和关联索引文件。插入记录时它先把记录追加到表文件的末尾堆表方式然后对每个索引调用insert_entry把新记录的RecordId挂到索引上。这里有一个经典问题如果记录插入成功但索引插入失败怎么办MiniOB的处理是“同时失败”即插入记录前先把所有索引的插入准备好如果中途失败会调用rollback删除已经插好的索引项。这在教学版里已经算考虑得不错了。我测试时会在insert into t values(1,a)之后手动删掉其中一个索引文件然后再插入同key记录观察系统是否报错以及会不会留下孤立记录。表扫描是select的底层支撑。MiniOB的scan_record接收一个RecordScanner的格式化参数和过滤条件从表文件的第二页开始逐页读逐条判断是否满足条件。这里的扫描是真正的“全表扫描”没有任何下压优化。所以当你做实验对比“有索引和没索引的查询性能差异”时需要手动为表创建索引并确认SQL真的走了索引。MiniOB的select默认不会根据where里的字段自动选索引——哪个索引被使用取决于你构造SelectStmt时是否带上了索引条件。4. 实战编译MiniOB并跑通建表、插入、索引查询4.1 从源码构建依赖与踩坑MiniOB的构建系统是CMake源码里通常还带一份Makefile。我建议直接用CMake因为依赖关系更清晰。先确认系统里有bison、flex、cmake和C17编译器。Ubuntu上缺的话就装sudo apt-get install -y bison flex cmake g libreadline-dev然后在源码根目录执行mkdir -p build cd build cmake .. -DCMAKE_BUILD_TYPEDebug -DENABLE_DEBUG_SYMBOLON make -j4这里-DCMAKE_BUILD_TYPEDebug非常关键。MiniOB内部有大量的ASSERT和LOG_DEBUG宏Release模式下这些不会输出遇到问题只能瞎猜。改成Debug之后崩溃时会打印出具体的文件名和行号配合gdb能省掉大量时间。编译过程中最常见的两个问题一是系统里没有bison或版本太老导致yacc_sql.tab.c生成失败解决办法是安装新版bison后重新cmake二是flex生成的lex.yy.c和bison的token编号不匹配表现是SQL语句解析全部报语法错误。这种情况通常是修改了.y或.l文件而没有重新生成可以用make clean make彻底重建。4.2 用bplus_tree_test.cpp验证索引正确性源码包里自带了bplus_tree_test.cpp这个测试文件可以直接编译运行用来验证B树的基本操作是否正确。它一般会做插入、查找、删除、再查找这四步并在每个步骤后打印页状态。运行命令# 在build目录下路径可能随构建配置变化 ./bin/bplus_tree_test如果测试卡在insert_entry回读时找不到key问题大多出在比较函数上。MiniOB对整型key和字符型key使用不同的比较逻辑实现在attr.h或type.h里。你手动构造key时如果类型标记设置错误比如把INTS写成CHARSB树按字典序比较整数就会出现“10小于2”的错误结果。这里给一个排查思路输出B树每一层的页号和key列表逐层检查是否有continue访问失眠页面串线。页号串线的典型症状是查找某key时能走到叶子页但叶子页里的记录key与预期不符这时候优先检查get_page返回的指针是否被重复unget导致缓冲池页面被错误重置。4.3 遇到段错误怎么办MiniOB的教学代码不保证没有bug尤其是你改动后段错误几乎必然出现。最常见的崩溃点是Record的数据指针越界。由于表记录是按字段长度拼在一起的读取字段时用的是字段偏移表如果偏移算错memcpy就会读越界。下面是我在gdb里常用的一组命令gdb ./bin/observer # 在gdb里运行服务端 run # 另开终端用obclient执行会崩溃的SQL # 回到gdb打印调用栈 bt # 查看当前执行函数的行号与变量 frame 3 info locals如果bt显示崩溃在memcpy或strcpy十有八九是字段长度问题。把table.cpp里建表时的字段长度计算逻辑打开看一遍重点检查AttrInfo的length是否包含了字符串结尾的\0。MiniOB的数据格式中char(n)会额外占用一个字节存长度如果你在插入操作里把n1误写成了n插入数据一长就会越界。提示把disk_buffer_pool.cpp里的PAGE_SIZE打印出来。有些实验要求改页大小但表文件和索引文件是持久化的页大小改了之后旧文件全部读不了不是代码bug是格式不对。删除数据目录重建即可。5. 进阶为MiniOB的表扫描加上字段级过滤条件最后这一节我给你一个可以直接动手的改造点在scan_record之外做一个字段级过滤的封装让业务方不用拼SQL也能按字段取值扫描记录。MiniOB原生的扫描方式必须依赖SelectStmt走完整条解析链路但很多实验场景里你已经知道字段偏移直接遍历记录更高效。实现思路是利用表元数据里记录的字段偏移把每一条原始记录的数据指针按偏移切出来再做比较// 伪代码对应table.cpp中scan_record的上层封装 void scan_with_filter(Table *table, int field_offset, AttrType type, void *expected, int len) { RecordFileScanner scanner; Record record; table-scan_begin(scanner); while (scanner.has_next()) { scanner.next(record); char *field_data record.data() field_offset; if (type INTS) { if (memcmp(field_data, expected, sizeof(int)) 0) { // 匹配成功处理该记录 handle_record(table, record); } } else if (type CHARS) { if (strncmp(field_data, (char *)expected, len) 0) { handle_record(table, record); } } } table-scan_end(); }这段代码的关键在field_offset的获取。你不能硬编码偏移值否则表结构一改就全错。正确做法是遍历table-table_meta().field_metas()找到目标字段后累加之前所有字段的长度得出该字段在当前记录里的字节偏移。整型字段长度为sizeof(int)字符串字段长度为len sizeof(uint8_t)因为MiniOB在存储字符串时会在前面放一个字节的长度值。这样每次扫描只需要做一次偏移计算后面的比较都是直接内存操作性能远高于反复解析SQL。改造完成后你可以写一个简单的性能对比在表里插入10万条记录用select * from t where id50000走原版执行器再调用scan_with_filter直接按偏移查前者在Debug模式下能明显感受到延迟后者几乎瞬时返回。这个对比不是MiniOB内置功能而是你自己加进去的实验点。做完之后你对“解析开销”和“扫描开销”的体感会非常具体以后再去看MySQL的handler接口就能理解为什么很多过滤要下推到存储引擎层去做了。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。