☰
从零手写miniSQL:存储引擎、B+树索引与SQL解析全链路拆解
2026/10/31 18:57:56 网站建设 项目流程

简介:这份资源是浙江大学数据库设计课程的期末大程成果——miniSQL迷你数据库系统,面向正在学习数据库原理、希望动手理解SQL底层实现的高校学生与开发者。压缩包共29个文件,约885KB,以C/C++源码为主体,包含9个cpp与9个h文件,覆盖缓冲管理、记录管理、索引管理、B+树、目录与解释器等核心模块,另附可执行文件、测试文件、说明文档及一份PDF设计报告,可直接运行或研读源码。已有1532人学习下载,说明其在数据库课程学习者中具有一定认可度。读者可从中获得一套完整的数据库引擎实现范例:通过源码理解SELECT、INSERT、UPDATE、DELETE等语句的解析与执行流程,结合设计报告掌握关系模型、ACID属性、查询优化与索引结构如何落地,并借助测试用例验证系统正确性与稳定性,适合作为课程设计参考或数据库内核入门实践材料。

1. 从零手写一个 miniSQL:这份数据库课设资源到底能拿来干什么

如果你正在准备数据库课程的期末大程,或者想找一个能真正跑起来、能看懂内部结构的关系型数据库实现来啃,那这份 miniSQL 资源值得认真拆一遍。它不是调库拼出来的玩具,而是用 C/C++ 从存储层、索引层一路写到 SQL 解析和执行层的小型关系数据库,覆盖建表、插入、删除、查询、索引这些核心链路。适合两类人:一类是要交数据库课设、需要一份结构清晰可复现的参考实现;另一类是想搞明白 B+ 树索引、页式存储、SQL 词法语法分析在代码里到底长什么样的一线开发者。下面按「它是什么、怎么跑起来、坑在哪」的顺序拆。

2. 存储引擎与 B+ 树索引:miniSQL 的骨架怎么搭

2.1 页式存储与缓冲池的选型理由

关系型数据库绕不开的第一个问题就是数据怎么落盘。这份 miniSQL 采用的是经典的页式存储加缓冲池方案,而不是简单的整表读写文件。原因很直接:整表读写在小数据量下看不出问题,一旦表记录数上去,每次查询都全量加载文件,性能会崩得很难看。页式存储把数据切成固定大小的页(常见做法是 4KB 一页),缓冲池在内存里缓存热点页,读写都走内存,脏页再刷回磁盘。

我一般会先看它的页管理模块,重点确认三件事:页大小是否固定、页内记录是怎么组织的、空闲页怎么回收。这份实现里页内用槽位(slot)管理变长记录,每条记录有个偏移量数组,删除记录时只标记槽位为空而不立即搬移数据,这是避免频繁整理页内碎片的常见做法。缓冲池部分用哈希表做页号到帧号的映射,配合 LRU 或时钟算法淘汰。你要复现的话,先把这几个结构体的字段含义搞清楚,后面索引和执行层都建立在这上面。

// 页头结构示意:记录数、空闲槽位、下一页指针 struct PageHeader { uint32_t record_count; // 当前页有效记录数 uint32_t free_slot; // 下一个可用槽位下标 int32_t next_page; // 页链中的下一页,-1 表示无 char data[PAGE_SIZE - sizeof(uint32_t) * 3 - sizeof(int32_t)]; };

上面这段是页头的典型组织方式,record_count和free_slot决定了插入时往哪个槽位写,next_page把同一张表的页串成链表。参数上要注意PAGE_SIZE必须和磁盘块大小对齐,常见取 4096;如果你改成 8192,缓冲池帧数和索引节点容量都要跟着调,否则会出现页内放不下索引项、B+ 树扇出变小的问题。

2.2 B+ 树索引的插入与分裂逻辑

