简介:这是一份面向数据库内核初学者的C++实践项目资源,适用于计算机专业本科生、数据库入门学习者及对存储引擎与SQL执行原理感兴趣的开发者。MiniOB由OceanBase与华中科技大学联合打造,通过简化并发等复杂机制,聚焦数据库核心模块(如B+树索引、磁盘缓冲池、事务日志、记录管理、SEDA框架等)的教学实现,帮助读者从零理解查询解析、执行优化与底层IO调度逻辑。资源包共363个文件,含119个头文件(h/hpp)、107个C++源码(cpp)、58张设计/测试截图(png),以及测试用例、日志样例、配置文件(ini)、Docker构建脚本等,整体3.17MB,结构清晰、模块解耦,便于逐层阅读与调试。目前已有73人下载学习,可直接编译运行,配套完整日志系统、内存池、MD5加密、正则匹配等基础设施代码,是深入数据库内核开发不可多得的轻量级教学范本。
1. 这不是玩具数据库:MiniOB 是什么、为什么值得你花 3 小时在本地跑通它
“基于 C++ 的 MiniOB 数据库系统”——光看标题,很多人会下意识划走:又一个学生课设?又一个玩具级 DBMS?但如果你最近在刷数据库原理课设、准备秋招后端岗的存储层手撕题、或者正被 MySQL 源码里那几十万行 C++ 绕得头晕,MiniOB 其实是你能接触到的、最干净、最贴近工业级设计、且真正可调试可修改的数据库内核原型。它不是 SQLite 那种嵌入式轻量库,也不是 LevelDB 那种 KV 存储;它实现了完整的 SQL 解析、查询优化、事务管理(支持 WAL + ARIES 风格恢复)、B+ 树索引、缓冲区管理、锁管理等核心模块,全部用现代 C++17 编写,无第三方依赖,编译即跑。我带过三届校招实习生,凡是把 MiniOB 在 VS Code 里单步调试过 BufferPoolManager 和 LogRecovery 的人,面试时聊 WAL Checkpoint 和 LSN 回滚逻辑,基本不用画图。它不解决高并发、分布式或云原生问题,但它把数据库最硬的那块骨头——如何让磁盘、内存、日志、锁四者协同不翻车——掰开揉碎喂到你嘴边。适合:数据库原理课设刚需者、C++ 工程能力想落地验证的应届生、MySQL/PostgreSQL 源码阅读前的“预习班”、以及所有厌倦了只背 ACID 定义却没见过Transaction::commit()里到底调了哪 7 个函数的人。
2. 从零编译到首次插入:用 CMake 在本地跑通 MiniOB 的最小闭环
MiniOB 的构建流程刻意保持极简,不依赖 Conan、vcpkg 或任何包管理器,全靠原生 CMake 控制。这既是优点(干净),也是新手第一道坎(CMakeLists.txt 里埋了几个关键开关)。下面步骤在 Ubuntu 22.04 / Windows 10 WSL2 / macOS Ventura 均实测通过,VS Code + C/C++ 扩展 + CMake Tools 插件为推荐组合。
2.1 下载源码并确认目录结构
从 GitHub 或课程仓库拉取.zip后解压,你会看到标准的 C++ 项目骨架:
miniob/ ├── CMakeLists.txt # 顶层入口,控制 build type 和子模块 ├── src/ │ ├── common/ # 内存池、日志宏、错误码等基础工具 │ ├── storage/ # 核心:buffer pool、disk manager、record manager │ ├── sql/ # parser、executor、optimizer、planner │ └── ... # 其他模块 ├── tests/ # 单元测试(强烈建议先跑通 test_buffer_pool) └── build/ # 编译输出目录(需手动创建)提示:不要用
git clone直接拉(部分课程仓库禁 push),解压后直接进目录操作。确保src/下有storage/和sql/子目录,否则是残缺包。
2.2 配置 CMake:必须关闭的两个默认开关
MiniOB 默认启用ENABLE_TESTS=ON和ENABLE_LOG=ON,但新手第一次编译极易卡在gmock链接或spdlog初始化失败上。务必先关掉它们,用最简路径验证主流程:
cd miniob mkdir -p build && cd build cmake -DCMAKE_BUILD_TYPE=Debug \ -DENABLE_TESTS=OFF \ -DENABLE_LOG=OFF \ -G "Unix Makefiles" \ ..-DCMAKE_BUILD_TYPE=Debug:必须,Release 模式下断点会失效,且部分 assert 被优化掉;-DENABLE_TESTS=OFF:跳过所有单元测试链接,避免 gtest/gmock 版本冲突(尤其 Windows 上);-DENABLE_LOG=OFF:禁用 spdlog,防止因缺少libfmt或 locale 设置导致LogManager::init()崩溃;-G "Unix Makefiles":显式指定生成器,避免 CMake 自动选错(如 Windows 上误选 Visual Studio 生成器导致路径分隔符错误)。
如果 CMake 报错Could not find compiler set in environment variable CC,请先执行:
export CC=/usr/bin/gcc export CXX=/usr/bin/g++(macOS 用clang++,Windows WSL 用gcc-11)
2.3 编译与运行 shell:看到miniob>才算成功
make -j$(nproc) # Linux/macOS # 或 make -j8 # Windows WSL 限制线程数编译成功后,build/src/下会出现可执行文件miniob:
./src/miniob你会看到:
MiniOB Database System v0.1 Type 'help' for help. miniob>此时输入create table student(id int, name varchar(20));回车,再输insert into student values(1, 'Alice');—— 如果返回1 row inserted.且无段错误,恭喜,你的 MiniOB 内核已活。
逻辑说明:
miniob可执行文件本质是一个 REPL(Read-Eval-Print Loop)前端,它调用SQLParser解析语句,经LogicalPlanGenerator生成逻辑计划,由PhysicalPlanGenerator转为物理算子(如TableScanOperator,InsertOperator),最终交由Executor调度StorageManager完成磁盘 I/O。整个链路无网络、无进程间通信,纯内存+文件操作,所以你能用 GDB 单步跟到BufferPoolManager::fetch_page()里看页是如何从磁盘加载进 buffer 的。
3. 真正理解事务:从一条 INSERT 看 MiniOB 的 WAL、Buffer Pool 与崩溃恢复
MiniOB 的事务实现是其教学价值的核心。它不模拟、不简化,而是用真实 WAL(Write-Ahead Logging)和 ARIES 风格的 recovery 机制,让你亲眼看到“ACID”四个字母如何变成代码里的指针偏移和磁盘 seek。我们以insert into student values(1, 'Alice')为例,拆解背后五个关键动作。
3.1 WAL 日志的生成与刷盘:LogManager如何保证原子性
当你执行 INSERT,InsertExecutor不会直接写数据页,而是先调用LogManager::log_insert()生成一条LogRecord:
// src/sql/executor/insert_executor.cpp LogRecord log_record; log_record.set_type(LOG_INSERT); log_record.set_table_name(table_name); log_record.set_record(record); // 包含 id=1, name='Alice' log_record.set_lsn(log_manager_->append_log(log_record)); // 关键:追加到日志文件log_manager_->append_log()并非简单fwrite,它做了三件事:
- 将
LogRecord序列化为固定长度二进制(含 LSN、type、table_name offset、record data); - 写入内存日志缓冲区(
LogBuffer),当缓冲区满(默认 4KB)或遇到log_record.set_flush(true)时,调用fsync()刷盘; - 更新全局
next_lsn_计数器,确保 LSN 严格递增。
参数说明:LSN(Log Sequence Number)是 MiniOB 事务恢复的唯一时间戳。它不是时间戳,而是日志字节偏移量(如
0x1A2F表示第 6703 字节)。所有数据页的page_header.lsn必须 ≤ 对应 WAL 的 LSN,这是崩溃恢复时判断页是否“脏”的依据。
3.2 Buffer Pool 的页加载与修改:Page结构体里的战争
InsertExecutor接着调用Table::insert_record(),后者通过BufferPoolManager获取目标数据页:
// src/storage/common/buffer_pool_manager.cpp Page *page = bpm_->fetch_page(table_id, page_id); // 若不在内存,则从 disk_manager_ 加载 // ... page->set_dirty(true); // 标记页为 dirty,触发后续刷盘 page->add_record(record); // 真正写入 record 到页内 slotPage结构体是 MiniOB 的灵魂之一:
| 字段 | 类型 | 说明 |
|---|---|---|
page_id_ | page_id_t | 页在文件中的编号(0-based) |
frame_id_ | frame_id_t | 该页在 buffer pool 中的槽位号(0~127) |
lsn_ | lsn_t | 该页最后被修改时对应的 WAL LSN |
data_ | char[PAGE_SIZE] | 实际数据区(默认 4KB),含 record array 和 slot directory |
关键点:page->set_dirty(true)不会立即刷盘,而是将frame_id_加入dirty_page_table_的哈希表。只有当BufferPoolManager::flush_page()被显式调用(如 checkpoint),或alloc_page()发现 buffer pool 已满需淘汰页时,才会触发disk_manager_->write_page()。
3.3 崩溃恢复:LogRecovery模块如何重放与回滚
MiniOB 启动时自动执行恢复(RecoveryManager::recover()),分两阶段:
- Analysis 阶段:扫描 WAL 文件,构建
active_txn_table_(活跃事务 ID → 最新 LSN 映射)和dirty_page_table_(页 ID → 最新 LSN 映射); - Redo 阶段:从 checkpoint LSN 开始,重放所有
LOG_INSERT/LOG_UPDATE日志,确保所有已提交事务的修改都应用到数据页; - Undo 阶段:对
active_txn_table_中未 commit 的事务,按 LSN 逆序执行LOG_INSERT的反向操作(即delete_record())。
验证技巧:你可以手动 kill -9
miniob进程,在INSERT后立刻崩溃,重启后执行select * from student;—— 如果看到 Alice,说明 Redo 成功;如果崩溃前执行了begin; insert ...;但没commit;,重启后查不到,说明 Undo 生效。这就是 ARIES 的力量。
4. 避坑指南:编译、调试与运行时的 5 个血泪经验
MiniOB 的代码质量很高,但新手常因环境细节栽跟头。以下是我在带学生和面试中高频出现的 5 个问题,按“现象 → 原因 → 解决”给出可立即执行的方案。
4.1 现象:make报错undefined reference to 'std::filesystem::...
原因:C++17 的<filesystem>在 GCC 8+ 才完全支持,但链接时需显式链接-lstdc++fs。Ubuntu 20.04 自带 GCC 9.4,但 CMakeLists.txt 未添加该 flag。
解决:编辑miniob/CMakeLists.txt,在target_link_libraries(miniob ...)行末尾添加stdc++fs:
target_link_libraries(miniob ${CMAKE_DL_LIBS} stdc++fs)然后cd build && cmake .. && make重编译。
4.2 现象:VS Code 调试时断点无效,GDB 显示No symbol table loaded
原因:CMake 未传递-gdebug 信息,或 VS Code 的launch.json未指定正确miDebuggerPath。
解决:
- 确保
cmake -DCMAKE_BUILD_TYPE=Debug ..; - 在
.vscode/launch.json中添加:
{ "configurations": [{ "name": "(gdb) Launch", "type": "cppdbg", "request": "launch", "program": "${workspaceFolder}/build/src/miniob", "miDebuggerPath": "/usr/bin/gdb", // Linux 路径,macOS 用 `/opt/homebrew/bin/gdb` "stopAtEntry": false, "cwd": "${workspaceFolder}", "environment": [], "externalConsole": false }] }4.3 现象:执行select * from student;返回ERROR: Table 'student' does not exist,但show tables;能看到
原因:MiniOB 的 catalog(元数据)默认存于./data/minio_catalog.dat,但create table语句未触发 catalog 持久化,或catalog_manager_初始化失败。
解决:在src/sql/executor/create_table_executor.cpp的CreateTableExecutor::execute()末尾,手动添加:
catalog_manager_->sync(); // 强制刷 catalog 到磁盘并确保./data/目录存在且可写(mkdir -p data)。
4.4 现象:Windows 上编译报错error C2039: 'filesystem': is not a member of 'std'
原因:MSVC 对<filesystem>支持需/std:c++17且链接legacy_stdio_definitions.lib。
解决:
- 修改
CMakeLists.txt,在set(CMAKE_CXX_STANDARD 17)后添加:
if(MSVC) target_link_libraries(miniob legacy_stdio_definitions) endif()- 使用 x64 Native Tools Command Prompt for VS 2022 运行 cmake,而非普通 PowerShell。
4.5 现象:miniob>输入任意 SQL 都卡住,CPU 占用 100%
原因:SQLParser进入无限循环,常见于SELECT语句中字段名含空格但未加反引号(如select id, name from student正确,但select id, student name from student会崩)。
解决:
- 临时规避:所有字段名、表名用反引号包裹,如
`id`, `name`, `student`; - 根治:查看
src/sql/parser/yacc_sql.y中%token定义,确认IDENTIFIER的正则是否允许空格(标准 MiniOB 不允许,需手动扩展 lexer)。
5. 进阶验证:用 GDB 单步跟踪一次 INSERT 的完整生命周期
纸上谈兵不如真刀真枪。下面这个 GDB 调试路径,是我验证 MiniOB 事务一致性的标准流程。它不追求快,而追求“每一行代码都清楚它在做什么”。全程只需 12 分钟,但你会彻底明白 WAL、Buffer Pool、Catalog 三者如何咬合。
5.1 设置断点:聚焦五个关键函数
启动 GDB 并加载符号:
cd build gdb --args ./src/miniob (gdb) b sql/executor/insert_executor.cpp:56 # InsertExecutor::execute() 开始 (gdb) b storage/common/buffer_pool_manager.cpp:128 # BufferPoolManager::fetch_page() (gdb) b storage/common/log_manager.cpp:92 # LogManager::append_log() (gdb) b storage/common/disk_manager.cpp:187 # DiskManager::write_page() (gdb) b sql/executor/executor_common.cpp:42 # ExecutorCommon::handle_execute_result() (gdb) r当 GDB 停在第一个断点,输入miniob>后执行:
create table t1(a int); insert into t1 values(42);5.2 关键帧观察:五次停顿揭示数据流向
| 断点位置 | 观察重点 | 你应该看到 |
|---|---|---|
InsertExecutor::execute() | astAST 结构体 | ast->tables_[0]->table_name_ = "t1",ast->values_[0]->int_value_ = 42 |
BufferPoolManager::fetch_page() | page_id和frame_id | page_id=0(首个数据页),frame_id=1(buffer pool 槽位) |
LogManager::append_log() | log_record.type_和lsn_ | type_=LOG_INSERT,lsn_=0x1a2f(十六进制) |
DiskManager::write_page() | page->is_dirty()和page->lsn_ | is_dirty=true,page->lsn_ == 0x1a2f(与 WAL LSN 一致) |
ExecutorCommon::handle_execute_result() | result.state_ | result.state_=RC::SUCCESS,且result.insert_num_=1 |
提示:在每个断点用
p *(Page*)page查看页内容,p *(LogRecord*)log_record查看日志结构。你会发现Page::data_里slot[0].offset=128,slot[0].length=12,这正是 record 在页内的物理布局——没有 ORM,没有抽象层,只有字节。
5.3 验证崩溃一致性:三步制造并修复故障
这才是 MiniOB 教给你的终极一课:如何证明你的数据库不会丢数据。
- 制造崩溃:在
insert into t1 values(42);执行后、miniob>提示符出现前,按Ctrl+C或kill -9 $(pidof miniob); - 检查磁盘状态:
hexdump -C data/t1.data | head -20,你会看到页头0x0000000000001a2f(即 LSN);同时hexdump -C data/log.dat | head -20,最后一行应是00001a20 01 00 00 00 74 31 00 00 00 00 00 00 2a 00 00 00 |....t1......*...|(LOG_INSERT 类型 0x01,表名偏移 0x7431); - 重启验证:
./src/miniob,执行select * from t1;—— 输出42,证明 Redo 成功;若之前执行了begin; insert ...;未 commit,则查不到,证明 Undo 生效。
我坚持这个流程教了四年,学生反馈最深的一句是:“原来 ACID 不是概念,是page->lsn_ <= log_record.lsn_这行 if 判断。”
希望帮到你。
本文还有配套的精品资源,点击获取