☰
C++手写数据库内核:MiniOB从源码到实战全解析
2026/10/13 18:13:28 网站建设 项目流程

简介:基于C++的MiniOB数据库系统压缩包提供完整源码,MiniOB由OceanBase与华中科技大学联合开发,是面向在校学生的数据库内核入门实践工具。项目围绕存储管理、查询优化、事务处理等模块设计,并简化并发等复杂环节,适合初学者通过源码理解数据库内核的工作机制;同时支持SELECT、INSERT、UPDATE、DELETE等基础SQL语句解析执行,以及INI配置解析、文件读写等实用功能。资源共360个文件,核心代码以C++为主,含121个h头文件、107个cpp源文件,并辅以58个png示意图、19个md说明文档及多组test/result测试用例等,包体大小约3.18MB,目录结构清晰,便于按模块对照学习。目前已有133人浏览学习,对希望动手实践数据库内核的开发者而言,是一份精简且可编译、可扩展的入门参考。

1. C++ 写一个数据库系统,先从这个 MiniOB 源码包开始

用 C++ 写一个数据库系统,听起来像大厂资深工程师才能碰的事。但 MiniOB 这个项目把门槛拉到了普通学生面前:它是 OceanBase 团队开源的教学数据库内核,用 C++ 实现,代码规模控制在万行级,目标是让你亲手把 SQL 解析、缓冲池、B+ 树索引、执行器、事务这些教科书概念落成能编译、能调试、能跑真实 SQL 的系统。这个 zip 解决的是三类诉求:数据库课程设计拿不出像样的交付、学完数据库系统概论依然说不清一条 select 到底怎么执行、面试被追问索引原理时只能背八股。适合有一学期 C++ 基础、愿意花一个学期啃代码的人。别指望解压后双击能跑,但按下面这套路径走,两天内能见到第一个属于自己的数据库进程。

2. 先拆代码地图:MiniOB 的核心模块与一条 SQL 的旅程

拿到一个数据库系统的源码,最蠢的打开方式就是直接跑构建脚本。你连它是行存还是列存、一条 SQL 经过几个处理阶段都不知道,编译报错也没法定位。我的习惯是花 30 分钟把目录翻一遍,在脑子里建一张“代码地图”,再动手。

2.1 核心模块对照教科书:每个目录对应数据库系统概论的一个章节

MiniOB 的源码结构非常“教学”,模块划分基本照着经典数据库教材的章节走。第一块是 SQL 解析,词法分析器和语法分析器负责把字符串变成语法树,这对应教科书里“关系系统的查询处理”前半段。第二块是执行器,负责把语法树变成可执行的算子链,对应查询执行那段。第三块是存储管理,包括缓冲池和记录管理,对应“数据库物理存储”章节。第四块是 B+ 树索引,对应索引那一章。第五块是事务与并发控制,对应事务章节。

这五块在源码里就是几个目录的事。数据库系统概论第六版里分五章讲的东西,被 MiniOB 压缩在一个工程里,而且每块之间的调用关系是显式的。你不一定要每个目录都读透,但必须知道它们在哪。拿到包后第一件事就是在src/observer下面找storage、sql、session这几个目录,它们分别是存储引擎、SQL 处理链、会话管理的地盘。

还有一个容易忽略的目录是事件调度相关的代码,比如event。MiniOB 把一次请求拆成多个处理节点,这种设计是工业数据库常见的做法,好处是每一段的职责单一,坏处是刚看源码的人容易绕晕。我的建议是先别管调度框架本身,把注意力放在“一条 SQL 从进入到返回经历了哪些处理节点”上,这一步走通,整个项目就通了一半。

2.2 一条 insert 从客户端到磁盘经历了什么

我们在客户端敲下一条insert into t values (1, 'a'),在 MiniOB 里的旅程大致是这样的。客户端把字节流发到服务端,网络层把字节流还原成 SQL 字符串,然后交给会话层。会话层把这个字符串丢给解析器,解析器用词法分析拆 token、用语法分析按文法规则组装成一棵抽象语法树。紧接着语义分析阶段把语法树里的表名、字段名和实际表结构绑定起来,字段类型不匹配、表不存在这类错误在这一层被拦下。

绑定完成之后进入优化和执行阶段。优化器判断这条语句能不能走索引、条件的下推方式,执行器按“火山模型”一层层拉取数据。到了存储层,缓冲池先看目标页在不在内存,不在就把磁盘页加载进来,然后在页里找空闲槽位,写入字段值,把页标记为脏页,最终返回成功。整个路径从内存到文件系统,再到磁盘,每一层都在前一层的基础上加了一点东西。