miniSQL 的索引层是整个项目里最值得细看的部分。它用 B+ 树做聚簇或辅助索引,叶子节点存记录指针或主键,内部节点只存键值做路由。为什么选 B+ 树而不是哈希索引?因为课设场景下范围查询和有序遍历是刚需,哈希索引做不了WHERE id > 100这类条件,而 B+ 树叶子节点用链表串起来,范围扫描非常自然。

插入逻辑的关键在节点分裂。当叶子节点满了,要把它一分为二,把中间键上推到父节点;父节点满了继续向上分裂,直到根节点。这里最容易翻车的是分裂后父节点指针的更新和兄弟节点链表的维护。我建议你先把单节点插入跑通,再构造一个刚好触发分裂的测试用例,用打印语句把分裂前后的树结构打出来对照。

// 叶子节点分裂的核心步骤(简化示意) void BPlusTree::splitLeaf(Node* leaf, Node* parent) { Node* right = new Node(leaf->is_leaf); int mid = leaf->key_count / 2; // 把后半部分键值搬到新节点 for (int i = mid; i < leaf->key_count; i++) { right->keys.push_back(leaf->keys[i]); right->rids.push_back(leaf->rids[i]); } leaf->key_count = mid; right->next = leaf->next; // 维护叶子链表 leaf->next = right; parent->insertKey(right->keys[0], right); // 上推分隔键 }

这段代码里mid的取法决定了分裂是否均衡,常见取key_count / 2;right->next = leaf->next这一行是叶子链表不断链的关键,漏了它范围查询就会在分裂点断掉。parent->insertKey之后如果父节点溢出,还要递归处理,这部分别偷懒跳过,否则树高上去后查询会丢记录。

2.3 把存储和索引接起来的执行路径

光有存储和索引还不够,得看它们怎么被上层调用。一条INSERT进来,执行器先通过系统表找到目标表的元信息,拿到根页号,然后调用记录管理器在数据页插入记录,拿到记录 ID 后再调用索引管理器把键值和 RID 插进 B+ 树。查询走索引时反过来:先在 B+ 树里定位到 RID,再回表取记录。这条链路你要能画出来,调试时才知道问题出在哪一层。

实操上建议按这个顺序验证:先只测存储层,插入若干记录后直接按页号读出来比对;再接索引层,插入后按主键查 RID;最后接 SQL 层跑完整语句。每层单独有测试入口,出问题时能快速定位是页管理、索引还是解析的锅。

3. SQL 解析与执行:从词法分析到结果输出

3.1 词法语法分析的实现路线

miniSQL 的 SQL 层一般分两步:词法分析把字符流切成 token,语法分析把 token 序列按文法组装成语法树。这份实现走的是手写递归下降的路子,而不是上 yacc/bison 生成器。手写的好处是依赖少、调试直观,课设环境里不用折腾工具链;代价是文法一复杂就容易写乱,所以它支持的 SQL 子集是有限的,通常覆盖CREATE TABLE、DROP TABLE、INSERT、DELETE、SELECT(带简单WHERE)、CREATE INDEX这几类。

词法分析器核心是一个状态机,遇到字母数字进标识符,遇到引号进字符串,遇到运算符单独成 token。语法分析按语句类型分派到不同函数,每个函数负责吃掉自己那部分 token 并构造节点。你要扩展语法的话,先改词法加 token 类型,再在对应语法函数里加分支,最后在执行器里补上节点处理,三步缺一不可。

// 词法分析主循环示意 Token Lexer::nextToken() { skipWhitespace(); if (isalpha(cur())) return readIdentifier(); // 关键字或表名 if (isdigit(cur())) return readNumber(); if (cur() == '\'' || cur() == '"') return readString(); return readOperator(); // = < > 等 }

skipWhitespace要处理空格、制表、换行;readIdentifier读完还要查关键字表,把SELECT、FROM这类词标成关键字 token 而不是普通标识符。字符串读取要注意转义和闭合引号,漏处理会导致后续 token 全错位,这是解析层最常见的翻车点。

