简介:基于C++的MiniOB数据库管理系统源码包,由OceanBase与华中科技大学联合开发,是面向数据库初学者的入门级内核实践项目。项目对并发等复杂特性做了适度简化,可帮助在校学生和开发者理解数据库内核各模块的功能与协作关系,适合课程设计、实验训练或自学研读。压缩包共352个文件,大小约3.14MB,以C++头文件和源文件为主,含118个h与101个cpp,另配有测试用例、结果文件、说明文档及构建脚本,便于编译运行和对照学习。源码覆盖数据库与表的创建删除、记录增删改查、条件过滤扫描、索引管理等核心模块,并包含B+树等模块的测试程序,代码结构清晰、模块划分明确,便于逐段阅读和二次开发。目前已有118人学习下载,是快速上手数据库内核实现的实用参考资料。
1. MiniOB 是什么?为什么值得自己从头编译一遍
我第一次接触到 MiniOB,是在准备数据库内核相关问题时发现的一个教学用数据库管理系统。它用 C++ 编写,代码规模比真正的数据库小很多,却有一条足够完整的 SQL 执行链路:能输入 SQL、能解析、能生成执行计划、能读写磁盘页。对正在学数据库原理的人而言,MiniOB 最大的价值是把“黑匣子”打开:从一条select到一个 Buffer Pool 里的页,每一步都能在源码里找到对应函数。这篇笔记会沿着“编译出来跑通一条 SQL → 读懂核心模块 → 亲手加一个 SQL 命令 → 学会排查 C++ 工程的老大难问题”这条主线展开,适合想用真实项目补齐数据库和 C++ 实践的人。
2. 从零编译 MiniOB:环境准备、CMake 参数与最小运行命令
2.1 先摸清目录结构,别急着执行 make
把源码 zip 解压后,第一眼通常是CMakeLists.txt、README以及一堆src下层目录。我见过不少人在这一步就直接mkdir build && cmake .. && make,然后收获一堆找不到头文件的报错。这不是运气问题,是没有先理解构建目标的依赖关系。
MiniOB 的工程一般会拆成公共依赖和主程序两部分:公共依赖在deps/下,可能包含 json、libevent、Google Test 等;主程序在src/observer下,又按模块分成sql/parser、sql/executor、storage、session、common这些子目录。你最终要的可执行文件来自 observer,但编译过程会把多个子目录编成静态库再链接。想快速建立全局观,我习惯先跑两条命令:
find . -maxdepth 3 -name "*.cpp" | head -50 grep -rn "add_executable\|add_library" --include="CMakeLists.txt" .逻辑说明:第一条命令看到源码文件分布,第二条命令看到构建目标。add_executable会生成可执行文件,add_library生成静态库。MiniOB 里 observer 对应的可执行目标名通常就叫 observer,你在排查链接报错时要区分“哪个目标没生成”。
参数说明:grep 的-rn会把行号打出来,方便直接打开 CMakeLists 定位。如果嫌输出多,可以在find命令后追加| sort,也可以把范围缩小到src/observer。看 CMakeLists 时,重点找add_executable(observer ...)后面跟着哪些源文件,以及每个子目录的add_library(... STATIC ...),这决定了后面往哪个文件加代码才会被编进去。
2.2 环境选择和编译器版本
准备环境前,先明确目标:你需要在当前系统里装好 CMake、一个支持 C++17 的编译器和 Flex/Bison(如果源码自带生成好的 parser 文件,Flex/Bison 可以省)。以 Linux 为例,包管理器一条命令能装齐:
sudo apt install -y g++ make cmake flex bisonmacOS 上对应命令是brew install gcc cmake flex bison。Windows 上则相对麻烦,常见做法是用 Visual Studio 的 Developer PowerShell,再配合 CMake 生成 VS 工程。这里有一个高频玄学报错:在弹出的 MSVC 窗口里编译成功了,生成的 exe 双击跑不起来,提示缺少 DLL。这通常不是因为编译器有问题,而是目标机器没装 Microsoft Visual C++ 2015-2022 Redistributable (x64)。MiniOB 是带控制台的工程,运行前补上这个运行库能省很多事。
如果你用的是 VSCode,请记住 VSCode 配置 C/C++ 环境只影响编辑器的智能提示和调试,它不会替你编译。编译仍然由 CMake + 编译器完成,VSCode 只是通过 tasks.json 或 CMake Tools 插件把命令包了一层。我自己的经验是先用命令行把构建跑通,再回到 VSCode 里做调试,否则一旦出错你很难分清是环境问题还是代码问题。
2.3 最小构建命令和参数含义
有了依赖,进入构建。命令看起来不长,但每个参数都有后果。下面是我常用的最小命令:
mkdir -p build && cd build cmake .. -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_FLAGS="-fsanitize=address -g" make -j$(nproc)逻辑说明:-DCMAKE_BUILD_TYPE=Debug让编译结果带着调试信息,且不做过多优化,打断点时变量可读。-fsanitize=address是 AddressSanitizer 开关,运行时一旦出现越界、使用已释放内存,进程会立刻打印红字报告。你不要等到项目做大再开它,第一次编译就应该开。make -j$(nproc)用满 CPU 核数,初次编译能快不少。
参数说明:如果机器内存小,-j$(nproc)容易把内存吃爆,可以把$(nproc)换成 4 或 2。如果你不想开 ASAN,可以删掉-fsanitize=address,但一定要保留-g,否则后续排查段错误只能靠猜。常见参数的影响可以对照下面这张表:
| 参数 | 作用 |
|---|---|
-DCMAKE_BUILD_TYPE=Debug | 生成调试信息,关闭优化,适合断点 |
-DCMAKE_BUILD_TYPE=Release | 开优化,适合测性能,但变量优化后难读 |
-fsanitize=address | 内存错误立即暴露,调试期强烈建议开 |
-j4 | 并行编译任务数,小内存机器建议用这个值 |
构建完成后,可执行文件会出现在build/bin/observer或者build/src/observer/observer,取决于 CMake 的RUNTIME_OUTPUT_DIRECTORY是不是统一指到 build/bin。可以这样找:
find . -type f -name "observer" -perm -111然后启动最小实例:
./bin/observer -f ../etc/observer.ini注意这里-f指定的是 observer 的配置文件路径,配置文件里一般包含端口、日志路径、内存缓冲池大小等。不要把它理解成“启动参数”。MiniOB 自带一个客户端,连上后执行create table t(id int);如果能返回 success,就说明编译和运行链路已经通了。
2.4 连接客户端与常见参数
很多教程会忽略客户端,导致自己写脚本测不了 SQL。MiniOB 的客户端也是一个可执行文件,通常在 bin 下叫obclient或者client。连上方式:
./bin/obclient -p 6789它支持交互式输入,也支持把 SQL 用管道喂进来。端口号和 observer.ini 中的port对应。如果你换了端口,客户端不指定就会默认找 6789。这里有第一个坑:客户端命令行没有历史记录的话,长 SQL 容易打错,我的经验是把 SQL 写进.sql文件再重定向输入,这样能留痕。
为了让后面 debug 容易,配置文件中日志级别可以设成 debug。日志文件路径尽量用绝对路径,否则你从 build 目录启动和从源码根目录启动时,相对路径会指向不同位置,排查问题时容易翻车。
更完整的验证可以用管道一次性执行三条 SQL:
{ echo "create table t(id int);"; echo "insert into t values(1);"; echo "select * from t;"; } | ./bin/obclient -p 6789逻辑说明:每行一条 SQL,分号是语句结束符。如果这三条都能返回预期结果,说明 Parser、执行器和存储模块都能正常工作。这个脚本可以保存下来,后面每次改完代码都跑一遍,相当于最简陋的回归测试。
便宜行事提示:如果你用 VSCode 调试 MiniOB,不要直接在 VSCode 的终端里启动 observer,否则断点断不住。建议在launch.json里配置program指向build/bin/observer,args填["-f", "../etc/observer.ini"]。这样每次按 F5 就能在 SQL 执行路径里打断点,比在命令行里attach省心得多。
3. 读源码应该先抓哪几块:Parser、Executor 与存储引擎
3.1 一条 SQL 是怎么走进 MiniOB 的
读完编译配置,下一步是把执行链路读懂。不要按文件顺序读,要按“一条 SQL 的旅程”读。MiniOB 的 SQL 入口一般经历:socket 读取 → 会话处理 → lexer/parser 生成 Parse AST → 转换为 Statement → 生成执行器。这一串过程里,最需要关注的是 parser 到 statement 的分层。因为 MiniOB 的语法解析基于 Flex/Bison,对不熟悉这两个工具的 C++ 工程师来说,很容易被生成出的.tab.cpp吓到。
核心要点在于:词法文件负责把字符串切成 token,语法文件负责按规则把 token 组装成 AST。你可以把它们看成“字符串数组初始化”和“匹配堆栈”的组合。比如select * from t;经过 lexer 后会变成一串 token:SELECT、*、FROM、IDENTIFIER(t)。这个 token 流送入 parser 后,Parser 产出的是一个个节点,但节点并不直接执行。MiniOB 中典型的做法是:由 Statement 或 CommandExecutor 完成从 AST 到执行计划的动作。
给一个不依赖具体版本的简化结构,说明这一层的形状:
class Stmt { public: virtual ~Stmt() = default; virtual CommandType type() const = 0; }; class SelectStmt : public Stmt { public: CommandType type() const override { return CommandType::SELECT; } // 真正查询时还会持有 projection、table id、where 条件等成员 };逻辑说明:SelectStmt只是输入和输出之间的“信息载体”,它不负责读磁盘,也不负责计算。它把select * from t里的表名和列名塞进成员变量,交给下一层。MiniOB 里有不少项目都采取这种分层:Parser 层只做语法检查,语义和权限不在这一层展开。
参数说明:type()的返回值在很多 switch 语句里出现,是分发执行器的依据。如果你想加一条新 SQL,至少要在这里加一个枚举项,并让 Stmt 返回它。不要试图绕过 Stmt 直接把解析结果塞给执行器,那会污染代码边界。
3.2 执行器和物理算子的边界
Stmt 还不是可执行的计划。MiniOB 中常见的一层是Executor,有些版本会称作CommandExecutor。观察它的接口,你会发现很多 Executor 都有一个execute()成员。这个方法的输入是 Stmt,输出是结果集或影响的行数。以插入为例:
class InsertExecutor { public: RC execute() { // 1. 拿到表对象 // 2. 构造 Record,把字段值按表结构序列化 // 3. 调用表/索引的 insert 方法 return RC::SUCCESS; } };逻辑说明:这个注释似乎在讲废话,但它意味着你在阅读源码时不要同时钻进“表怎么锁”和“页怎么拆”两个泥潭。先看 execute 方法的第一行走到最后一行,看了它调哪些接口;第二遍再进入被调用的接口内部。MiniOB 因为是教学项目,很多函数只有几十行,非常适合这样逐层下钻。
参数说明:RC是返回码,测试时如果execute返回失败,日志里会带对应错误码。有些版本RC是一个 enum class,使用前需要打印strrc(ret)或类似函数。下次你在日志里看到RC=-160这类数字,先找strrc的实现,别靠猜。
这里还需要补充一个认知:MiniOB 的教学定位决定了它没有商业数据库那样完整的查询优化器。你会发现很多查询的执行计划非常简单,甚至就是“全表扫描 + 过滤”。你在读代码时不要抱着“这里一定有基于代价的优化”的心态,否则会浪费时间。把它当 C++ 工程读,而不是当工业数据库读。
3.3 存储引擎:Buffer Pool 和 Record 是关键
读到这里你会发现,SQL 的执行最终会落到表扫描和增删改。MiniOB 的存储引擎通常会实现一个缓冲池(Buffer Pool)来管理磁盘页。只要你理解了这条主线,就能回答面试中常见的“一个页怎么被加载到内存”。MiniOB 的 Buffer Pool 一般会提供固定大小页的缓存,淘汰算法通常是 LRU 或简化版本。下面是一个典型的接口形状:
class Frame { public: PageNum page_num() const { return page_num_; } char *data() { return data_; } bool is_dirty() const { return dirty_; } private: PageNum page_num_ = -1; char *data_ = nullptr; bool dirty_ = false; };逻辑说明:Frame是磁盘页在内存中的形象。data_指向一块连续内存,读写页的字节都通过它。注意到这里用了裸指针,这就是为什么后面调试时容易碰到越界。如果你决定深入改造,最好保持 RAII 思路,但不要立刻把 MiniOB 里所有裸指针都换成unique_ptr,教学代码的牵一发动全身会让你改到崩溃。
参数说明:PageNum在 MiniOB 中一般是 int32 或 int64,决定一个数据库文件最多能有多少页。你在配置文件中看到的 buffer pool 大小,最终就是要能装下 N 个 Frame。如果数据量超过内存缓冲,代价就是频繁的磁盘换入换出,这时可以在 SQL 层用索引避免全表扫描。
3.4 C++ 细节:字符串、指针和值传递的边界
MiniOB 的代码是很好的 C++ 学习素材,因为它大量使用char*、std::string、引用和指针混排。很多第一次读源码的人都会问:为什么这里不直接用 string?原因很简单:Record 里字段的字节流经常要和磁盘上的二进制布局保持一致,std::string多了额外的堆分配和长度字段,不适合直接当页内存储结构。但它很适合做函数参数。
常见场景是:从 Record 里取字段时,你需要把一段char*转成std::string做字符串比较。一种做法是:
const char *field = record->get_field(column_index); size_t field_len = record->get_field_length(column_index); std::string field_str(field, field_len);逻辑说明:第二个参数field_len必须传对,因为char*本身不携带长度。如果你直接用std::string(field),它会按\0截断,碰巧字段中间有\0时数据就错了。这是典型的“C++ 字符串数组初始化”和“指针语义”问题,也是很多项目里隐性 bug 的来源。
参数说明:column_index从 0 开始,取到的field指针是页内地址,不要在内存页被淘汰后继续保存。必要时拷贝到 self-contained 的char[]或std::string里。这段经验在实现聚合和索引时尤其重要。
3.5 优化器?先别期待它有
很多人读数据库源码会习惯性去找优化器模块。MiniOB 的教学版通常不会实现完整的 RBO/CBO,顶多有一些简单规则,比如“能下推的条件尽量下推”。这不是缺陷,而是为了让你先看懂执行链路。如果你以后要去读真正带优化器的系统比如 OceanBase 或 TiDB,再回头对比 MiniOB,你就会发现“哪一层少了什么”是极好的学习素材。
读到这里,你已经有能力定位一个新功能需要改哪些地方。第 4 章我们亲手加一条 SQL,把 Parser、Executor 和验证链路真正串起来。
4. 动手在 MiniOB 里加一条 SQL:SHOW VERSION 的完整链路
4.1 先别急着写,找到 SHOW TABLES 的实现
需求很简单:让客户端执行SHOW VERSION;,返回一个版本字符串。这个命令不查表,最适合做第一次改动。要完成它,一般要动三类文件:词法文件、语法文件、C++ 执行类。
但第一步不是直接写代码,而是找同类实现。MiniOB 里往往已经有show tables、help这类命令,照着它们复制是最稳妥的路径。用全局搜索快速定位:
grep -rn "show tables" src/observer/sql/parser src/observer/sql/executor | head -20逻辑说明:这条命令把 parser 和 executor 里所有出现show tables的地方列出来。MiniOB 的命令实现通常会有一个 Stmt、一个 Executor、一个注册入口,通过这个命令你能看到完整的四条线索。不要凭空发明新的调用链,兼容旧逻辑的代价最小。
4.2 在词法和语法里加规则
词法文件通常以.lex或.l结尾。在其中新增关键词,大致的模板是:
"show" { return TokShow; } "version" { return TokVersion; }逻辑说明:TokShow和TokVersion是 C++ 侧可识别的 token 枚举。注意"show"匹配大小写敏感,MiniOB 通常对关键字有大小写转换,有的版本会把 SQL 先统一成大写,这样你只需要写一个规则。如果你的版本没有统一,要同时添加"SHOW"和"show"两条,或者用正则表达式[Ss][Hh][Oo][Ww]这种写法。
再在语法文件里加产生式。伪代码如下:
statement : show_version_stmt | ... ; show_version_stmt: TOK_SHOW TOK_VERSION { /* 构造一个 ShowVersionStmt */ } ;逻辑说明:这里把语法规则指向 Stmt 的创建函数。MiniOB 的 parser 生成代码比较臃肿,但不要手改生成的.tab.cpp,下次重新生成全没了。只改源文件,让构建流程重新生成。
参数说明:token 名不能和已有枚举重复。有的工程会生成TokenType在头文件里,你增加后需要确认所有 switch 都覆盖了新的 case,否则编译器会报-Wswitch警告,有些构建把警告视为错误。
4.3 新增 Stmt 和 Executor
在 C++ 侧,至少要写一个ShowVersionStmt和ShowVersionExecutor。下面给出一个裁剪过的骨架:
class ShowVersionStmt : public Stmt { public: static RC create(const std::string &sql, Stmt *&stmt) { // 这里可以检查 sql 是否非空 stmt = new ShowVersionStmt(); return RC::SUCCESS; } }; class ShowVersionExecutor { public: RC execute() { std::string version = "my-miniob-0.1"; // 把 version 打包成 Tuple,塞到结果集 return RC::SUCCESS; } };逻辑说明:ShowVersionStmt::create是典型工厂函数,语义检查放在这里;ShowVersionExecutor::execute是实际干活的地方。这里故意用了std::string,而不是char*,因为在 Executor 内部没问题,你只要在写入 Tuple 时再用.c_str()和长度拷贝。这种做法和前面讲到的“磁盘页内必须用 char*”有区别,别混淆。
参数说明:RC和Status两个返回码在不同版本里可能共存。我见过 MiniOB 早期版本用RC,后期用Status包装。如果你在源码里看到Status::SUCCESS,就照着已有代码风格来,不要在同一个文件里混合两种风格。
4.4 重新编译并验证
改动完成后,重新构建:
cd build cmake .. -DCMAKE_BUILD_TYPE=Debug make -j$(nproc)如果 Parser 源文件改了,CMake 会自动重新生成 parser,你不需要手工跑 bison。但有个坑:如果用了难以察觉的语法冲突,生成过程不会报错,但解析会跑进错误分支。这时可以打开 parser 的-v选项,生成.output文件查看冲突点。
运行验证:
./bin/observer -f ../etc/observer.ini & echo "show version;" | ./bin/obclient -p 6789预期输出是一个类似my-miniob-0.1的值。如果看到错误,先看日志文件。这一步跑通后,你就具备给 MiniOB 加任何简单命令的基础能力。接下来再去做带表结构的命令时,只需要把“从 schema 读表名、返回 Tuple 列表”这两个动作补上即可。
这里有一个容易被忽略的点:如果你加的命令需要访问表,要在 Executor 里拿当前数据库的Db对象,然后根据表名查表。这个链路在show tables里一定有现成代码,复制过来改字段名就能用。不要自己再去 new 一个 Table,那会让新表脱离会话上下文,最终表现为“命令能解析,但表不存在”。
5. 我踩过的 MiniOB 开发坑:编译、内存与排查方法
5.1 一编译就报 undefined reference
现象:明明make提示编译成功,链接时却报undefined reference to 'create_show_version_stmt'之类。
原因:新加的 Stmt 源文件没有被包含进 target 的源文件列表。MiniOB 的 CMakeLists 用一串文件名写死源文件,你只创建 cpp 文件不会自动进入构建。
解决:打开对应目录的 CMakeLists.txt,把新.cpp文件名加进set(SOURCE_FILES ...)或add_library(...的列表里,然后重新 cmake。我习惯先 grep 现有源码列表,把新文件按字母序插入,这样 git diff 更清晰。如果加了文件仍然报同样错误,执行grep -rn "ShowVersion" CMakeLists.txt src/observer/CMakeLists.txt看看是不是路径拼错。
5.2 运行起来动不动段错误
现象:执行 SQL 时进程直接 segmentation fault,有时候崩溃发生在malloc或free。
原因:字段读取时越界。常见的错法是把char*存下来,等内存页被淘汰后再用;或者把一个std::string的.c_str()传给接口后,原 string 被析构,指针悬空。这里和前面讲到的“指针语义”直接相关。
解决:在 CMake 中打开-fsanitize=address,重新编译后崩溃会发生在第一次越界时。ASAN 报告会显示是“heap-buffer-overflow”“use-after-free”等,并给出调用栈。用这个栈到 GDB 里下断点,能快速定位到具体行。每次改完代码先跑一遍insert/select回归脚本,别等写完一大块再集中调试。
5.3 改了 parser 但没有生效
现象:更新了 lexer 和 yacc 文件,客户端执行新 SQL 仍然提示语法错误,而且日志没有任何变化。
原因:CMake 可能没有重新生成 parser,因为你依赖的文件名和真实文件名大小写或路径不一致;也可能是系统装了多个 Flex/Bison 版本,生成出的 parser 被构建缓存跳过。
解决:清理 build 目录中 parser 生成文件,重新 cmake。如果还不行,用touch更新 yacc 源文件的 mtime,然后make VERBOSE=1,看实际执行的编译命令里有没有 lexer/yacc 那一步。有这个习惯后,真问题会立刻暴露,不必盯着不动的终端怀疑人生。
5.4 客户端显示卡住不返回
现象:输入select * from t后像死机,光标一直等待。
原因:MiniOB 的客户端按语句结束符来识别“输入完”。没有分号时,它认为你还会继续输入,因此一直等。这是初学者最容易遇到的“黑匣子”现象。
解决:检查 SQL 末尾是否写了分号。在脚本批量测试时,尤其要确保每一行都以分号结尾。也可以先用echo管道模拟一次性输入,如果没分号,管道会把多条 SQL 合并成一条,解析器会报错而不是卡住。用管道喂 SQL 时,我习惯在每条 SQL 后强制加一个空格和分号,避免行尾隐形字符导致粘连。
5.5 日志太多,找不到有效错误
现象:设置了 debug 日志后,终端全是打印,反而没有看到真正的错误行。
原因:很多 MiniOB 版本把日志写到 observer 的输出或日志文件里,而客户端看不到服务端日志。如果服务端和客户端都在同一终端跑,输出互相覆盖,很难定位。
解决:先把 observer 的输出重定向到独立文件,比如:
./bin/observer -f ../etc/observer.ini > observer.log 2>&1 &然后在客户端复现问题,再grep -i "error" observer.log。顺手在日志里加上语句文本,排查时可以把 SQL 和错误信息串起来。这是我坚持的习惯:不要开十个终端,只留一个干净的重定向文件,日志里能看到当次 SQL 的全过程比什么都重要。
6. 再进一步:把 MiniOB 当测试驱动数据库:自测脚本与性能基线
6.1 用脚本把常用 SQL 固化成回归测试
把常用 SQL 写进一个文件,用管道批量执行:
#!/usr/bin/env bash set -euo pipefail while IFS= read -r line; do echo "$line" | ./bin/obclient -p 6789 done < cases.sqlcases.sql内容放在这里:
create table t(id int, name char(16)); insert into t values(1, 'a'); select * from t; show version; drop table t;逻辑说明:set -euo pipefail让脚本在第一个非零退出时停止,防止后面 SQL 悄悄失败。我习惯把 SQL 按“建表 - 插入 - 查询 - 删除”排序,保证多次运行不会因为表已存在而失败。这是最简陋却有价值的回归测试,每次改代码后先跑它,能挡掉大部分低级错误。
6.2 给性能建立基线
用time记录一批查询的执行耗时:
for i in {1..100}; do echo "select * from t;" | ./bin/obclient -p 6789 > /dev/null done time (for i in {1..100}; do echo "select * from t;" | ./bin/obclient -p 6789 > /dev/null done)说明:输出重定向到/dev/null避免打印开销。性能比较是在同样数据集上对比代码改动前后的real time。这个数据不是用来做报告的,而是给自己看的“后悔药”:如果改完索引,查询时间反而变长,先怀疑是不是索引没有落到执行计划,而不是直接怀疑机器变慢。
6.3 下一个该研究什么
如果上面都能跑通,下一步我建议去做索引。MiniOB 对 B+ 树索引做了简化实现,你可以从“等值查找是否生效”开始,对比全表扫描和走索引的差异。做完之后,再去读事务模块和 WAL,这两个方向才是数据库面试的重点。我的习惯是每次改动后先跑回归脚本,再开 ASAN,最后跑性能对比。这个顺序能帮我排除掉 80% 的隐藏问题。也希望帮到你。
本文还有配套的精品资源,点击获取