
简介基于C的MiniOB数据库管理系统源码包是OceanBase与华中科技大学联合打造的入门级数据库内核实践项目面向在校学生及初涉数据库内核的开发者。项目简化了并发与安全实现聚焦建库建表、记录增删改查、条件扫描、索引管理等核心模块适合通过动手训练快速理解数据库内核的模块划分与协作机制并体会一条SQL从解析到执行所经过的存储与索引链路。压缩包共352个文件主体为118个h头文件与101个cpp源文件覆盖bplus_tree、disk_buffer_pool、execute_stage、seda_config等关键实现另含58张png示意图、19个md说明文档、18个result与test文件以及少量脚本和配置文件整体大小约3.14MB目录结构清晰便于按模块定位学习。目前已有118人学习压缩包内含完整工程代码与部分测试输出可作为课程设计、数据库实验或自学内核的参考蓝本帮助读者串联起存储、索引、执行等环节。1. 拿到 MiniOB 源码包后先搞清楚这个 C 数据库到底能干什么MiniOB 是一个用 C 写成的迷你数据库管理系统源码包编译后能得到能执行建表、插入、查询、删除的服务端和配套客户端。对想弄懂一条 SQL 进去之后底层发生了什么的开发者来说它的价值在于麻雀虽小五脏俱全解析、执行、存储、日志全链路都摊在一个桌面项目里。我第一次碰它时就翻了车缺依赖编译不过、端口被占用起不来、改完代码忘了重新编译就反复怀疑人生。回头复盘这些坑都不是项目本身的问题而是没把模块先看清楚就急着动手。这篇笔记按架构是什么 → 本地怎么跑 → 改动怎么验证 → 坑在哪展开适合拿它做实验环境的在校同学也适合准备内核面试、想建立代码级体感的开发者。2. 拆解 MiniOB 的模块骨架一条 SQL 从客户端到磁盘要过几道关拿到源码包先别急着编译。教学数据库的代码量通常不大但目录切得很有讲究。我一般会先花半小时把顶层目录过一遍把网络、解析、执行、存储四个词贴到对应目录上后面遇到问题能少翻很多无用代码。以下内容是常见版本的实现方式具体命名以你手头这份为准。2.1 网络层与会话循环客户端命令是怎么进到内核的MiniOB 采用客户端-服务端结构。服务端启动后监听一个 TCP 端口客户端连上来之后进入收命令-执行-回结果的循环。这类教学项目几乎都做文本协议客户端把 SQL 字符串发过去服务端解析执行后把结果行和状态码返回。协议不搞二进制编码因为目标是把内核链路讲透而不是追求吞吐。定位代码时我习惯先找会话处理类很多版本里它叫 SessionStage。它的职责是接收一个请求、调用执行器、把结果回包相当于数据库的前台窗口// 示意代码会话处理入口的简化形态类名与头文件以源码为准 class SessionStage { public: // 收到一条客户端请求返回执行结果状态码 RC handle_request(const Request req); private: CommandExecutor command_executor_; // 执行器负责分发到具体命令 };看懂这个类能顺便回答一个高频面试问题服务端怎么处理多个连接常见实现有三种每连接一个线程、线程池、单线程串行。教学版为了把复杂度控制在可读范围往往会选每连接一个线程锁竞争集中在存储层。你在源码里搜pthread_create或者线程池相关的目录一眼就能看出这份源码选了哪条路。2.2 SQL 解析与执行器从文本到可执行操作的转换链路解析是很多初学者第一次翻车的地方。常见做法有两种老一点的版本用 flex bison 生成词法和语法解析器改一个关键词要重新生成 C 代码后来的版本为了方便调试改成了手写递归下降解析器。不管哪种最终产物都是同一类东西一棵 AST抽象语法树也就是把 SQL 文本里的表名、列名、条件摘出来装进结构体// 示意select 语句对应的 AST 节点 struct SelectStmt { std::vectorstd::string fields; // select 后面的列名 std::vectorstd::string tables; // from 后面的表名 std::vectorCondition conditions; // where 条件通常是三元组 }; struct Condition { std::string left; // 左值列名或常量 std::string op; // 运算符, , , , , ! std::string right; // 右值 };解析器只负责文本变结构不负责这个操作执行起来快不快。教学版通常把优化器剪裁到只剩一层简单变换有的版本直接不区分物理计划和逻辑计划解析完就交给执行器。执行器的标准写法是按命令类型分发每个命令一个子类// 示意命令分发逻辑 RC CommandExecutor::execute(const SqlNode node) { switch (node.type) { case SqlType::CREATE_TABLE: return create_table_executor_.execute(node); case SqlType::INSERT: return insert_executor_.execute(node); case SqlType::SELECT: return select_executor_.execute(node); case SqlType::DELETE: return delete_executor_.execute(node); default: LOG_ERROR(unsupported sql type: %d, node.type); return RC::UNSUPPORTED; } }我建议拿到源码后先在这个分发处停五分钟把每个分支对应的执行器文件打开扫一眼执行链路的全貌就出来了。想在代码里快速定位用两个 grep 就够# 找会话处理和命令分发的声明位置 grep -rn SessionStage src/observer | head -20 # 找执行器目录下都有哪些命令实现 grep -rln Executor src/observer/sql | head -30注意理解一个边界解析器生成 AST 之后条件里的列名是否真实存在于表里是由执行阶段做语义检查的不是解析器管的。很多新手改代码时把校验逻辑塞进解析器导致解析器和执行器职责混乱后面加功能时改一处崩两处。2.3 存储与日志表、记录、页面与事务的协作存储层是 MiniOB 另一个重点。磁盘上每个表通常对应一个数据文件文件由缓冲池切成固定大小的页page页内再按记录record组织。读数据不是直接 read 文件而是先向缓冲池要页缓冲池负责把最近用过的页留在内存内存不够时按淘汰策略换出。理解到这一层就能明白为什么数据库不能只写 fwrite。记录在页内的组织方式也值得看页头记录已用和空闲的槽位删除记录是标记删除还是物理挪走不同版本取舍不同。标记删除实现简单但会让页内产生空洞物理挪走碎片少却要维护槽位索引。教学版为了简化常见做法是标记删除。事务部分教学版本有两种取舍早期实现用锁 redo 日志撑起最基本的原子性和持久性新一些的实现会引入简化版 MVCC让读写不互相阻塞。打开 trx 目录看几分钟能分清这份源码是锁表还是版本快照就能判断它适合做哪些实验锁表的适合改并发控制MVCC 的适合分析版本清理。3. 在本地把 MiniOB 编译跑通依赖检查、编译脚本与第一条 SQL编译这一步卡住的人最多但九成都是依赖问题。先把环境确认完再动编译能省一整晚。3.1 编译前的依赖检查g、CMake 与 bison/flex 缺一不可编译一个 C 数据库内核工具链就四样编译器、构建系统、make、以及可能用到的解析器生成工具。先一次性确认依赖作用检查命令g编译 C17 源码g --versioncmake生成构建文件cmake --versionmake驱动实际编译make --versionbison/flex老版本解析器生成bison --version / flex --version# 一次性检查四个依赖缺哪个装哪个 g --version cmake --version make --version bison --version逻辑说明第一行确认编译器支持 C17很多老版本 Ubuntu 自带的 g 太旧编译到 lambda 或智能指针相关代码时会报语法错误第二行确认 CMake 版本不能太低教学项目如果声明了较新的 cmake_minimum_required旧版本会在生成阶段直接拒绝第三、四行是解析器相关的如果这份源码用的是手写解析器bison 缺失也能编译过判断依据是看源码目录下有没有 lex.l、yacc.y 这类文件。3.2 用 build.sh 一键编译与手动 CMake 编译的取舍最常见的做法是解压后直接跑源码包自带的构建脚本它内部通常就是建 build 目录 cmake make三步。我喜欢手动 CMake 是因为能控制构建类型Debug 带符号、关优化适合打断点Release 跑得快适合测性能。# 方式一源码包自带脚本最省事 cd miniob bash build.sh # 方式二手动 CMake便于控制构建类型 mkdir -p build cd build cmake .. -DCMAKE_BUILD_TYPEDebug make -j4 # -j 后面的数字按 CPU 核数调整参数说明-DCMAKE_BUILD_TYPEDebug告诉编译器加-g并且不做-O2优化代价是二进制体积大、运行慢但 gdb 里能看清楚每一行变量值-j4是并行编译任务数数核时留一两个给系统比如 8 核机器用-j7比-j8更稳内存小的机器并行数太高会编译到一半被 OOM 杀掉。如果 build.sh 中途失败先删掉 build 目录再重跑不要留着半成品目录追加编译。3.3 启动 observer 并连接 obclient最小可复现运行流程编译成功不等于能跑。服务端启动前先确认两件事配置文件路径对不对、数据目录存不存在。常见做法是启动时用-f指定配置文件服务端读取端口和日志级别数据库文件默认落在配置里指定的目录。# 终端 1前台启动服务端方便直接看日志 cd miniob/build ./observer -f ../etc/observer.ini # 终端 2启动客户端连接本机 ./obclient 127.0.0.1 6789服务端起来后在客户端里依次执行下面这几条验证最基础的读写链路create table t(id int, name char(32)); insert into t values (1, a); insert into t values (2, b); select * from t; exit;参数说明连接命令的第一个参数是服务端 IP第二个是端口端口必须和 observer.ini 里配的一致。配置文件常见的会包含这几类项配置项典型值说明port6789TCP 监听端口改了这里客户端也要跟着改max_connection_num10最大连接数测试时够用log_levelINFO改成 DEBUG 能看到解析和执行的细节日志如果exit不生效可能是这份客户端用的命令是quit在客户端里敲help能看到支持的命令清单。老一些的版本客户端二进制可能叫client而不是obclient以你编译产物里实际生成为准。3.4 先跑一遍回归测试给后续改动留下行为基线改代码之前最重要的事是先确认这份源码自带的测试在编译产物上能通过多少。这决定了后面你改了代码出问题时到底是你的锅还是环境本来就坏。常见的做法是 test 目录下放一组 SQL 用例和一个比对脚本# 回归测试脚本通常在 test 目录下脚本名以源码包为准 cd miniob/test bash run_test.sh # 或 python3 run_test.py跑完注意看两个数字总用例数和通过数。如果通过率不是 100%先记下失败用例的名字多半是环境差异导致比如浮点输出格式、字符集、时间戳。这个基线非常重要我习惯把结果存成一个文件后面每次改完代码都重新跑一遍对比通过数从几变成几比肉眼盯着代码找回归快得多。4. 改第一处 MiniOB 源码给查询执行加上耗时统计并验证跑通只是开始改一处代码并验证行为变化才算真正摸到内核的边。下面这个改动很小但覆盖了定位入口 → 改代码 → 重新编译 → 验证的完整循环。4.1 定位执行入口从会话层到 CommandExecutor 的分发链路先回答一个问题服务端处理一条 SQL入口到底在哪答案是会话层那个接收请求的函数它通常会调用一个命令分发器再根据命令类型转到具体执行器。用 grep 把链路串起来# 定位会话处理和命令分发 grep -rn SessionStage src/observer | head -20 grep -rn CommandExecutor src/observer | head -20找到之后打开对应文件确认调用关系handle_request→command_executor_.execute→ 各子类执行器。这次改动就加在最外层好处是能统计所有 SQL 的耗时而不是只统计某个算子先拿到整条链路的粗粒度数据再决定要不要往下钻。4.2 用 chrono 加耗时统计改动点与参数说明加耗时统计不需要改动任何执行逻辑只用标准库的时钟包一层调用并在日志里输出 SQL 原文方便对照哪条语句慢// 示意在会话处理入口给查询执行加上耗时统计 // 具体头文件与类名以你手头这份源码为准 #include chrono RC SessionStage::handle_request(const Request request) { auto start std::chrono::steady_clock::now(); // 开始计时 RC rc command_executor_.execute(request.sql()); // 真正的执行入口 if (rc ! RC::SUCCESS) { LOG_WARN(execute failed, rc%s, strrc(rc)); } auto end std::chrono::steady_clock::now(); // 结束计时 long cost_us std::chrono::duration_caststd::chrono::microseconds( end - start) .count(); LOG_INFO(query cost%ld us, sql%.128s, cost_us, request.sql().c_str()); return rc; }逻辑说明steady_clock是单调时钟不会因为系统时间被手动修改而跳变比用time()统计耗时可靠duration_castmicroseconds把时间差转成微秒用来观察教学库几百微秒到几毫秒的查询量级正合适。参数说明%.128s是为了截断超长 SQL避免一条大事务把日志刷爆LOG_INFO能不能在日志里看到取决于配置文件里的 log_level 是不是 INFO 或更低。如果跑完看不到这行日志去 observer.ini 里把 log_level 改成 DEBUG 再重启这是排查为什么没日志最常见的解法不是代码没生效。4.3 重新编译与验证日志、返回值和回归测试三件事改完代码必须走一遍完整验证顺序别乱先重新编译再启动服务端然后执行几条 SQL 看日志最后跑回归测试确认没有破坏原有行为。# 清掉旧产物重新编译避免增量编译的缓存陷阱 cd miniob/build rm -rf * # 或者回到源码根目录删掉整个 build 后重新 cmake cmake .. -DCMAKE_BUILD_TYPEDebug make -j4验证点有三个第一服务端前台运行时客户端执行任意 SQL终端或日志文件里应该出现query cost... us这行。拿select * from t和insert into t values (...)分别试能看到插入通常比查询耗时更多因为要写数据文件并触发日志。第二执行一条语法错误的 SQL比如select from t;日志里应该出现execute failed且返回值不是 SUCCESS这能证明你加的失败分支也生效了。第三回到 test 目录重跑回归脚本通过数和改动之前完全一致。这一步是防回归的底线如果通过数掉了说明改动在某个用例上改变了行为先用二分法定位是哪一类 SQL 受影响再回头看代码。5. MiniOB 避坑指南编译、连接、调试中的 5 个高频问题这个项目我见过太多人卡在同一个地方下面五条是按出现频率排的踩坑记录每一条都是先看现象再讲原因最后给解决路径。5.1 编译期报错缺 bison/flex 或 CMake 版本过老现象跑 build.sh 时解析器相关目标报bison: command not found或flex: command not found或者 CMake 还没开始编译就直接提示版本过低。原因老版本源码的解析器是用 flex 和 bison 生成的这两个工具不在 PATH 里就编不过CMake 版本低于项目声明的最低版本时cmake 阶段直接退出连 make 都到不了。解决先装依赖再删掉 build 目录重来。装完之后重开终端确认 PATH 生效不要省这一步。# Ubuntu/Debian 系 sudo apt install -y build-essential cmake bison flex rm -rf build bash build.sh5.2 服务端启动失败端口被占用与数据目录不存在现象observer 起不来日志里出现bind failed或者提示某个数据目录不存在客户端连过去直接拒绝。原因端口被别的进程占了或者当前工作目录不对程序按相对路径找不到配置里指定的数据目录。前者是环境冲突后者是启动姿势问题。解决先看端口占用再确认启动时的工作目录。lsof或ss都行。# 查看 6789 端口被谁占用 ss -anlp | grep 6789 # 杀掉占用进程或者换一个端口并在客户端里同步改有一点很容易忽略observer 对数据目录的判断通常基于当前工作目录所以尽量在 build 目录下启动并且配置文件里的路径用从源码根目录算起的相对路径或绝对路径别从其他目录启动后靠 cd 碰运气。5.3 客户端连不上服务端监听端口和配置文件不同步现象obclient 启动后一直卡在连接阶段或者服务端日志里根本没有新连接记录。原因最常见的不是网络问题而是服务端实际监听的端口和你客户端填的端口不一致。改过 observer.ini 忘了重启服务端或者起了两个 observer 实例后起的那个绑定在别的端口上。解决用ss -anlp | grep observer看服务端实际监听的是哪个地址和端口再去客户端连接命令里对齐。如果本机起了两个实例把多余的杀掉只保留一个。5.4 改了代码不生效构建缓存与旧二进制文件的玄学现象源码改得很确定重新编译也提示成功但运行起来行为还是老样子。原因八成是增量编译没把改动的对象文件重编或者你运行的 observer 根本不是刚编出来的那个。教学库代码改动常常涉及头文件头文件没被依赖追踪到就只重编了当前文件其他引用方还在用旧声明。解决不要信增量编译直接删 build 目录重新全量编译。启动前ls -l看一眼二进制时间戳确认真是刚生成的。rm -rf build bash build.sh ls -l build/observer # 看时间戳是否刚刚更新这条看着像玄学其实是几乎所有教学项目最容易踩的坑。记住一个习惯改动涉及头文件时直接全量重编省下的时间远大于增量编译那几分钟。5.5 运行期段错误用 gdb 与 ASan 快速定位现象执行某条 SQL 时进程直接 core dump终端没有任何业务日志只剩一段退出码。原因多半是解析后的节点没初始化、记录越界或者释放了一个还在使用的页。这种问题在 Release 版下症状最迷惑因为优化把变量布局改掉了。解决用 Debug 构建并打开 AddressSanitizer把内存错误变成带文件名和行号的报告再用 gdb 抓现场。# 开 ASan 重新编译 Debug 版 cd miniob/build cmake .. -DCMAKE_BUILD_TYPEDebug \ -DCMAKE_CXX_FLAGS-fsanitizeaddress -fno-omit-frame-pointer -g make -j4 # gdb 复现崩溃 gdb ./observer (gdb) run -f ../etc/observer.ini # 崩溃后打调用栈 (gdb) btASan 报告会直接指出是哪一行越界、哪一行重复释放配合bt看到的调用栈基本能在十分钟内定位。如果你的源码包自带的 CMake 支持ENABLE_ASAN之类的开关优先用开关而不是手动塞编译参数。6. 把 MiniOB 变成你自己的项目三个进阶方向与验证习惯当编译、运行、改日志这条路走顺之后MiniOB 就不再是别人的作业了。下面三个方向按难度排序任选一个都能做出能写进简历的成果关键是每个方向都配一条验证闭环。方向改动链路难度验证方式支持取模运算符 %词法识别 → AST 节点 → 表达式求值低执行 SQL 验证结果实现简单哈希索引索引存储结构 查询条件匹配高造 10 万行数据对比耗时自动化回归脚本测试用例 预期输出比对低每次改动跑一遍对比通过数取模运算符是理解全链路的最小闭环从一个新 token 进入词法分析到 AST 里多一种条件表达式再到求值函数里实现取模改完你就能把一条 SQL 如何变成计算结果完整串讲出来面试时这是一个很自然的项目亮点。哈希索引方向更有挑战动的是存储层和执行层的接口。改之前先看现有索引或全表扫描的代码确认查询条件派发到哪一层再决定是扩展还是替换。验证时要注意一个陷阱对比带索引和不带索引的耗时之前必须清理数据文件缓存否则缓冲池里存着热页索引优势会被内存命中掩盖跑出加了索引反而更慢的假象。我个人的习惯是每次改动前先打一个干净的基线快照改完跑回归对比通过数再做性能对比时强制加一条清缓存步骤。这个习惯救过我一次——当时改完索引代码没清缓存跑出更慢的假数据差点把正确实现回退掉。数据库内核的学习没有捷径但 MiniOB 把这条路拆成了能一扇扇打开的小门。找个安静的下午从编译到第一条 SQL再到改一行代码你会比读十篇理论文章收获更大。希望帮到你。本文还有配套的精品资源点击获取