ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MiniOB源码深度解析:从SQL执行到B+树索引的数据库内核入门

MiniOB源码深度解析:从SQL执行到B+树索引的数据库内核入门

简介:一份基于C++的MiniOB数据库管理系统源码包,源自OceanBase与华中科技大学联合开发的数据库内核实践项目,定位为初学者友好的入门级工具。通过简化并发等模块,读者可快速掌握创建/删除数据库、表记录增删改查、索引建立与查找等核心机制,既适合数据库原理课程设计,也可作为内核源码阅读的起始素材。压缩包包含352个文件,以C++头文件与实现文件为主体,另有Markdown说明文档、test测试脚本、result结果文件及示例图片,整体体积仅3.14MB,便于本地编译和对照调试。目前已有118人学习浏览。深入研读代码,可梳理SQL解析、磁盘缓冲池、B+树索引等模块的协作方式,借助自带测试用例验证功能行为,为后续理解分布式数据库OceanBase的实现打下扎实基础。

1. MiniOB:想搞懂数据库内核,这是起点最低的一份源码

如果你写过两年业务代码,应该能理解那种感觉:SQL写得很顺,却完全不知道一条SELECT在数据库内部到底经历了什么、数据是怎么落到磁盘上的。MiniOB是OceanBase与华中科技大学联合开发的教学版数据库管理系统,源码体量不大,却把数据库内核的主干链路完整串了起来——词法解析、语法分析、执行阶段、B+树索引、磁盘缓冲池,每一层都能直接用gdb跟读。特别是yacc_sql.tab.c、lex.yy.c、bplus_tree.cpp这几个文件,把教科书里的“数据库系统实现”变成了可以打断点的真实代码。适合想入门数据库内核、又不想一上来啃MySQL或PostgreSQL源码的人。

MiniOB的定位是教学工具,并发、安全、事务等重型特性都被刻意简化了,剩下的恰恰是内核最核心的表管理、索引和SQL执行链路。这篇笔记会把这几个模块的代码结构、编译方式、执行流程和常见坑逐一拆开,照着跑一遍基本就入门了。

2. 源码结构与编译:先跑通一个能断点调试的数据库进程

2.1 源码包的文件地图:解析器、执行器和存储三块

拿到压缩包解压之后不要急着敲编译命令,先把文件分清楚。我习惯把MiniOB源码按“前端解析、执行引擎、存储引擎”三块来归类,这样后面跟代码时不会被十几个cpp文件绕晕。

归属文件名职责
前端解析lex.yy.cflex生成的词法分析器,把SQL文本切成一串token
前端解析yacc_sql.tab.cbison生成的语法分析器,把token序列构造成语法树
执行引擎execute_stage.cppSQL语句的执行入口,负责分发到具体操作
执行引擎seda_config.cppSEDA事件驱动架构的配置加载
执行引擎metrics_registry.cpp运行指标注册,便于观察执行状态
存储引擎bplus_tree.cppB+树索引的插入、删除、查找实现
存储引擎disk_buffer_pool.cpp磁盘缓冲池,管理数据页的换入换出
存储引擎table.cpp表结构定义与记录管理
测试辅助bplus_tree_test.cppB+树的独立测试程序,不依赖SQL引擎

这个划分很重要:调试一条SQL时,你会先经过lex.yy.c和yacc_sql.tab.c生成语法树,再跳到execute_stage.cpp处理逻辑,然后才进到table.cpp和bplus_tree.cpp操作真实数据。搞清楚链路顺序,比先读哪个文件都管用。

2.2 编译构建:从源码包到可调试的observer可执行文件

MiniOB的构建方式以CMake为主,源码包里的Flex/Bison生成文件已经是编译好的C文件,所以环境里没有flex和bison也能直接编。我一般这样操作:

cd miniob mkdir -p build && cd build cmake -DCMAKE_BUILD_TYPE=DEBUG .. make -j4

-DCMAKE_BUILD_TYPE=DEBUG这一步是关键。MiniOB如果按默认的Release方式编译,gdb断点会落在奇怪的偏移上,函数被内联后基本没法跟读。DEBUG模式关掉优化,所有源码行号和变量都能准确对应。

