简介这份基于C的MiniOB数据库系统源码包是OceanBase与华中科技大学联合开发的数据库内核入门实践项目。它面向在校学生和数据库初学者重点帮助理解存储管理、查询优化、事务处理等模块通过简化实现降低学习门槛并支持SELECT、INSERT、UPDATE、DELETE等常用SQL操作。资源共360个文件压缩包仅3.18MB主体为121个h头文件与107个cpp源文件另配PNG架构图、Markdown文档、测试用例、INI配置及辅助脚本完整覆盖词法语法分析、B树索引、缓冲池、执行器等核心机制便于对照源码逐层研读。目前已有131人学习下载适合作为数据库课程设计、内核入门或项目实践的参考资料。借助完整工程、构建脚本与测试程序读者可快速搭建MiniOB环境结合文档和示例理解SQL从解析、优化到执行的完整链路也可按模块逐段调试是深入学习数据库内核的实用素材。1. MiniOB 是什么为什么值得读这套 C 数据库源码MiniOB 是蚂蚁 OceanBase 团队开源的教学用数据库内核整套系统用 C 写成目标不是生产而是把数据库到底怎么工作拆到一门课能讲完的规模。你拿到手的这个压缩包就是一份完整可编译的 MiniOB 源码有 SQL 解析、执行器、B 树索引、Buffer Pool 缓冲池还有配套的客户端和服务端。对想补数据库内功的 IT 从业者、正在做数据库课程设计的学生来说读这套源码比背 C 八股有用得多——你能亲手看到一条select从字符串变成执行计划再落盘的全过程。后面按架构、编译、改代码、排错四条线展开所有操作都能在你本地这份源码里复现。2. 从 Buffer Pool 到执行器MiniOB 的 C 架构拆解2.1 源码目录先定位 6 个核心模块拿到 zip 解压后第一件事不是急着编译而是先把目录结构过一遍。MiniOB 的源码规模不大但分层很规矩常见的布局如下miniob/ ├── src/observer/ │ ├── sql/ │ │ ├── parser/ # lex_sql.l / yacc_sql.y / parse_defs.h │ │ ├── expr/ # 表达式求值 │ │ ├── optimizer/ # 逻辑计划与物理计划 │ │ ├── operator/ # 火山模型执行算子 │ │ └── executor/ # 语句分发与执行入口 │ ├── storage/ │ │ ├── buffer/ # Buffer Pool 缓冲池 │ │ ├── record/ # Record Manager 记录管理 │ │ └── table/ # 表定义与关联索引 │ └── event/ # 会话事件 ├── etc/observer.ini # 服务端配置 ├── unittest/ # 单元测试 └── build.sh / CMakeLists.txt模块核心职责建议入口文件Buffer Pool页缓存、磁盘页加载与淘汰buffer_pool_manager.cppRecord Manager记录槽位与增删改原语record_file_handler.cppIndexB 树键值维护bplus_tree.cpp / index.cppParserSQL 字符串转语法树yacc_sql.y / parse_defs.hOperator火山模型算子operator/ 目录Executor语句分发与结果组装default_stage.cpp / 各 Stmt 工厂不同版本目录名会有出入老版本把执行逻辑集中放在default_stage.cpp的一个大分发里新版本拆成了Stmt - LogicalOperator - PhysicalOperator的工厂链。判断标准只有一个——从src/observer/init.cpp的main函数顺着调用链走语义边界跑不出上面六类。这一步值得花十分钟因为后面每次加功能你都得先回答改动落在哪个模块。2.2 Buffer Pool 与 Record Manager数据落盘的最小闭环数据库和普通文件读写最大的区别是它不信任操作系统替你规划的缓存。MiniOB 的 Buffer Pool 自己管理若干页页大小由源码里的BP_PAGE_SIZE这类常量定义一般是 4KB 到 16KB。所有对表数据的读取都先查缓冲池未命中才从磁盘加载页淘汰策略在课程版本里通常就是简单的 LRU 或寻找空闲 frame。这块代码建议重点看两个方法// buffer 模块中两个核心接口的简化形态 Page *BufferPoolManager::get_page(int page_num) { // 1. 先查内存映射表命中直接返回避免磁盘 IO // 2. 未命中时分配一个 frame用文件描述符读入页数据 // 3. 记录 pin_count防止页在引用期间被换出 } void BufferPoolManager::unpin_page(int page_num) { // 减少 pin_count // 当 dirty 标记置位时由后续 flush 或淘汰时机统一写回磁盘 }这里有一个容易被忽视的细节pin_count不为 0 的页不能被换出这是正确性的底线。课程实验里改坏 Buffer Pool 的典型症状是查询结果偶发错乱根因往往就是把仍在引用中的页提前刷掉了。验证方法很简单把表数据量加到超过缓冲池容量再反复全表扫描对比结果。B 树的叶子节点内部对键值的查找用的就是二分查找复杂度 O(log n)这部分代码恰好把数据结构课上的 C 二分查找落到了真实场景里。Record Manager 负责把表文件里的记录按固定槽位组织向上层提供insert_record、delete_record、get_record原语。它与 Buffer Pool 的分工是上层只认逻辑记录号底层才关心页号与页内偏移。改这两层时建议在get_page入口打印页号访问序列你会直观看到一条 SQL 背后发生了多少次缓冲命中/未命中的切换。提示修改 Buffer Pool 淘汰逻辑后一定要做数据量大于缓冲池容量的压力测试只跑小表验证不出换页路径。2.3 SQL 解析与执行器一条 select 的旅程MiniOB 的词法与语法是 flex/bison 生成的老搭档.l文件把字符串切成 token.y文件按文法归约成抽象语法树语法树节点定义在parse_defs.h。一条select * from t where id 1会先变成SelectSqlNode里面挂着投影列、表名和条件列表单个条件就是ConditionSqlNode// parse_defs.h 中条件节点的典型定义 struct ConditionSqlNode { char *left_operand; // 字段名或常量 char *right_operand; CompOp comp; // 等于、不等于、大小比较 };解析完之后进入执行器MiniOB 采用经典的火山模型每个算子实现一个next()从子节点拉一条元组处理完向上抛出。最常见的链路是TableGetOperator - PredicateOperator - ProjectOperator分别对应扫表、过滤、投影三个动作。调试执行计划时在PredicateOperator::next()里打断点就能看到每一行数据是怎么被where条件拦下来的。理解这条链路的价值在于第四章加 UPDATE 语句时词法加一个关键字、语法加一条产生式、语法树加一个节点、执行器加一个算子四处改动对应四个模块链路是完整的。很多人改到一半编译报错就慌其实报错位置本身就是最好的导航——编译器会告诉你改漏了哪一环。3. 本地编译与启动用 CMake 把 MiniOB 跑起来3.1 环境准备与编译参数MiniOB 对平台有点挑剔官方支持 Linux 和 macOSWindows 上最稳的路是 WSL 里的 Ubuntu不要在原生 Windows 环境里跟 MSVC 较劲。这套代码不依赖外部数据库但需要 g、cmake、make、flex、bison。一个常见干扰项是在 Windows 上装 Python 相关包时撞见error: microsoft visual c 14.0 or greater is required那是 pip 装包需要 MSVC 编译器和 MiniOB 构建没有关系换到 WSL 里编译可以完全绕开。# 以 Ubuntu/WSL 为例装齐构建工具链 sudo apt-get install -y g cmake make flex bison libjsoncpp-dev # 在解压后的源码根目录执行 cmake -S . -B build -DDEBUGON -DCMAKE_BUILD_TYPEDebug cmake --build build -j $(nproc) ls build/bin/-DDEBUGON打开调试日志与断言课程实验阶段建议一直保持-j后面的并发数按 CPU 核数给避免小机器编译内存溢出。如果你用 VSCode 读代码建议顺手让 CMake 生成compile_commands.json配合 C/C 插件的跳转和补全会准很多。第一次全量编译大约几分钟编译产物都落在build/bin/下这是后面所有操作的基础。3.2 启动 observer 并用客户端连上编译产物里有两个可执行文件observer是服务端obclient是配套的命令行客户端。服务端默认监听 6789 端口配置文件在etc/observer.ini。# 终端 A前台启动服务端日志直接打到标准输出 ./build/bin/observer -f ./etc/observer.ini # 终端 B连接本机 6789进入 SQL 交互 ./build/bin/obclient -h 127.0.0.1 -p 6789连上之后先跑一套最朴素的增删改查验证内核基本功能正常create table student (id int, name char(32), score float); insert into student values (1, alice, 90.5); insert into student values (2, bob, 85.0); select * from student where id 1; create index student_idx on student (id); delete from student where id 2;能依次拿到正常结果说明解析、执行、存储、索引四条链路都是通的。这里有一个值得做的验证create index建完索引后再执行select * from student where id 1观察日志里走的是全表扫描还是索引查找。MiniOB 课程版的优化器不一定总是选中索引这个知道有索引但不一定用得上的取舍正是你后续自己补成本估算逻辑的起点。3.3 配置文件与常见启动失败排查etc/observer.ini里最常调的是缓冲池大小和日志级别以你这份源码里真实的键名为准一般能看到类似结构[server] PORT 6789 MAX_CONNECTION_NUM 100 [buffer_pool] BUFFER_POOL_SIZE 1024 # 单位是页不是字节 [log] LOG_LEVEL INFO启动失败的高频原因集中在这三类症状原因处理connect refused端口没起来、被占用或客户端端口写错lsof -i:6789确认改了 PORT 同步改客户端参数启动即段错误配置里的数据目录用了相对路径数据目录与日志路径全部改绝对路径WSL 下编译极慢源码放在/mnt/c/跨文件系统整个目录复制到家目录再编译注意把数据目录和日志文件路径都写成绝对路径是 MiniOB 启动排错的第一步八成路径相关的问题都出在这里。日志级别从INFO调到DEBUG后日志里会直接打印每条 SQL 对应的解析树特征和执行算子类型这个习惯会贯穿第五节的排错过程。4. 动手改功能以 UPDATE 语句为例走通 MiniOB 全链路4.1 词法与语法在 lex_sql.l 和 yacc_sql.y 里加规则很多课程版本把update留作实验题新版本自带实现。不管自带还是自补链路完全一样只是你要不要亲手写一遍的区别。先从词法开始把UPDATE加进lex_sql.l的关键字表否则词法分析器会把它当成普通标识符。然后在yacc_sql.y里参照 insert 的规则加一条产生式update_stmt: UPDATE IDENT SET update_units WHERE where_clause { // 归约动作里把表名、更新单元列表、条件列表 // 组装成一个 UpdateSqlNode挂到 parse result 上 } ;注意update_units需要单独定义成列表规则update_units : update_unit | update_units COMMA update_unit这是 yacc 里表达一个或多个的标准写法。语法归约只负责把 token 变成结构不要在这里做类型检查词法层拿到的都是裸字符串。4.2 语法树节点与语句分发语法树节点的定义放在parse_defs.h字段就是你在 SQL 里能写出来的内容的镜像struct UpdateUnit { char *field_name; // 要更新的列名 Value value; // 新值先不推导类型 }; struct UpdateSqlNode { char *table_name; // 目标表 std::vectorUpdateUnit update_units; // 支持一次更新多列 std::vectorConditionSqlNode conditions; // WHERE 条件 };Value是 MiniOB 对字段值的统一封装内部用AttrType区分整型、浮点、字符串。分发的中心在新版本是Stmt::create_stmt工厂老版本在default_stage.cpp的大 switch 里按SqlNodeType进入对应分支把ParsedSqlNode翻译成可执行的UpdateStmt。这层要做真正的元数据校验——表是否存在、列是否存在、值类型是否匹配。报错越早后面的执行器越省事凡是能在这层拦下来的错误都不要拖到执行期。4.3 执行器读取、改写、写回与索引维护执行阶段分三步定位记录、改写字段、写回。核心流程如下RC UpdateExecutor::execute() { // 1. 通过表对象拿到 record_handler // 2. 遍历表记录或走索引定位对命中行复制一份内存副本 // 3. 在内存副本上按 update_units 写入新值 // 4. 调用写回接口刷盘并逐个同步一级、二级索引 }动手时有两个坑必须处理。第一个坑是字符串变长原记录里char(32)存了 10 个字符改写成 40 个字符就会超过槽位。MiniOB 的简化存储结构不能像 InnoDB 那样把行迁移到溢出页所以要么拒绝过长的值并返回明确错误码要么在解析层就按字段长度截断不要静默写坏相邻记录。第二个坑是索引同步更新了作为索引键的列必须先删旧键再插新键顺序反了会导致 B 树里出现两份键值。验证顺序的办法是在索引模块的插入和删除函数里各加一行日志用一条带唯一索引的更新语句观察调用次序。提示MiniOB 课程版事务能力很薄多数版本只支持单语句隐式事务。两个客户端同时 update 同一行不要把它当作预期能通过的功能那是后续自己实现锁管理器时才覆盖的场景。5. 验证与排错三个立刻能用的 MiniOB 调试技巧5.1 把日志级别调到 DEBUG直接看执行算子把observer.ini里的日志级别从INFO改成DEBUG并重启。每条 SQL 执行后日志会打出解析出的语句节点类型、优化器选择的算子链以及各算子处理的记录数。排查语法对了但结果错这类问题时这是最快的手段先确认语法树字段对不对再确认执行算子链选没选对最后看过滤条件是否生效。5.2 在算子的 next() 上打断点针对只影响特定 SQL的 bug用 gdb 挂在算子方法上gdb ./build/bin/observer break PredicateOperator::next run -f ./etc/observer.ini # 另开终端用 obclient 发一条带 where 的 select # 命中断点后 print 当前元组内容continue 观察逐行过滤断点命中后配合bt看调用栈能同时看到解析与执行上下文。如果算子内是链表或数组遍历给断点加condition指定行号区间避免每行都停。5.3 用 ctest 跑回归用例别只靠手工 SQLMiniOB 自带unittest/目录构建完成后用ctest统一跑cd build ctest --output-on-failure # 也可以直接运行单个测试二进制例如最小化复现 B 树问题 ./bin/bplus_tree_test加新功能的正确姿势是双保险先在解析层写一个输入 SQL 字符串、断言语法树字段值的用例再到执行层写建表、插数据、执行语句、查询断言结果的用例。这样改坏 Buffer Pool 或 B 树时是单测先红而不是你手工敲 SQL 敲到怀疑人生。拿到一份新版本 MiniOB 源码时我的习惯是先跑一遍基线测试全绿再动任何代码。本文还有配套的精品资源点击获取