C++ MiniSQL数据库管理系统源码解析与实战

📅 发布时间:2026/9/26 1:40:20
C++ MiniSQL数据库管理系统源码解析与实战
简介一份基于C实现的MiniSQL数据库管理系统完整源码面向数据库课程设计、实验教学与初涉关系型数据库内核的学习者可用于理解SQL解析、执行与存储引擎的协作流程。项目参考CMU15445 BusTub框架扩展改造覆盖缓冲池管理、B树索引、记录管理、持久化数据页分配回收及Parser与Executor衔接等核心模块能支撑常见SQL语句的解析和执行。压缩包共377个文件以h、cc、cpp源码为主辅以py脚本、cmake与bazel构建配置、yml工程文件及测试用例整体约1.05MB目录结构清晰便于按模块阅读与二次开发。目前已有80人学习适合具备一定C基础、希望通过完整项目快速入门数据库内核实现细节的读者。 拿到(源码)基于C的MiniSQL数据库管理系统.zip 这个包的人多半已经处在数据库课程设计或毕业设计的中段。你会被里面一堆.cpp和.h文件劝退也可能误以为这只是一个MySQL的玩具版。但 MiniSQL 恰好把数据库最核心的几件事——SQL解析、缓冲池、记录存储、B树索引——压缩在几千行C代码里用来验证工程能力刚刚好。它适合两类人一类是要交课设或毕设的学生需要跑通并能讲清楚每一层在干什么另一类是准备C岗位面试、想用一个小而完整的项目把数据库八股串起来的人。下面按我实际拿到这类源码包时的做法从架构、编译、代码走读、排错到扩展一条线讲完。具体文件命名以你手里的包为准但这套思路对绝大多数 MiniSQL 实现都通用。2. MiniSQL 的调用链一条 SQL 从键盘敲入到磁盘落盘中间经过哪几层拿到源码先别急着编译第一件事是建立调用链的全局认知。MiniSQL 再小也是一个完整的数据库管理系统代码通常会按解析、执行、存储、索引、缓冲池这几块组织。常见文件命名是 parser.cpp、executor.cpp、catalog.cpp、record.cpp、buffer.cpp、index.cpp外加一个 main.cpp 做交互入口。它的执行路径基本是用户在 shell 里敲 SQL词法分析把它拆成 token语法分析生成查询结构执行器拿着结构去查目录、找数据文件再决定要不要走索引最后通过缓冲池读写磁盘页。下面把这条链路拆成三段来讲这也是你给老师讲项目时最容易讲出条理的部分。2.1 词法与语法解析手写递归下降为什么是课程设计的主流MiniSQL 的语法集很小核心就五种语句create table、drop table、create/drop index、insert、delete、select。因为文法规模不大绝大多数课程设计都用手写词法加递归下降而不是引入 flex/bison。手写的好处是你完全掌控代码断点一打就能看到“order”这种关键字在哪一步被吞掉。先看词法这块常见做法是一场循环把输入切成 token遇到空白、括号、逗号、分号就切一刀enum class TokenType { IDENT, NUMBER, STRING, SYMBOL, END }; struct Token { TokenType type; std::string text; }; std::vectorToken tokenize(const std::string sql) { std::vectorToken tokens; std::string cur; for (size_t i 0; i sql.size(); i) { char c sql[i]; if (isspace(c) || c ( || c ) || c , || c ;) { if (!cur.empty()) { // 数字和字符串的区分留给语法分析这里统一按词法单元收集 tokens.push_back({IDENT, cur}); cur.clear(); } if (c ( || c ) || c , || c ;) tokens.push_back({SYMBOL, std::string(1, c)}); } else if (c \) { // 字符串字面量直接收集到下一个单引号期间允许空格 std::string val; i; while (i sql.size() sql[i] ! \) val sql[i]; tokens.push_back({STRING, val}); } else { cur c; } } if (!cur.empty()) tokens.push_back({IDENT, cur}); tokens.push_back({END, }); return tokens; }这段代码的逻辑是按字符扫描空格只做分隔、不进入 token单引号后面的内容按字符串整体收集避免把带空格的字符串再切开。第一版先别管数字和标识符的类型细分等 parser 里去区分就行。这里你真正要记住的参数是“字符串字面量的边界用单引号”很多查询失败都出在字符串里带了未转义的单引号。语法分析层拿到 token 流后按照第一个 token 分发create 后面跟 table 还是 indexselect 后面跟列名还是星号where 后面接条件表达式。常见做法是每种语句写一个 parse 函数返回一个统一的 Query 结构体里面塞 sql_type、table_name、columns、values、conditions。你走读代码时先找到这个结构体它就是你理解整套代码的钥匙。executor 后面的所有逻辑都围绕这个 Query 展开。2.2 记录存储数据文件、数据页与定长记录解析层下面就是存储层。MiniSQL 的数据文件通常是一张表一个文件文件名直接叫表名页大小常见取 4096 字节。它的数据页不是简单地堆字节而是在页头放一些管理信息常见字段包括页面偏移、页内记录条数、空闲空间起始位置、指向下一个空闲页的指针。记录采用定长存储也就是建表时每一种字段占多少字节都是算死的。constexpr int PAGE_SIZE 4096; constexpr int PAGE_HEADER_SIZE 24; struct PageHeader { int32_t next_free_page; // 下一个可用页的文件偏移-1 表示没有 int32_t record_count; // 当前页内记录条数 int32_t free_offset; // 空闲区起始位置从页头之后开始算 int32_t reserved; // 留作扩展很多实现会放页类型标记 }; int max_records_per_page (PAGE_SIZE - PAGE_HEADER_SIZE) / record_size;这个计算贯穿整个存储层插入时从 free list 找页计算页内偏移删除时不立刻搬移数据而是标记槽位空闲页满了再申请新页并串成链表。参数上有几个值得注意的坑页头大小必须对齐到 4 字节否则不同编译器下 sizeof 结果有差异record_size 必须是定长的不能用变长字符串直接落盘。你读代码时先找到这两个常量再找到插入记录时算偏移的那几行存储层就算读通了一半。2.3 执行器、目录管理器与索引三层之间的接口长什么样执行器是中间人。它从 parser 拿到 Query先去 catalog 查这张表存不存在、有哪些列、数据文件文件名是什么。catalog 本身也落盘常见实现是把表结构序列化到单独的系统文件中。然后执行器再决定select 带 where 条件且字段上有索引就走 B 树索引拿到一个记录 id 列表再逐个去数据页取记录没有索引就只能顺序扫描全部数据页。这一层最常见的接口签名是std::vectorRID index_scan(const std::string table, const std::string field, const Value lower, const Value upper);RID 是记录 id通常就是“数据页偏移 页内槽号”的组合32 位或 64 位整数索引叶子节点里存的就是它。BufferPool 提供 FetchPage/UnpinPage 接口RecordManager 通过页偏移向 BufferPool 要页。你走读时检查一件事执行器查询数据时是一层层调用缓冲池还是自己直接开文件读。如果发现直接 fopen/fread说明这个包的缓冲池没接进查询路径这是 MiniSQL 实现里很典型的一个偷工减料点答辩时也常被问到。到这里调用链的骨架已经清楚了。你可以动手做第一件实操打开源码按 parser → executor → catalog/record/index → buffer 的顺序列出文件依赖画一张手写调用路径图然后进下一章解决“怎么让它跑起来”的问题。3. 把源码 zip 变成可运行的程序先过编译再谈其余很多同学拿到包后第一反应是双击 README 里的 exe结果报错然后就开始怀疑人生。其实 MiniSQL 这类源码包最不重要的就是那个编译好的 exe真正有价值的是源码。你要做的事是自己重新构建一遍。这能顺便验证源码完整性也能帮你避开网上流传的各种魔改版、半成品包。下面给两条我在 Windows 下常用的编译路径一条用 CMake一条用命令行 g按你机器的现有环境二选一即可。3.1 Windows 下的构建准备CMake 还是直接开命令行先检查 zip 里有没有 CMakeLists.txt 或 Makefile。有就用现成的没有就自己写一个最小 CMakeLists。课程设计的源码文件数量通常不会超过二十个直接放在 src 目录下的情况很多这个最小构建文件足够用cmake_minimum_required(VERSION 3.10) project(MiniSQL) set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_STANDARD_REQUIRED ON) if(NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE Debug) endif() add_executable(minisql src/main.cpp src/parser.cpp src/executor.cpp src/catalog.cpp src/record.cpp src/buffer.cpp src/index.cpp ) target_include_directories(minisql PRIVATE src)三个参数值得说明CMAKE_CXX_STANDARD 设成 11因为很多 MiniSQL 源码是 C11 时代写的用 C17 编译会有少量兼容问题CMAKE_BUILD_TYPE 默认设成 Debug方便后面打断点等确认没问题再改成 Release源文件列表建议手写而不是用 aux_source_directory因为有些包里 src 下还放了 test 文件自动收集会把测试一起编译进来导致重复 main。如果你喜欢在 VSCode 里配 C/C 环境CMake 这条路径最顺。装好 C/C 扩展后用 CMake Tools 直接打开这个文件它会自动帮你配置 tasks.json 和 launch.json。如果你不想折腾 VSCode 的调试配置直接跳过用下面这节的方法一条命令解决问题。3.2 命令行 g 编译排除 IDE 干扰快速验证源码完整性命令行编译的好处是出错信息干净不会被 IDE 的缓存和索引干扰。MinGW-w64 或 Windows 自带的 g 都行把上一节文件列表里的 .cpp 全部传给编译器g -stdc11 -Wall -Wextra -O0 -g \ -o minisql \ src/main.cpp \ src/parser.cpp \ src/executor.cpp \ src/catalog.cpp \ src/record.cpp \ src/buffer.cpp \ src/index.cpp-O0 -g 是调试期组合不做优化、带上调试符号断点命中的变量值才可信。-Wall -Wextra 把警告全打开课程设计源码里最常见的警告是“有符号/无符号比较”和“未使用参数”这两种通常不影响运行但出现“未定义引用”就说明编译命令漏了源文件。链接阶段报错时先核对列表里是不是缺了某个实现文件这是最多人翻车的地方。3.3 第一次跑通的最小闭环建表、插入、查询编译成功不代表程序能跑很多包在运行时还要读当前目录下的数据文件。进入程序后的第一个动作一定是最小闭环而不是一上来测复杂查询。常见交互是启动后进入带提示符的 shell支持的语法类似下面这样./minisql create table student(id int, name char(20), age int); insert into student values(1, Tom, 21); select * from student where id 1;如果控制台交互不完全一样以你包里 README 的说明为准。这一组命令的验证意义在于建表会写 catalog插入会写数据文件查询会走解析、执行、存储读路径全链路都过了一遍。跑通后立刻检查当前目录是不是多了 student.db 之类的文件。没生成文件说明数据只在内存里重启后大概率丢数据这是后文要排查的重点。4. 核心代码走读从 record_size 到 B 树分裂的参数是怎么定出来的MiniSQL 这种量级代码抠细节才是收获。很多新手读代码是逐行读读完整个人是蒙的。我建议反过来先找几个决定全局的核心参数顺着参数去读代码。这些参数就是你自己写小数据库时的设计决策也是答辩时老师最爱追问的地方。下面按记录、缓冲池、索引三个维度来讲每个维度都给了可以直接照抄的代码骨架和参数边界。4.1 定长记录设计为什么 MiniSQL 用 char(20) 而不是 varcharMiniSQL 不用 varchar原因很实际变长记录会让“算偏移”变得复杂插入和删除时要搬数据代码量翻倍。课程设计的评分重点通常不在变长存储所以定长是主流。定长设计的核心是一个字段偏移表建表时就算好每一列在记录里的位置enum class FieldType { INT, FLOAT, CHAR }; struct ColumnDef { std::string name; FieldType type; int32_t len 0; // int/float 填 4char 填用户声明的长度 int32_t offset 0; // 该字段在记录缓冲区里的起始偏移 }; int32_t calc_record_size(std::vectorColumnDef cols) { int32_t size 0; for (auto col : cols) { col.offset size; size (col.type FieldType::CHAR) ? col.len : sizeof(int32_t); } return size; }逻辑很直白每个字段按预设长度累加char(n) 就占 n 字节int/float 统一占 4 字节。参数里最容易忽略的是字符串结束符如果实现里把 char(20) 当成“最多存 20 字节”那么写数据时一定要做长度截断否则一个 30 字节的字符串会直接把后面字段的数据覆盖掉。很多内存越界崩溃根子就在这一行。拿到 record_size 就能反推页容量。假设页大小 4096、页头 24 字节、记录大小 24 字节那么每页最多放 (4096-24)/24 169 条记录。这个数是你验证插入逻辑时最好用的参照插到第 170 条时程序必须去申请新数据页如果它没有这么做说明页满了还在原地写属于严重实现错误。4.2 缓冲池的 LRU 链表别把 buffer size 调到无限大缓冲池是 MiniSQL 容易被忽略又必考的一块。常见实现是有固定数量的页帧每个帧对应一块内存页从磁盘读进来时就占一个帧帧被占用时打个 pin 标记淘汰时优先选 unpin 的帧。下面这段是 LRU 链表最核心的更新操作void BufferPool::pin(int frame_id) { auto it lru_list_.find(frame_id); if (it ! lru_list_.end()) { lru_list_.erase(it); // 从当前位置摘掉 } lru_list_.push_front(frame_id); // 放到最近使用端 } int BufferPool::victim() { for (auto it lru_list_.rbegin(); it ! lru_list_.rend(); it) { if (pins_[*it] 0) { // 找最久没用且没被 pin 的帧 return *it; } } return -1; }pin 操作把帧移到链表头淘汰时从链表尾开始找第一个没被 pin 的帧。这里你要注意一个反直觉的地方不要随手把这个池子的容量调到几千。缓冲池过大时LRU 链变长每次访问都要维护链表位置MiniSQL 这种体量反而会变慢更麻烦的是如果某个实现是“所有脏页只在淘汰时写回”大缓冲池会让事务结束后的数据迟迟不落盘一断电全没了。常见课程设计实现里缓冲帧数取 100 到 300 是合理区间够用且调试方便。4.3 B 树索引order、查找路径与节点分裂索引是 MiniSQL 里最有技术含量的一块。B 树的叶子节点存“键 RID 列表”非叶子节点只存键和孩子指针。查找从根开始每层找到第一个大于等于目标键的孩子下钻。代码骨架如下// 假设 node 是定长节点结构keys 升序排列 RID btree_search(BTreeNode* node, int32_t key) { while (!node-is_leaf) { int i 0; while (i node-key_count key node-keys[i]) i; node node-children[i]; // 注意 i key_count 时走最右孩子这是最容易漏的边界 } int slot lower_bound(node-keys, node-key_count, key); if (slot node-key_count node-keys[slot] key) return node-rids[slot]; return INVALID_RID; }这个查找循环里有三个边界叶子判断必须在进循环前做孩子指针数量比 key 数多一个相等键要往右还是往左找各实现有差异。走读时重点看分裂逻辑节点满了以后常见做法是从中间位置一分为二中间键上升到父节点。order 就是每个节点最多能装的键数它决定树高。十万条记录、order 去 100 时树高大概在 log_{100/2}(100000) 约等于 3 层三次磁盘 IO 就能定位到记录。order 别选太大太大时单节点分裂要拷贝大量数据插入性能会断崖式下跌。读到这里你已经把 MiniSQL 的核心参数都过了一遍。下面进入真正让人头大的部分编译不通过、跑几步就崩、重启丢数据。这些坑几乎每个做过 MiniSQL 的人都会碰到几个。5. 拿到 MiniSQL 源码后最常踩的 5 个坑一份排查手册这一章是血泪经验汇总。下面五条按出现频率排序每条都按“现象 → 原因 → 解决”的顺序写你照着对照就能少走弯路。5.1 编译报错却定位不到行号先怀疑编码和 C 标准现象用 MSVC 打开源码满屏 C2065 或者“语法错误”报错行指向某个中文字符串常量附近而且错误行号根本对不上。原因源码文件是 UTF-8 无 BOM 编码MSVC 默认按 GBK 解析中文注释和字符串被读成乱码直接把后续代码“吃掉”。解决在 CMake 里给编译器加 /utf-8 参数或者用 VSCode 把源码另存为 UTF-8 with BOM。最快判断方法是用记事本打开源文件看中文注释是否正常不正常就转码。命令行 g 很少出这个问题它默认按 UTF-8 处理。注意Windows 控制台默认代码页是 936GBK程序输出的中文在 UTF-8 终端里也可能乱码这是运行期问题和编译期编码问题是两回事。5.2 exe 在别的机器上启动报错“找不到 VCRUNTIME140.dll”现象自己机器上编译好的 minisql.exe 拷到别的电脑双击没反应或直接弹窗提示缺少 VCRUNTIME140.dll。原因你用 MSVC 编译目标机器没装对应版本的 Microsoft Visual C Redistributable。解决两个办法任选。一是发布时把对应版本的 VC Redistributable 一并安装二是改用 MinGW-w64 的 g 静态链接编译命令加 -static-libgcc -static-libstdc生成 exe 就不依赖这个运行库。需要确认编译架构和系统一致32 位 exe 放 64 位系统上有时会报 0xc000007b属于同一个排查路径。5.3 跑几条 SQL 就崩溃内存越界藏在 record_size 算错里现象插入几十条正常数据后程序在随机位置崩溃报“堆损坏”或 SIGSEGV。用调试器单步又很难复现。原因最常见的根子是字段长度没算对。比如 char(20) 实际存储时加了结束符占 21 字节但 record_size 只按 20 算或者插入的中文字符串单字节截断写到下一列的地盘上。解决用 AddressSanitizer 编译一遍直接定位。g 编译时加 -fsanitizeaddress -g然后跑最小复现用例。它会把越界写入的具体函数和行号直接打出来比自己一行行猜快得多。这类问题修完一定要加回归用例把“插入超长字符串再查回来”写进测试脚本防止后面改索引时顺手改坏。5.4 插入中文后查询结果乱码或数据文件打不开现象insert 一条带中文的记录select 出来是乱码更诡异的是建表时表名用中文程序直接报“文件打不开”。原因文件路径编码问题。Windows 下 fopen 默认用 ANSI 编码解析路径源码里字符串字面量是 UTF-8中文路径就成了无效编码。数据乱码则是代码页不匹配程序内部按 UTF-8 存控制台按 GBK 显示。解决第一选择是表名列名强制用英文这是 MiniSQL 类项目的通用约定也是最省事的做法。如果必须支持中文数据把程序启动处的 setlocale 打开并在终端里执行 chcp 65001 切到 UTF-8 代码页。不建议在源码层面硬扛因为你还要考虑数据文件跨平台拷贝的问题中文路径会一路带来麻烦。5.5 重启后数据查不到索引文件和表文件不同步现象建表、插入、查询都正常退出程序再启动select 出来是空表或者没建索引时能查到建了索引反而查不到。原因数据文件写了但索引没更新。插入时只往表文件追加记录没同步插入 B 树或者 create index 只建了空的索引文件没有回填已有数据。重启后缓冲区里残留的索引数据失效查询走索引自然查不到。解决先看插入路径确认 insert 是否在写完记录后调用了 index_insert 或类似接口再看 create index 是否遍历了现有数据回填。最省事的规避是“先建表插入数据最后 create index”但这是苟且方案答辩前必须把索引回填逻辑补齐。判断方法很简单删除索引文件后重启再查一次如果能查到问题就锁定在索引同步上。6. 给 MiniSQL 加一条 DROP TABLE用调试器验证你对这套代码的掌握程度如果你完整跟到了这里下一步不是去重写 B 树而是找一个覆盖面足够广、又不会破坏现有功能的小功能去练手。我强烈建议给 MiniSQL 补一条 DROP TABLE。它比 CREATE TABLE 短又横跨了解析、目录管理、文件操作三条路径是一条完美的验证链路。具体来说先在 parser 的语句分发处加一个分支识别drop table 表名这种格式写进 Query 结构体。然后在 executor 的 switch 里加对应 case调用 catalog 删除表结构并删除对应数据文件。代码骨架大致是这个样子if (query.sql_type SQLType::DROP_TABLE) { if (!catalog.has_table(query.table_name)) { throw std::runtime_error(table not exists); } // 先关掉文件句柄再删文件顺序反了会出现“文件被占用”的报错 file_mgr.close_file(query.table_name); std::string data_file query.table_name .db; if (std::remove(data_file.c_str()) ! 0) { std::cerr drop failed: file not found\n; } catalog.drop_table(query.table_name); }加完之后用调试器在 drop_table 函数入口下断点触发一次 drop table单步观察一个字符串是怎么从 parser 一路穿透到文件删除的。这一步比看十遍代码都有用它能把前四章讲的调用链在调试器里真正串起来。如果调试器里发现 drop 语句根本没走到你的新代码那就是 parser 分发条件写错了退回检查解析分支。我当年第一次改这类项目时在“文件句柄没关就删除”上翻过车Windows 会直接报文件占用之后每次加功能都会养成两个习惯先查文件生命周期再查索引要不要同步更新。这两个习惯一直沿用到现在。希望帮到你。本文还有配套的精品资源点击获取