这就是为什么考试背“查询处理五步骤”总是记不住。你背的时候没有一条真实的 SQL 在脑子里走完全程,自然记不牢。MiniOB 的价值就在于,这条链路的每一段你都能用调试器走一遍,断点一打,教科书上的流程图就变成真实的调用栈了。

2.3 别急着构建,先用 grep 把主链路捞出来

很多同学喜欢在 vscode 里同时打开整个源码包,从第一个文件开始读,读十行就放弃。正确姿势是先把处理链路的入口函数名找出来,再用调试器定位。

unzip -q MiniOB.zip -d ~/miniob && cd ~/miniob # 主链路通常由事件调度框架串起来,stage 后缀的函数名就是断点位置 grep -rn "derive\|execute\|parse" src/observer/event --include=*.cpp | head -40

这段命令的逻辑是:.zip解压后进入根目录,然后直接搜事件调度目录里带parse、execute等关键词的函数,把这些函数名当作整个主链路的“路标”。参数说明:-rn表示递归搜索并显示行号,--include=*.cpp限定只看 C++ 源文件,head -40只取前 40 行防止输出太长。搜完以后你通常能看到类似parse_stage、execute_stage这样的处理节点名,每个名字就是一个可以下断点的位置。

这一步花不了几分钟,但价值很高。它把“读源码”这个模糊任务变成了“沿着主链路读 5 个关键函数”的具体任务。数据库系统的黑匣子,就是从这些带stage后缀的函数逐步打开的。

3. 从 zip 到能跑的数据库:构建、启动与第一次建表

代码地图有了,现在开始让这个系统真正跑起来。MiniOB 的构建环境要求不复杂,但有几个前置条件必须满足,否则编译过程会非常折磨。

3.1 构建前置:别在 Windows 上硬刚,用 Linux 或 WSL

MiniOB 的构建依赖 g++、make、cmake、flex 和 bison。其中 flex 和 bison 是解析器生成工具,用来把语法描述文件转换成 C++ 代码,这两个工具在 Windows 原生环境下的安装体验非常差。如果你只有 Windows 机器,常见做法是装 WSL,然后在 WSL 里操作。vscode 配置 C/C++ 环境时注意一点:C/C++ 插件的编译器路径要指向 WSL 里的 g++,而不是 Windows 的编译器,否则智能提示和实际编译环境不一致,头文件路径会一片飘红。

sudo apt update sudo apt install -y build-essential cmake flex bison flex --version && bison --version

第一行更新软件源,第二行一次性安装编译工具链和解析器生成器,第三行验证版本。build-essential包含 g++、gcc 和 make,cmake负责构建系统生成。注意 flex 和 bison 这两个包缺一不可,漏掉的话,编译到解析器部分会直接报找不到命令。

3.2 用 build.sh 编译 debug 版并启动 observer

源码包根目录通常自带构建脚本。常见做法是用bash build.sh debug编一个调试版本,因为 debug 版默认带符号表、关闭了部分优化,还开了更严格的断言检查,对后续用 gdb 跟踪执行路径几乎必不可少。release 版虽然性能好,但调试信息少,定位问题很难受。

cd ~/miniob bash build.sh debug ls build/bin cd build/bin ./observer -f ../../etc/observer.ini & sleep 1 tail -f ../../log/observer.log

ls build/bin是为了确认产物生成成功,里面一般能看到一个服务端程序observer和一个客户端程序client。启动服务端时-f指定配置文件路径,配置文件里关键是端口、日志级别、缓冲池大小这些参数。&让进程在后台运行,sleep 1等一下再查看日志,tail -f持续跟踪日志输出,确认进程正常起来没有报错。

如果启动失败,第一时间看日志而不是猜。端口占用、配置路径错误、数据目录权限不足都会在日志里留下线索。这一步踩的坑,我会在第 5 章专门展开。

3.3 第一次建表:create、insert、select 对应你刚看过的模块

服务端起来以后,另开一个终端连接客户端。这个客户端是一个命令行交互程序,连上以后可以直接敲 SQL,和操作 MySQL 的感觉很像。

cd ~/miniob/build/bin ./client -p 6789 # 在客户端交互界面逐行执行: # create table t (id int, name char(20), score float); # insert into t values (1, 'Alice', 90.5); # select * from t;

