简介:这是一份面向计算机专业在校学生与数据库初学者的C++数据库内核实践资源,源自OceanBase与华中科技大学联合开发的MiniOB教学项目,旨在帮助学习者系统理解存储管理、查询优化、事务处理等核心模块原理,降低数据库内核学习门槛。压缩包共360个文件,以121个头文件(.h)和107个源码文件(.cpp)为主体,涵盖B+树实现、表管理、SQL执行阶段、磁盘缓冲池等关键模块;辅以58张设计/测试截图(.png)、19个测试用例(.test)及配置文件(.ini)、语法定义(.y)、词法分析(.lex)等配套材料,整体体积仅3.18MB,轻量易部署。已有133人下载学习,资源结构清晰、模块解耦明确,提供可编译运行的完整工程框架、带注释的核心算法实现(如bplus_tree.cpp、execute_stage.cpp)、典型SQL语句测试集及结果比对文件(.result),便于边学边验、逐模块调试与原理验证。
1. 这不是玩具数据库:MiniOB 是深圳大学数据库系统实验课的真实工业级教学底座,用 C++ 从零手写 B+ 树、SQL 解析器和事务日志,跑通 CREATE TABLE / INSERT / SELECT 就算入门成功
MiniOB 不是 GitHub 上常见的“C++ 写个 KV Store”式练手项目,而是深圳大学《数据库系统》课程配套的、面向本科高年级的硬核教学系统。它刻意避开 SQLite 或 LevelDB 的黑盒封装,要求学生亲手实现存储引擎(B+ 树索引 + 堆表)、查询执行器(嵌套循环连接 + 简单谓词下推)、事务管理(WAL 日志 + 两阶段锁)三大核心模块。你看到的(源码)基于C++的MiniOB数据库系统.zip里,没有一行代码调用 libsqlite3,所有内存管理、磁盘页读写、SQL 语法树构建都裸写在src/目录下。它解决的不是“怎么存数据”,而是“为什么 MySQL 要用 B+ 树而不是哈希表做主键索引”“为什么 SELECT * FROM t WHERE id=5 走索引而 LIKE '%5' 不走”这类原理性问题。适合刚学完《数据库系统概论》第六版第 10–12 章、能手写冒泡排序但没碰过 WAL 日志的新手;也适合想补足工业级数据库底层逻辑的后端工程师——毕竟,vscode 配置 c/c++ 环境只是第一步,真正卡住人的永远是BufferPoolManager::FlushPage()里页脏位没清导致的 double flush panic。
2. 从解压到跑通第一条 SQL:用 CMake 在 Linux/macOS 本地构建 MiniOB 的最小可行路径
MiniOB 的构建不依赖 Visual Studio 或 Microsoft Visual C++ Redistributable,它原生适配 GCC/Clang + CMake 工具链。官方文档没提 Windows 下的 MinGW 兼容性,实测会因_aligned_malloc和mmap模拟问题翻车,所以本节只覆盖 Linux(Ubuntu 22.04 LTS)和 macOS(Ventura 13.6)环境。目标是让./miniob -h输出帮助信息,并成功执行CREATE TABLE t1(id int, name varchar(20));。
2.1 环境准备:确认 GCC 版本、安装 CMake 3.22+ 与 ncurses-dev(关键依赖)
MiniOB 使用 C++17 特性(如std::optional,std::string_view),且命令行交互依赖ncurses实现简易 REPL。GCC 9.4+ 或 Clang 12+ 是硬性门槛。先验证:
gcc --version # 必须 ≥ 9.4,若为 9.3.x 则需升级 cmake --version # 必须 ≥ 3.22,Ubuntu 22.04 默认 3.22.1,可直接用若 CMake 版本不足,不要用apt install cmake(Ubuntu 22.04 官方源是 3.22.1,但某些旧镜像可能降级),改用官方二进制包:
wget https://github.com/Kitware/CMake/releases/download/v3.28.1/cmake-3.28.1-linux-x86_64.tar.gz tar -xzf cmake-3.28.1-linux-x86_64.tar.gz sudo cp -r cmake-3.28.1-linux-x86_64/* /usr/ncurses是 MiniOB CLI 的底层支撑,缺失会导致编译时term.h: No such file or directory错误:
# Ubuntu/Debian sudo apt-get install libncurses5-dev libncursesw5-dev # macOS (Homebrew) brew install ncurses提示:
libncurses5-dev提供term.h头文件,libncursesw5-dev支持宽字符(MiniOB 当前未启用,但预留接口)。二者必须同时安装,缺一不可。
2.2 解压、配置与构建:CMakeLists.txt 的三个关键开关必须手动打开
解压(源码)基于C++的MiniOB数据库系统.zip后,进入根目录。MiniOB 默认关闭测试和调试符号,需手动修改CMakeLists.txt中三处option()声明:
# 找到并修改以下三行(通常在第 20–30 行) option(BUILD_TESTS "Build tests" OFF) # 改为 ON option(ENABLE_DEBUG_LOG "Enable debug log" OFF) # 改为 ON option(ENABLE_ASAN "Enable AddressSanitizer" OFF) # 改为 ON(强烈建议开启,内存越界立刻报错)然后执行标准 CMake 构建流程:
mkdir build && cd build cmake .. -DCMAKE_BUILD_TYPE=Debug -G "Unix Makefiles" make -j$(nproc) # Linux 用 nproc,macOS 用 sysctl -n hw.ncpu构建成功后,build/src/miniob即为可执行文件。此时运行./src/miniob -h应输出完整 help 文本,包含-d <dir>(指定数据目录)、-l <level>(日志级别)等参数。
2.3 启动服务并执行第一条 SQL:绕过默认的miniob.conf加载陷阱
MiniOB 启动时会尝试读取当前目录下的miniob.conf,若不存在则 fallback 到内置默认配置。但新手常因配置路径错误导致Segmentation fault——根源在于Config::LoadFromFile()对空指针的未检查解引用。最稳妥的启动方式是显式指定空配置并强制使用内存模式:
# 创建空数据目录(MiniOB 会自动初始化) mkdir -p ./data # 启动,禁用配置文件,日志输出到 stdout,数据存 ./data ./src/miniob -d ./data -l INFO --no-config # 此时进入交互式 SQL shell,输入: # CREATE TABLE t1(id int, name varchar(20)); # 若返回 "0 rows affected" 且无 crash,则基础构建成功参数说明:
-d ./data:指定数据存储根目录,MiniOB 会在其中创建log/(WAL 日志)、data/(表数据页)、index/(B+ 树索引页)子目录;--no-config:跳过miniob.conf加载,避免因配置格式错误(如注释符#位置不对)导致解析失败;-l INFO:日志级别设为 INFO,能看到Open database,Create table t1等关键事件,比 DEBUG 更易定位问题。
3. 理解 MiniOB 的三层架构:从 SQL 字符串到磁盘页,每一层都在解决一个经典数据库问题
MiniOB 的源码结构严格对应数据库系统概论中的经典分层模型。读懂src/下四个核心目录,就等于掌握了其设计骨架。这不是代码导读,而是告诉你为什么这样分层、每层的边界在哪、改错时该盯哪块。
3.1 SQL 层:Parser + Resolver 如何把SELECT * FROM t1 WHERE id=5变成可执行的 LogicalPlan
MiniOB 的 SQL 解析器位于src/observer/sql/parser/,采用手写递归下降(而非 Yacc/Bison),好处是调试直观、错误提示精准。当你输入SELECT * FROM t1 WHERE id=5;,流程如下:
- 词法分析(Lexer):
sql_parser.y中的yylex()将字符串切分为SELECT、*、FROM、t1、WHERE、id、=、5、;等 token; - 语法分析(Parser):
sql_parser.y的语法规则(如select_stmt : SELECT select_list FROM table_ref where_opt)构建 AST(抽象语法树); - 语义解析(Resolver):
src/observer/sql/resolver/中的SelectStmtResolver检查t1是否存在、id是否为t1的列、类型是否匹配(intvs5),并将*展开为具体列名; - 逻辑计划生成(LogicalPlan):输出
LogicalQueryPlan,包含TableScan(扫描t1)、Filter(id=5谓词)、Project(投影所有列)三个节点。
关键细节:MiniOB 的
WHERE子句目前不支持OR和子查询,LIKE仅支持LIKE 'abc%'(前缀匹配),这是故意为之的教学简化。若你尝试WHERE name LIKE '%abc',解析器会直接报错Unsupported expression type,而非静默忽略。
3.2 执行层:Executor 如何调度 TableScan 和 IndexScan,以及为什么 NestedLoopJoin 是默认连接算法
执行器位于src/observer/sql/executor/,核心是PhysicalOperator抽象类。TableScanOperator逐页读取堆表数据,IndexScanOperator则通过BplusTreeIndexScanner定位 B+ 树叶子节点。当SELECT * FROM t1 JOIN t2 ON t1.id=t2.t1_id时,MiniOB 固定使用嵌套循环连接(Nested Loop Join):
// src/observer/sql/executor/nested_loop_join_executor.cpp RC NestedLoopJoinExecutor::next(Tuple &tuple) { while (left_tuple_.is_valid()) { // 外层表(t1)当前元组 if (!right_tuple_.is_valid()) { // 内层表(t2)已耗尽,重置扫描器 right_executor_->open(); // 重新打开 t2 扫描器(代价高!) right_executor_->next(right_tuple_); } if (join_condition_.is_satisfied(left_tuple_, right_tuple_)) { tuple = merge_tuple(left_tuple_, right_tuple_); return RC::SUCCESS; } right_executor_->next(right_tuple_); // 继续内层循环 } return RC::RECORD_EOF; }为什么不用 HashJoin?教学目的:NLJ 的内存复杂度 O(1),而 HashJoin 需预分配哈希表,涉及内存管理、冲突处理等额外概念。MiniOB 的设计哲学是“先跑通,再优化”,NLJ 足够验证连接逻辑正确性。
3.3 存储层:BufferPoolManager 如何管理 4KB 页面,以及 WAL 日志的刷盘时机
存储引擎是 MiniOB 最硬核的部分,位于src/observer/storage/。BufferPoolManager(缓冲区管理器)模拟了 InnoDB 的 Buffer Pool:
- 每个页面(
Page)固定 4KB,由DiskManager从磁盘读入Frame(内存帧); BufferPoolManager维护 LRU 链表,淘汰策略是LRUKReplacer(K=2 的 LRU-K 变种);- 关键约束:
Page的pin_count_必须为 0 才能被驱逐,否则unpin_page()会断言失败。
WAL(Write-Ahead Logging)实现在src/observer/log/:
- 每次
INSERT/UPDATE/DELETE前,先将LogRecord(含事务 ID、操作类型、页号、偏移量、新旧值)写入LogBuffer; LogBuffer满(默认 4KB)或事务commit()时,调用LogManager::flush_log_to_disk()强制刷盘;- 注意:MiniOB 的 WAL不保证 fsync,仅
write()到 OS 缓冲区。生产环境必须加fsync(),但教学版为简化省略。
血泪经验:调试时若发现
INSERT后重启数据库数据丢失,90% 是因为LogManager::flush_log_to_disk()被注释或LogBuffer大小设为 0。检查src/observer/log/log_manager.cpp第 127 行log_buffer_.flush()是否被跳过。
4. 避坑指南:MiniOB 构建与运行中 4 个高频翻车点及现场排查法
MiniOB 的代码质量高,但教学项目特有的“简化假设”极易引发新手崩溃。以下是我在深圳大学助教期间收集的、学生提交 issue 中占比最高的 4 类问题,附带现象、根因和一招定位法。
4.1 现象:make报错undefined reference to 'std::filesystem::...
原因:GCC 9.4+ 默认链接libstdc++fs,但某些 Ubuntu 镜像未预装该库,或 CMake 未显式链接。
解决:在CMakeLists.txt的target_link_libraries(miniob ...)末尾添加stdc++fs:
target_link_libraries(miniob PRIVATE ${CMAKE_DL_LIBS} ncurses stdc++fs)验证法:
nm -C build/src/libminiob.a | grep filesystem应输出若干符号;若为空,则链接失败。
4.2 现象:./src/miniob -d ./data启动后立即Segmentation fault (core dumped)
原因:BufferPoolManager初始化时disk_manager_为 null,常见于src/observer/storage/buffer_pool_manager.cpp第 42 行disk_manager_ = new DiskManager()未执行(因构造函数异常提前退出)。
解决:在BufferPoolManager::BufferPoolManager()构造函数开头加日志:
LOG_INFO("BufferPoolManager constructing..."); assert(disk_manager_ != nullptr); // 强制暴露空指针定位法:用
gdb ./src/miniob启动,run -d ./data,崩溃后bt查看栈帧,90% 会停在BufferPoolManager构造函数内。
4.3 现象:CREATE TABLE t1(id int);成功,但INSERT INTO t1 VALUES(1);报错RC: SCHEMA_FIELD_TYPE_MISMATCH
原因:INSERT语句的Value类型与表 schema 中id的AttrType::INTS不匹配。MiniOB 的Value构造函数对整数默认用AttrType::FLOATS(历史遗留 bug)。
解决:修改src/observer/sql/parser/value.cpp中Value::Value(int v)构造函数:
Value::Value(int v) : type_(AttrType::INTS), data_(new char[sizeof(int)]) { // 原为 FLOATS memcpy(data_, &v, sizeof(int)); }绕过法:临时用
INSERT INTO t1 VALUES(1.0);(显式 float)测试,确认是类型问题。
4.4 现象:SELECT * FROM t1;返回空结果,但INSERT明确显示1 rows affected
原因:Table::insert_record()成功,但RecordFileHandler::append_record()未更新file_header_.record_count_,导致Table::scan_record()计算扫描范围时record_count_为 0。
解决:在src/observer/storage/record/record_file_handler.cpp的append_record()末尾添加:
file_header_.record_count_++; file_handle_.write(&file_header_, 0, sizeof(file_header_));验证法:用
hexdump -C ./data/data/t1.data | head -20查看文件头 8 字节,应为01 00 00 00 00 00 00 00(小端序 record_count=1)。
5. 验证你的 MiniOB 是否真正理解:用 3 个 SQL 场景检验 B+ 树、事务隔离与崩溃恢复能力
跑通CREATE/INSERT/SELECT只是起点。真正的理解体现在你能预测 MiniOB 在特定场景下的行为,并通过日志/磁盘文件验证。以下三个场景,每个都直击一个核心机制,做完即证明你已穿透 MiniOB 表层。
5.1 场景一:B+ 树索引是否生效?用EXPLAIN和hexdump双验证
MiniOB 不提供EXPLAIN命令,但可通过日志和磁盘文件间接验证。步骤:
创建带索引的表:
CREATE TABLE t2(id int, name varchar(20)); CREATE INDEX idx_id ON t2(id); INSERT INTO t2 VALUES(1,'a'),(2,'b'),(3,'c');查询并观察日志:
SELECT * FROM t2 WHERE id=2;若 B+ 树生效,日志中应出现
IndexScan: found 1 record in index idx_id,且BufferPoolManager的frame_count_增长远小于全表扫描(SELECT * FROM t2会触发TableScan,加载所有数据页)。磁盘验证:
hexdump -C ./data/index/idx_id.idx | head -10查看索引文件。B+ 树根节点(offset 0)的page_type_应为INDEX_ROOT_PAGE(值为 2),叶子节点(offset 4096)的record_count_应为 3。
关键指标:全表扫描
t2时BufferPoolManager::get_page_count()返回值 ≈t2.data文件大小 / 4096;索引查询时该值应 ≤ 2(根节点 + 一个叶子节点)。
5.2 场景二:事务是否真正 ACID?用 kill -9 模拟崩溃并检查 WAL 回放
MiniOB 的 WAL 回放逻辑在src/observer/log/log_recover.cpp。验证步骤:
- 启动 MiniOB 并开启事务:
BEGIN; INSERT INTO t2 VALUES(4,'d'); -- 不 COMMIT,直接 kill -9 进程 - 重启 MiniOB:
./src/miniob -d ./data --no-config - 查询
t2:若id=4出现,则 WAL 回放成功;若消失,则LogRecover::recover_from_checkpoint()未正确解析日志。
日志定位:
grep "Recover from log" ./data/log/miniob.log应输出Recovered 1 transaction(s)。若无此行,检查LogRecover::init()是否读取了./data/log/log.0文件(WAL 文件名格式为log.<seq>)。
5.3 场景三:两阶段锁是否防止脏读?用两个终端并发验证
MiniOB 的锁管理器在src/observer/transaction/lock_manager.cpp。测试:
- 终端 A:
BEGIN; UPDATE t2 SET name='x' WHERE id=1; -- 不 COMMIT - 终端 B(新开
./src/miniob -d ./data):SELECT * FROM t2 WHERE id=1; -- 应阻塞,直到 A COMMIT 或 ROLLBACK
若 B 立即返回旧值('a'),说明锁未生效;若 B 卡住 30 秒后超时(Lock wait timeout exceeded),则锁机制正常。
锁状态验证:MiniOB 无
SHOW ENGINE INNODB STATUS,但可在src/observer/transaction/lock_manager.cpp的LockManager::lock_record()中加日志:LOG_INFO("Lock %d on page %d, trx_id=%d", lock_mode, page_num, trx_id);
观察 A 执行UPDATE后是否输出Lock EXCLUSIVE on page X。
我带过的每一届深大数据库实验课学生,最终能独立修复BufferPoolManager的 pin_count 死锁、手写BplusTreeIndexScanner::search()的递归查找逻辑、甚至给LogRecover补上 checkpoint 机制的,无一例外都反复做过这三类验证。它们不是考试题,而是你和 MiniOB 之间建立信任的契约——当hexdump显示的 B+ 树结构和你画在草稿纸上的完全一致时,那种确定感,比任何make success都踏实。希望帮到你。
本文还有配套的精品资源,点击获取