3.2 执行器的算子组织

语法树建好后,执行器按节点类型递归求值。SELECT的执行路径通常是:从FROM拿到表,决定走全表扫描还是索引扫描,然后对每条候选记录应用WHERE条件过滤,最后投影出需要的列。这份实现里算子划分比较朴素,没有做复杂的火山模型优化,但对课设来说够用。

判断走不走索引的逻辑值得看一眼:如果WHERE里有等值条件且该列建了索引,就走索引定位 RID 再回表;否则全表扫描。这里有个容易忽略的点——索引扫描拿到的 RID 顺序不一定是记录在页里的物理顺序,回表时如果按 RID 随机读页,缓冲池命中率会下降。数据量小的时候无所谓,数据量一大就能感觉到差异。

-- 验证索引是否生效的典型语句 CREATE TABLE student (id INT, name CHAR(20), age INT); CREATE INDEX idx_student_id ON student(id); INSERT INTO student VALUES (1, 'A', 20); SELECT * FROM student WHERE id = 1; -- 应走 idx_student_id

建表时字段类型和长度要写对,CHAR(20)的 20 是字节长度不是字符数;建索引后插入会自动维护 B+ 树,如果插入报错先查索引键类型和表列类型是否一致。SELECT的WHERE条件列如果和索引列类型不匹配,可能退化成全表扫描,这点在调性能时要盯住。

3.3 系统表与元数据管理

表结构、索引信息这些元数据得有地方存。miniSQL 一般用一张系统表记录所有用户表的表名、列定义、根页号,再用另一张记录索引信息。启动时先加载系统表,后续所有 DDL 都更新它。这里的设计取舍是:系统表本身也用同一套存储机制,好处是代码复用,坏处是引导阶段要特殊处理——系统表还没加载时怎么读它自己。

常见做法是固定系统表的根页号,启动时直接从那个页号读。你如果改页大小或存储格式,记得同步改这个固定入口,否则一启动就崩。调试 DDL 时先确认系统表写入成功,再查用户表是否真的建出来了,两步分开验证能省不少时间。

4. 编译、运行与测试:把这份课设真正跑起来

4.1 构建环境与依赖确认

这份资源是 C/C++ 实现,构建方式通常是 CMake 或直接 Makefile。拿到代码先看根目录有没有CMakeLists.txt或Makefile,有的话按它来,别自己手敲编译命令。依赖上一般只需要标准库和 pthread(如果做了并发),不需要额外第三方库,这对课设环境很友好。

# 典型构建流程 mkdir build && cd build cmake .. make -j4 # 或直接 make make

cmake ..之后留意输出里有没有找不到头文件或库的报错,有的话先补依赖再往下走。make -j4的 4 是并行编译任务数,机器核少就调小,避免内存吃满。编译产物一般在build/或bin/下,找到可执行文件名再运行。

4.2 启动与基本功能验证

跑起来之后先别急着上复杂 SQL,按「建表 → 插入 → 查询 → 建索引 → 再查询 → 删除」的顺序走一遍,确认主链路通。每步都看返回结果和磁盘文件变化,尤其是插入后数据文件大小有没有增长、建索引后有没有多出索引文件。

-- 最小验证脚本 CREATE TABLE t (id INT, val INT); INSERT INTO t VALUES (1, 100); INSERT INTO t VALUES (2, 200); SELECT * FROM t; CREATE INDEX idx_t_id ON t(id); SELECT * FROM t WHERE id = 2; DELETE FROM t WHERE id = 1; SELECT * FROM t;

如果某一步报错,先看错误信息指向哪一层:语法错误多半在解析,执行错误看执行器,段错误大概率在存储或索引的指针操作。建议开编译器的调试符号(-g),配合 gdb 定位崩溃点,比盲猜快得多。

4.3 测试用例的组织方式