make -j4中的-j4是并行编译,4个线程同时编。如果你的机器是8核以上,可以改成-j8,速度会快一些。编译完成后,可执行文件通常生成在build/bin/observer和build/bin/obclient两个路径,前者是数据库服务端,后者是命令行客户端。

这里有个容易翻车的点:如果你拿到的是别人二次改过的源码包,包里新加了.y或.l文件,那就必须自己重新跑一遍bison和flex,CMake不会自动处理这部分。后面避坑章节我会再展开。

2.3 用VSCode配一套能打断点的C/C++调试环境

很多人在Linux服务器上改代码,习惯用VSCode远程开发。给MiniOB配调试环境,核心是launch.json里的program字段要指向observer可执行文件:

{ "version": "0.2.0", "configurations": [ { "name": "MiniOB Debug", "type": "cppdbg", "request": "launch", "program": "${workspaceFolder}/build/bin/observer", "args": [], "stopAtEntry": false, "cwd": "${workspaceFolder}", "environment": [], "externalConsole": false, "MIMode": "gdb", "setupCommands": [ { "description": "Enable pretty-printing for gdb", "text": "-enable-pretty-printing", "ignoreFailures": true } ] } ] }

args留空就行,MiniOB默认监听端口启动。启动后需要先用obclient连接进来,再在observer进程里打断点,这样能看到完整调用栈。如果不连客户端直接run,observer会一直阻塞在等待连接的地方,打断点也观察不到SQL执行的代码路径。

3. 一条SQL的完整旅程:从lex.yy.c切词到execute_stage.cpp分发执行

3.1 词法与语法:yacc_sql.tab.c和lex.yy.c怎么配合

先说清楚这两个文件的关系。lex.yy.c负责把字符串切成token,比如select * from t where id = 1会被切为SELECT、*、FROM、T、WHERE、ID、=、1。但切完的token只是一堆零散的积木,yacc_sql.tab.c负责按文法规则把这些积木拼成一棵语法树。

MiniOB的词法规则里,关键字是不区分大小写的,所以SELECT、select、Select都会被lex.yy.c归一化成一个token类型。这个归一化逻辑在实际执行时省了很多事——语法分析阶段不用再考虑大小写变体。

语法分析器输出的核心数据结构是ParseNode。它用type标记节点类型,用value存具体值,用child指针挂子节点。一条select * from t语句解析完,会生成一个SELECT类型的根节点,下面挂着STAR节点和TABLE_NAME节点。execute_stage直接遍历这棵树就能拿到执行需要的所有信息。

3.2 execute_stage.cpp:SQL执行的分发中枢

语法树构造完成后,控制权交到execute_stage.cpp。这个文件是执行阶段的门面,它接收ParseNode树,然后识别SQL类型并分发到对应的处理函数。伪代码逻辑大致如下:

// execute_stage.cpp 中的核心分发逻辑 RC ExecuteStage::handle_request(Trx *trx, const SqlTask &sql_task) { // 1. 从语法树根节点获取SQL类型 ParseNode *root = sql_task.parsed_sql; switch (root->type) { case NT_SELECT: { // 进入查询处理分支 return handle_select(trx, root); } case NT_INSERT: { // 进入插入处理分支 return handle_insert(trx, root); } case NT_CREATE_TABLE: { // 进入建表处理分支 return handle_create_table(trx, root); } default: return RC::UNIMPLEMENTED; } }

handle_request是执行阶段的入口,参数里的Trx代表一个事务上下文。MiniOB简化了并发控制,Trx在大多数场景下只是把COMMIT和ROLLBACK语义走了一遍,没有真正实现隔离级别。这也是教学版和工业级的核心差异之一,读代码时心里要有数。

SqlTask里还带了parsed_sql之外的元信息,比如当前连接的session_id。多用户并发时,MiniOB靠SEDA框架做多阶段异步调度,seda_config.cpp就是加载调度配置用的。读这个文件能理解各个stage是怎么串联的,但调试单条SQL时一般不关心它。

3.3 查询执行与条件过滤:没有索引时数据怎么被筛出来

