简介:miniSQL 是浙江大学数据库设计课程的期末大程成果包,面向正在学习数据库系统原理、希望从代码层面理解 SQL 解析与执行机制的本科生及进阶开发者。压缩包共 29 个文件、约 885KB,包含 9 个 cpp 与 9 个头文件构成的 C/C++ 源码、9 个测试文本、1 份 PDF 设计报告和 1 个可直接运行的 exe,覆盖缓冲管理、记录管理、索引管理、B+ 树、解释器等模块。已有 1532 人学习,读者可结合源码、报告与测试用例,梳理基本 SQL 语句到查询执行、事务与并发测试的完整链路。可执行文件便于快速验证系统行为,源码与报告则有助于理解索引结构、ACID 等理论在工程中的落地,适合课程复盘、项目参考和数据库内核入门。
1. 从一个“能跑通就不错了”的误区说起:miniSQL 课程设计到底要交付什么
不少人拿到 miniSQL 数据库课程设计题目,第一反应是先写一个 SQL 解析器,把SELECT语句打印出来就算跑通;真正验收时,老师让你建索引做范围查询、删除一半记录再插入、重启后继续查,问题立刻暴露。这个期末大程要交付的不是“能解析 SQL 的 Demo”,而是一个用 C/C++ 实现的单机数据库内核缩影:SQL 子集可执行、记录能落盘、B+ 树索引真正被查询计划使用、异常输入不崩溃。它适合正在做数据库课程设计、毕设原型,或者想用一套完整骨架理解数据库内核的人。下面按“需求边界—总体设计—核心模块—避坑—验收”拆开讲。
2. 总体架构与存储选型:四层结构与4KB页布局怎么定
2.1 需求边界:先锁 SQL 子集与对象模型
miniSQL 最容易失控的地方是语法范围。我的建议是先把 SQL 子集写进一页纸的需求表,再动手写代码。课程设计通常要求支持CREATE TABLE、DROP TABLE、CREATE INDEX、DROP INDEX、INSERT、DELETE、UPDATE、SELECT,WHERE里支持=、<>、<、<=、>、>=和AND,类型支持int、float、char(n)即可。不要一上来做JOIN、子查询、聚合和视图,这些会把解析器和执行器同时拖进深水区。
对象模型也要先定:数据库是一个目录,表是一个数据文件加一个系统表记录,索引是一个独立文件。表名、列名、列类型、列长度、主键列、索引名这些元数据,通常的做法是放在catalog系统表里,启动时加载到内存。很多同学把表结构硬编码在代码里,后面加列、改索引全要重编译,这就是典型的给自己埋雷。
我一般会要求每个表至少有一个主键,主键上自动建唯一索引;非主键索引允许重复键,但 B+ 树里要存RID作为卫星数据。这样SELECT走索引时能直接回表,DELETE时也能定位到具体记录。需求边界写清楚,后面架构才有稳定的接口。
2.2 四层结构与调用边界
这套 miniSQL 采用四层结构:SQL 层、执行层、索引层、存储层。SQL 层负责词法、语法、AST 和语义检查;执行层用火山模型把SeqScan、IndexScan、Filter、Project、Insert、Delete、Update串起来;索引层只暴露search、insert、delete、rangeScan;存储层管页、记录、缓冲池和文件。
调用边界要硬约束:执行器不能直接读文件,必须通过BufferPool拿Page;索引层不能自己解析 SQL,只接收“键值 + RID”;系统表访问也走执行器,不要在每个模块里散落fopen。这样做的好处是,后面加日志、加锁、做恢复时,只需要在存储层和执行层之间插一层,不用把代码翻个底朝天。
一个常被忽略的点是错误处理边界。解析阶段发现表不存在,应该在语义检查阶段返回明确错误,而不是等到执行器next()时才空指针。通常的做法是:Catalog提供getTable(name),执行器在构造计划前完成绑定,把表对象、列偏移、比较函数提前解析好。参数说明上,表对象里存schema、fileId、indexList;列对象里存type、len、offset、nullable。这些结构定稳,后面代码量会少很多。
2.3 4KB页与槽位目录:记录怎么放
存储层建议用固定 4KB 页,页号从 0 开始,文件按页号随机读写。页内采用槽位目录(slotted page):页头存记录数、空闲空间指针、槽位数组;记录从页尾向前增长,槽位从页头向后增长。删除记录时只把槽位标记为无效,插入时优先复用空洞,空间不足再整理页。
// page.h:4KB 槽位页的核心布局 constexpr int PAGE_SIZE = 4096; constexpr int PAGE_HEADER = 16; struct RID { int pageId; // 页号 int slotId; // 槽位号 }; struct PageHeader { int recordCount; // 有效记录数 int freeSpaceOffset; // 空闲区起始偏移(向页尾增长) int slotCount; // 槽位总数 int nextPageId; // 溢出页或链表页号,-1 表示无 }; // 在页内插入一条记录,返回槽位号;空间不足返回 -1 int insertRecord(char* page, const char* record, int len) { PageHeader* hdr = reinterpret_cast<PageHeader*>(page); int slotDirEnd = PAGE_HEADER + hdr->slotCount * sizeof(int); if (hdr->freeSpaceOffset - slotDirEnd < len + (int)sizeof(int)) { return -1; // 先交给页整理,再重试 } int slotId = hdr->slotCount++; int recOff = hdr->freeSpaceOffset - len; memcpy(page + recOff, record, len); int* slots = reinterpret_cast<int*>(page + PAGE_HEADER); slots[slotId] = recOff; // 槽位存记录偏移 hdr->freeSpaceOffset = recOff; hdr->recordCount++; return slotId; }这段代码的关键点是槽位数组和记录区相向增长。freeSpaceOffset指向当前空闲区起始位置,插入时先减len再拷贝;槽位数组存的是记录偏移,不是记录本身,所以页内整理时只需要移动记录并更新槽位,RID里的slotId不变。参数上,len必须小于页内最大可用空间;如果返回-1,调用方应先做compactPage,把无效槽位清掉,再重试一次。注意不要用recordCount当槽位总数,删除后的槽位要保留并标记-1,否则RID会错位。
变长字段建议在记录内部用“定长头 + 变长区”表示:每个列在 schema 里有固定偏移,char(n)直接定长,varchar用 2 字节长度前缀。课程设计里不要做太复杂的 NULL 位图,可以用一个字节标记每列是否为空,但要在文档里写清楚。记录头存deleted标志和nextRID可选,用于页内链表。页尾剩余空间小于最小记录长度时,写入失败并触发页分裂或新页分配。
2.4 缓冲池与文件管理:CLOCK替换和脏页回写
缓冲池是存储层的黑匣子,执行器和索引层只认Page*。通常的做法是维护一个frame数组,每个 frame 有页号、脏位、引用位和 latch。替换算法用 CLOCK 就够课程设计用:命中时把引用位置 1,缺页时从时钟指针扫一圈,遇到引用位为 0 的帧淘汰,为 1 的置 0 继续。脏页淘汰前必须writePage回文件。
// buffer_pool.cpp:简化的 CLOCK 淘汰 Page* BufferPool::fetchPage(int fileId, int pageId, bool& isHit) { int frame = findFrame(fileId, pageId); if (frame != -1) { isHit = true; frames_[frame].ref = 1; return frames_[frame].page; } isHit = false; frame = clockHand_; while (true) { if (frames_[frame].ref == 0) break; frames_[frame].ref = 0; clockHand_ = (clockHand_ + 1) % poolSize_; } if (frames_[frame].dirty) { fileManager_->writePage(frames_[frame].fileId, frames_[frame].pageId, frames_[frame].data); } fileManager_->readPage(fileId, pageId, frames_[frame].data); frames_[frame].fileId = fileId; frames_[frame].pageId = pageId; frames_[frame].dirty = false; frames_[frame].ref = 1; clockHand_ = (clockHand_ + 1) % poolSize_; return frames_[frame].data; }findFrame用哈希表做(fileId, pageId) -> frame映射,命中直接返回。ref是 CLOCK 的引用位,dirty表示页被修改过。poolSize_课程设计取 64 到 128 个帧就够,太大反而增加调试难度。readPage要处理文件尾:如果页号超过文件长度,分配新页并清零。writePage用pwrite或fseek + fwrite,注意偏移量按pageId * PAGE_SIZE计算。验收时经常查“删除记录后文件是否变小”,答案通常是否定的,页不会自动回收,但空闲页可以挂在 freelist 上复用,这一点要在设计文档里说明,避免被问住。
3. SQL解析与执行器:从递归下降AST到火山模型
3.1 词法语法分析:手写递归下降还是用生成器
课程设计里我优先手写词法分析和递归下降解析器。原因很实际:SQL 子集不大,手写代码可控,报错位置清楚,答辩时能讲明白每个函数在干什么。用 Lex/Yacc 或 ANTLR 也能做,但环境配置和生成代码会吃掉大量时间,调试错误还不如手写直接。
词法分析把输入切成IDENT、NUMBER、STRING、KEYWORD、SYMBOL五类。关键字不区分大小写,标识符保留原始大小写但比较时转小写。字符串用单引号,内部两个单引号表示一个单引号。数字要区分整数和浮点,负号在语法层处理,不要塞进词法。语法分析按语句类型分派:parseCreate、parseInsert、parseSelect、parseDelete、parseUpdate。表达式解析用优先级爬升,AND高于OR,比较运算符高于AND,这样WHERE a = 1 AND b > 2 OR c < 3的树形不会错。
// parser.cpp:SELECT 语句的简化递归下降入口 std::unique_ptr<SelectStmt> Parser::parseSelect() { expect(KEYWORD_SELECT); auto stmt = std::make_unique<SelectStmt>(); if (match(SYMBOL_STAR)) { stmt->selectAll = true; } else { do { stmt->columns.push_back(parseIdentifier()); } while (match(SYMBOL_COMMA)); } expect(KEYWORD_FROM); stmt->table = parseIdentifier(); if (match(KEYWORD_WHERE)) { stmt->where = parseExpr(); // 返回 Expr 抽象树 } expect(SYMBOL_SEMI); return stmt; }expect在 Token 不匹配时抛出带行号和列号的ParseError,不要只打印“语法错误”。parseExpr返回Expr节点,叶子是ColumnRef或Literal,内部节点是BinaryOp。selectAll和columns二选一,后面语义检查再展开成具体列。参数上,where允许为空,表示全表扫描;table只支持单表,不支持别名和JOIN,这是需求边界内合理取舍。
3.2 AST与语义检查:系统表Catalog怎么配合
AST 只是语法结构,真正执行前要做语义检查:表是否存在、列是否存在、类型是否可比、插入值个数是否匹配、主键是否重复。Catalog在启动时从系统表加载所有表定义,提供getTable、getColumn、getIndex。系统表本身也是普通表,通常叫__tables、__columns、__indexes,但不要让学生直接SELECT到它们,可以在解析层屏蔽双下划线开头的表名。
语义检查阶段把ColumnRef绑定成(tableId, columnIndex, type, offset),把Literal转成统一的值对象。比较运算要求两边类型兼容:int和float可以隐式提升到float,char和int不允许直接比,必须报错。INSERT要检查NOT NULL和主键唯一性;唯一性检查可以走主键索引,也可以先做一次IndexScan,课程设计里前者更稳。UPDATE不允许更新主键列,或者更新主键时先删旧索引再插新索引,后者容易在事务边界出错,我一般建议直接限制。
系统表字段可以按下面这样设计,字段不要贪多,够用即可:
| 系统表 | 关键字段 | 用途 |
|---|---|---|
__tables | table_id, table_name, first_page, root_index | 表元数据和数据文件入口 |
__columns | table_id, col_name, type, len, offset, is_primary | 列定义和记录内偏移 |
__indexes | index_id, index_name, table_id, col_name, root_page, is_unique | 索引元数据和 B+ 树根页 |
这些表在CREATE TABLE时写入,在DROP TABLE时删除。Catalog内存里用unordered_map<string, TableMeta>缓存,写系统表成功后更新缓存;失败则回滚内存修改。注意系统表更新也要走缓冲池,不要直接fwrite,否则脏页顺序和恢复逻辑会对不上。
3.3 执行器Volcano模型:SeqScan、Filter、Project、Insert的接口
执行器统一实现Executor接口:open()初始化,next()返回一行或nullptr,close()释放资源。火山模型的好处是算子可以层层嵌套,Project(Filter(SeqScan))这种计划树写起来很自然。每个算子持有子算子指针,next()递归拉取。行数据用Row表示,内部是vector<Value>,Value带类型标签。
// executor.h:火山模型基类和顺序扫描 class Executor { public: virtual void open() = 0; virtual Row* next() = 0; // 返回 nullptr 表示结束 virtual void close() = 0; virtual ~Executor() = default; }; class SeqScanExecutor : public Executor { public: SeqScanExecutor(TableMeta* table, BufferPool* bp) : table_(table), bp_(bp), pageId_(0), slotId_(0) {} void open() override { pageId_ = table_->firstPage; slotId_ = 0; } Row* next() override { while (pageId_ != -1) { Page* page = bp_->fetchPage(table_->fileId, pageId_); PageHeader* hdr = reinterpret_cast<PageHeader*>(page->data); while (slotId_ < hdr->slotCount) { int off = getSlotOffset(page->data, slotId_++); if (off < 0) continue; // 跳过已删除槽位 return decodeRow(page->data + off, table_->schema); } pageId_ = hdr->nextPageId; slotId_ = 0; } return nullptr; } void close() override {} private: TableMeta* table_; BufferPool* bp_; int pageId_; int slotId_; };open()把游标定位到第一页第一个槽位,next()逐槽位解码,遇到off < 0跳过删除记录,当前页扫完跳到nextPageId。decodeRow按 schema 的偏移和长度把字节转成Value,变长字段先读长度前缀。FilterExecutor持有Expr和子执行器,循环调用子执行器next(),用eval(expr, row)判断真假;ProjectExecutor按输出列列表裁剪行。InsertExecutor在next()里只执行一次插入,然后返回nullptr,插入前检查主键索引,插入后更新索引。
参数说明:TableMeta里的firstPage是数据文件第一页页号,nextPageId在页头里维护;slotId_是当前页内槽位游标。注意SeqScan不能假设记录连续,删除后的空洞必须跳过。Row的生命周期由调用方管理,next()返回内部缓冲指针时要在文档里写清楚,避免上层释放后悬空。
3.4 DDL与系统表:CREATE TABLE落盘过程
CREATE TABLE的执行顺序建议固定为:解析并检查重名 → 分配table_id→ 创建数据文件第一页 → 写__tables→ 写__columns→ 如果有主键,创建唯一索引并写__indexes→ 更新Catalog缓存。任何一步失败都要清理已创建的文件和系统表记录,课程设计里可以不做完整事务,但至少要做到“先检查后写”,不要把半成品留在磁盘上。
CREATE INDEX类似:检查表和列存在、索引名不重复 → 分配索引文件 → 建 B+ 树根页 → 扫描表数据把已有记录插入索引 → 写__indexes。已有数据回填是很多同学漏掉的步骤,建完索引后查询走索引却查不到旧数据,验收时一测就露馅。回填可以用SeqScan加IndexInsert,插入时如果键重复且索引唯一,要报错并回滚索引文件。
DROP TABLE先删索引文件,再删数据文件,最后删系统表记录。文件删除失败不要静默忽略,至少写错误日志。DROP INDEX只删索引文件和元数据,不动表数据。DDL 的每个操作都要在Catalog里加锁,课程设计单线程可以简化,但至少要防止解析阶段和缓存刷新交错。
4. 索引与查询优化:B+树、范围扫描与规则代价折中
4.1 B+树节点结构与键值比较器
miniSQL 的索引用 B+ 树,叶子节点存(key, RID),内部节点存(key, childPage)。页大小 4KB,节点头存isLeaf、keyCount、nextLeaf、parent可选。键类型要和列类型一致,比较器由 schema 提供:int直接比,float用容差比较,char(n)按字典序比较并补空格。唯一索引在插入时发现相同键要报错,非唯一索引允许相同键,但RID要作为第二排序键,保证范围扫描顺序稳定。
// bplus_node.h:B+ 树节点布局 struct BPlusNode { bool isLeaf; int keyCount; int nextLeaf; // 叶子链表,内部节点为 -1 // 后续内存布局:keys[keyCount] + children[keyCount+1] // 叶子:keys[keyCount] + rids[keyCount] }; // 比较函数示例:char(n) 补空格后按字典序 int compareKey(const Value& a, const Value& b, ColumnType type, int len) { if (type == TYPE_INT) return (a.i < b.i) ? -1 : (a.i > b.i ? 1 : 0); if (type == TYPE_FLOAT) { if (fabs(a.f - b.f) < 1e-6) return 0; return a.f < b.f ? -1 : 1; } return strncmp(a.s.data(), b.s.data(), len); }节点容量按(PAGE_SIZE - header) / (keyLen + ptrLen)计算,内部节点和叶子节点可以不同。nextLeaf把叶子串成链表,范围扫描先定位到起始叶子,再沿链表向右扫。compareKey必须和建索引时用的比较器一致,否则二分查找会错。float用容差比较是课程设计里的常见做法,但要在文档里写清楚,避免“为什么 1.0 和 1.0000001 查不到”的争议。
4.2 插入与分裂:课程设计最容易写错的指针维护
B+ 树插入分三步:从根向下找到目标叶子,插入键值,如果溢出则分裂并把中间键上推到父节点。分裂时要注意:叶子分裂保留中间键在右叶子,内部节点分裂把中间键上推后从原节点删除;父节点溢出继续递归分裂;根分裂时新建根,树高加一。最容易翻车的是父指针和nextLeaf:叶子分裂后左叶子的nextLeaf要指向右叶子,右叶子的nextLeaf继承原值;父节点插入新孩子时,孩子指针数组和键数组要同步移位。
// bplus_tree.cpp:叶子插入与分裂的简化逻辑 bool BPlusTree::insert(Key key, RID rid) { LeafNode* leaf = findLeaf(key); if (leaf->isFull()) { LeafNode* right = splitLeaf(leaf); // 分裂并维护 nextLeaf if (compareKey(key, right->firstKey()) >= 0) leaf = right; } leaf->insertSorted(key, rid); // 唯一索引先查重 if (leaf->isOverflow()) { Key upKey = leaf->middleKey(); Node* right = leaf->split(); insertIntoParent(leaf, upKey, right); // 递归处理父节点 } return true; }findLeaf从根开始,内部节点用二分找到最后一个key <= 目标的孩子。splitLeaf把后半部分键值移到右叶子,设置nextLeaf,返回右叶子。insertIntoParent在父节点中找到左孩子的下标,插入upKey和右孩子指针;父节点溢出则继续分裂。参数上,key是索引列的值,rid是记录位置;唯一索引在insertSorted前先search,存在则返回冲突。注意分裂后要立即把新页写回缓冲池并标记脏,否则断电后树结构不完整。
4.3 索引扫描与范围查询:把WHERE条件下推
IndexScan接收索引名、下界、上界和开闭区间,先定位起始叶子,再沿nextLeaf扫描,直到超过上界。等值查询是范围查询的特例。扫描到(key, RID)后,用RID回表读记录,再交给上层Filter做剩余条件判断。条件下推的原则是:能走索引的谓词交给IndexScan,不能走索引的谓词留给Filter,不要把所有条件都塞进索引层。
// index_scan.cpp:范围扫描骨架 Row* IndexScanExecutor::next() { while (curLeaf_ != -1) { LeafNode* leaf = fetchLeaf(curLeaf_); while (pos_ < leaf->keyCount) { Key k = leaf->keyAt(pos_); RID rid = leaf->ridAt(pos_++); if (outOfRange(k)) return nullptr; // 超过上界,结束 if (checkLower(k)) { Row* row = table_->getByRID(rid); if (row && filter_->eval(row)) return row; } } curLeaf_ = leaf->nextLeaf; pos_ = 0; } return nullptr; }outOfRange判断当前键是否超过上界,超过就结束扫描;checkLower处理下界开闭。table_->getByRID通过缓冲池读页并解码记录。注意RID可能指向已删除记录,回表后要检查删除标志,删除标志为真则跳过。范围扫描的代价主要来自回表,如果查询只涉及索引列,可以做成覆盖索引直接返回键值,但课程设计里不必强求。
4.4 简单优化器:规则加代价的折中
课程设计不需要 Cascades 优化器,规则加简单代价就够。规则一:有等值条件且列上有索引,优先IndexScan;规则二:有范围条件且列上有索引,IndexScan加Filter;规则三:没有可用索引,SeqScan加Filter;规则四:UPDATE/DELETE的WHERE能走索引就走索引,否则全表扫。代价估算用页数:SeqScan代价等于表页数,IndexScan代价等于索引树高加匹配记录数乘回表页数,匹配记录数可以粗略用 10% 估算,或者用EXPLAIN输出让用户自己看。
EXPLAIN在答辩时很好用,打印计划树和估算代价,能直观说明“为什么这条查询走了索引”。计划树节点包括SeqScan(table)、IndexScan(index, range)、Filter(expr)、Project(cols)、Insert(table)、Delete(table)、Update(table)。优化器只做单表选择,不处理JOIN,这是需求边界内的合理取舍。参数上,索引选择要检查索引列是否出现在WHERE的合取项里,OR条件不轻易下推,除非两边都能走索引。
5. 避坑与常见问题:存储、索引、SQL边界最容易翻车的五处
5.1 存储与索引类问题
现象一:删除大量记录后插入变慢,文件越来越大,最后插入失败。原因通常是删除只标记槽位,页内空闲空间不整理,新记录又只往最新页追加。解决方法是实现页整理:扫描槽位,把有效记录向前压缩,更新槽位偏移,重置freeSpaceOffset;页内空间不足时再分配新页。更稳的做法是维护 freelist,把空闲页挂链,插入优先复用。
// compactPage:把有效记录向前压缩,回收槽位空洞 void compactPage(char* page) { PageHeader* hdr = reinterpret_cast<PageHeader*>(page); int* slots = reinterpret_cast<int*>(page + PAGE_HEADER); int writeOff = PAGE_SIZE; for (int i = 0; i < hdr->slotCount; ++i) { if (slots[i] < 0) continue; // 已删除槽位 int len = getRecordLen(page + slots[i]); writeOff -= len; memmove(page + writeOff, page + slots[i], len); slots[i] = writeOff; } hdr->freeSpaceOffset = writeOff; hdr->recordCount = countValidSlots(page); }writeOff从页尾向前走,memmove处理记录重叠,slots[i]更新为新偏移。注意getRecordLen要能根据记录头算出变长记录总长度;整理后无效槽位保留为-1,不要压缩槽位数组,否则RID会变。
现象二:建完索引后,旧数据查不到,新插入数据能查到。原因是CREATE INDEX只建了根页,没有回填已有记录。解决方法是建索引后立刻SeqScan全表,对每条记录执行IndexInsert;如果唯一索引冲突,报错并删除索引文件。回填期间要避免并发写入,课程设计单线程也要在文档里写明“建索引时表不可写”。
现象三:B+ 树范围扫描丢记录,等值查询正常。多半是叶子分裂时nextLeaf没维护,或者父节点孩子指针错位。排查时先打印叶子链表,检查键是否递增、nextLeaf是否形成环;再检查内部节点键和孩子数量是否满足keyCount + 1 == childCount。修复时在分裂函数里统一维护nextLeaf,父节点插入用memmove同步移键和指针。
5.2 SQL执行与事务类问题
现象四:WHERE name = 'abc'查不到char(10)里存的'abc '。原因是char(n)存储补空格,比较时没有按同一规则处理。解决方法是比较器统一补空格到n长度再比,或者在语义检查阶段把字面量补齐。varchar不要补空格,按实际长度比较。字符串里的单引号转义也要在词法层处理,'it''s'应解析为it's。
现象五:程序正常退出后数据在,异常退出后表打不开。原因是脏页只在缓冲池淘汰时回写,没有 checkpoint,也没有恢复日志。课程设计至少要做两件事:一是close时把所有脏页刷盘并写回系统表;二是启动时检查数据文件页数,若系统表记录的页号超过文件长度,给出明确错误而不是越界读。如果要做加分项,可以加简单的 redo 日志:插入、删除、更新前先写日志记录(pageId, offset, oldValue, newValue),恢复时重放未完成操作。事务隔离在单线程课程设计里通常不做,但要在答辩时说明边界:当前实现是单用户、无并发、异常恢复有限,不冒充完整数据库。
现象六:UPDATE更新主键后索引里还留着旧键。原因是只改了记录没维护索引。解决方法是先删旧索引项,再插新索引项,最后写记录;如果新键唯一冲突,回滚记录和索引。更简单的做法是限制主键列不可更新,在语义检查阶段直接报错,课程设计里这是可接受的取舍。
排查时我习惯按“文件—页—记录—索引—计划”五层看:先确认文件页数,再确认页头slotCount和freeSpaceOffset是否越界,再解码记录看字段偏移,再打印索引叶子链表,最后看EXPLAIN计划是否走了预期索引。这个顺序能快速定位问题在哪一层,避免一上来就改解析器。
6. 验收与答辩:一张检查表和三条演示路径怎么走
验收前我会先跑一张检查表,把“能演示”变成“可复现”。下面这张表可以直接抄到答辩 PPT 的最后一页:
| 验收项 | 检查动作 | 通过标准 |
|---|---|---|
| 建表与元数据 | 建两张表,重启后DESC或查询系统表 | 表结构、列类型、主键索引都在 |
| 数据落盘 | 插入 1000 条,关闭程序再启动查询 | 记录数一致,无乱码 |
| 索引命中 | 对主键列做等值与范围查询,看EXPLAIN | 计划显示IndexScan,结果正确 |
| 删除与复用 | 删除一半记录再插入同量数据 | 不崩溃,查询结果正确 |
| 异常输入 | 表不存在、列类型不匹配、重复主键 | 返回明确错误,不崩溃 |
| 边界条件 | 空表查询、char(n)满长、float相等比较 | 行为符合文档说明 |
演示路径建议走三条:第一条 DDL 加 DML,建表、插数据、更新、删除,证明基本 SQL 闭环;第二条索引,建索引后对比EXPLAIN前后计划,展示范围扫描和回表;第三条异常与重启,故意输错 SQL、重复主键,再重启查数据,证明错误处理和持久化。每条路径控制在两分钟内,提前把 SQL 脚本存好,不要现场手敲。
答辩时老师常问“你的 B+ 树分裂后父节点怎么维护”“删除记录后空间怎么回收”“为什么这个查询没走索引”。回答时用计划树和页布局图讲,不要背概念。我带的项目里,凡是提前把检查表跑一遍的,验收时都稳;凡是只准备“正常流程”的,往往在异常输入上翻车。从那以后我每次做课程设计验收都强制走一遍“重启 + 异常 + 范围查询”三件套,希望帮到你。如果你正准备把 miniSQL 这类期末大程做成可演示版本,这套四层结构、页布局、执行器接口和验收表可以直接作为骨架参考,按资源说明获取即可。
本文还有配套的精品资源,点击获取