简介:这份基于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.cpp |
| Record Manager | 记录槽位与增删改原语 | record_file_handler.cpp |
| Index | B+ 树键值维护 | bplus_tree.cpp / index.cpp |
| Parser | SQL 字符串转语法树 | yacc_sql.y / parse_defs.h |
| Operator | 火山模型算子 | 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 和 macOS,Windows 上最稳的路是 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 -DDEBUG=ON -DCMAKE_BUILD_TYPE=Debug cmake --build build -j "$(nproc)" ls build/bin/-DDEBUG=ON打开调试日志与断言,课程实验阶段建议一直保持;-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::vector<UpdateUnit> update_units; // 支持一次更新多列 std::vector<ConditionSqlNode> 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 跑回归用例,别只靠手工 SQL
MiniOB 自带unittest/目录,构建完成后用ctest统一跑:
cd build ctest --output-on-failure # 也可以直接运行单个测试二进制,例如最小化复现 B+ 树问题 ./bin/bplus_tree_test加新功能的正确姿势是双保险:先在解析层写一个"输入 SQL 字符串、断言语法树字段值"的用例,再到执行层写"建表、插数据、执行语句、查询断言结果"的用例。这样改坏 Buffer Pool 或 B+ 树时,是单测先红,而不是你手工敲 SQL 敲到怀疑人生。拿到一份新版本 MiniOB 源码时,我的习惯是先跑一遍基线测试全绿,再动任何代码。
本文还有配套的精品资源,点击获取