课设验收通常看功能覆盖和边界处理。自己补测试时按维度组织:正常路径、空表查询、重复主键插入、超长字符串、删除不存在记录、索引列更新。每类写几条,跑完记录结果。这份资源如果自带测试脚本,先跑一遍看基线,再往里加自己的用例。没有的话就手写一个 SQL 脚本文件,用重定向喂给程序,方便回归。

5. 避坑与排查:这份 miniSQL 最容易翻车的几个地方

5.1 页内记录删除后空间不回收

现象:反复插入删除后,表数据量不大但查询变慢,数据文件持续膨胀。原因:删除只标记槽位为空,没有做页内碎片整理,空闲槽位散落在页中间,新记录只能往页尾追加。解决:实现一个简单的页整理逻辑,在页内空闲空间不足但有效记录占比低时触发重排;或者至少在插入时优先复用空闲槽位而不是只看页尾。

5.2 B+ 树分裂后范围查询丢记录

现象:数据量超过单节点容量后,WHERE id > x的结果少了若干条。原因:叶子节点分裂时兄弟链表没接对,或者父节点分隔键上推错误,导致遍历在分裂点断开。解决:分裂后打印整条叶子链表,逐节点核对键值是否连续;重点检查right->next = leaf->next和父节点键更新两处。

5.3 索引键类型与表列类型不一致

现象:建了索引但查询仍走全表扫描,或插入时报类型转换错误。原因:建索引时键类型推断和表列定义对不上,比如表列是INT而索引按字符串比较。解决:建索引时强制校验列类型,执行器判断走索引前也做一次类型匹配检查,不匹配就退回全表扫描并给出提示。

5.4 系统表引导阶段读不到元数据

现象:程序启动即崩溃或所有 DDL 失败。原因:系统表根页号写死但存储格式改过,或系统表初始化逻辑在加载前就被调用。解决:固定系统表入口页号并在启动流程里保证它最先加载;改存储格式时同步改这个入口,加一条启动自检打印系统表记录数。

5.5 字符串和变长字段的边界处理

现象:插入接近字段长度上限的字符串时崩溃或截断异常。原因:长度校验用的是字符数而非字节数,或拷贝时没留结束符位置。解决:统一按字节长度校验,拷贝时预留\0位置,超长直接报错而不是静默截断。

6. 进阶玩法:给 miniSQL 加一个 EXPLAIN 和性能对照

把基础功能跑通后,最有价值的进阶动作是给它加一个简易EXPLAIN,把执行计划打出来——这条查询走的是全表扫描还是索引扫描、预计扫多少页。实现上不用复杂,在执行器决定扫描方式的地方埋一个输出点,把表名、是否用索引、索引名打出来即可。加完之后你就能直观看到「建索引前后同一条查询的计划差异」,调优时不再靠猜。

// 执行器里埋点输出执行计划 void Executor::explainSelect(SelectNode* node) { bool use_index = canUseIndex(node->where); printf("TABLE: %s | SCAN: %s", node->table.c_str(), use_index ? "INDEX" : "FULL"); if (use_index) printf(" | INDEX: %s", node->where->col.c_str()); printf("\n"); }

canUseIndex判断WHERE是否有等值条件且列上有索引,逻辑和真正执行时保持一致,否则计划会和实际行为对不上。埋点位置放在执行前,别放在结果输出后,不然报错时看不到计划。

再进一步可以做性能对照:造一张几千行的表,分别在全表扫描和索引扫描下跑同一查询,用clock()计时打印。我自己的血泪经验是,第一次做这种对照时忘了清缓冲池,第二次跑明显比第一次快,误以为是索引生效,其实是页已经在内存里了。从那以后我每次做性能对照都强制先重启进程或清空缓冲池再计时,这个习惯帮我避开了不少玄学数据。希望这份拆解能帮到你,把课设真正跑通、讲清楚。

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

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

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

立即咨询