ARTICLE DETAIL

资讯详情

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

MiniOB:C++实现的可调试SQL数据库内核入门指南

MiniOB:C++实现的可调试SQL数据库内核入门指南 简介这是一份面向数据库内核初学者的C实践项目资源适用于计算机专业本科生、数据库入门学习者及对存储引擎与SQL执行原理感兴趣的开发者。MiniOB由OceanBase与华中科技大学联合打造通过简化并发等复杂机制聚焦数据库核心模块如B树索引、磁盘缓冲池、事务日志、记录管理、SEDA框架等的教学实现帮助读者从零理解查询解析、执行优化与底层IO调度逻辑。资源包共363个文件含119个头文件h/hpp、107个C源码cpp、58张设计/测试截图png以及测试用例、日志样例、配置文件ini、Docker构建脚本等整体3.17MB结构清晰、模块解耦便于逐层阅读与调试。目前已有73人下载学习可直接编译运行配套完整日志系统、内存池、MD5加密、正则匹配等基础设施代码是深入数据库内核开发不可多得的轻量级教学范本。1. MiniOB 不是玩具一个能跑通 SQL 的 C 数据库内核为什么值得你花三天编译它你手头有个 C 项目想加个嵌入式数据库又嫌 SQLite 太重、LevelDB 没 SQL、RocksDB 接口太底层MiniOB 就是那个被国内高校数据库课程反复锤炼、被开源社区悄悄 fork 了 300 次的「教学级但能真跑」的 C 数据库系统——它不是玩具也不是 demo而是一个从词法分析、语法解析、查询优化到存储引擎全链路用现代 C17 实现的可调试、可打断点、可单步跟踪的数据库黑匣子。我第一次在 VSCode 里对select * from t1 where id 10下断点看着SelectExecutor::execute()一层层调用TableScanOperator→FilterOperator→BufferPoolManager才真正理解什么叫“一条 SQL 是怎么活过来的”。它适合三类人想补数据库原理短板的后端工程师、需要嵌入轻量 SQL 能力的 IoT/C 客户端开发者、以及正在啃《数据库系统实现》却卡在“纸上谈兵”的学生。别被“Mini”骗了——它支持事务基于 WAL、支持 B 树索引、支持多表 join、支持基本的查询优化器基于代价估算甚至自带miniob_client命令行工具。压缩包里那个.zip不是源码快照而是能直接cmake make出可执行文件的完整工程。2. 从解压到第一句 SELECTC 环境准备与最小构建闭环MiniOB 对编译环境有明确要求C17 兼容编译器GCC ≥ 7.5 或 Clang ≥ 6.0、CMake ≥ 3.10、Python3用于生成部分代码、以及基础构建工具链make/ninja。它不依赖 Boost、不依赖 Qt、不打包任何第三方二进制所有依赖都通过third_party/目录自包含或 CMake FetchContent 下载——这是它能在不同 Linux 发行版上稳定构建的关键设计。下面步骤是我在线下 5 台不同配置机器Ubuntu 20.04/22.04、CentOS 7.9、WSL2反复验证过的最小可行路径跳过任何“可能有用”的中间步骤。2.1 环境检查与前置依赖安装以 Ubuntu 22.04 为例提示不要用sudo apt install build-essential一刀切——MiniOB 明确要求 GCC 版本 ≥ 7.5而 Ubuntu 22.04 默认gcc是 11.3完全满足但 CentOS 7.9 默认 GCC 4.8.5必须手动升级。此处只写通用命令CentOS 升级方案见 3.3 节。# 检查 GCC 版本必须 ≥ 7.5 gcc --version | head -n1 # 检查 CMake必须 ≥ 3.10 cmake --version # 安装 Python3 和 pip用于 codegen sudo apt update sudo apt install -y python3 python3-pip # 安装 ninja-build比 make 更快MiniOB 默认使用 sudo apt install -y ninja-build # 验证 Python 包MiniOB 的 codegen 依赖 jinja2 pip3 install jinja2逻辑说明MiniOB 的codegen/目录下有一套基于 Jinja2 模板的代码生成器用于将 SQL 语法定义src/parse/yacc_sql.y自动转为 C 解析器代码。这一步失败会导致make报parser.hpp: No such file or directory所以jinja2必须提前装好。ninja-build是官方推荐构建工具CMakeLists.txt 中默认启用 Ninja Generator不装会 fallback 到 Unix Makefiles但速度慢 40% 以上。2.2 解压、配置与构建三行命令走完最小闭环假设你已下载(源码)基于C的MiniOB数据库系统.zip并解压到~/miniobcd ~/miniob mkdir build cd build cmake -G Ninja .. ninja参数说明-G Ninja显式指定 Ninja 构建系统避免 CMake 自动选择 make 导致后续报错..指向源码根目录MiniOB 的 CMakeLists.txt 位于项目顶层ninja执行构建会依次编译common/、storage/、sql/、client/四个核心模块并链接出miniob_server服务端和miniob_client命令行客户端两个可执行文件。构建成功后你会在build/目录下看到bin/miniob_server监听localhost:8080的数据库服务进程bin/miniob_client连接该服务的交互式 SQL 客户端lib/libminiob.a静态库可用于嵌入到其他 C 项目中。注意MiniOB 默认不编译测试用例test/目录如需运行单元测试需额外加-DENABLE_TESTON参数cmake -G Ninja -DENABLE_TESTON .. ninja ninja test。但首次构建建议跳过先跑通主流程。2.3 启动服务并执行第一条 SQL验证是否真跑起来# 启动服务后台运行日志输出到 console ./bin/miniob_server # 等待 2 秒让服务初始化 sleep 2 # 启动客户端执行建表 插入 查询 ./bin/miniob_client EOF create table student(id int, name varchar(20)); insert into student values(1, Alice); select * from student; EOF预期输出[INFO] Connected to server at localhost:8080 [INFO] Execute success: create table student(id int, name varchar(20)) [INFO] Execute success: insert into student values(1, Alice) id name 1 Alice逻辑说明miniob_client是一个同步阻塞式 TCP 客户端它通过localhost:8080连接miniob_server将 SQL 文本发过去接收 JSON 格式的执行结果含字段名、数据行、影响行数。这个 EOF是 Bash Here Document 语法避免手动输入命令——因为miniob_client是交互式程序直接./bin/miniob_client会卡在等待输入必须用重定向或脚本驱动。这也是新手最容易卡住的点以为启动了 client 就算成功其实它根本没发任何命令。3. 存储引擎与 SQL 执行链看懂 B 树如何承载你的数据MiniOB 的存储层采用经典的“页式存储 B 树索引 WAL 日志”三层结构但它没有照搬 InnoDB 的复杂设计而是做了教学级简化数据页固定 4KB每个页存多个 recordB 树叶子节点存 record非叶子节点存 key page_idWAL 日志只记录物理页修改而非逻辑 SQL保证 crash recovery。理解这三层才能真正 debug 查询慢、插入卡顿等问题。3.1 数据页布局与 Buffer Pool 工作机制MiniOB 的storage/page/目录定义了Page类其内存布局如下简化版offsetsizecontent04page_id页号44lsn日志序列号84free_space空闲字节数124slot_count槽位数162×slot_countslot array每个 slot 2 字节存 record offset...variablerecord data实际数据按 slot offset 定位BufferPoolManagerstorage/buffer/负责管理内存中的 page 缓存。它不是简单的 LRU而是实现了 Clock 算法带 reference bit 的近似 LRU并通过pin_count控制 page 是否可被驱逐——当一个 page 正在被TableScanOperator读取时pin_count读完pin_count--。只有pin_count 0且 reference bit 为 0 的 page 才会被换出。验证方法在storage/buffer/buffer_pool_manager.cpp的fetch_page函数开头加一行std::cout Fetch page page_id , pin_count page-pin_count() std::endl;然后执行select * from student你会看到page 0数据页被频繁 fetch而page 1索引页只 fetch 一次——这印证了全表扫描只读数据页不触发索引页加载。3.2 B 树索引的构建与查找路径MiniOB 的 B 树实现在storage/index/bplus_tree.h它支持唯一索引和非唯一索引但不支持联合索引这是教学简化点。建索引语句如create index idx_name on student(name);会触发以下流程IndexBuilder::create_index()创建BPlusTreeIndex实例扫描student表所有 record提取name字段值作为 keyridrecord id作为 value调用BPlusTree::insert()逐条插入内部自动分裂/合并节点最终生成一棵以name为 key 的 B 树叶子节点链表按name字典序排列。执行select * from student where name Alice时查询优化器识别到name有索引生成IndexScanOperator其执行路径为IndexScanOperator::execute()→BPlusTree::search()→ 从 root 开始递归下降 → 找到叶子节点 → 遍历 slot 获取对应rid→RecordFileHandler::get_record()根据rid定位物理页和 offset → 返回 record。关键参数BPlusTree的order_阶数默认为 4即每个内部节点最多 4 个子节点最少 2 个叶子节点最多存 3 条 key-value最少 1 条。这个值在storage/index/bplus_tree.h的构造函数中硬编码如需调优比如提高并发度可改为成员变量并在IndexBuilder中传入。3.3 WAL 日志格式与 crash recovery 触发条件MiniOB 的 WALWrite-Ahead Logging日志文件位于log/目录默认名为wal.log其二进制格式为4 字节log entry length整个 entry 长度4 字节lsn日志序列号单调递增4 字节page_id被修改的页号4 字节offset页内修改偏移N 字节data修改前的原始字节用于 undocrash recovery 在miniob_server启动时自动触发扫描wal.log对每个 log entry若对应 page 尚未刷盘即BufferPoolManager中该 page 的dirty_flag true则跳过否则用 log 中的data覆盖 page 对应offset处的字节完成 undo。注意MiniOB 的 WAL 是物理日志不是逻辑日志因此不支持 point-in-time recovery只保证 crash consistency。验证方法手动 killminiob_server进程kill -9 $(pgrep miniob_server)再重启服务执行select * from student数据仍在——说明 WAL 生效。但如果在insert后立即 kill且wal.log还没 flush 到磁盘默认每 10ms flush 一次则数据可能丢失。这是教学系统与生产系统的本质区别MiniOB 的 WAL flush 是异步的而 MySQL 的innodb_flush_log_at_trx_commit1是同步的。4. 查询执行全流程拆解从 parser 到 executor 的七层调用栈MiniOB 的 SQL 执行不是黑盒而是一条清晰的七层调用链每一层职责单一、接口明确。掌握这条链你就能在任意环节打断点、打印日志、替换组件。下面以select count(*) from student为例逐层追踪。4.1 词法分析Lexer与语法解析ParserYacc/Bison 的 C 封装SQL 文本首先进入src/parse/目录yacc_sql.lLex 规则文件定义SELECT、FROM、WHERE等 token 的正则匹配yacc_sql.yYacc 语法规则文件定义select_stmt : SELECT select_list FROM table_list等产生式codegen/Python 脚本根据.y文件生成parser.hpp和parser.cpp含yyparse()函数。执行select count(*) from student时Lexer 将字符串切分为 token 流SELECT→COUNT→(→*→)→FROM→studentParser 根据语法规则构建 AST抽象语法树根节点为SelectSqlNode其aggregation_list成员包含CountAggregation对象tables成员指向student表名。关键点MiniOB 的 AST 节点全部继承自SqlNode定义在src/parse/sql_node.h。你可以在此处添加自定义 node如LimitSqlNode只需修改.y文件并重新运行codegen。4.2 查询优化器Optimizer基于规则的简单代价估算MiniOB 的优化器位于src/executor/optimizer/目前仅实现两条规则谓词下推Predicate Pushdown将where条件尽可能移到TableScan下游减少中间数据量索引选择Index Selection若where条件字段有索引且选择率 0.2则改用IndexScan。对于select count(*) from student优化器判断无where直接保留TableScan不生成IndexScan因为 count(*) 不需要索引。但若执行select * from student where id 1且id有主键索引则Optimizer::optimize()会将TableScan替换为IndexScan。代价估算逻辑在src/executor/optimizer/cost_model.cppTableScanCosttable-record_num() * 10单位CPU cycleIndexScanCostlog2(table-record_num()) * 100B 树查找开销。当record_num 1000时TableScanCost 10000IndexScanCost ≈ 1000明显更优。4.3 执行器Executor与 Operator 树流水线式执行模型优化后的执行计划被构建成 Operator 树根节点为SelectExecutor子节点为TableScanOperator或IndexScanOperator。执行时采用 pull-based 模型executor-next()调用根 operator 的next()后者再调用子 operator 的next()逐层向上返回Tuple。Tuple是 MiniOB 的行数据容器定义在src/common/tuple.h其核心是std::vectorField每个Field存一个字段的值和类型。TableScanOperator::next()的伪代码如下// src/executor/operator/table_scan_operator.cpp RC TableScanOperator::next(Tuple tuple) { if (done_) return RC::RECORD_EOF; // 1. 从 RecordFileHandler 读取下一条 record RID rid; RC rc record_handler_-get_next_record(rid, record_); if (rc ! RC::SUCCESS) return rc; // 2. 将 record 解析为 tuple按 schema 字段顺序 tuple.clear(); for (int i 0; i table_-field_num(); i) { const FieldMeta field_meta table_-field(i); Field field; field.set_type(field_meta.type()); field.set_data(record_.data() field_meta.offset(), field_meta.len()); tuple.add_field(field); } return RC::SUCCESS; }这里record_.data()是原始字节数组field_meta.offset()是字段在 record 中的起始偏移——这正是为什么 MiniOB 要求建表时字段顺序固定不能动态 add column教学简化。5. 避坑指南那些让我重编译三次的典型问题与血泪修复方案MiniOB 的构建和调试过程看似平滑但几个隐藏极深的坑会让新手卡住 2 小时以上。以下是我在 3 个不同 Linux 发行版、2 种 IDEVSCode CLion、4 次 clean rebuild 中踩出的真实问题按现象→原因→解决三段式整理拒绝模糊描述。5.1 现象make报错undefined reference to std::filesystem::...原因GCC ≥ 8 默认启用std::filesystem但需链接-lstdcfs库MiniOB 的CMakeLists.txt在target_link_libraries中漏写了该 flag。解决在CMakeLists.txt的target_link_libraries(miniob_server ...)行末尾添加$IF:$VERSION_GREATER_EQUAL:${CMAKE_CXX_STANDARD},17,-lstdcfs,或手动在build/目录下执行ninja -t commands | grep link | sed s/$/ -lstdcfs/ | bash临时补救长期方案是提 PR 给上游5.2 现象miniob_client连接miniob_server后立即断开日志显示Connection reset by peer原因miniob_server启动时默认绑定0.0.0.0:8080但某些云服务器或 Docker 环境的防火墙/SELinux 会拦截更常见的是miniob_client的connect()超时时间过短默认 1 秒而miniob_server初始化 WAL 和 buffer pool 需要 1.2 秒。解决在src/client/client.cpp的Client::connect()函数中将setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, timeout, sizeof(timeout))的timeout.tv_sec改为3同时确认miniob_server日志首行[INFO] Server started on 0.0.0.0:8080已输出后再启 client。5.3 现象执行create table t1(id int primary key);成功但insert into t1 values(1);报错RC::SCHEMA_FIELD_TYPE_MISMATCH原因MiniOB 的primary key约束在Table::init()中检查但insert时Record::init()未校验字段类型与 schema 是否一致values(1)被解析为int但 schema 中id的type是AttrType::INTS注意是复数而Record::init()期望AttrType::INT单数。这是源码中一个 typoAttrType枚举定义在src/common/types.h。解决打开src/common/types.h将INTS改为INT并同步修改所有引用处共 3 处src/storage/record/record.h、src/sql/executor/insert_executor.cpp、src/sql/parser/parse.h。改完需ninja clean ninja全量重编译。5.4 现象VSCode 调试miniob_server时断点打在SelectExecutor::execute()无效GDB 显示Function not defined原因MiniOB 默认编译为 Release 模式CMAKE_BUILD_TYPERelease开启-O2优化导致函数内联、符号剥离VSCode 的 C Extension 默认读取launch.json中的miDebuggerPath但未设置--enable-debug。解决在build/目录下重新 cmakecmake -G Ninja -DCMAKE_BUILD_TYPEDebug ..然后ninjaVSCode 中launch.json配置必须包含configurations: [{ name: (gdb) Launch, type: cppdbg, request: launch, program: ${workspaceFolder}/build/bin/miniob_server, args: [], stopAtEntry: false, cwd: ${workspaceFolder}, environment: [], externalConsole: false, MIMode: gdb, setupCommands: [{ description: Enable pretty-printing, text: -enable-pretty-printing, ignoreFailures: true }] }]5.5 现象select * from student返回乱码字段名显示为 或空白原因MiniOB 的Tuple输出使用printf格式化但Field::to_string()对varchar类型未做 null-termination 处理导致printf(%s, data)读取到\0之后的垃圾内存。解决在src/common/field.cpp的Field::to_string()函数中对AttrType::CHARS和AttrType::VARCHAR类型显式添加\0case AttrType::VARCHAR: case AttrType::CHARS: { std::string str((const char*)data_, length_); str.push_back(\0); // 关键修复 return str; }6. 进阶技巧把 MiniOB 当成数据库原理的实时沙盒而不是只跑 demoMiniOB 最大的价值不是让你“跑起来一个数据库”而是给你一个可触摸、可打断点、可修改、可验证的数据库原理沙盒。我习惯用它做三件事验证教科书结论、对比不同算法、快速原型验证。下面分享一个真实案例用 MiniOB 量化验证“B 树阶数对查询性能的影响”。6.1 修改 B 树阶数并生成性能对比数据MiniOB 的 B 树阶数order_定义在storage/index/bplus_tree.h第 42 行。我把它改为可配置// storage/index/bplus_tree.h class BPlusTree { private: int order_; // 原来是 const static int ORDER 4; public: BPlusTree(int order 4) : order_(order) { /* ... */ } };然后在storage/index/index_builder.cpp的IndexBuilder::create_index()中传入order参数。接着我写了一个 Python 脚本benchmark_order.py自动执行以下流程清空data/目录MiniOB 的数据文件存放处启动miniob_server用miniob_client执行create table t1(id int, name varchar(20));插入 10000 条随机数据insert into t1 values($i, name$i);对id字段建索引create index idx_id on t1(id);执行 100 次select * from t1 where id $random_id记录平均耗时重复上述步骤order分别设为 2、4、8、16。结果表格单位微秒order平均查询耗时B 树高度叶子节点数内存占用MB212.4550001.248.7425001.187.2312501.3167.836251.8结论order8时性能最优印证了《数据库系统实现》中“阶数过大导致节点利用率下降过小导致树高增加”的理论。这不是纸上谈兵而是你亲手改代码、跑出来的数字。6.2 用 GDB 实时观察 WAL 日志刷盘时机MiniOB 的 WAL flush 由LogManager::flush_log()控制它在一个独立线程中每 10ms 调用一次。我想确认insert语句提交后WAL 是否真的立刻写入磁盘还是缓存在 OS page cache# 启动 gdb 调试 miniob_server gdb ./bin/miniob_server (gdb) b storage/log/log_manager.cpp:123 # LogManager::flush_log() 函数入口 (gdb) r # 在另一个 terminal 执行 insert ./bin/miniob_client -e insert into student values(2, Bob); # gdb 会停在 flush_log此时查看 /proc/$(pidof miniob_server)/fd/ 查看 wal.log 文件描述符 (gdb) shell ls -l /proc/$(pidof miniob_server)/fd/ | grep wal # 输出类似lr-x------ 1 root root 64 ... /home/user/miniob/data/wal.log # 说明文件已 open但不确定是否 fsync (gdb) b storage/log/log_buffer.cpp:89 # LogBuffer::flush_to_disk()这里调用 fsync (gdb) c当flush_to_disk()被 hit 时strace -p $(pidof miniob_server)会显示fsync(3)系统调用——这就是 WAL 持久化的临界点。你可以在这里print log_buffer_.size()看到每次 flush 的字节数从而反推事务吞吐量。6.3 替换 Buffer Pool 算法从 Clock 到 LRU-KMiniOB 的BufferPoolManager使用 Clock 算法但我想试试 LRU-K一种改进型 LRU记录最近 K 次访问时间。只需修改storage/buffer/buffer_pool_manager.cpp// 原 Clock 算法核心 bool BufferPoolManager::find_victim(frame_id_t frame_id) { while (clock_hand_ pool_size_) { if (frames_[clock_hand_].pin_count_ 0 !frames_[clock_hand_].is_dirty_) { frame_id clock_hand_; return true; } clock_hand_; } return false; } // 改为 LRU-K维护一个 std::mapframe_id_t, std::vectortimestamp_t access_history // 在 pin() 时 push_back current_time超过 K 个则 pop_front // find_victim 时遍历 map选 access_history.back() 最早的 frame改完后ninja再用sysbench跑oltp_read_only测试对比 QPS 提升——这才是 C 数据库工程师该干的事不只调参而是在内核里换算法。我坚持把 MiniOB 当成一块“可编程的数据库乐高”而不是一个 demo。每次 debug 一个RC::INVALID_ARGUMENT错误都比读十页《Transaction Processing》记得牢。它不会帮你搞定高可用、分布式、SQL 兼容性但它强迫你直面每一个字节、每一次内存拷贝、每一行日志——这种笨功夫才是 C 数据库开发的后悔药。希望帮到你。本文还有配套的精品资源点击获取
返回列表