ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MiniOB数据库教学系统:C++手写B+树与WAL日志实战指南

MiniOB数据库教学系统:C++手写B+树与WAL日志实战指南 简介这是一份面向计算机专业在校学生与数据库初学者的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 id5 走索引而 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模拟问题翻车所以本节只覆盖 LinuxUbuntu 22.04 LTS和 macOSVentura 13.6环境。目标是让./miniob -h输出帮助信息并成功执行CREATE TABLE t1(id int, name varchar(20));。2.1 环境准备确认 GCC 版本、安装 CMake 3.22 与 ncurses-dev关键依赖MiniOB 使用 C17 特性如std::optional,std::string_view且命令行交互依赖ncurses实现简易 REPL。GCC 9.4 或 Clang 12 是硬性门槛。先验证gcc --version # 必须 ≥ 9.4若为 9.3.x 则需升级 cmake --version # 必须 ≥ 3.22Ubuntu 22.04 默认 3.22.1可直接用若 CMake 版本不足不要用apt install cmakeUbuntu 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_TYPEDebug -G Unix Makefiles make -j$(nproc) # Linux 用 nprocmacOS 用 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 id5变成可执行的 LogicalPlanMiniOB 的 SQL 解析器位于src/observer/sql/parser/采用手写递归下降而非 Yacc/Bison好处是调试直观、错误提示精准。当你输入SELECT * FROM t1 WHERE id5;流程如下词法分析Lexersql_parser.y中的yylex()将字符串切分为SELECT、*、FROM、t1、WHERE、id、、5、;等 token语法分析Parsersql_parser.y的语法规则如select_stmt : SELECT select_list FROM table_ref where_opt构建 AST抽象语法树语义解析Resolversrc/observer/sql/resolver/中的SelectStmtResolver检查t1是否存在、id是否为t1的列、类型是否匹配intvs5并将*展开为具体列名逻辑计划生成LogicalPlan输出LogicalQueryPlan包含TableScan扫描t1、Filterid5谓词、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.idt2.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 链表淘汰策略是LRUKReplacerK2 的 LRU-K 变种关键约束Page的pin_count_必须为 0 才能被驱逐否则unpin_page()会断言失败。WALWrite-Ahead Logging实现在src/observer/log/每次INSERT/UPDATE/DELETE前先将LogRecord含事务 ID、操作类型、页号、偏移量、新旧值写入LogBufferLogBuffer满默认 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 默认链接libstdcfs但某些 Ubuntu 镜像未预装该库或 CMake 未显式链接。解决在CMakeLists.txt的target_link_libraries(miniob ...)末尾添加stdcfstarget_link_libraries(miniob PRIVATE ${CMAKE_DL_LIBS} ncurses stdcfs)验证法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_count1。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 id2;若 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若id4出现则 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。测试终端 ABEGIN; UPDATE t2 SET namex WHERE id1; -- 不 COMMIT终端 B新开./src/miniob -d ./dataSELECT * FROM t2 WHERE id1; -- 应阻塞直到 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都踏实。希望帮到你。本文还有配套的精品资源点击获取
返回列表