简介这份资源是浙江大学数据库设计课程的期末大程成果——miniSQL迷你数据库系统面向正在学习数据库原理、希望动手理解SQL底层实现的高校学生与开发者。压缩包共29个文件约885KB包含9个cpp源文件、9个头文件、9个txt说明、1份PDF设计报告和1个可直接运行的exe源码与可执行程序齐备无需自行编译即可体验。已有1532人学习下载说明其在数据库课程学习者中具有一定参考价值。项目围绕SELECT、INSERT、UPDATE、DELETE等基本SQL语句及JOIN、事务处理展开涉及关系数据模型、ACID属性、查询优化与索引结构等核心概念并配有测试用例验证功能完整性。读者可通过阅读源码理解SQL如何被解析执行借助设计报告梳理项目目标、设计决策与问题解决方案再运行测试用例观察系统在各类查询与并发场景下的表现从而把课堂理论落到具体工程实践中。1. 从零手写一个 miniSQL为什么数据库课设值得认真做一遍数据库这门课期末大程如果只是交一份 ER 图加几张建表语句说实话很难拉开差距。真正能体现功力的是用 C/C 从零实现一个能跑 SQL 的 miniSQL 引擎——自己写词法分析、语法解析、执行计划、存储管理、索引结构最后让CREATE TABLE、INSERT、SELECT ... WHERE、CREATE INDEX这些语句真正跑起来。这套方案面向的正是要交数据库课程设计、毕设或需要一份完整数据库内核实现参考的人。它不依赖任何现成数据库引擎纯 C/C 手写把「SQL 语句怎么变成磁盘上的字节」这条链路完整走一遍。下面按需求、架构、模块实现、避坑、验收的顺序把这份 miniSQL 方案拆到可以直接照着做的程度。2. 需求与总体架构一个能跑 SQL 的最小数据库内核长什么样2.1 功能边界先划清楚做 miniSQL 最容易翻车的地方不是写不出来而是范围失控。一上来就想支持 JOIN、子查询、事务、并发控制最后连SELECT都跑不通。我一般会把功能边界压到「能演示完整链路」的最小集合通常的做法是锁定这几条功能类别必须支持明确不做数据定义CREATE TABLE、DROP TABLEALTER TABLE数据操作INSERT、DELETE、UPDATE批量导入数据查询SELECT 全表、WHERE 单条件/多条件、ORDER BYJOIN、子查询、聚合索引CREATE INDEX、DROP INDEX、等值查询走索引范围查询优化数据类型int、float、char(n)日期、大文本约束主键、唯一索引外键、CHECK这个边界不是拍脑袋定的而是按「每个功能能否在两周内跑通并演示」倒推的。主键和唯一索引必须做因为它们是索引模块的天然验证场景JOIN 不做因为它会把执行器从单表扫描直接拉到多表连接复杂度翻倍而演示价值有限。把这张表贴在需求文档第一页答辩时老师问「为什么不做事务」你指着边界表说「这是本期范围外的明确取舍」比临场解释强得多。2.2 四层架构与模块职责miniSQL 的经典分层是四层从 SQL 文本到磁盘字节依次是解析层、执行层、索引层、存储层。每层只和相邻层打交道接口定死之后各写各的最后拼起来才不会互相打架。SQL 文本 │ ▼ [解析层] 词法分析 → 语法分析 → 生成语句对象 │ ▼ [执行层] 语句分发 → 执行器建表/插入/查询/删除 │ ▼ [索引层] B 树索引管理可选走索引 │ ▼ [存储层] 缓冲池 → 页管理 → 磁盘文件解析层负责把SELECT * FROM student WHERE id 1;切成 token 再组装成语法树输出一个结构化的语句对象比如SelectStmt{ table, columns, conditions }。执行层拿到语句对象后按类型分发到对应执行器执行器调用索引层判断能不能走索引最终通过存储层读写页。存储层是地基缓冲池管理内存中的页、页管理负责页内记录的增删改、磁盘文件负责持久化。这个分层的关键在于接口先行。我一般会先把每层的头文件写出来比如存储层对外只暴露readPage(pageId)、writePage(pageId, data)、allocatePage()三个函数执行层根本不需要知道页内部怎么组织记录。接口定完四个人可以并行开发最后联调时问题只可能出在接口理解偏差上而不是「你改了我的实现」。2.3 技术选型与工程结构语言用 C11 及以上理由很实际需要 RAII 管理文件句柄和内存页需要std::vector、std::string处理变长数据纯 C 写起来指针满天飞容易内存泄漏。构建用 CMake目录结构按层划分minisql/ ├── CMakeLists.txt ├── src/ │ ├── parser/ # 词法、语法、语句对象 │ ├── executor/ # 各语句执行器 │ ├── index/ # B 树 │ ├── storage/ # 缓冲池、页管理、磁盘 │ └── common/ # 错误码、类型定义、配置 ├── test/ # 单元测试与 SQL 脚本 └── data/ # 运行时数据文件目录CMake 里把每个子目录编成静态库主程序链接它们这样单测可以只链某一层。common/放全局类型比如enum class DataType { INT, FLOAT, CHAR }和统一的错误码避免各层各定义一套类型导致转换时对不上。这套结构不复杂但能让你在写到第三周时还找得到代码在哪。3. 解析层实现词法分析与语法分析怎么落地3.1 词法分析把 SQL 切成 token词法分析器lexer的职责是把输入字符串按规则切成 token 流。miniSQL 的 token 类型不多关键字SELECT、FROM、WHERE 等、标识符、数字常量、字符串常量、运算符、、、、、、分隔符逗号、分号、括号。手写一个状态机就够不需要引入 flex。// lexer.h enum class TokenType { KEYWORD, IDENTIFIER, NUMBER, STRING, OP_EQ, OP_LT, OP_GT, OP_LE, OP_GE, OP_NE, COMMA, SEMICOLON, LPAREN, RPAREN, END }; struct Token { TokenType type; std::string text; // 原始文本 int line; // 行号报错用 }; class Lexer { public: explicit Lexer(const std::string sql); std::vectorToken tokenize(); // 一次性切完 private: std::string input_; size_t pos_ 0; int line_ 1; void skipWhitespace(); Token nextToken(); };tokenize()循环调用nextToken()直到遇到END。skipWhitespace()跳过空格、制表符、换行同时维护行号。关键字识别用一个std::unordered_setstd::string存所有保留字标识符切出来后查表判断是关键字还是普通标识符。数字常量要处理负号和小数点字符串常量用单引号包裹、内部支持转义。这里有个血泪经验报错信息一定要带行号和出错位置。学生写解析器最容易只返回一个false调试时完全不知道错在哪。Token里存line解析失败时打印「第 3 行期望 FROM实际得到 WHERE」能省掉大量猜谜时间。3.2 语法分析递归下降生成语句对象语法分析用递归下降因为 miniSQL 的文法简单手写比 yacc/bison 更可控也更容易在答辩时讲清楚。核心思路是每个非终结符对应一个函数函数内部按文法规则依次匹配 token。// parser.cpp 片段解析 SELECT 语句 std::unique_ptrSelectStmt Parser::parseSelect() { expect(TokenType::KEYWORD, SELECT); auto stmt std::make_uniqueSelectStmt(); // 列列表* 或 col1, col2, ... if (match(TokenType::OP_MUL)) { stmt-selectAll true; } else { stmt-columns.push_back(expectIdentifier()); while (match(TokenType::COMMA)) { stmt-columns.push_back(expectIdentifier()); } } expect(TokenType::KEYWORD, FROM); stmt-tableName expectIdentifier(); // 可选 WHERE 子句 if (match(TokenType::KEYWORD, WHERE)) { stmt-condition parseCondition(); } expect(TokenType::SEMICOLON); return stmt; }expect()在类型不匹配时抛解析异常match()在匹配时消费 token 并返回 true。parseCondition()递归处理AND/OR连接的复合条件生成一棵条件树执行层遍历这棵树做过滤。语句对象用std::unique_ptr管理解析完直接交给执行层生命周期清晰。设计说明递归下降的优点是错误定位准、代码即文法缺点是左递归文法要改写。miniSQL 的文法里没有复杂的左递归改写成本很低。参数上SelectStmt里的condition用std::shared_ptrConditionNode存树结构节点类型分COMPARISON、AND、OR三种执行层按节点类型递归求值即可。3.3 语句对象与错误处理约定所有语句对象继承一个基类Statement带一个StmtType枚举执行层用switch分发。错误处理统一用异常解析层抛ParseError执行层抛ExecError主循环捕获后打印错误信息并继续读下一条 SQL。这样单条语句出错不会让整个程序崩掉交互式命令行才能用。struct Statement { virtual ~Statement() default; }; struct CreateTableStmt : Statement { std::string tableName; struct ColumnDef { std::string name; DataType type; int length; bool isPrimary; }; std::vectorColumnDef columns; };ColumnDef里length只对char(n)有意义int/float固定长度。主键标记isPrimary在建表时用来自动创建唯一索引这是后面索引模块的入口。这套对象设计不追求通用只服务 miniSQL 的语句集合够用且好维护。4. 存储与索引层页管理、缓冲池和 B 树索引4.1 页管理与磁盘文件布局存储层的最小单位是页通常定 4KB和操作系统页大小对齐。每个表对应一个数据文件文件由定长页顺序排列组成页号从 0 开始。页内布局分页头和数据区页头存记录数、空闲空间偏移、下一页指针用于溢出页数据区存变长记录。// page.h constexpr int PAGE_SIZE 4096; struct PageHeader { uint16_t recordCount; // 当前页记录数 uint16_t freeOffset; // 空闲区起始偏移 int32_t nextPage; // 溢出页号-1 表示无 }; class Page { public: char data[PAGE_SIZE]; PageHeader* header() { return reinterpret_castPageHeader*(data); } // 在页内插入一条记录返回槽位号空间不足返回 -1 int insertRecord(const std::vectorchar record); bool deleteRecord(int slot); std::vectorchar getRecord(int slot); };insertRecord从freeOffset处追加记录同时在页尾维护一个槽位目录slot directory存每条记录的偏移和长度。删除记录时只把槽位标记为无效不立即搬移数据避免频繁整理。页满时返回 -1由上层分配新页并用nextPage串成链表。设计说明槽位目录是页内变长记录管理的关键它让记录可以原地更新而不影响其他记录。参数上PAGE_SIZE定 4096 是权衡结果——太小页数多、文件碎片多太大单页读写浪费 I/O。4KB 是大多数系统的默认值miniSQL 沿用即可。4.2 缓冲池内存页的调度与淘汰缓冲池管理固定数量的内存页帧对外提供「按页号取页」的接口内部负责从磁盘加载、脏页回写、满了之后淘汰。miniSQL 用 LRU 淘汰策略就够不需要复杂的时钟算法。class BufferPool { public: BufferPool(const std::string file, size_t poolSize); Page* fetchPage(int pageId); // 取页未命中则加载 void unpinPage(int pageId, bool dirty); // 用完释放 void flushAll(); // 退出时回写所有脏页 private: struct Frame { Page page; int pageId -1; int pinCount 0; // 被引用次数0 不可淘汰 bool dirty false; size_t lastUsed 0; }; std::vectorFrame frames_; std::unordered_mapint, size_t pageToFrame_; size_t clock_ 0; };fetchPage先查pageToFrame_命中则更新lastUsed并pinCount未命中则找一个pinCount 0的帧脏页先回写再从磁盘读入新页。unpinPage减引用计数并标记脏位。flushAll在程序退出或DROP TABLE时调用。这里有个容易忽略的点pinCount 必须严格配对。执行器取页后忘了 unpin缓冲池很快耗尽表现为「跑几条语句就卡住」。我一般会在fetchPage里加断言pinCount超过阈值就打印警告联调时能第一时间发现漏 unpin 的地方。4.3 B 树索引等值查询怎么走索引索引层实现一棵 B 树键是索引列的值值是记录所在的(pageId, slot)。miniSQL 只需要支持等值查询和插入删除不需要范围扫描所以 B 树的叶子节点用有序链表串联即可内部节点存键和子节点指针。// bplus_tree.h 关键接口 class BPlusTree { public: // 插入键值对key 为序列化后的索引列值 bool insert(const std::string key, const RID rid); bool remove(const std::string key); // 等值查找返回所有匹配的记录位置 std::vectorRID search(const std::string key); private: int rootPageId_; // 节点分裂、合并等内部操作 void splitLeaf(int pageId); void splitInternal(int pageId); }; struct RID { int pageId; int slot; };insert从根向下找到目标叶子插入后若溢出则分裂把中间键上推到父节点父节点溢出继续分裂直到根。search同样从根向下到叶子后沿链表找所有相等键。键的比较用memcmp对序列化后的字节做字典序比较int和float要先转成大端序再序列化否则字节序比较结果不对——这是个经典坑后面避坑章节会展开。设计说明B 树的阶数每个节点最多多少键由页大小和键长决定通常的做法是让一个节点刚好占一页。索引文件独立于数据文件CREATE INDEX时扫描全表建树DROP INDEX时删文件。执行器在生成执行计划时判断 WHERE 条件是否命中索引列命中则走search拿 RID 再回表否则全表扫描。5. 执行层与避坑语句执行器怎么写、哪些地方最容易翻车5.1 执行器分发与查询执行流程执行层拿到语句对象后按类型分发。以 SELECT 为例执行流程是确定表 → 判断能否走索引 → 逐条取记录 → 用条件树过滤 → 投影列 → 输出。走索引和全表扫描的差别只在「怎么拿记录」这一步过滤和投影逻辑复用。// executor.cppSELECT 执行核心 void SelectExecutor::execute(const SelectStmt stmt) { Table* table catalog_-getTable(stmt.tableName); std::vectorRID candidates; // 尝试走索引条件树里找可用的等值条件 auto indexCond findIndexCondition(stmt.condition, table); if (indexCond) { candidates table-getIndex(indexCond-column) -search(serializeKey(indexCond-value)); } else { candidates table-scanAll(); // 全表扫描 } for (const RID rid : candidates) { auto record table-getRecord(rid); if (stmt.condition !evalCondition(stmt.condition, record)) continue; printRecord(record, stmt.columns); // 投影输出 } }findIndexCondition遍历条件树找形如col 常量且col上有索引的节点。evalCondition递归求值条件树AND节点两边都真才真OR节点一边真即真。printRecord按stmt.columns投影selectAll时输出全部列。参数说明serializeKey把常量按列类型序列化成字节串必须和建索引时的序列化方式完全一致否则查不到。这是执行层和索引层的隐式契约我一般把它抽成common/serialize.h里的公共函数两边都调它杜绝各写一套。5.2 避坑清单五条最容易翻车的点现象一索引查不到数据全表扫描却正常。原因通常是键序列化不一致比如建索引时int按小端序存查询时按大端序序列化字节比较结果对不上。解决方法是把序列化逻辑收敛到公共函数建索引和查询都调同一个并在单测里加一条「插入后立即按索引查」的用例。现象二跑几十条 INSERT 后程序卡死。原因是缓冲池页帧被 pin 住没释放fetchPage找不到可淘汰的帧。解决方法是检查每个fetchPage是否都有配对的unpinPage在fetchPage里加pinCount上限断言超限直接报错定位调用栈。现象三删除记录后查询还能查到。原因是删除只标记了槽位无效但扫描时没跳过无效槽位。解决方法是在scanAll和getRecord里统一检查槽位有效位无效槽位直接跳过。这个 bug 很隐蔽因为数据还在页里只是逻辑上不该被看到。现象四字符串比较结果和预期相反。原因是char(n)定长存储时尾部补了\0或空格比较时把填充字符也算进去了。解决方法是在比较前按列定义截断到实际长度或者统一用「补空格到 n 再比较」的规则并在文档里写清楚。现象五建表后重启程序表不见了。原因是 catalog系统表只存在内存里没持久化。解决方法是把表结构元数据也写进一个系统文件启动时加载。catalog 本身可以当成一张特殊的表用同一套存储层读写省得另写一套持久化逻辑。5.3 系统表与元数据管理catalog 管理所有表的元数据表名、列定义、索引列表、数据文件路径。最省事的做法是把 catalog 当成一张内存表启动时从catalog.dat加载每次 DDL 操作后回写。表结构变化不频繁全量回写的开销可以接受。struct TableMeta { std::string name; std::vectorColumnDef columns; std::vectorstd::string indexColumns; // 已建索引的列 std::string dataFile; }; class Catalog { public: void load(const std::string path); void save(const std::string path); TableMeta* getTable(const std::string name); void addTable(const TableMeta meta); void dropTable(const std::string name); };load在程序启动时读文件反序列化成TableMeta列表save在每次CREATE/DROP TABLE、CREATE/DROP INDEX后调用。dropTable除了删元数据还要删数据文件和索引文件并释放缓冲池里该表的所有页——漏了这步下次建同名表会读到旧页表现为「新表里有旧数据」非常迷惑。6. 验收与答辩怎么证明这个 miniSQL 真的能跑验收的核心不是「代码写完了」而是「拿一组 SQL 脚本跑一遍结果可复现、错误可解释」。我一般会准备三组测试脚本分别对应功能正确性、边界情况和错误处理答辩时现场跑比口头讲架构有说服力。第一组是功能冒烟脚本覆盖建表、插入、查询、更新、删除、建索引、走索引查询的完整链路CREATE TABLE student (id int, name char(20), score float, PRIMARY KEY(id)); INSERT INTO student VALUES (1, A, 90.5); INSERT INTO student VALUES (2, B, 85.0); SELECT * FROM student WHERE id 1; CREATE INDEX idx_name ON student(name); SELECT id, score FROM student WHERE name B; UPDATE student SET score 95.0 WHERE id 2; DELETE FROM student WHERE id 1; SELECT * FROM student;跑完逐条核对输出重点看走索引的查询结果和全表扫描是否一致。第二组是边界脚本空表查询、插入重复主键、char 列超长、WHERE 条件类型不匹配验证错误提示是否清晰。第三组是持久化脚本建表插数据后退出程序重启再查数据还在才算过。答辩时按「需求边界 → 架构分层 → 关键实现 → 演示 → 取舍说明」的顺序讲控制在十分钟内。老师最常问的三个问题提前准备好答案为什么用 B 树不用哈希等值查询哈希也行但 B 树是有序结构为后续范围查询留了扩展空间缓冲池为什么用 LRU实现简单miniSQL 的访问模式局部性够强LRU 足够不支持事务怎么保证一致性单语句原子性靠「先写日志再改页」的简化版或者明确说明本期不做靠单线程串行执行规避。验收表可以这样列每项打勾才算交付验收项通过标准验证方式DDL 正确性建表/删表后 catalog 与文件一致重启后查表存在性DML 正确性增删改后查询结果符合预期冒烟脚本逐条核对索引一致性走索引与全表扫描结果相同同条件两种方式对比错误处理非法 SQL 有明确报错且不崩溃边界脚本持久化重启后数据不丢退出重进再查从那以后我每次带课设验收都强制要求先跑一遍「退出重进再查」这条因为持久化是最容易在最后一天才发现没做的模块而它恰恰是数据库区别于内存数据结构的关键。希望这套结构能帮你把 miniSQL 从「能编译」推到「能演示、能答辩」需要完整工程和测试脚本的话按文中的目录结构组织即可直接对照实现。本文还有配套的精品资源点击获取