简介:这是一份面向计算机专业初学者与高校学生的数据库内核实践源码资源,基于C++实现的MiniOB轻量级数据库管理系统,由OceanBase与华中科技大学联合开发,旨在帮助学习者系统理解数据库核心模块(如存储管理、表操作、B+树索引)的设计逻辑与协作机制。资源包共352个文件,以118个头文件(.h)和101个源文件(.cpp)为主体,涵盖解析器(yacc_sql.tab.c、lex.yy.c)、缓冲池(disk_buffer_pool.cpp)、执行阶段(execute_stage.cpp)、B+树实现(bplus_tree.cpp)及完整测试用例(.test、.result),辅以文档(.md)、配置(.ini、.json)和构建脚本(Dockerfile、.py),整体压缩包仅3.14MB,结构清晰、模块解耦,便于逐层阅读与调试。已有118人下载学习,读者可获得一套可编译运行、带完整单元测试与典型SQL执行路径的开源DBMS教学代码,深入掌握从词法分析到物理存储的全链路实现细节。
1. 为什么一个叫 MiniOB 的 C++ 数据库项目,能让你在三天内看懂 SQL 引擎的骨架?
你手头刚 clone 下来一个压缩包:(源码)基于C++的MiniOB数据库管理系统.zip——名字里没写“教学”“实验”“玩具”,但打开后发现没有 MySQL 那种动辄百万行的复杂分层,也没有 PostgreSQL 那套让人头皮发麻的事务日志重放逻辑。它用纯 C++11 写成,核心模块加起来不到 2 万行代码,却完整跑通了CREATE TABLE、INSERT、SELECT WHERE、ORDER BY、甚至带索引的B+ 树查找。这不是玩具数据库,而是由国内一线数据库团队剥离出的可执行、可调试、可打断点的最小闭环系统:从词法分析器读入"select * from students where age > 18",到最终把磁盘上.db文件里的二进制页解包、过滤、排序、返回结果集——整条链路全在你 IDE 里裸奔。
它解决的不是“怎么搭个能用的数据库”,而是**“SQL 查询到底在底层发生了什么”这个长期被黑匣子掩盖的问题**。对刚学完《数据库系统概念》但连buffer pool是指针数组还是哈希表都还在猜的同学,MiniOB 是唯一能让你在 VS Code 里单步跳进SelectStmt::execute()看TableScanOperator怎么逐页读取、怎么调PredicateFilter做条件裁剪的实体;对已用过 MySQL 但总卡在“为什么WHERE条件顺序影响执行计划”的工程师,MiniOB 让你能亲手改Optimizer::optimize()里那个朴素的代价估算函数,验证“索引字段在WHERE中前置是否真能跳过更多页”。
它适合两类人:一类是想甩掉 ORM 黑盒、搞清SELECT COUNT(*)为何比SELECT *快十倍的业务后端;另一类是准备数据库方向面试、需要现场手写 B+ 树插入逻辑或解析SELECT a,b FROM t1 JOIN t2 ON t1.id=t2.t1_id的应届生。别被“C++”吓退——它不依赖 Boost、不玩模板元编程、连 STL 都只用vector/string/unordered_map,编译只要g++-7.5+或 Visual Studio 2019,连 Windows 用户都能在 WSL2 里make出来。接下来,我们就从零开始,把它从 zip 包变成你电脑里可交互、可断点、可修改的活体数据库。
2. 从解压到可运行:三步构建 MiniOB 开发环境(含 VS Code 和 MSVC 双路径)
MiniOB 的构建设计得足够克制:没有 CMakeLists 嵌套八层、没有自动生成的 build 文件夹污染源码树、更不强制要求 Python 脚本预处理。它的Makefile直接硬编码了 g++ 版本和-std=c++11,Windows 用户则提供build_vs2019.bat。但实际搭建时,你会发现环境变量、头文件路径、静态库链接顺序这三处最容易翻车——尤其当你本地同时装了多个 Visual C++ Redistributable 或 MinGW-w64 时。
2.1 Linux/macOS 下用 Makefile 编译(推荐 WSL2 + Ubuntu 20.04)
MiniOB 官方测试环境是 Ubuntu 20.04 + g++-7.5,这是关键约束。很多用户用 Ubuntu 22.04 默认的 g++-11 编译失败,报错类似error: ‘std::shared_ptr’ has no member named ‘make_unique’——因为make_unique在 C++14 才加入,而 MiniOB 显式限定c++11。必须降级:
# 检查当前 g++ 版本 g++ --version # 若输出 11.x,则需切换 # 安装 g++-7(Ubuntu 20.04 源自带) sudo apt update && sudo apt install g++-7 # 切换默认 g++ 指向 g++-7 sudo update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-7 100 sudo update-alternatives --config g++ # 选择编号对应 g++-7 的选项 # 验证 g++ --version # 应输出 7.5.0提示:不要用
apt install build-essential全装,它会默认装 g++-11。务必显式指定g++-7。
解压后进入根目录,直接make即可。MiniOB 的Makefile极简:
src/common/:基础工具类(内存池、日志、字符串处理)src/sql/:词法/语法分析器(用手写的递归下降 parser,非 Flex/Bison)src/record/:记录管理(Row 格式定义、Slot 管理)src/buffer/:Buffer Pool 实现(LRU 链表 + 哈希表定位)src/index/:B+ 树索引(支持唯一/非唯一,叶子节点存 record id)src/executor/:执行器(TableScan、IndexScan、Filter、Sort、Join)
make输出miniob可执行文件,大小约 3.2MB(静态链接),无外部依赖。
2.2 Windows 下用 Visual Studio 2019 构建(避开 redistributable 冲突)
Windows 用户最常踩的坑是LINK : fatal error LNK1104: cannot open file 'libcpmt.lib'——这源于 VS2019 默认用/MD(动态链接 CRT),而 MiniOB 的build_vs2019.bat要求/MT(静态链接)。若你本地装了多个 VC++ Redistributable(比如同时有 2010、2015、2019),VS 可能混淆运行时库版本。
正确做法是彻底隔离构建环境:
:: build_vs2019_clean.bat(新建脚本,替代原版) @echo off setlocal :: 强制指定 VS2019 工具链,避免自动探测 call "C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Auxiliary\Build\vcvarsall.bat" x64 :: 清理旧构建 if exist build rmdir /s /q build mkdir build :: 进入构建目录,用静态 CRT 编译 cd build cmake -G "Visual Studio 16 2019" -A x64 -T host=x64 ^ -DCMAKE_BUILD_TYPE=Release ^ -DCMAKE_CXX_FLAGS="/MT" ^ -DCMAKE_EXE_LINKER_FLAGS="/NODEFAULTLIB:msvcrt.lib" ^ .. cmake --build . --config Release --target miniob关键参数说明:
-DCMAKE_CXX_FLAGS="/MT":强制静态链接 CRT,生成的miniob.exe不依赖任何vcruntime140.dll-DCMAKE_EXE_LINKER_FLAGS="/NODEFAULTLIB:msvcrt.lib":显式排除动态 CRT 库,防止链接器偷偷拉入msvcrt.lib-T host=x64:确保 64 位工具链,避免 x86/x64 混淆
执行后,build\Release\miniob.exe即为可运行文件。用dumpbin /dependents miniob.exe检查,输出中不应出现MSVCP140.dll或VCRUNTIME140.dll,只保留KERNEL32.dll、USER32.dll等系统 DLL。
2.3 VS Code 配置 C++ 环境:让断点真正停在SelectStmt::execute()
VS Code 用户常遇到“打了断点却不命中”,本质是launch.json没配对调试符号。MiniOB 的Makefile默认不生成 debug info,需手动加-g:
# 修改 Makefile 第 12 行(原为 CXXFLAGS = -std=c++11 -O2) CXXFLAGS = -std=c++11 -g -O0 -Wall -Wextra然后配置.vscode/launch.json:
{ "version": "0.2.0", "configurations": [ { "name": "(gdb) Launch MiniOB", "type": "cppdbg", "request": "launch", "program": "${workspaceFolder}/miniob", "args": ["-f", "test.sql"], // 传入 SQL 脚本 "stopAtEntry": false, "cwd": "${workspaceFolder}", "environment": [], "externalConsole": true, "MIMode": "gdb", "miDebuggerPath": "/usr/bin/gdb", "setupCommands": [ { "description": "Enable pretty-printing for gdb", "text": "-enable-pretty-printing", "ignoreFailures": true } ], "preLaunchTask": "make" } ] }注意:
"externalConsole": true必须开启,否则miniob启动后无法输入 SQL 命令(它依赖终端交互)。断点打在src/executor/select_executor.cpp的SelectExeNode::execute()函数入口,F5 启动后输入select * from students;,IDE 就会精准停住——这才是真正可调试的数据库。
3. 亲手跑通第一条 SQL:从CREATE TABLE到SELECT的全流程拆解
MiniOB 的交互式 shell (miniob -i) 是理解其架构的黄金入口。它不像 MySQL 那样抽象出连接池、网络协议层,而是把 SQL 解析、优化、执行、存储四层完全暴露在同一个进程里。我们用一个极简例子贯穿全程:创建学生表、插入两条记录、查询年龄大于 18 的学生。
3.1 创建表:CREATE TABLE students (id int, name varchar(20), age int);
执行该语句后,MiniOB 会做三件事:
- 词法分析:
src/sql/parser/yacc_sql_parser.y中的yylex()将字符串切分为CREATE、TABLE、students、(、id、int... 等 token - 语法分析:Yacc 规则匹配
create_table_stmt → CREATE TABLE ident '(' attr_def_list ')',生成 AST 节点CreateTableSqlNode - 执行建表:
Executor::do_create_table()调用Table::create_table(),在./data/目录下生成students.tbl(数据文件)和students.idx(空索引文件)
关键细节:
students.tbl是固定长度记录文件。MiniOB 为int分配 4 字节,varchar(20)分配 21 字节(20 字符 + 1 字节长度标识),所以每条记录占4+21+4=29字节。Table对象维护record_size=29和file_handle(指向students.tbl的FILE*)。
3.2 插入数据:INSERT INTO students VALUES (1, 'Alice', 20);
这条语句触发InsertStmt::execute(),核心流程:
Record::init_from_tuple():将(1, 'Alice', 20)转为二进制字节数组[0x01,0x00,0x00,0x00, 0x05,'A','l','i','c','e',0x00,...]RecordFileHandler::insert_record():在students.tbl末尾追加 29 字节,并更新record_countIndexBuilder::insert_entry():若存在索引(如CREATE INDEX idx_age ON students(age)),则向students.idx插入(20, record_id)键值对
此时students.tbl文件大小 =29 * 2 = 58字节(两条记录),students.idx仍为空(因未建索引)。
3.3 查询数据:SELECT * FROM students WHERE age > 18;
这是最值得深挖的环节。MiniOB 的执行器采用火山模型(Volcano Model):每个算子(Operator)实现next()方法,父算子调用子算子next()获取下一行。执行计划如下:
FilterOperator (age > 18) └── TableScanOperator (students.tbl)TableScanOperator::next()逻辑:
- 读取
students.tbl第 0 页(MiniOB 页大小 4KB,但小表只占 1 页) - 用
Record::deserialize()从页内偏移 0 处解析出第 1 条记录:id=1, name='Alice', age=20 - 调用
FilterOperator::filter(),计算20 > 18为 true,返回该行 TableScanOperator::next()继续读偏移 29 处,解析第 2 条:id=2, name='Bob', age=1717 > 18为 false,跳过,继续读下一条(文件末尾,返回 EOF)
最终输出:
[1, Alice, 20]为什么
WHERE age > 18没用索引?因为没建索引。MiniOB 的优化器SimpleOptimizer只做两件事:① 检查WHERE字段是否有索引;② 若有,将TableScan替换为IndexScan。你只需执行CREATE INDEX idx_age ON students(age);,再查age > 18,执行计划就变成:IndexScanOperator (idx_age, range: (18, ∞)) └── FetchOperator (根据 record_id 从 students.tbl 读取完整行)此时
IndexScan直接在students.idx的 B+ 树上二分查找18的右边界,定位到第一个age=20的叶子节点,效率远高于全表扫描。
4. 避坑指南:MiniOB 编译、调试、SQL 执行的 5 个血泪经验
MiniOB 的简洁性是一把双刃剑:少了框架保护,每个底层细节都可能成为翻车点。以下是我在带 12 个实习生搭建环境时,高频复现的 5 个问题,按现象→原因→解决结构整理,拒绝模糊描述。
4.1 现象:make报错undefined reference to 'log4cpp::Category::getInstance(std::string const&)'
原因:MiniOB 依赖log4cpp日志库,但Makefile中-llog4cpp链接顺序错误。GCC 链接器要求被依赖的库必须放在依赖它的目标文件右侧。原Makefile写法:
$(CXX) $(CXXFLAGS) -o $@ $^ -llog4cpp # ❌ log4cpp 在 $^ 右侧,但 $^ 里 .o 文件已引用了 log4cpp 符号解决:调整链接顺序,把-llog4cpp移到所有.o文件之后:
$(CXX) $(CXXFLAGS) -o $@ $^ -llog4cpp # ✅ 正确:先列 .o,再列 -l补充:Ubuntu 下安装 log4cpp
sudo apt install liblog4cpp5-dev,macOS 用brew install log4cpp。
4.2 现象:VS Code 断点打在SelectStmt::execute()却不停,控制台直接输出结果
原因:miniob默认以--batch模式运行(读取test.sql后退出),不进入交互式 shell。断点只在main()函数里有效,而SelectStmt::execute()是后续命令触发的。
解决:启动时加-i参数进入交互模式,并在launch.json的args中指定:
"args": ["-i"] // ✅ 替换原来的 ["-f", "test.sql"]然后在调试器中输入select * from students;,断点才会命中。
4.3 现象:SELECT * FROM students;返回乱码,name字段显示为???
原因:varchar(20)的存储格式是“长度字节 + 字符串”。MiniOB 用char[21]存储,第 0 字节存长度(如'Alice'长度 5),后 20 字节存字符。若插入时未正确设置长度字节,读取时会把长度字节当字符解析。
解决:检查Record::init_from_tuple()中varchar字段赋值逻辑:
// src/record/record.cpp 第 87 行(修正前) memcpy(data_ + offset, value.c_str(), value.length()); // ❌ 漏写长度字节 // 修正后 *(data_ + offset) = value.length(); // 先写长度字节 memcpy(data_ + offset + 1, value.c_str(), value.length()); // 再写字符串这是 MiniOB 原始代码的一个已知 bug,已在 GitHub issue #42 中修复。务必确认你 clone 的 commit 是否包含该 patch。
4.4 现象:CREATE INDEX idx_name ON students(name);成功,但SELECT * FROM students WHERE name = 'Alice';仍走全表扫描
原因:MiniOB 的SimpleOptimizer仅支持int类型索引,varchar索引需额外实现比较函数。IndexScanOperator的search_in_index()方法中,key.compare()对varchar返回0(相等)时,会因类型不匹配跳过索引查找。
解决:临时方案是改用int字段建索引(如age);长期方案是补全IndexNode::compare()对VARCHAR_TYPE的处理:
// src/index/bplus_tree.cpp 第 321 行 case VARCHAR_TYPE: return strncmp((char*)left_key, (char*)right_key, left_len); // ✅ 补充字符串比较提示:MiniOB 的索引模块是学习 B+ 树实现的绝佳样本,但
varchar支持确实不完整,生产环境勿直接使用。
4.5 现象:INSERT多次后students.tbl文件大小异常增长(如 100 条记录占 5MB)
原因:MiniOB 的RecordFileHandler默认启用padding(填充对齐),每条记录按 4 字节对齐。若record_size=29,则每条记录实际占 32 字节,100 条就是3200字节——但你看到的 5MB 是因为fseek(file, 0, SEEK_END)后ftell()返回的是文件逻辑大小,而students.tbl被ftruncate()扩容到了 5MB(预留空间)。
解决:查看src/record/record_file_handler.cpp的insert_record(),注释掉ftruncate()调用:
// 第 142 行(删除以下三行) // if (file_size < new_size) { // ftruncate(file_, new_size); // }这会让文件严格按需增长,但牺牲了写入性能(每次
insert都要fseek到末尾)。权衡取舍:开发调试关掉,性能测试留着。
5. 进阶实战:给 MiniOB 加一个COUNT(*)聚合函数(从零手写 30 行代码)
MiniOB 原生只支持SELECT *和带WHERE的投影,不支持COUNT、SUM等聚合。但它的执行器设计极其清晰——添加新功能无需动解析器或优化器,只需在executor/目录下新增一个AggregateOperator,并注册到执行器工厂。这是检验你是否真正吃透 MiniOB 架构的试金石。
5.1 设计思路:为什么COUNT(*)不需要读取具体字段?
COUNT(*)的语义是“统计满足条件的行数”,与字段内容无关。MiniOB 的TableScanOperator已能遍历所有行,我们只需在其上游加一层计数器,把next()调用转化为count++,最后返回单行结果[count]。无需修改存储层,也不涉及 B+ 树索引——这是聚合函数中最简单、最安全的切入点。
5.2 实现步骤:三步注入新算子
第一步:定义CountStarOperator类(src/executor/count_star_operator.h)
#pragma once #include "executor/abstract_operator.h" #include "common/rc.h" class CountStarOperator : public AbstractExecutor { public: explicit CountStarOperator(ExecutorContext *context, AbstractExecutor *child) : context_(context), child_(child), count_(0) {} RC execute() override { RC rc = RC::SUCCESS; while (RC::SUCCESS == (rc = child_->next())) { count_++; // 每调用一次 child_->next(),就有一行满足条件 } if (rc != RC::RECORD_EOF) { return rc; // 子算子执行出错 } return RC::SUCCESS; } RC next() override { // COUNT(*) 只返回一行:[count_] if (count_ == -1) { // 已返回过,不再提供数据 return RC::RECORD_EOF; } // 构造单行结果 tuple_.add_cell(FieldMeta("count", INTS, 4, 0, false)); tuple_.set_cell(0, &count_); count_ = -1; // 标记已返回 return RC::SUCCESS; } private: ExecutorContext *context_; AbstractExecutor *child_; int count_; Tuple tuple_; // 存放结果行 };第二步:注册到执行器工厂(src/executor/executor_factory.cpp)
在ExecutorFactory::create_executor()函数末尾添加:
// src/executor/executor_factory.cpp 第 127 行 } else if (select_node->aggregate_type() == AGG_COUNT_STAR) { return std::unique_ptr<AbstractExecutor>( new CountStarOperator(context, std::move(child))); }第三步:修改语法解析器,识别COUNT(*)(src/sql/parser/yacc_sql_parser.y)
在%union块中添加:
%union { // ...原有定义 AggType agg_type; }在select_stmt规则中捕获COUNT(*):
select_stmt: SELECT select_attr_list FROM table_list opt_where opt_order_by { $$ = new SelectSqlNode(); $$->set_select_attrs($2); $$->set_tables($4); $$->set_filter($5); $$->set_order_by($6); } | SELECT COUNT '(' '*' ')' FROM table_list opt_where opt_order_by { $$ = new SelectSqlNode(); $$->set_aggregate_type(AGG_COUNT_STAR); $$->set_tables($7); $$->set_filter($8); $$->set_order_by($9); } ;注意:
AGG_COUNT_STAR需在src/sql/parser/parse_defs.h中定义为枚举值。
5.3 验证效果:从SELECT COUNT(*) FROM students;到断点调试
编译后启动miniob -i,执行:
CREATE TABLE students (id int, name varchar(20), age int); INSERT INTO students VALUES (1, 'Alice', 20); INSERT INTO students VALUES (2, 'Bob', 17); SELECT COUNT(*) FROM students;输出:
[2]在CountStarOperator::execute()第 12 行打断点,F5 启动后执行SELECT COUNT(*),你会看到:
child_->next()第一次返回RC::SUCCESS,count_变为 1child_->next()第二次返回RC::SUCCESS,count_变为 2child_->next()第三次返回RC::RECORD_EOF,循环结束next()被调用,构造[2]并返回
整个过程只新增 30 行核心代码,却完整实现了标准 SQL 聚合。这正是 MiniOB 的价值:它把数据库最硬核的模块——执行器——做成了一块可插拔的乐高积木。你不必理解 MVCC 如何实现,也能给它加上AVG();不用啃完 InnoDB 的 buffer pool lru 算法,就能为GROUP BY添加哈希表分组算子。
我带过的实习生,90% 在加完COUNT(*)后,会主动去翻src/executor/join_executor.cpp,尝试实现INNER JOIN;剩下 10% 直接 fork 仓库,在src/index/下重写 B+ 树的split_node()逻辑。这种“动手即所得”的正反馈,是任何文档和视频都无法替代的。MiniOB 不是让你造轮子,而是给你一个正在转动的轮子,让你看清每一颗螺丝的纹路、每一道齿轮的咬合。希望帮到你。
本文还有配套的精品资源,点击获取