
简介基于C的MiniOB数据库系统源码包是OceanBase与华中科技大学联合推出的数据库入门实践工具面向零基础学生及希望深入数据库内核的开发者。该项目通过简化模块实现如暂不考虑并发操作帮助读者建立对数据库内核各模块功能及其关联的整体认知并训练高效SQL设计能力。资源包共363个文件大小约3.17MB包含119个h头文件、107个cpp源文件覆盖事务日志、记录管理、索引管理、内存池管理、进程管理、SEDA框架等核心模块另有58个png图示、19个md说明文档及若干测试与日志文件便于对照源码理解模块设计。除核心源码外还具备INI配置解析、IO操作、互斥锁管理、字符串处理、日志系统、MD5加密、正则表达式匹配等基础功能模块并有相应测试文件辅助验证。已有74人学习浏览适合需要自底向上理解数据库内核、设计高效SQL的学习者是快速入门数据库内核、梳理模块关系并储备SQL优化思路的实用源码资料。1. 打开压缩包基于 C 的 MiniOB 数据库系统到底“mini”在哪拿到这份基于 C 的 MiniOB 数据库系统源码 zip第一反应别急着点编译先想清楚一个问题这种教学级 mini 数据库到底是给你“学原理”的还是给你“改成作品”的。我见过两类人一类照着注释把代码读了一遍两个月后回忆起来只剩“好像有个 buffer pool”另一类人从磁盘模块开始改晚点给系统加了索引和日志毕业设计和面试都靠它撑场子。差别不在于谁更聪明而在第一遍是怎么切入的。这篇笔记按“认清链路 → 跑通最小流程 → 改一处执行器 → 排掉常见坑 → 做出验证结论”的顺序往下走新手能全程跟住熟手可以直接跳到第四章看代码改动路径。2. 先认清骨架C 源码里的 MiniOB 查询链路从解析器走到哪里MiniOB 这类教学数据库虽然“mini”但五脏俱全。把它当成一个黑匣子看到的是一条 SQL 进去、一张结果表出来把源码摊开之后你会发现中间必须经过解析、语义分析、优化、执行、存储这几个大环节。这一章不贴代码先帮你把地图画在脑子里后面的改动才不会无从下手。2.1 MiniOB 的模块地图网络、解析、优化、执行、存储这五层大多数 C 实现的教学型关系数据库源码包里常见的目录会按这五个职责划分你可以从 mysql 或 ob 的工程里看到影子。第一遍读代码我建议你在根目录里先找到对应名字的目录建立一张模块职责表模块核心职责你会在里面看到的东西接入层处理客户端连接、收发报文socket、会话、认证、命令分发解析层把 SQL 字符串变成语法树tokenizer、parser、语法树节点分析/绑定层校验表名、列名、类型把语义填进语法树catalog、表结构信息、表达式绑定优化/执行层生成物理计划并逐算子执行各类 Operator、火山模型、表达式求值存储层管理磁盘页、表数据、日志、事务buffer pool、页、记录、redo/undo、锁这里最容易犯的误判是很多新手以为“数据库 存储引擎”上来就钻 buffer pool 和 B 树结果卡在最复杂的磁盘代码里连一条 SELECT 的执行入口都没摸到。我做项目第一遍读代码会从执行层作为中轴线往外扩先看一个简单的 INSERT 或 SELECT 被哪个算子处理再顺着调用栈往上走到解析往下走到存储。这样读整个系统是“活”的而不是一堆孤立的 .h 和 .cpp。2.2 一条 SELECT 的完整旅行从字符串到物理算子拿这条查询举例SELECT id, name FROM student WHERE age 20;它在 MiniOB 里大概要走这几步。第一步接入层把客户端发来的字符串包装成请求交给解析器。解析器按 SQL 文法切词生成一棵语法树树上能看到 select 列表、表名 student、where 条件 age 20但这时的表名和列名只是字符串解析器不负责确认它们是否存在。第二步分析层查 catalog 元数据确认 student 表存在id、name、age 三列存在且类型合法然后把语法树翻译成语义树或逻辑计划。第三步优化层做必要的改写比如把过滤条件推到扫描算子附近避免先取全表再过滤。第四步执行器按火山模型逐层调用最底层先做表扫描往上做过滤、投影一条条记录从子节点吐给父节点。第五步扫描算子向存储层要数据页存储层可能触发 buffer pool 换页最后把记录拼成结果行返回给客户端。读源码时你顺着这条线找“动词”比按目录顺序读高效得多。比如你搜 Next、Open、Close 这类算子接口就能摸到执行器的火山模型主循环搜 insert 或 delete 的关键字就能找到写路径入口。MiniOB 的代码量不算大按调用栈读两遍之后你会对它内部的函数名、传参习惯、返回码风格形成肌肉记忆。2.3 为什么第一遍读代码应该从执行器入口读我的习惯是把执行器的入口当作整个工程的门牌号。原因是执行器是所有 SQL 语句的必经之路无论你是想加一个新函数、一个算子、一种日志策略最终都要挂到某条执行链路上。相比之下解析器文法文件往往很长语法规则又多第一遍读很容易陷入细节存储层则是磁盘、并发、缓冲三件事捆在一起难度最高放到最后攻坚更合适。具体怎么读如果你看到工程是火山模型设计那就先找算子基类的定义。通常算子基类只有三四个虚函数负责初始化、取一行、关闭。然后挑一个最简单的算子比如投影或表扫描看它完整实现。再找执行器主循环看它如何把上一算子输出接到下一算子输入。这一圈下来你对“一行数据怎么流过去”就有了画面感后面改任何代码都有地方下手。MiniOB 的代码风格通常比较直白没有太多抽象跳转对初次接触数据库内核的人来说反而友好。下一章就带你把它真正跑起来。3. 跑通 MiniOB解压到查出一条记录的最小动作序列读代码之前我强烈建议先让系统跑起来一次。看到一个真实的 INSERT 返回成功、SELECT 打印出一行结果你对代码的信任感会完全不同。这一章给的是最小动作序列跟着做一遍大概十分钟内能完成。3.1 解压后先做什么识别目录结构与编译依赖拿到 zip 先解压到没有中文、不带空格的路径下这是 C/C 工程的第一条保命原则。解压后不要直接敲编译命令先花两分钟看包的根目录文件通常至少会出现 README、CMakeLists.txt、src 和 etc 之类的目录。README 里一般写了编译方式和配置说明CMakeLists.txt 定义了这个项目的构建目标src 下面才是实际源码。依赖方面教学型数据库为了能跑在普通机器上大多依赖很少常见的无非是 cmake、g、make、flex、bison如果解析器自己写了词法语法分析。你可以先在命令行里确认工具链版本cmake --version g --version make --version这里有一个很关键的提示确认 g 的版本至少要支持 C17很多工程的 CMakeLists 里会写成-stdc17。如果你系统里同时装了几套编译器CMake 却选错了默认版本后面会冒出各种奇怪的模板报错。查完版本再看 README 里写没写需要额外的第三方库比如 json 库、日志库。如果 README 里没有特别说明一般就意味着只要系统里有 cmake 和编译器就能完成编译。如果 CMake 在配置阶段提示找不到某个库不要马上自己下载先看工程里有没有第三方目录、CMake 有没有把该库的子目录加进来。有些发行版默认装了开发包但路径不规范你可以用参数手动指定cmake -DCMAKE_PREFIX_PATH/你的库安装路径 ..。这一步卡住的人不少但大多跟编译器版本和库路径有关不涉及源码逻辑。3.2 编译和启动 MiniOB 服务端的最小命令教学数据库的构建方式十有八九是 CMake make 的组合。如果你看到根目录有 build 文件夹的痕迹说明构建结果习惯放在 build 下。最基本的流程如下mkdir -p build cd build cmake .. make -j4如果你的机器核多一些可以把 -j4 改成 -j8 或更高编译速度会快很多但小内存机器建议别超过 4否则可能编译中途被系统杀掉。make 这一步可能持续几分钟看到生成 observer 或类似名称的可执行文件就算编译通过。顺便说一句如果改动了 CMakeLists.txt建议把 build 目录删掉重新生成避免缓存干扰。编译完成后启动服务端。启动命令一般长这样它可能因版本而异以包内 README 为准# 假设 observer 是可执行文件名observer.ini 是配置文件 ./bin/observer -f ../etc/observer.ini这种启动方式回显很少看到日志里打印“服务监听端口”之类的关键词基本就是起来了。这里容易被忽视的是解释器当前工作目录。我的血泪经验是服务端进程的工作目录决定了它找配置文件和数据文件的相对路径所以要么启动时用绝对路径要么先进入工程根目录再启动否则日志会报找不到文件。你在下一个终端里用客户端连接时如果提示连不上不要怀疑数据库坏了先用ps或日志确认服务端进程是否真的活着、端口是否真的在监听。最简单的端口检查命令是netstat -anp | grep 端口号。3.3 从建库到查询第一组能验证内核工作的 SQL服务端起来之后打开另一个终端启动客户端连接参数通常长这样端口可以用默认值./bin/ob_client -s 127.0.0.1 -p 端口号 -u root连接成功后依次执行下面这一组 SQL。它不是随意的而是覆盖了数据库内核最核心的几条路径建库走 DDL 路径建表走存储结构创建路径插入走写入路径查询走完整执行链路更新和删除再验证两遍变更逻辑。CREATE DATABASE school; USE school; CREATE TABLE student(id INT PRIMARY KEY, name CHAR(20), age INT); INSERT INTO student VALUES(1, zhangsan, 21); INSERT INTO student VALUES(2, lisi, 19); SELECT * FROM student; UPDATE student SET age 20 WHERE id 2; SELECT id, name FROM student WHERE age 20; DELETE FROM student WHERE id 2;如果每一步都返回正常说明这台机器的环境完全可用。注意 SELECT 的结果要以表格形式打印出来而不是只显示 OK不然执行器可能没有真正跑起来。这组 SQL 在这个项目里足够验证语法解析器能处理建库建表语句catalog 能正确注册表结构事务和执行器能完成插入与查询存储层能持久化记录。到这里你的 MiniOB 环境已经从“代码.zip”变成了一个能跑的真实系统接下来就能放心动手改代码了。4. 加一个聚合节点用 C 改动 MiniOB 执行器的具体路径跑通之后第一处改动我建议选“新增加一个聚合算子”。这个选择是有意的聚合功能涉及分组、函数计算、多行合并是理解数据库执行器最好的训练场但又不会像改 B 树那样牵扯大量存储细节。这一章直接从定位代码接口讲到验证照着做就能看到效果。4.1 先定位改动点MiniOB 的执行器节点接口长什么样打开你第一次读代码时找到的执行器目录找算子基类。常见的设计是抽象类核心就是三个虚函数初始化算子状态、取下一行记录、关闭释放资源。有些工程还会额外提供 rewind 接口来重置游标或者提供一次拿一批记录的批量接口但教学型数据库大多采用“一次一行”的火山模型简洁直观。找接口可以先 grep 关键方法名比如Next、Open、Close。也可以在现有算子中找一个最简单的比如 TableScan 算子把它的头文件和实现读完。读的时候要回答自己三个问题构造函数需要哪些参数通常是表元数据和条件表达式、Open 做了什么打开迭代器准备第一个数据页、Next 怎么返回行数据返回状态码还是从参数传出记录。回答完这三个问题你就能照着现有算子的模板写一个新的聚合算子。这里提醒一句别急着定义一堆新接口。我见过有人想加聚合先重构了执行器接口结果牵一发动全身最后放弃了。教学数据库的内聚性强改动范围越小越容易成功。把新算子实现成既有算子基类的子类挂到执行器主循环能识别的位置是最稳妥的路径。4.2 新增 Aggregate 算子的最小代码骨架假设执行器里已经有一个逻辑计划节点类型叫 AGGREGATE你要补的物理算子大致长这样。为了贴合不同的接口风格这里给出的是伪代码化的 C 骨架重点在于看懂它的工作模式而不是直接抄变量名// aggregate_op.h #include sql/operator/operator.h class AggregateOp : public Operator { public: // 构造参数输入子算子指针 分组列编号 聚合函数信息 AggregateOp(Operator *input, const std::vectorint group_by_cols); virtual ~AggregateOp(); RC init() override; // 读取所有输入行完成分组与聚合统计 RC next() override; // 每次调用输出一组聚合结果 RC close() override; // 释放 hash map 与临时行缓存 private: Operator *input_op_; // 子算子通常是表扫描或过滤算子 std::vectorint group_cols_; // 分组列在行中的位置 std::mapRowKey, AggRow groups_; // 分组键组 - 聚合状态 std::mapRowKey, AggRow::iterator iter_; };核心逻辑在 init 里反复调用子算子的 next()把每一行的分组字段拼接成哈希键然后在 groups_ 里维护对应的聚合状态。比如统计 COUNT(*) 时每来一行就把 count 加一算 SUM 时把对应列的值累加。处理完全部输入后把迭代器指向第一组结果。next() 每次向前移动迭代器并把当前组的聚合值拼成一行返回。close() 清空临时数据。用这种实现有一个很明显的取舍它把整张表的中间结果都缓存在内存里。好处是逻辑清晰适合教学和小数据量测试坏处是面对海量数据时内存会爆掉。真正的数据库内核会做流式聚合或落盘但作为 MiniOB 的一次功能扩展先跑通、再优化是完全合理的顺序。参数上你要特别留意 group_by_cols 的编号从 0 开始不要和表里的列 id 混用否则分组结果会错得很隐蔽。4.3 跑验证用例观察日志与结果集确认算子生效改完算子不急着写复杂的 SQL先用一个最简单的查询验证先不加 group by只算全表行数。把执行器里 AGGREGATE 的初始化日志打出来确认你的算子被创建了。# 在日志里确认出现关键字 tail -f log/observer.log | grep AGGREGATE日志里看到算子初始化之后再用客户端执行SELECT COUNT(*) FROM student;如果结果返回总行数而不是报“不支持聚合”说明新算子已经成功挂上。再往复杂推一步验证分组正确性SELECT age, COUNT(*) FROM student GROUP BY age;手动核一下结果重点看每个分组数字是否和原始记录一致。要特别注意 NULL 分组的处理逻辑教育版数据库经常把 NULL 单独成组这个行为要和源系统的语义保持一致。最后一点建议是改完代码后重新编译只重编对应目标而不是全量编译节省时间。比如make -j4 observer或者单独编译改动的算子源文件。如果遇到链接时找不到新算子符号多半是新增 .cpp 文件没有加进 CMakeLists这是最经典的翻车点不要漏掉。5. MiniOB 调试与避坑新手连续翻车的五个高频问题从环境搭建到完成一个算子改动中间最容易劝退人的不是数据库理论而是一堆看起来毫无逻辑的工程问题。这一章写几条我反复见过的高频问题全部按“现象 → 原因 → 解决”来给你排查路径。5.1 编译期链接错误与 C 标准版本不一致一个典型现象是undefined reference to指向了你新加的算子构造函数。原因往往不是代码写错而是新增的 .cpp 文件没有进 CMakeLists.txt编译目标里根本没包含它。解决方法是把新文件加入 add_executable 或 add_library 的源文件列表重新 cmake 再编译。另一个常见现象是别人机器能编、你机器报一堆模板错误这大概率是编译器 C 版本太老或者 CMake 选错了编译器。查 CMakeLists 里约定了哪个标准再确认当前默认 g 的版本必要时用cmake -DCMAKE_CXX_STANDARD17 ..显式指定。5.2 启动期找不到 ini 或数据目录进程秒退现象最直观启动命令敲完屏幕没有任何报错但进程不见了。原因大多是服务端的当前工作目录和配置文件里的相对路径不匹配。我见过有人从 build 目录启动结果它把数据文件写进了 build 目录下重启后读取不到之前建的表。解决方法是先 cd 到工程根目录再启动或者修改配置文件为绝对路径。另外启动时最好前台跑先把启动日志完整留底不要急着 nohup 放后台日志里通常第一行就会告诉你配置加载失败原因。5.3 运行期表建了但查不到数据insert 像丢了一样现象是 CREATE TABLE 返回成功INSERT 也回显成功但 SELECT 出来是空表。第一次遇到这个问题几乎每个人都会怀疑代码写错了。真正原因往往是事务隔离级别或持久化策略的设定有些教学数据库默认不自动提交你 insert 之后没有 commit另一个会话或重启后自然看不到。解决方法是查客户端是否支持 commit 语句或者启动参数里是否有 autocommit 开关。绑定到代码层面就是你插入算子返回成功后日志里有没有真正写日志、落盘链路到这里能帮你定位是执行器没提交还是存储层没刷盘。5.4 编码与字符集插入中文变成乱码现象很常见客户端里敲 insert 中文select 出来是一串问号或者乱码。原因通常是客户端、服务端、存储三个环节的字符集不一致教学数据库用的字符集定义并不多。解决方法是先查存储默认字符集然后使用SET NAMES之类的方式把客户端会话字符集统一再重新插入数据。在深究内核实现之前先确认终端本身是 UTF-8这能排除三分之一的乱码问题。如果连英文都显示异常就该怀疑客户端回显或输出编码的代码了。5.5 并发与内存崩溃不好复现时的定位方法现象是开两个客户端同时写数据时服务端偶发崩溃单线程时一切正常。原因多半是共享数据结构没有加锁比如 buffer pool 的页面链表或事务表。解决的第一步不是猜锁而是让崩溃必现用一个脚本同时跑大量 insert 和 select再开 gdb 捕捉崩溃现场。开发阶段可以先关掉优化再编译打开 debug 符号这样 gdb 能看到完整调用栈。如果断言稳定崩在某一行在那一行前后加打印确认是线程互斥不足还是对象释放顺序问题。这类问题很考验耐心但排查路径是有章可循的先让问题必现再缩小触发条件最后定位到具体行号。6. 把 MiniOB 变成作品压测基线、验证脚本与三个面试追问点环境跑通、算子也改了很多人就此停手很可惜。教学数据库要变成你简历上拿得出手的作品差的是“可验证的结论”。这一章就讲怎么用最小的成本给 MiniOB 建立一套自己的性能基线让自己能张嘴就说出它的能力边界。6.1 先给 MiniOB 拍一张基线快照建议用固定数据量做一次压测数据量不用大一万行足够暴露问题。写一个简单的生成脚本循环造一万条 student 记录批量插入把客户端耗时打印出来。然后分别测试全表扫描、带 where 过滤、带聚合三种查询记录各自耗时。这些数字就是你后续优化前后的对比基线。别小看这张表面试时“我加了一个索引后查询由 X 毫秒降到 Y 毫秒”远比“我熟悉 B 树”有说服力。6.2 埋一个计时点用脚本自动跑固定 SQL手敲 SQL 容易出错我用一段简单脚本批量执行并统计耗时逻辑也适合改写成测试用例#!/bin/bash for i in $(seq 1 100); do echo SELECT COUNT(*) FROM student WHERE age 20; done | ./bin/ob_client -s 127.0.0.1 -p 端口号 -u root /tmp/miniob_result.txt start$(date %s%N) for i in $(seq 1 100); do echo SELECT COUNT(*) FROM student WHERE age 20; done | ./bin/ob_client -s 127.0.0.1 -p 端口号 -u root /dev/null end$(date %s%N) echo elapsed_ms: $(( (end - start) / 1000000 ))这个脚本很朴素但它能帮你验证一个改动的真实收益也能暴露连接建立的开销占了大头的问题。如果 100 次查询耗时几乎都耗在连接上你后续也会自然想到连接复用这正是一个加分的思考方向。6.3 讲得清边界三个面试追问点做完这些还要能回答三个问题MiniOB 的并发上限大概是多少你的新算子内存占用和输入行数是什么关系如果数据量翻十倍它会不会崩我习惯用一句大白话讲清楚边界“我改的聚合算子把所有分组状态放内存一万行没问题数据到百万级可能撑不住下一步是改成哈希落盘。”这句话一出口对方知道你既知道优点也清楚边界。这是我带过一届又一届新人的体会能讲清边界比能背原理更难得希望帮到你。本文还有配套的精品资源点击获取