-p 6789指定服务端端口,默认配置下服务端监听 6789。建表语句里char(20)是定长字段,内部按固定 20 字节存储,不是按实际字符长度动态分配;float固定 4 字节。这条建表语句执行后,MiniOB 会在数据目录创建一个对应表结构的文件,字段元信息被持久化。

第一次跑通这三条 SQL,意味着你刚才在代码地图里看到的所有模块——解析器、执行器、存储层、页管理——已经以一个完整系统的形式运转起来了。接下来再往深处走,才是真正拉开差距的部分。

3.4 想改 SQL 语法?parser 重编译的正确姿势

跑到这一步,很多人的第一个想法是“给 MiniOB 加一条自己的 SQL 语法”。这个想法非常好,但会提前踩到一个经典坑。MiniOB 的解析器由.y文件描述语法规则,bison 根据它生成对应的 C++ 文件。问题是:如果你只改了.y文件,然后直接重新构建,增量构建很可能不认账,生成文件不被重新编译,你新增的语法毫无反应。

cd ~/miniob # 修改 parser 目录下的 *.y 语法文件之后 # 手动清理生成的 parser 产物,避免 make 增量构建不重建 rm -f src/observer/sql/parser/*tab.c src/observer/sql/parser/*tab.h bash build.sh debug

rm -f删除 bison 生成的 tab 文件,后面的bash build.sh debug全量重新生成。这里的关键理解是:.y文件是语法规则的“源代码”,tab 文件是生成物,Make 对生成依赖的追踪有时不够可靠。手动删除生成物,强制构建系统重新生成一遍,是解析器开发里的标准操作。

这一步做完,MiniOB 就不再是一个只能跑现成 SQL 的玩具了,它变成了你可以“改方言”的数据库系统。语法加在哪、执行逻辑怎么写,这些问题会带着你进入下一章的核心实现。

4. 存储与索引的最小实现:页、B+ 树和事务的一次看懂

现在到了整个项目最硬的部分。跑通 SQL 只是热身,真正决定你能否自信地说“我懂数据库内核”的,是存储与索引这层。这章我会用教学简版的代码示意把关键结构讲清楚,重点在理解设计逻辑,不是逐行背源码。

4.1 记录怎么放在页上:定长槽位与偏移

数据库存储的最小单位是页,MiniOB 的默认页大小通常是 4096 字节。一张表的所有记录被切分到若干页里,每页内部有页头和槽位区。教学版常见的设计是定长记录:插入记录时计算好每条记录占多少字节,然后顺序放进页里。

// 教学简版:定长记录页头与槽位示意 struct PageHeader { int32_t page_no; // 物理页号,对应文件内的偏移索引 int32_t slot_cnt; // 页内已有记录槽个数 int32_t free_off; // 空闲空间起点偏移 }; struct TableSlot { int32_t offset; // 记录在页内的偏移 int32_t length; // 记录长度 };

page_no是这个页在表文件里的编号,MiniOB 找页靠它。slot_cnt记录页里已经存了几条数据,free_off指向下一个空闲写入位置。TableSlot让记录可以做到逻辑连续、物理不一定连续,删除一条记录时不需要搬动后面的数据,直接把槽位标记为可用即可。

为什么教学版偏向定长记录?因为变长记录要处理碎片回收、记录搬迁、槽位复用,复杂度会指数上升。你建表时写的char(20)、int、float都是定长类型,行大小一次算定,页里能放多少条记录在表结构创建时就知道。如果你想验证这一点,可以建一张字段很多的表,插入满一页的数据后观察文件大小变化,能直观体会页与槽的概念。

4.2 Buffer Pool:数据库为什么不直接读文件

如果每次读取都走操作系统的文件接口,数据库的性能会有巨大损耗。核心原因有两个:一是系统调用和磁盘 I/O 的固定开销高,二是数据库需要自己控制页的淘汰策略,而不是依赖操作系统的 LRU。所以 MiniOB 内部维护了一个缓冲池,所有页的读写都经过它。

// 教学简版:缓冲池里的帧结构 class Frame { public: char data[4096]; // 页的内容 int32_t pin_count; // 正在使用这个页的线程数 bool dirty; // 是否被修改过,脏页需要回写 int64_t last_used; // 最近访问时间戳,LRU 淘汰用 };