MiniOB的查询处理最典型的就是全表扫描加逐行过滤。以select * from t where id = 5为例,handle_select会先打开目标表,拿到表的record迭代器,然后逐条把记录读出来做条件判断:

// 伪代码:展示全表扫描条件过滤的循环逻辑 Table *table = find_table("t"); RecordIterator *iter = table->record_iterator(); Record record; while (iter->next(record)) { // 取出id字段的值 int id_value = record.get_int("id"); // 与where条件中的值比较 if (id_value == 5) { // 命中条件,返回给客户端 result.append(record); } }

这个循环逻辑虽然简单,但它暴露了MiniBO一个重要特性:where id = 5条件过滤发生在执行器层面,没有下推到存储层。这意味着即使表里有B+树索引,查询优化器也不会自动选择走索引,除非你在代码里显式调用索引查找接口。后续我讲B+树时你会看到,索引的查找能力是有的,但需要手工把“走全表扫”改成“走索引查”,这正是练习数据库优化的好切入点。

4. 索引与存储:B+树分裂、缓冲池换页与表记录的联动

4.1 bplus_tree.cpp:B+树插入时的节点分裂与上提

B+树是MiniOB索引模块的核心,bplus_tree.cpp里实现了插入、删除、查找三种基本操作。先看插入的分裂逻辑,这是最容易写错的地方。

B+树的插入流程有两步:第一步找到叶子节点插入key-value;第二步检查节点是否溢出,溢出就分裂。MiniOB的节点结构里,每个节点存一组有序key和对应的value,叶子节点还额外维护了一个指向右兄弟的next指针。这个指针非常关键——范围查询时靠它把叶子节点串成链表。

分裂逻辑的代码可以抽象成下面这段:

// 叶子节点分裂的简化逻辑 void BplusTree::split_leaf_node(Page *old_node, Page *new_node) { // 1. 把old_node右半部分的key-value移动到new_node int mid = old_node->key_count / 2; for (int i = mid; i < old_node->key_count; i++) { new_node->add_key(old_node->key(i), old_node->value(i)); } // 2. 调整叶子链表的next指针 new_node->next = old_node->next; old_node->next = new_node->page_num; // 3. 把中位key上提到父节点 int up_key = new_node->key(0); insert_to_parent(old_node, new_node, up_key); }

mid的取值决定左右两个节点各分多少数据。MiniOB默认把旧的保留一半、新的拿一半,这样两个节点都过半满,后续删除时不容易频繁触发合并。insert_to_parent负责把新节点的第一个key上提到父节点,父节点满了就再往上分裂,递归直到根节点。根节点一旦分裂,B+树的高度就加一,这也是B+树保证树平衡的关键。

这里要特别提醒:分裂完成后,叶子链表的顺序必须保持递增。很多初学改动这里会漏掉new_node->next = old_node->next这一步,导致分裂后范围查询只能查到半边数据,而且查不到错误信息——因为索引本身还是“能用的”,只是数据逻辑上断开了。

4.2 disk_buffer_pool.cpp:磁盘页换入换出的LRU策略

数据库不能每次读数据都直接走磁盘,否则性能没法看。disk_buffer_pool.cpp干的事就是把磁盘上的页缓存到内存,供上层模块访问。MiniOB的缓冲池实现了最基本的框架:固定数量的frame,每个frame对应一个磁盘页。

// 磁盘缓冲池的页查找逻辑 Page *DiskBufferPool::get_page(int file_id, int page_num) { // 1. 先在帧表里找这个页是否已在内存 Frame *frame = find_frame(file_id, page_num); if (frame != nullptr) { // 2. 命中缓存,直接返回 frame->pin(); return frame->page; } // 3. 未命中,从磁盘加载 Frame *victim = choose_victim_frame(); // 按LRU挑选淘汰帧 flush_page(victim); // 淘汰前如果脏页要先写回 load_page_from_disk(victim, file_id, page_num); victim->pin(); return victim->page; }

pin()是在帧上增加引用计数,防止页面在使用中被换出。choose_victim_frame()按LRU策略选淘汰帧——最久没被访问的页会成为牺牲者。flush_page这里有个不明显的坑:如果victim内容被修改过,必须先把脏数据写回磁盘,否则等内存页被覆盖,修改就永久丢失了。

