简介:这份资源是浙江大学数据库设计课程的期末大程成果——miniSQL迷你数据库系统,面向正在学习数据库原理、希望动手理解SQL底层实现的高校学生与开发者。压缩包共29个文件,约885KB,包含9个cpp源文件、9个头文件、9个txt说明、1份PDF设计报告和1个可直接运行的exe,源码与可执行程序齐备,无需自行编译即可体验。已有1532人学习下载,说明其在数据库课程学习者中有一定认可度。项目围绕SQL语句解析与执行展开,涉及SELECT、INSERT、UPDATE、DELETE等基本操作,以及索引结构、缓冲管理、记录管理、查询优化与事务ACID特性等核心模块,配合设计报告可了解目标设定、实现细节与问题解决思路。读者既能研读C/C++源码理解数据库引擎内部运作,也能运行测试用例验证功能正确性,适合作为课程设计参考或数据库系统入门实践平台。
1. 从零手写一个能跑 SQL 的数据库:这个 C/C++ 期末大程到底值不值得拆
很多人学数据库,学到最后脑子里只有一句SELECT * FROM t,至于这条语句在引擎里怎么被拆成词法单元、怎么走索引、怎么落盘,全是黑匣子。这个项目就是冲着打破黑匣子去的:用 C/C++ 从零实现一个 miniSQL,覆盖建库建表、增删改查、索引、缓冲管理和基本的事务语义,属于那种“跑通一遍,数据库课才算真听懂”的工程型作业。它适合两类人:一类是正在啃数据库原理、想找个能编译能调试的完整参考实现的学生;另一类是想看看一个存储引擎从 SQL 文本到磁盘页之间到底隔了几层的手艺人。下面我按“它是什么、怎么跑、核心怎么实现、哪里容易翻车、怎么改”的顺序,把这份源码拆开讲。
2. 工程结构与技术选型:一个 miniSQL 被切成了哪几层
2.1 目录骨架与模块职责
这类 C/C++ 数据库课程项目,常见做法是按“前端解析—中间执行—后端存储”三段式切分,目录大致长这样(不同版本命名会有出入,但职责基本对应):
miniSQL/ ├── src/ │ ├── parser/ # 词法分析、语法分析,产出语句对象 │ ├── catalog/ # 元数据管理:库、表、列、索引的定义 │ ├── executor/ # 执行器:把语句对象翻译成对记录/索引的操作 │ ├── record/ # 记录管理:行格式、页内槽位、表的堆文件 │ ├── index/ # B+ 树索引实现 │ ├── buffer/ # 缓冲池:页的换入换出、脏页回写 │ ├── storage/ # 磁盘管理器:文件、页读写 │ └── common/ # 错误码、配置、工具函数 ├── test/ # 测试 SQL 脚本与期望输出 ├── CMakeLists.txt └── README.md这个分层不是摆设,它决定了你调试时的定位路径:SQL 跑不对,先看 parser 产出的语法树对不对;语法树对了但结果错,往 executor 走;执行逻辑对但数据丢了,问题一定在 record/buffer/storage 这条链上。我一般排查时会在这三层各打一个日志点,比满屏printf高效得多。
技术选型上,语言是 C/C++,构建用 CMake(少数版本用 Makefile),没有引入第三方解析库,词法和语法分析基本是手写的递归下降。这一点很关键:它意味着你不需要装 flex/bison 也能编译,但也意味着 SQL 支持的语法子集是有限的,别拿完整 MySQL 的语法去怼它。
2.2 编译与首次运行
标准流程是先建构建目录再编译,避免污染源码树:
mkdir -p build && cd build cmake .. make -j4编译产物通常是一个可执行文件(如minisql),启动后进入交互式命令行,逐条输入 SQL 并以分号结尾执行。首次跑通建议按这个顺序验证,每一步都能暴露不同层的问题:
create database testdb; use testdb; create table student (id int, name char(16), score float, primary key(id)); insert into student values (1, 'alice', 90.5); select * from student; create index idx_name on student(name);如果第一条create database就报错,先查工作目录的写权限和数据库根目录配置;如果建表成功但插入失败,重点看记录长度计算和页空间判断;如果插入成功但select空结果,八成是缓冲池没把脏页刷出去或者游标遍历逻辑有边界问题。这套“逐条验证”的习惯,比一上来跑一个大测试脚本更容易定位。
2.3 元数据是怎么存的
catalog 层负责记住“有哪些库、每个库有哪些表、每张表有哪些列和索引”。常见做法是把这些信息序列化成固定格式写进系统表或独立的元数据文件,启动时加载进内存。这里有个设计取舍值得注意:元数据如果和用户数据共用缓冲池,实现统一但容易在启动阶段出现“读元数据要先初始化缓冲池、缓冲池初始化又要读元数据”的循环依赖。多数实现会把 catalog 单独走一条轻量 IO 路径,绕开缓冲池。你二次开发加新类型(比如varchar)时,改的第一处往往就是 catalog 里的列类型枚举和它的序列化长度。
3. 核心链路拆解:一条 SQL 从文本到磁盘页
3.1 词法与语法分析:把字符串变成语句对象
parser 是整个系统的入口。手写解析器的典型结构是:词法分析器把输入切成 token 流(关键字、标识符、数字、字符串、符号),语法分析器按文法规则消费 token,构造出对应的语句对象。下面是一个简化后的词法扫描核心,能看出这类实现的通用套路:
// 简化版词法扫描:从输入串中切出下一个 token Token Lexer::nextToken() { skipWhitespace(); // 跳过空白,注释也在这里处理 if (pos >= input.size()) return {TokenType::END, ""}; char c = input[pos]; if (isalpha(c) || c == '_') { // 标识符或关键字 size_t start = pos; while (pos < input.size() && (isalnum(input[pos]) || input[pos] == '_')) pos++; std::string word = input.substr(start, pos - start); return {lookupKeyword(word), word}; // 查关键字表,命中则是关键字 } if (isdigit(c)) { // 整数或浮点字面量 size_t start = pos; while (pos < input.size() && (isdigit(input[pos]) || input[pos] == '.')) pos++; return {TokenType::NUMBER, input.substr(start, pos - start)}; } pos++; return {symbolType(c), std::string(1, c)}; // 单字符符号:, ( ) ; 等 }逻辑说明:skipWhitespace负责吞掉空格和换行,lookupKeyword用一张哈希表把select/insert/create等词识别成关键字,其余按标识符处理。参数上要注意pos是全局游标,任何分支都必须保证它前进,否则会死循环——这是手写词法器最常见的翻车点。字符串字面量(单引号包裹)通常单独一个分支处理,要支持转义和未闭合报错。
语法分析用递归下降,每个非终结符对应一个函数,比如parseSelect()里依次匹配SELECT、列列表、FROM、表名、可选的WHERE条件。产出的语句对象一般是个基类指针加若干派生类(SelectStmt、InsertStmt 等),executor 靠dynamic_cast或虚函数分发。这里我建议给每个语句对象加一个toString(),调试时打印出来,比在解析器里打断点直观。
3.2 执行器与记录管理:一行数据在页里怎么摆
执行器拿到语句对象后,对insert要做的事是:定位目标表 → 计算这行记录的字节长度 → 在表的堆文件中找一页有足够空闲空间的页 → 写入并更新页头。记录在页内的组织常见两种:定长槽位数组(每行固定偏移)和带槽目录的变长布局。课程项目多用前者,实现简单。核心写入逻辑大致如下:
// 向指定页插入一条记录,返回槽位号;空间不足返回 -1 int RecordPage::insertRecord(const char* data, int len) { if (header.freeSpaceSize < len + sizeof(Slot)) return -1; // 空间不够 int slotId = header.slotCount++; Slot& slot = slots[slotId]; slot.offset = header.freeSpaceOffset; // 从空闲区尾部往前分配 slot.length = len; memcpy(pageData + slot.offset, data, len); header.freeSpaceOffset += len; header.freeSpaceSize -= (len + sizeof(Slot)); return slotId; }逻辑说明:freeSpaceOffset从页尾向前增长,槽目录从页头向后增长,两者相遇即页满,这是经典的“双端增长”布局。参数len是序列化后的行长度,必须和建表时按列类型算出的定长一致,否则后续按偏移读取会错位。Slot里存 offset 和 length,删除时只需把槽标记为无效(墓碑标记),真正的空间回收留给页整理或 vacuum 逻辑——很多实现偷懒不做回收,跑久了页会“假满”,这是后面避坑章节要说的点。
select的执行路径是:通过 catalog 拿到表的根页 → 沿页链遍历 → 对每页的每个有效槽反序列化成行 → 若有WHERE则逐行求值过滤 → 输出。条件求值器通常是个简单的表达式树,支持列与常量的比较,复杂表达式(函数、子查询)多数版本不支持,别指望。
3.3 B+ 树索引:为什么你的等值查询快不起来
索引模块是这类项目的重头戏。B+ 树实现的关键在于节点分裂与合并的边界处理。插入时先找到目标叶子,插入后若超出容量则分裂并把中间键上推到父节点,父节点满了继续递归分裂,直到根。下面这段是叶子节点插入后判断分裂的骨架:
// 叶子节点插入键值对,返回是否需要分裂以及上推的分隔键 bool BPlusLeaf::insert(int key, const RID& rid, int& pushKey, Node*& newNode) { int pos = lowerBound(key); // 二分找插入位置 keys.insert(keys.begin() + pos, key); rids.insert(rids.begin() + pos, rid); if (keys.size() <= maxKeys) return false; // 未超容量,无需分裂 int mid = keys.size() / 2; // 从中间劈开 BPlusLeaf* right = new BPlusLeaf(); right->keys.assign(keys.begin() + mid, keys.end()); right->rids.assign(rids.begin() + mid, rids.end()); keys.resize(mid); rids.resize(mid); right->next = this->next; // 维护叶子链表,范围扫描要用 this->next = right; pushKey = right->keys.front(); // 上推右节点最小键 newNode = right; return true; }逻辑说明:lowerBound用二分保证键有序,maxKeys是节点容量(通常按页大小除以键值大小估算)。分裂后必须维护叶子层的next指针,否则范围查询where id > 10走到叶子边界就断了——这是索引实现里最容易被忽略、又最影响正确性的一处。pushKey是给父节点用的分隔键,父节点据此决定往左还是往右走。删除时的合并逻辑更麻烦,很多课程实现只做“标记删除 + 不合并”,查询正确但空间浪费,二次开发时如果要做真正的删除回收,得把兄弟节点的借用与合并补全。
索引和执行器的衔接点是:insert时除了写记录,还要把主键或索引列的值连同 RID 插进对应 B+ 树;select时优化器(如果有的话)判断WHERE是否命中索引列,命中则走索引拿 RID 再回表,否则全表扫描。多数 miniSQL 的“优化器”就是几条 if 判断,别期待基于代价的选择。
4. 避坑与排查:跑不通时先看这几处
4.1 现象:编译过了,一运行就段错误
原因通常是空指针解引用,集中在两处:catalog 加载时表元数据没找到却直接用了,或者缓冲池返回了空页帧但调用方没判空。解决方式是给所有“按名字查找”的接口加断言和错误码返回,启动阶段先把系统表加载日志打出来,确认库表元数据真的读进来了再往下走。
4.2 现象:插入几百行后突然报“页空间不足”
原因多半是删除只做了墓碑标记、没有页内整理,空闲空间碎片化,页头统计的freeSpaceSize看着够但连续空间不够。解决是插入失败时触发一次页整理(把有效记录紧凑排列、重算槽偏移),或者干脆换页继续插。我一般会在页头额外记一个“最大连续空闲”,插入前先判断。
4.3 现象:带索引的查询结果比全表扫描还慢或结果缺失
原因常见于索引键类型和列类型不一致(比如int列按字符串比较),或者范围扫描时叶子链表没接上。排查方法是先关掉索引走全表扫描对比结果集,一致则问题在索引维护,不一致则问题在记录层。索引维护要重点看插入/删除时 B+ 树是否同步更新,很多 bug 是“记录删了索引没删”。
4.4 现象:程序退出后数据全没了
原因是脏页只在内存里改了、退出时没刷盘,或者刷盘顺序错了(先写数据页后写元数据,崩溃后元数据指向不存在的页)。解决是在正常退出路径上强制flush所有脏页并落元数据,调试期可以加一个“每次事务提交即刷盘”的开关,牺牲性能换确定性。
提示:调试存储层时,把页号、槽号、记录长度打进日志,比打印整行数据有用得多,出问题时能直接定位到具体页。
5. 二次开发怎么改:从加一个类型到补一套事务
想在这个项目上做增量,最稳的切入点是“加一个数据类型”,因为它会强迫你把 parser、catalog、record、索引四条链都走一遍,改完对整个架构的理解会上一个台阶。以加varchar为例,改动点大致是:
| 改动位置 | 具体动作 | 验证方式 |
|---|---|---|
| parser | 词法识别varchar关键字,语法接受varchar(n) | 建表语句能解析出列类型和长度 |
| catalog | 列类型枚举加一项,序列化时存长度 | 重启后表结构还在 |
| record | 行长度改为变长计算,序列化带长度前缀 | 插入不同长度字符串后读回一致 |
| 索引 | 字符串键的比较规则统一(大小写、排序) | 等值与范围查询结果正确 |
改完类型,第二个值得做的是把“删除不回收”补成真正的空间复用,这会牵动页整理和 B+ 树删除合并,是理解存储引擎空间管理的最好练习。第三个方向是事务:课程项目多数只做到“语句级原子”,你可以加一个简单的 undo 日志,让begin/commit/rollback真正生效,验证方法是故意在事务中途 kill 进程,重启后检查未提交的修改是否被回滚。
这里有个血泪经验:每次改完一个模块,别急着跑大测试脚本,先用最小 SQL 序列验证——建表、插一行、查一行、删一行、再查。我吃过一次亏,改完变长记录后直接跑批量插入,结果错位的数据把索引也带歪了,排查了两天才发现根因是行长度计算少算了一个长度前缀字节。从那以后我每次动 record 层,都强制先走一遍“单行往返”的最小验证,再放开批量。希望这个习惯能帮到你,少走点弯路。
本文还有配套的精品资源,点击获取