data是页内容的内存镜像,文件里读到什么就放这里。pin_count告诉缓冲池这块页“有人正在用”,正在用的帧不能被淘汰。dirty标志位的存在意味着,页被修改后不会立刻写盘,而是等淘汰时或安全检查点再回写,这就是经典的延迟写策略。last_used用于 LRU 淘汰,每次访问刷新这个时间戳,淘汰时找最早的。理解这几个字段,等于理解了所有数据库缓冲池的共同骨架。

MiniOB 在这种设计上加了自己的简化:替换策略直接,不处理极其精细的并发竞争,但这不妨碍你理解核心机制。真正重要的是,从这里能很自然地推导出数据库“为什么需要 checkpoint”——因为脏页越积越多,系统宕机后恢复的代价就越大。这个认知比死记硬背定义要值钱得多。

4.3 B+ 树索引:找到记录位置再回表

索引是数据库系统概论里最容易被抽象描述搞晕的部分。MiniOB 的 B+ 树实现把“索引到底长什么样”还原成了一段可读的代码:从根节点出发,按键值比较逐层下沉,到达叶子节点后,叶子节点保存的是键值和一条记录的物理位置——行号。拿到这个行号后,再去对应的页里读取完整记录,这个动作叫“回表”。

插入时的分裂逻辑是 B+ 树的难点,我用一个表把三种场景理清:

插入时空间情况动作需要修改的页
叶子未满直接插入并保持键序叶子页
叶子满、父节点未满叶子分裂,新键插入父节点叶子页 + 父页
叶子满、父节点也满叶子分裂、父节点分裂,逐层向上传播叶子页 + 父页 + 祖父页,甚至根

这个表值得反复看。大多数人在 B+ 树上翻车,不是因为不懂“分裂”这个概念,而是没搞清分裂后父节点应该插什么键、根节点什么时候长高。MiniOB 的调试器可以帮上大忙:在插入代码路径上下断点,逐页打印节点内容,你能亲眼看到一次插入如何把某个页挤爆,然后触发新节点诞生。

要特别记住“回表”这个动作。MiniOB 的索引结构里,叶子节点存的是记录位置,不是字段内容本身。这意味着走索引不一定总比全表扫描快——如果需要读取的行数很多,回表的随机 I/O 代价可能超过顺序扫描。这个认知直接引出第 5 章的一个高频坑:小表上建了索引,查询却不用。

4.4 事务与 MVCC:教学版是怎么简化并发控制的

事务这块,MiniOB 做的是简化版的多版本并发控制。核心思想是:每个事务获得一个递增的事务 ID,记录的每一次修改都记录下事务 ID,读取数据时通过比较事务 ID 判断这条记录对当前事务是否可见。这样读写互不阻塞,读事务看到的是一个一致性的快照。

实际教学版的实现比完整 MVCC 简单得多:没有完整的 redo/undo 日志体系,没有事务隔离级别的完整体面,但骨架是真实的——begin 开始一个事务,commit 让修改生效,rollback 回滚。用调试器跑一下begin; insert; rollback; select;这个序列,比看十遍理论都清楚。

MiniOB 在这里的定位不是“把 InnoDB 重写一遍”,而是让你能用第一行代码理解事务的本质。面试时被问“MVCC 怎么实现”,你能从读视图、事务 ID、可见性判断这三个词讲出逻辑,这个项目的教学使命就算完成了。

5. MiniOB 避坑指南:从编译失败到查询不报错但结果不对

跑通系统之后,真正的成长从踩坑开始。我把自己和其他人在这类教学数据库项目上踩过的高频坑,整理成五条,每条按现象、原因、解决的顺序写。这些坑的具体形态可能因代码版本有别,但底层逻辑是通用的。

5.1 bison/flex 生成 parser 时编译不过

现象:构建过程在解析器部分报错,报错信息指向某段带yacc、lex字样的文件,或者报一堆“本次生成的代码里缺头文件”。

原因:flex 和 bison 是解析器生成工具,生成出来的 C++ 代码对工具版本有一定敏感性。系统里这两个工具没装全,或者版本组合差异较大,生成代码的函数签名与项目原有头文件对不上,编译就断了。

解决:先确认工具确实装了,flex --version && bison --version都要能输出。如果版本差异太大,用 apt 安装发行版自带的版本,优先匹配系统源里的稳定版,不要手动从源码编译最新版。最土但最有效的办法是:彻底删除构建目录,从零重新执行构建脚本,排除增量编译搅局。

5.2 改了语法文件却不生效

现象:修改.y文件新增了一条语法规则,重新构建后执行新语法,服务端还是报 “unknown syntax” 或类似错误。