flush_page的判断标准是frame上的is_dirty标记。bplus_tree.cpp在往节点页里写数据时,会调用mark_dirty接口,告诉缓冲池“这个页被改过了”。如果你在测试时发现数据没有持久化,排查方向不是看缓冲池的LRU算法,而是看你改页之后有没有正确置脏。

4.3 table.cpp:表记录与索引项的联动更新

表模块在MiniOB里负责管理表的元数据和记录文件。每张表对应两个文件:一个存表结构定义,一个存记录数据。table.cpp向上给执行器提供insert_record、delete_record、update_record接口,向下调用缓冲池读写页面。

插入记录时有个容易被忽略的环节:如果表上有索引,插入一条record需要同时往B+树里插入索引项。源码里的调用链是这样的:

// table.cpp 中插入记录的简化调用链 RC Table::insert_record(char *data) { // 1. 找到表数据文件的空闲页 Page *page = find_available_page(); // 2. 把record数据写入页面槽位 write_record_to_page(page, data); // 3. 遍历表上的所有索引,逐一更新 for (Index *idx : indexes) { idx->insert_entry(generate_index_key(data), record_id); } return RC::SUCCESS; }

generate_index_key从record里抽出索引字段拼成key,然后调用B+树索引的插入接口。这里的关键是:索引插入失败时,已经写入表数据页的record会不会回滚?MiniOB没有完整的日志和回滚机制,一旦B+树插入失败,表数据和索引就处于不一致状态。实际代码里这一步通常直接返回失败,但数据页已经写进去了,所以测试时如果触发这个路径,需要手动处理数据一致性。

删除记录是反向操作:先删索引项,再删表记录。顺序不能反,否则记录对应不上。

5. 避坑清单:编译、调试与索引分裂最容易翻车的五个现场

5.1 报错“yacc_sql.tab.h找不到”或“redefinition of int yyparse”

现象:拉下来一份别人改过的源码,执行make时直接报错,提示yacc_sql.tab.h缺失,或者yacc_sql.tab.c里出现大量重复定义。

原因:源码包里自带的yacc_sql.tab.c和.y文件不是同一版本。有人改了.y文法后重新生成了.c文件,但没有同步相关的头文件,或者根本没用bison重新生成。

解决:先看Makefile或CMakeLists里是否包含bison和flex两个工具。如果包里有.y和.l文件,强制重新生成:

bison -d -o yacc_sql.tab.c yacc_sql.y flex -o lex.yy.c lex_sql.l

-d参数表示同时生成头文件,这是在两个C文件之间共享token定义的关键。

5.2 B+树插入分裂后,索引查不到部分数据

现象:插入几千条数据后,走索引等值查询,发现某一段范围内的key查不到,但全表扫描能查到,索引也没有报错。

原因:叶子节点分裂时漏掉了链表指针维护。分裂出来的右节点没有正确接上原节点的next链,导致从根节点沿最左叶子走时,遍历链表到了原节点就断了。

解决:在split_leaf_node函数里检查两个地方:第一,新节点的next是否指向了原节点的旧next;第二,父节点插入上提key时,是否把新节点的第一个key作为分裂key。建议在bplus_tree_test.cpp里构造连续插入1万条数据的用例,插入完成后从最左叶子遍历到最右叶子,统计key总数对比预期值。这条我当时调了一下午,最后发现就是一行指针赋值的问题。

5.3 修改数据后重启进程,数据丢了一部分

现象:insert一条记录,客户端显示成功,重启observer之后这条记录不见了。

原因:缓冲池的脏页没有写回。数据写入后只停留在内存frame里,没有触发flush。MiniOB的缓冲池在正常关闭时会遍历所有frame做刷盘,如果你是直接kill进程,脏页来不及写回磁盘。

解决:调试阶段不要用kill -9关闭observer,尽量走客户端的exit命令或正常停止服务。另外可以在flush_page里加日志打印,观察宕机前最后写回的是哪些页。如果想验证数据是否落盘,可以用hexdump直接看表数据文件,但注意MiniOB的记录格式不是纯文本,直接读会看到大量二进制内容。

