首页 / 资讯中心 / 文章详情

MiniOB源码实战:从编译到跑通SQL再到添加聚合函数

MiniOB源码实战:从编译到跑通SQL再到添加聚合函数 ★ FEATURED ARTICLE
简介关系型数据库的底层实现涉及SQL解析、存储管理和执行器三大核心链路理解这些概念是掌握数据库内核的关键。MiniOB作为一款可编译运行的最小关系型数据库将词法语法解析、执行计划生成、记录与页存储、结果集返回等环节用C源码完整呈现特别适合学生和开发者通过实践理解数据库工作原理。从环境准备、编译工具链、启动服务端到执行建表与查询再深入解析器、存储层和执行器的协作方式最后通过添加聚合函数走通功能扩展路径整条流程清晰且可操作。本文围绕MiniOB源码展开详细记录编译避坑、调试定位和功能扩展的实战经验帮助读者将抽象概念落地到真实代码快速建立对数据库系统的整体认知。1. MiniOB 不只是课程作业一个能跑 SQL 的最小关系型数据库第一次拿到 MiniOB 的源码包很多人把它当成普通 C 课程设计以为读读文档就能交差。但打开src目录会发现这套代码把数据库最核心的几条链路全拆出来了词法与语法解析、SQL 到执行计划的转换、记录与页的存储管理、真正取数返回的执行器。它不是一个教学玩具——编译后能生成独立服务端进程客户端连上端口就能执行建表、插入、查询、删除这些标准 SQL 操作。这个源码包适合两类人。一类是正在学数据库系统的学生课程要求改源码加功能时MiniOB 的模块边界清晰从解析器改一个关键字比在大型数据库里找入口容易得多另一类是工作后想补底层功底的开发者把存储结构和算子执行在真实 C 代码里过一遍比只看概念图扎实。下文按“编译跑通 → 跑一条 SQL → 读关键源码 → 排错 → 加功能”这条最常走的路展开不写泛泛而谈的架构赏析。2. 把 MiniOB 源码编译成可执行文件工具链与构建入口先明确一个常被忽略的事实MiniOB 的源码包里没有手写的 SQL 解析器解析部分由 flex 和 bison 在构建过程中根据规则文件生成。这意味着拿到源码后的第一件事不是打开编辑器读代码而是确认本机工具链完整。不少同学在编译阶段卡了一整天最后发现不是代码问题而是 flex 或 bison 没装生成步骤被跳过编译器直接报找不到头文件。2.1 准备工具链确认 flex、bison 和 CMake 在位我一般会先跑一组命令确认基础工具版本缺什么补什么而不是直接cmake ..cmake --version g --version flex --version bison --version这四条命令分别确认构建系统、编译器、词法生成器、语法生成器。很多 Linux 环境默认装了前两个但 flex 和 bison 经常缺失。Debian/Ubuntu 系可以用apt install flex bison补齐macOS 用 Homebrew 装也行。不要试图在 CMakeLists 里绕开它们——MiniOB 的 parser 模块在构建时会调用 flex 生成词法扫描器、调用 bison 生成语法分析器缺了这一步后续读源码会少掉一整块核心逻辑。版本问题上我吃过亏。曾经有一台机器上 flex 版本特别老生成的扫描器在解析长 SQL 时行为怪异后来统一升级到新版本才稳定。如果你用的是系统自带的包管理器安装一般不会有大问题如果是手工编译的老版本工具链建议先升级再构建。这里多说一句遇到编译报错先检查工具链不要怀疑源码——MiniOB 的源码在主流编译器上都能正常构建反而是环境不一致导致的奇怪报错占了大多数。2.2 从 CMakeLists.txt 看懂构建入口构建前花十分钟看一遍顶层 CMakeLists.txt能省掉后面不少迷茫。MiniOB 的构建结构通常分成三块第三方依赖库、公共基础库、主体服务端程序。第三方依赖库放在deps目录下包括 json 解析、网络库、日志库等主体程序在src/observer目录里面又按职责拆成网络层、SQL 层、存储层。从一个 C 工程的角度看这个划分不算复杂但模块间的依赖关系值得先画清楚。标准构建命令如下mkdir -p build cd build cmake .. make -j4-j4表示并行编译资源紧张的机器可以改成-j2避免编译过程把内存吃满。cmake 阶段会检查 flex 和 bison 是否可用如果缺失会直接报错这也是为什么第一步要先确认工具链。如果你改了 CMakeLists.txt 或新增了源文件不需要删掉整个 build 目录直接重新执行cmake .. make即可增量编译会处理剩下的。构建完成后在 build 目录的输出路径下能找到observer可执行文件。observer是服务端进程负责监听端口、接收客户端请求、执行 SQL——它的名字直译是“观察者”但在这里它就是数据库服务端本身。客户端程序也在同一套构建产物里后面会用到。这里值得注意的一点是MiniOB 是教学项目构建产出不会像 MySQL 那样给你一堆 init 脚本和系统服务所有产物都是普通的可执行文件适合直接在前台跑起来调试。2.3 全量编译的失败现场与确认清单全量编译最常见的失败点有三个找不到 flex/bison 生成的头文件、第三方依赖编译不过、编译器标准不匹配。第一个问题的解法上面已经说过补工具链后删掉 build 目录重新 cmake。第二个问题多见于网络原因导致依赖拉取失败解法是检查 deps 目录是否完整——正常源码包会自带依赖源码不需要联网下载如果缺失就重新检查源码包的完整性。第三个问题比较隐蔽。MiniOB 需要支持 C17 的编译器如果系统 g 版本过低编译到模板相关的代码会报一些看不懂的错误。这时候不要硬着头皮改源码升级编译器或者切换更高版本的 GCC 是正路。我习惯在构建完成后随手跑一遍确认清单ls -l bin/observer file bin/observerls确认文件存在file确认是 ELF 可执行文件而不是脚本。如果这两条过了说明构建链路基本打通。第一次全量编译可能耗时几分钟耐心等它跑完中途如果有编译错误日志里会明确标出是哪个源文件、哪个模板实例化失败按路径去排查比盲猜要快得多。3. 跑通第一条 SQL从启动服务进程到 SELECT 返回结果编译通过只是开始真正有成就感的是看到一条 SQL 语句从客户端敲进去、经过服务端处理、最后返回正确结果。这一步会把你对“数据库”的认知从抽象概念拉回到具体进程间的数据流动。MiniOB 的服务端和客户端分离中间走 TCP 协议这意味着你可以用任何网络工具连接到服务端发 SQL只要协议对得上。3.1 启动 observer 与连接客户端的最小流程在 build 目录下启动服务端进程常见做法是直接运行 observer默认配置下它会监听本机的 6789 端口。如果需要指定配置文件通常用-f参数带上配置路径。日志默认打到标准输出这在调试阶段反而是好事——你能直接看到每个连接的建立和断开记录。cd build ./observer进程启动后不要急着敲 SQL先确认端口真的在监听。我一般会在另一个终端跑一条ss -ltnp | grep 6789之类的命令看到监听记录才继续。这一步能区分“服务端没起来”和“客户端连错地址”两种问题省掉无谓的排查。然后启动客户端./obclient -s 127.0.0.1 -p 6789-s指定服务端地址-p指定端口。如果你看到客户端提示符出现说明连接已经建立。注意 MiniOB 的客户端默认连的是本机回环地址如果服务端跑在别的机器上-s参数要改成对应的 IP。另外服务端启动时如果有防火墙记得放行对应端口否则客户端会报连接超时。3.2 建表、插入、查询一条 SQL 的完整调用链连接建立后按顺序敲三条语句先把自己的第一个表建出来create table t(a int); insert into t values(1); select * from t;如果每条语句都返回成功那你已经跑通了一条完整的数据库链路。这个过程看着简单背后的调用链值得理清楚客户端把 SQL 字符串通过 TCP 发给服务端网络层收到后交给 parser 模块做词法和语法分析生成一棵语法树接着做语义检查确认表存在、字段类型匹配然后进入执行阶段存储层打开表文件扫描记录最终把结果集序列化回传给客户端。MiniOB 的处理逻辑在src/observer/sql目录下执行入口是解析完语句后的分发逻辑。不同的语句类型走不同的处理分支create table走建表流程insert走记录插入流程select走查询流程。你可以在调试器里给执行入口打断点观察一条 SQL 从字符串变成内部数据结构再变成磁盘操作的全过程这是理解数据库最直观的方式。3.3 在调试器里观察执行器如何取数如果你用调试器启动 observer比如gdb --args ./observer在查询执行函数上下断点就能看到 select 的执行细节。我常用的观察点是执行器里处理查询的调度函数它拿到语法树后会构造一个执行算子逐行扫描表记录并返回结果。以下是伪代码层面的常规结构可以帮助你快速定位自己在源码里的位置// 常见执行器入口形状 void Executor::executeQuery(SelectStmt* stmt) { Table* table stmt-table(); RecordScanner scanner table-createScanner(); // 打开表扫描 while (scanner.hasNext()) { Record* rec scanner.next(); // 逐行取数 ResultRow row projectColumns(stmt, rec); // 投影需要的列 sendResult(row); // 回传给客户端 } }这段代码不是 MiniOB 的原样实现但结构上有代表性先拿到表对象创建扫描器逐行取数做列投影最后返回结果。你在源码里看到类似结构的函数基本就是执行核心。理解了这个模型后面加聚合函数、加算子都是在这条链路上做文章。调试器里中断下来你可以查看stmt里的表名、字段列表确认解析阶段的信息是否完整传递到了执行阶段。4. 关键源码导读解析器、存储层与执行器是怎么协作的跑通 SQL 之后真正的学习才刚刚开始。MiniOB 的源码不是拿来背的是拿来定位的——知道每个功能在哪个文件、哪条函数链路上实现你才具备改它的能力。这一章按数据流向顺序把三个关键模块怎么协作讲清楚。4.1 从 .l 和 .y 文件读懂 SQL 解析入口MiniOB 的解析器由两份规则文件驱动词法规则文件和后缀为.y的语法规则文件。词法文件定义关键字、操作符、字面量怎么被识别成 token语法文件定义 SQL 语句的结构规则比如 select 语句由哪些部分组成、表达式允许哪些运算符。这两份文件是数据库定义 SQL 方言的起点。读这两份文件不是靠背是靠搜索。你想知道某条 SQL 语法支不支持就直接在语法文件里搜对应的关键字想加一个关键字就从词法文件开始加。语法规则文件里每个产生式左边是语法成分右边是组成子项下面通常跟一段大括号内的动作代码这些动作代码在语法分析时执行用于构建语法树节点。我见过很多初学者一上来就想通读整个语法规则文件这没有必要。正确做法是找一个简单的产生式比如数值字面量的规则逐字看懂它的结构然后类比到更复杂的规则上。语法规则文件的易读性远高于手写递归下降解析器因为它们本来就是用声明式风格描述语言结构这也是 MiniOB 选择它的原因。4.2 存储层记录、页与表文件的关系存储层解决的是“数据怎么持久化”的问题。MiniOB 的表数据最终落在磁盘文件里但程序读写时不是直接操作文件偏移而是按页和记录两个层次管理。你可以把页理解成固定大小的磁盘块记录则是表里的一行数据一个页里放多条记录。这种分层是关系型数据库最常见的设计目的有二一是统一读写粒度二是让缓冲池管理成为可能。在源码里表对象通常负责维护自己的元数据包括表结构、字段列表、记录大小记录管理则负责在页里分配和回收空间。当你执行insert时存储层要做的事情是找到当前可用的页、在页内分配一个槽位、把记录数据拷贝进去、更新相关元信息。当你执行select * from t时扫描器的工作就是遍历一个表的所有页、把所有记录拿出来。记录在页内的组织方式值得认真读。有的实现是定长记录直接按偏移排列有的实现是变长记录需要额外的槽位表记录偏移量。MiniOB 的存储层做了不少简化但核心思路保留了。读这一部分源码时我建议打开一个 hexdump 工具看一眼建表后的磁盘文件长什么样实在不行就在调试器里查看页对象的内存数据比对着代码想象要直观得多。4.3 执行器火山模型里的算子拼接执行器是 MiniOB 里最容易扩展的部分因为它遵循经典的火山模型一个查询被拆成一串算子每个算子对外暴露取下一行的接口上层算子调用下层算子拿数据逐行向上流动。select * from t这个简单查询最少需要两个算子一个做全表扫描一个做结果投影。表扫描算子从存储层取记录投影算子从记录里挑出需要的列拼成结果行。这种模型的优势在于每个算子只做一件事且算子之间耦合度极低。你想加一个过滤条件就在表扫描和投影之间插一个过滤算子想排序就再插一个排序算子。执行器只负责把算子按正确顺序串起来。MiniOB 的算子代码量不大但结构上成熟很适合用来理解“表达式计算”“谓词下推”这些概念在代码里的落点。我在读执行器时习惯画一张数据流草图不画进文档就在纸上画一条 select 语句的查询计划长什么样哪个算子吃哪路数据、向谁输出。画完再回到源码里对照很快能定位到每个算子的入口和出口。5. MiniOB 避坑现场编译失败、内存错误与 SQL 行为异常的排查记录这一章写的都是实际调试中反复遇到的坑。每一条都按“现象 → 原因 → 解决”记录你在自己折腾时碰到类似问题可以直接按这里的路径排查不必从头开始猜。5.1 现象flex/bison 生成的代码报一堆“未定义引用”现象编译链接阶段报大量 undefined reference错误集中在解析器相关源文件。原因flex 或 bison 没有安装或安装版本太老生成的中间代码不完整更常见的是之前 build 目录里残留了一次失败编译的中间产物CMake 没有重新生成。解决先确认flex --version和bison --version正常输出然后直接删掉整个 build 目录重新 cmake。不要尝试手动补文件生成器的产物必须由构建系统统一管理。5.2 现象插入中文后 select 查不到内容现象执行含中文字符串的 insert 成功但随后查询该条记录时内容为空或乱码。原因客户端输入编码与服务端存储字节不一致或者词法分析对字符串字面量的解析规则太简单导致多字节字符被截断。解决先确认客户端终端编码是 UTF-8再看字符串解析规则是否按字节流完整拷贝。MiniOB 是教学实现对字符集处理一般不会太完善最稳妥的方案是测试数据暂时只用 ASCII等需要处理中文时再扩展字符串解析规则。5.3 现象自己加的算子导致段错误现象新增一个过滤或投影算子后程序在查询时报段错误调试器显示崩溃在内存访问相关指令。原因新算子没有正确初始化成员指针或者算子的输入算子为 nullptr 时没有做判空也可能在拿记录时用了越界偏移。解决用 gdb 跑起来在崩溃点执行bt查看调用栈定位是哪个算子、哪一行。九成是空指针或越界。前端时间我在调试一个排序算子时也遇到同样问题最后发现是排序缓冲没分配加一行初始化就解决了。5.4 现象并发连接下日志乱序查到后半夜现象开多个客户端同时发 SQL服务端日志顺序错乱甚至出现两条语句的输出穿插在一起。原因日志输出没有加锁多个线程同时写同一个输出流导致字符交叉。这不是 SQL 执行的问题是日志模块的问题。解决改用服务端统一的日志接口或者给打印语句加互斥锁。这个坑容易让人误判为并发执行 bug浪费大量时间在查询器上找问题——先看日志层再看执行层。5.5 现象Debug 版正常Release 版行为不一致现象Debug 构建下所有功能正常切到 Release 后结果不对或偶发崩溃。原因代码里有未初始化的局部变量或者依赖了未定义行为。Debug 版内存布局不同掩盖了问题Release 版优化后问题暴露。解决编译时开启告警选项逐个处理“maybe uninitialized”之类的告警。MiniOB 这种教学项目代码量不大只要把告警清零这类问题基本能根治。不要试图靠运气调试 Release 版先把所有未初始化变量找出来。6. 让 MiniOB 长出第一个新功能加一个聚合函数的四步改动走到这里你对 MiniOB 的解析、存储、执行三层已经心里有数。最后一章不写架构写一个完整的动手路径给 MiniOB 加上count聚合函数。这是个经典课程任务麻雀虽小但要走完“改语法 → 改语义 → 加算子 → 验证”四个环节能帮你检验前面的理解是否真的到位。6.1 四个改动点按依赖顺序推进第一步改词法规则文件把count加为关键字或特殊标识符。第二步改语法规则文件让 select 的目标列表允许出现聚合函数表达式同时构造一个包含函数名的语法树节点。第三步在语义分析里识别这个节点标记它是一个聚合调用而不是普通字段访问。第四步在执行器里实现聚合算子扫描全部记录、统计数量、返回单行结果// 聚合算子核心逻辑示意 class CountAggregateOperator { public: void addRecord(Record* rec) { count_; // 逐行计数 } int result() { return count_; // 返回聚合结果 } private: int count_ 0; };这段示意代码不绑定具体执行器接口但思路够用聚合算子和普通扫描算子最大的区别在于普通算子逐行输出结果聚合算子要等数据全扫完才能输出一行。改动完成后重新编译建表插入几行数据执行select count(*) from t如果返回值和手算一致说明链路是通的。6.2 验证技巧用最小数据集和差分测试验证新功能时我习惯用最小数据集手工核算再逐步加数据回归测试。先建一张三行数据的表执行select count(*) from t结果应该精确等于 3再插入一行结果变成 4。同时用select * from t人工数一遍做交叉验证。如果聚合结果不对先判断是语法树没构建对还是算子没执行对方法是在调试器里分别在解析完成和执行完成两处断点查看中间结果。这个四步改动的价值在于它逼着你把前面几章读过的所有模块串成一条线。很多人读源码时每个文件都能看明白但一动手就发现不知道从哪里改起过了这个坎后面加sum、加order by都是同一套方法论顺着解析到执行的路径往里填就行。这也是当年带我的人反复强调的一句话把一条 SQL 从文本变成结果集的过程走通一次你才算真的摸到了这个系统之后的扩展只是在往一条已经打开的链路上挂新节点而已。希望这个四步改动路径能帮你在 MiniOB 上迈出动手的第一步祝你把这条链路走通之后收获比预期更大。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站