原因:增量构建系统对.y文件的依赖追踪不可靠。bison 生成的文件是构建产物,Make 判断它“没有变化”时就不会重新编译,你改的规则根本没进入二进制。

解决:改动.y文件后,手动删除生成的 tab 文件和对应的对象文件,再完整重新构建。不要靠touch碰运气,直接删干净最省心。这个坑执行十次能踩八次,遇到“我明明改了为什么不生效”的头疼时刻,先想这一步。

5.3 client 连不上 observer

现象:启动 client 后提示Connection refused或者连接超时,无论如何都进不去交互界面。

原因:最常见的两种情况——observer 没有正常启动,或者端口不是你以为的那个。如果同时启动过多个 observer 实例,或者上一次的进程残留没杀干净,新进程可能没有绑定端口,也可能绑到了别的端口。

解决:不要盲猜,先查客观证据。ps aux | grep observer看进程在不在;netstat -tlnp | grep 6789看端口被哪个进程监听;然后看一眼日志——日志永远比错误提示更接近真相。如果端口确实被占,杀掉旧进程重启;如果进程没起来,回第 3 章的启动步骤查配置路径。

5.4 中文与超长字符串插入后乱码或截断

现象:插入中文字符串后,查询出来是乱码,或者只写入了一部分内容。

原因:char(n)在 MiniOB 这类定长存储里按字节计数的概率很大,而 UTF-8 编码下一个中文占 3 字节。你建表时写char(10),想存 10 个汉字,实际需要 30 字节的空间,字段长度直接不够。另一种情况是终端和客户端的字符编码不一致,问题不在存储层而在交互层。

解决:前期调试尽量用纯 ASCII 数据,避免把编码问题混入存储问题。如果确实需要中文业务,建表时把字段宽度按最大字节数预留,而不是按字符数预留。区分“截断”和“乱码”是两类问题:截断是存储层长度不够,乱码是编码转换问题,排查方向完全不同。

5.5 建了索引但查询还是全表扫

现象:表上建了索引,执行带等值条件的选择查询,性能没有提升,日志显示还是全表扫描。

原因:优化器选择索引是有条件的。条件字段与索引键完全匹配才能走 IndexScan;条件表达式里如果套了函数、做了运算,索引就失效;还有一个反直觉的情况——表数据很少时,全表扫描的顺序读可能比索引回表更快,优化器选了它认为更优的策略。

解决:先看执行计划或执行日志,确认到底有没有走索引;然后把索引建在查询最频繁的等值条件字段上,不要给选择性很低的字段建索引。想用这个现象深化理解是极好的机会:把一条走全表扫的查询,用调试器跟一遍执行器的算子调用顺序,你就能直观看到 MiniOB 何时构造 TableScan 算子、何时构造 IndexScan 算子,这比背优化器规则管用得多。

6. 把 MiniOB 变成简历上能讲的项目:三个往上走的实验

系统跑通、坑也踩过一轮之后,MiniOB 距离“你自己的项目”还差一次主动改造。我推荐三个进阶实验,难度递增,全部做完,你对数据库的理解会比现在高出一个量级。

实验一:给 MiniOB 加一个count(*)聚合函数。这个实验动到两个层:解析器里的函数语法、执行器里的聚合算子。做完以后能讲清楚一个非常关键的问题——聚合是在哪一层发生的,以及它为什么必须做成“先拉数据、再聚合”的模式。这个问题的答案和教科书上“聚合操作在查询执行阶段”这句话之间的距离,就是动手与不动的区别。

实验二:用 gdb 从客户端到存储层跟完一条 select。断点打在解析完成后、执行开始前的那个处理节点上,然后一步步看算子调用链。花一个周末画一张执行树出来。从那之后,面试被问“一条 select 怎么执行”,你可以讲到第二层以上,带着调用栈讲,而不是带着八股讲。

实验三:给缓冲池加一个脏页率统计接口。在页标记为脏和回写的地方各放一个计数器,分别跑顺序插入和随机插入,你会看到脏页率的巨大差异。这个实验不做,你可能永远理解不了 checkpoint 为什么存在于每一个真实数据库里。

我当年啃完这三个方向之后,最大的教训是:数据库这个领域没有玄学,任何看似复杂的概念最终都会落到某一行代码上。你踩过的坑不是浪费时间,是比读十遍书都牢的 debug 记忆。把这套路径走完,这台 MiniOB 就不只是网上下载的源码包,而是你简历上有技术细节可讲、有真实问题可聊的项目。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询