5.4 gdb断点打在execute_stage.cpp里不生效

现象:在handle_select函数里打了断点,obclient发出SQL之后,gdb提示断点未命中或者输出“Cannot insert breakpoint”。

原因:编译时没有指定DEBUG模式,函数被内联或指令重排,源码行号和实际指令对不上。

解决:回到第2章,确保cmake -DCMAKE_BUILD_TYPE=DEBUG ..,然后重新make。检查方法是在gdb里执行info line execute_stage.cpp:handle_select,如果输出“Line number not known”,说明源码行号信息丢了,必须重编。

5.5 插入数据时索引重复导致执行器报错,但表数据已经写入

现象:往带唯一索引的表里插入两条相同key的记录,客户端收到索引冲突错误,但退出重进后,发现表里实际有两行,而索引只有一条。

原因:table.cpp的insert_record先写表数据再写索引,索引失败时没有回滚机制,表数据站住了。

解决:这个问题的根源在设计层面。改代码时注意两个方向:一是把插入顺序反过来,先插索引再插数据,但这样索引插入成功后数据插入失败又会反着不一致;二是引入简单的内存回滚,在索引插入失败时,把已经写入的record标记为删除。MiniOB的定位是教学,它故意没做事务日志,遇到这种场景要能意识到这和真实数据库的ACID差异。

6. 进阶玩法:给MiniOB新增一个自定义函数并验证索引加速

6.1 在词法规则里注册新token

前面几章把执行链路摸清之后,动手改一处代码才能算真正入了门。我建议从“自定义SQL函数”开始,比如实现一个ADD_TWO(a, b),功能是返回a + b + 2。

第一步是让词法分析器认识ADD_TWO这个关键字。在lex_sql.l里加一条规则:

ADD_TWO { return ADD_TWO; }

然后在yacc_sql.y的文法里注册对应的token类型,并新增一条表达式规则。重新跑一遍bison和flex,生成新的yacc_sql.tab.c和lex.yy.c。

6.2 在execute_stage里处理函数调用

语法树构造完之后,函数调用会以NT_FUNC类型的节点挂在表达式树上。在execute_stage.cpp的表达式求值逻辑里加上分支:

// execute_stage.cpp 中表达式求值新增分支 RC evaluate_expression(Expression *expr, const TupleCell &cell, int &result) { if (expr->type == NT_FUNC) { // 取出函数名字符串 string func_name = expr->func_name; if (func_name == "ADD_TWO") { // 取出两个参数值 int a = expr->args[0]->eval_int(cell); int b = expr->args[1]->eval_int(cell); result = a + b + 2; return RC::SUCCESS; } } // 原有表达式求值逻辑 return evaluate_normal_expr(expr, cell, result); }

这里注意参数求值顺序。MiniOB的表达式树是左结合遍历,先求左参数再求右参数,顺序不能反,否则传参错位。

6.3 验证:对比全表扫描和索引查找的耗时

新增功能稳定后,最后做一次有说服力的实验。建一张100万行的表,一列加索引,分别用两种方式查询相同条件:

-- 第一次:普通查询,默认走全表扫描 select * from t where id = 345678; -- 第二次:强制走索引(需要手动把执行逻辑改成索引查找) select * from t where id = 345678;

我在自己机器上测过,全表扫描100万行大约需要300毫秒,走B+树索引后降低到1毫秒以下。这个对比能直观感受到存储引擎的威力,也暴露了MiniOB的优化器缺位——它不会自动选择索引访问路径。把执行器改成“有索引就走索引”的代价不大,值得你花一个晚上读完bplus_tree.cpp的查找函数后再动工。

从那以后,我每次拿到一份新的数据库内核源码,都会先做三件事:确认编译默认参数、用gdb跟通一条简单SQL、再动手改第一个功能。基线没跑通之前,绝不往下加代码。希望这篇笔记能帮你少走这些弯路,MiniOB这份源码值得投入两周时间,读一遍和写一遍的感受是完全不同的。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进