简介miniSQL 是浙江大学数据库设计课程期末大程的完整项目成果面向计算机专业学生、数据库课程学习者以及正在准备课程设计的开发者用于从零理解关系数据库管理系统的内部构造。压缩包共 29 个文件约 885KB以 cpp 与 h 源码为主体涵盖缓冲管理、记录管理、索引与 B 树、解释器等模块并配有可执行 exe、测试用例和一份 PDF 设计报告源码、程序与文档可互相印证。目前已有 1532 人学习下载。读者可对照报告梳理项目目标与设计取舍从源码中查看 SELECT、INSERT、UPDATE、DELETE 等语句的解析与执行路径体会 ACID、索引结构与查询优化在真实代码中的落点并借助测试文件验证各类查询与并发场景下的稳定性。1. 从 SQL 字符串到 4KB 字节miniSQL 期末大程到底在做什么数据库课程的期末大程十有八九落在 miniSQL 上用 C/C 从零写一个能跑 SQL 的小型关系数据库。真正的难点不是某个算法有多玄学而是从 SQL 字符串到磁盘上那 4KB 字节的整条链路任何一环偷懒后面都得推倒重来。我见过不少方案parser 写得漂亮执行器一跑就段错误回头一查是缓冲池的 pin 计数没配对。这份数据库设计要解决的就是这件事把大程拆成需求边界、总体架构、存储引擎、索引与执行、验收脚本几个能独立跑通、独立给分的部分。适合正在交数据库课程设计的同学也适合想拿一套完整 C/C 存储引擎骨架回去改的开发者。2. 需求边界与模块职责把 SQL 子集、分层和目录一次定死开工前最该做的事不是敲代码而是把所有要做和不做写成清单。数据库这类系统是强耦合的语法层多支持一个特性存储层和执行层都要跟着改。把边界先说清楚既省返工也方便在答辩时回答你这个为什么不支持 join——因为范围外写在报告里。2.1 需求清单SQL 子集、数据类型和约束定到哪一格一份能交付的 miniSQL通常按下面这张表划分范围。必须做的部分是评分底线加分项量力而行明确不做的部分反而能体现你懂取舍。语句/特性优先级说明CREATE / DROP TABLE必须含主键、NOT NULL 约束CREATE / DROP INDEX必须单列索引B 树实现INSERT必须支持多值插入更好DELETE必须WHERE 可带条件无 WHERE 表示清空UPDATE必须索引列被更新时同步维护索引SELECT 单表 WHERE必须支持 、、、、、多条件用 AND 连接ORDER BY / 聚合函数加分内存排序、内存聚合即可不必外排多表 JOIN / 子查询 / 视图 / 事务一般不做报告里写进范围外说明理由数据类型层面int、float、char(n) 三种足够覆盖绝大多数课程要求。char(n) 是定长的这一点很关键定长意味着一条记录的字节数在表定义时就确定了页内偏移可以直接算出来变长字段那一整套槽目录碎片整理逻辑都能省掉。学费就是不能存超长字符串但课程场景里没人会拿它存长文本。提示需求清单不要只写在脑子里把它写进 README 的第一节。后面每完成一项就打个勾答辩演示时这页纸比任何 PPT 都有说服力。另外要把错误处理的边界定下来表不存在、列不存在、类型不匹配、主键冲突、索引重名这五类错误必须给出明确提示而不是让程序直接崩掉。测试脚本通常就是靠 grep 这些错误信息来判断用例是否通过提示语写得好不好直接影响验收效率。2.2 分层架构解析、执行、存储三层怎么切整个系统的数据流是单向的SQL 文本进结果集或错误信息出。中间分成三层最自然。上层是解析层负责词法分析、语法分析产出抽象语法树中层是执行层拿语法树去查目录、生成计划、驱动算子拉数据下层是存储层管页、管缓冲池、管磁盘读写。三层之间只通过明确的接口通信任何人都不许跨层直接调对方的内部函数。我一般会先把这个总入口的头文件写死再往里填实现// DBManager 是整个系统对外唯一的门测试脚本、命令行、单元测试都从这里进 class DBManager { public: explicit DBManager(const std::string dbFile); ~DBManager(); // 一条 SQL 一条结果成功返回 true失败时 errMsg 带可读的错误原因 bool runSQL(const std::string sql, ResultSet *result, std::string *errMsg); private: Parser parser_; // 只依赖字符串不碰磁盘 CatalogManager catalog_; // 元数据的唯一权威来源 BufferPoolManager bpm_; // 全局唯一的页缓存所有模块共享 DiskManager disk_; // 只管按页号读写文件 };这个接口有三个设计点值得说。第一runSQL返回布尔值加错误信息而不是抛异常因为解析错误、语义错误、执行错误都需要被抓成一条人能看懂的话异常在这条链路上反而绕。第二BufferPoolManager是全局唯一实例任何模块要读页都得经过它这样页面替换、脏页回写、页锁只有一处实现不会出现两个模块各管一份缓存的血泪经验。第三Parser不持有任何存储层指针它只认字符串将来换语法、加语句类型都不会牵连下层。层与层之间传的数据结构也要定好。解析层输出SelectStmt、InsertStmt这类语句结构体执行层输出Tuple和Schema存储层只认page_id_t和裸字节。别图省事让解析层直接产出记录指针那样一旦表结构变化解析层的代码全得跟着改。2.3 Catalog 与全局元数据表定义存在哪里、怎么读回来元数据管理是很多方案最容易糊弄的地方。常见做法是把表结构放在内存的 map 里程序一退出就全丢下次打开数据库文件连表有几个列都不知道。正确做法是把 Catalog 当成系统表落到同一个数据库文件里启动时先加载。struct ColumnMeta { std::string name; ColumnType type; // INT / FLOAT / CHAR uint16_t len; // char(n) 的 nint 取 4float 取 4 bool notNull; bool isPrimary; }; struct TableMeta { std::string tableName; std::vectorColumnMeta columns; page_id_t firstPage; // 记录链表的头页-1 表示空表 page_id_t indexRoot; // 主键索引的根页-1 表示无索引 uint32_t rowCount; // 行数缓存供计划阶段估算代价 };rowCount这个字段看着不起眼作用是让执行层判断该走索引还是全表扫。如果每次查询都去数一遍行数等于白白多扫一次全表缓存住行数插入删除时顺手加减一代价几乎为零。indexRoot存的是根页号而不是内存指针因为程序重启后指针全失效页号才是稳定的。Catalog 本身也要落盘通常的做法是在数据库文件头部留几个固定页给系统表或者单独开一个.meta文件用文本或二进制序列化进去。我倾向单独文件好处是调试时能直接打开看坏处是要保证它和数据页的一致性所以在表结构变更时要先写元数据再改数据页。2.4 开发顺序先打通哪条链路后补哪块功能顺序错了项目会一直处于编译通过但跑不起来的状态。我一般按这条链路推磁盘管理器 → 缓冲池 → 记录管理 → 建表插入 → 全表扫描查询 → 索引 → 条件过滤 → 删除更新。前四步走完就已经能create table加insert加select *这是一条完整闭环能给别人演示。有闭环之后再往回补。索引要在记录管理稳定之后做因为 B 树存的 rowid 格式依赖记录层的页号和槽号定义记录层一改索引全废。条件过滤要在全表扫描之后做因为过滤本质上是扫描算子上套一层判断扫描没通过滤无从谈起。最不该先做的是解析器。语法分析写起来快容易给人进度不错的错觉但它不产生任何可验证的中间结果——解析出语法树之后没东西消费它你只能靠打印调试。等存储层终于通了才发现语法树的结构和执行的期望对不上那才是真的返工。3. 存储引擎4KB 页、缓冲池与定长记录的三件套存储层是整个项目的地基也是最容易出隐蔽 bug 的地方。它不出错则已一出错就是数据读不出来或者文件被写坏而且往往在演示前十分钟才暴露。这一层的设计原则只有一条所有结构都必须能通过页号精确定位任何依赖内存地址的东西都不能跨进程存活。3.1 页内布局头部、记录区和槽目录怎么对向生长页是磁盘和内存之间搬运的最小单位取 4096 字节和绝大多数文件系统的块大小对齐。页内不能只有记录还得有描述信息所以标准做法是把页切成三段头部从低地址往高地址长记录区紧随其后槽目录从页的高地址往低地址倒着长。两侧对向生长剩余空间就是中间那段空隙判断能不能放下新记录是 O(1) 的。constexpr size_t PAGE_SIZE 4096; // 页头定长放在页最前面任何模块拿到页都能立刻知道这页的状态 struct PageHeader { page_id_t pageId; // 页号落盘时用它算文件偏移 lsn_t lsn; // 日志序号不做 WAL 就恒为 0 uint16_t slotCount; // 已分配的槽数含已删除的墓碑槽 uint16_t freeSpacePtr; // 记录区末尾偏移记录从这里往后长 uint16_t freeSlotCount; // 可复用的空槽数决定要不要整理页 int32_t nextPage; // 同表下一页-1 表示链表到头 }; // 槽目录项记录在页内的位置和长度删除时只清槽不清记录区 struct Slot { uint16_t offset; uint16_t length; };用槽目录而不是让记录紧挨着排原因很实际记录要支持删除如果直接搬移后面的记录来填洞所有指向这些记录的 rowid 全部失效索引立刻变成一堆野指针。有了槽目录删除只需要把槽的length置零记录区那块空间标记为可复用rowid 本身不变索引不用动。代价是页内会产生外部碎片所以头部留了freeSlotCount等空槽积累到一定数量再触发一次页整理把所有记录往前提重写槽目录。页头里的nextPage构成同表记录的页链表。顺序扫描就从firstPage开始一路顺着nextPage走到底简单可靠。这条链表还有个隐含约定新插入的记录优先往链表尾部追加这样扫描顺序和插入顺序基本一致演示时结果看起来更正常。3.2 缓冲池与替换策略pin 计数和脏页位是两条命缓冲池的作用是把磁盘页缓存到内存让上层以为自己在大内存上操作。它的核心不是缓存命中率而是正确性一页在内存里被修改后什么时候写回磁盘、被谁写回、写回前有没有别人还在用这三件事搞错一件数据就坏了。class BufferPoolManager { public: BufferPoolManager(size_t poolSize, DiskManager *disk); // 命中则 pinCount 加一并返回未命中则挑一个 pinCount 为 0 的帧换出 Page *fetchPage(page_id_t pid); // 用完必须调isDirty 为 true 表示这页被改过需要回写 bool unpinPage(page_id_t pid, bool isDirty); // 分配新页新页返回时已经是 pin 住的状态 Page *newPage(page_id_t *pid); // 显式刷盘验收脚本里关库前会调一次 bool flushPage(page_id_t pid); private: struct Frame { Page page; int pinCount 0; // 有人在用就不能换出这是硬约束 bool isDirty false; }; std::vectorFrame frames_; std::unordered_mappage_id_t, size_t pageTable_; // 页号到帧下标的映射 std::listsize_t lruList_; // 头部是最久未使用的帧 std::mutex latch_; // 只保护 pageTable_ 和 lruList_粒度要短 };pinCount的语义是这页正在被使用不许动。任何fetchPage返回的页指针在使用期间都必须保持 pin用完立刻unpinPage。漏掉一次 unpin这页就永远换不出去池子被慢慢占满最后所有fetchPage都找不到可换出的帧程序卡死。反过来unpin 太早页可能在你还拿着指针的时候被换出并复用写进去的东西就跑到别的页上去了这种 bug 表现为随机数据错乱极难定位。替换策略用 LRU 就够池子一般给 64 到 256 帧。真正的难点在脏页处理被换出的帧如果是脏的必须先写磁盘再复用。很多人只记得换出忘了写回结果内存里改得好好的程序一退出数据全没这也解释了为什么重启后数据读不出来是最高频的翻车点。另一个细节是锁的粒度latch_只保护映射表和 LRU 链表绝不保护页数据本身页数据由事务层的锁负责两把锁混用必死锁。3.3 记录格式与 rowid定长记录怎么算偏移定长记录的好处在这里兑现。一条记录的字节数 记录头 所有列长度之和在表定义时就确定插入时直接按这个大小申请空间。取第 i 列不需要遍历把前面各列的长度累加就是偏移。struct RecordHeader { uint16_t numCols; uint16_t deleted; // 逻辑删除标记配合槽的墓碑位使用 }; // 按列下标取该列在记录内的起始地址O(n) 累加n 是列数可以忽略 char *columnPtr(char *record, const TableMeta meta, int i) { size_t off sizeof(RecordHeader); for (int k 0; k i; k) { off meta.cols[k].len; // char(n) 就是 nint/float 是 4 } return record off; }rowid 用一个 32 位整数编码高 16 位是页号低 16 位是槽号。这个设计意味着单表最多 65536 页、每页最多 65536 个槽按每页 4KB、每行 32 字节估算能撑到 800 万行左右课程项目绰绰有余。rowid 是索引里唯一需要存的东西索引项就是键值 rowid查到 rowid 再回表取整行这就是非聚簇索引的标准做法。要注意的是 rowid 一旦分配就不能变。更新一条记录时如果新记录还是同样的字节数原地覆盖即可如果表结构允许变长就得删旧插新rowid 会变所有索引项都要跟着改。定长方案直接规避了这个麻烦这也是我建议所有人从定长做起的原因。3.4 磁盘文件组织与落盘时机什么时候必须写磁盘管理器只做一件事给定页号算出文件偏移读写 4096 字节。偏移就是pageId * PAGE_SIZE简单到几乎没有出错空间但它的调用时机很讲究。正常路径下页的写回由缓冲池的换出逻辑触发磁盘管理器不需要自己判断。只在三个时刻必须强制刷盘程序正常退出前、执行完一条 DDL 语句后、以及用户显式要求时。第一条保证数据不丢第二条是因为建表、删表、建索引这类操作改了 Catalog元数据必须立刻落盘否则程序崩溃后会出现数据页在但表定义没了的孤儿页。文件本身不需要预分配按需往后追加就行。但要记录当前文件有多少页通常单独存一个页数计数器在文件头或者干脆用文件实际大小除以页大小算出来。后者更省事也不会出现计数器和实际内容不一致的问题我一般用后者。注意调试阶段可以在每次 flush 后打印一行日志写上页号和前 16 个字节的十六进制。数据错乱时对着这行日志和磁盘文件一比能省掉大量猜测时间。4. 索引与执行器B 树落地、索引选择和算子拼装到这一层系统已经能存取数据了缺的是快和能按条件取。B 树是索引的标准答案但真正把它写对的人不多问题几乎都出在分裂和父子指针的维护上。执行器则是把一个个独立算子串成流水线任何一个算子的next语义写错整条链路的输出就是错的。4.1 B 树节点布局与分裂策略B 树的节点和页是同一个东西一个节点占一页。内节点只存键和孩子页号叶子节点存键和 rowid所有叶子用next指针串成链表范围查询就靠这条链表顺序走。struct BPlusNode { NodeType type; // INTERNAL 或 LEAF uint16_t keyCount; // 当前键数 page_id_t parent; // 父节点页号分裂向上回写时用 page_id_t next; // 仅叶子有效指向下一个叶子 // 其后紧跟 key 数组再后面是孩子页号数组或 rowid 数组 }; // 阶数由页大小反推order (PAGE_SIZE - sizeof(BPlusNode)) / (sizeof(key) sizeof(page_id)) // 键类型不同阶数不同int 键大概能到 300 阶三层就能存上千万条分裂的逻辑要说清楚。叶子节点插入后键数超过阶数就从中间切成两半左半留在原页右半写进新页然后把中间那个键连同新页页号插到父节点。父节点如果也满了继续向上分裂直到根节点。根节点分裂时分配一个新页作为新根树高加一。这套流程本身不难难在每一步都要把parent指针回写正确并且所有被修改的页都要标脏。删除的处理可以简化。严格实现要支持节点合并和向兄弟借键代码量翻倍收益却很小——课程项目的数据规模根本到不了让树退化的程度。我的做法是删除只清键不合并允许节点键数低于下限代价是空间利用率低一点正确性完全不受影响。这个取舍要写进报告说清楚为什么不合并比硬着头皮写一个半吊子的合并逻辑更专业。4.2 索引选择的判断依据什么时候走索引什么时候全表扫有了索引不代表所有查询都该走索引。小表全表扫一页就扫完了走索引还要先查树再回表反而更慢。计划阶段要做一个简单的代价判断。// 计划阶段只做几条启发式规则够用且答辩时讲得清 PlanNode *Planner::planSelect(const SelectStmt stmt) { const TableMeta t catalog_.getTable(stmt.table); // 等值条件命中索引列几乎总是走索引 if (stmt.where.hasEqualityOn(t.indexColumn)) { return buildIndexScan(t, stmt.where); } // 范围条件命中索引且表足够大才值得走索引 if (stmt.where.hasRangeOn(t.indexColumn) t.rowCount 64) { return buildIndexScan(t, stmt.where); } return buildSeqScan(t, stmt.where); }阈值 64 不是拍脑袋它约等于一页能放下的记录数。表小到一页装得下时顺序扫描只需一次磁盘 IO而走索引至少要读根页、叶子页、再回表读记录页三次起步。这个判断逻辑写进报告答辩时被问到你的优化器做了什么就有了具体的回答而不是含糊地说做了索引优化。还有一类情况必须走全表扫条件是或者条件列上没有索引。这种查询如果强行走索引就要扫遍整棵树再回表比全表扫慢好几倍。把这条也写进计划的判断分支代码只多几行但体现的是对代价的理解。4.3 算子实现火山模型下的扫描、过滤与聚合执行器用火山模型每个算子实现init和next两个方法上层反复调next直到返回 false。这种设计的好处是算子之间完全解耦加一个新算子不用改任何已有代码。class Executor { public: virtual void init() 0; virtual bool next(Tuple *out) 0; // false 表示没有更多元组 virtual const Schema schema() const 0; virtual ~Executor() default; }; class IndexScanExecutor : public Executor { public: void init() override { iter_ index_-scan(lowKey_, highKey_); // 定位到叶子链表的起点 } bool next(Tuple *out) override { RID rid; if (!iter_.next(rid)) return false; // 叶子走完了 return table_-getRecord(rid, out); // 回表取整行 } private: BPlusTree *index_; BPlusTree::Iterator iter_; TableHeap *table_; Key lowKey_, highKey_; };IndexScanExecutor的next里藏着两个易错点。一是迭代器必须能跨叶子节点继续走靠的就是叶子链表的next指针如果建树时忘了维护它范围查询走到第一个叶子末尾就停了结果少一大截。二是回表时要重新 pin 记录页不能假设它还在池子里前面扫描索引时可能已经把记录页挤出去了。过滤算子包在扫描算子外面next里循环调用子算子的next直到条件成立才返回。聚合算子要复杂一点init时把所有元组分组的键算出来next第一次被调用时一次性扫完子算子、建好哈希表之后每次返回一组。这种物化的做法在数据量不大时完全够用也避免了流式聚合里状态管理的麻烦。4.4 事务与并发的取舍课程项目里做到哪一步事务是不是必须做取决于课程要求。如果需求里明确要求事务和并发控制那至少要实现这两样基于锁的并发控制和基于日志的恢复。如果没要求我的建议是只做到页级锁保证单条语句内部不会自己跟自己打架事务层面明确写进范围外。做的话锁管理器用两阶段锁锁粒度落在记录或页上配一张锁表记录持有者和等待者。死锁检测用等待图或者简单的超时回滚后者的代码量小一个数量级效果在课程演示场景里没有区别。日志用 WAL写数据页之前先把日志刷盘恢复时重放已提交事务、撤销未提交事务。// WAL 的核心约束日志必须先于数据页落盘这一行顺序反了恢复就废了 void Transaction::commit() { LogRecord rec{txnId_, LogType::COMMIT}; logManager_-append(rec); logManager_-flushTo(txnId_); // 必须等这一步返回才能去刷数据页 lockManager_-releaseAll(txnId_); }这段代码短但顺序是血的教训。如果在日志落盘之前就把脏页刷出去系统崩溃后恢复程序看到的是一个没有提交记录但数据已经改了的状态没法判断该保留还是该撤销。日志先行这个约束不是优化是正确性的前提。5. 避坑与排查五个让 miniSQL 在验收现场翻车的点存储引擎的 bug 有个共同特征它们不报错只是让数据悄悄变错。下面这五条是我见过频率最高的每一条都按现象、原因、解决三段来说。5.1 重启后数据读不出来脏页没回写现象是程序里插入查询一切正常退出再进去表还在但数据全空或者只剩最后一次插入的内容。原因基本锁定在缓冲池的换出逻辑。脏页被换出时必须先写磁盘很多人只写了换出没写回写或者unpinPage时忽略了isDirty参数导致脏位永远为 false。另一种情况是程序退出时没有遍历所有帧强制刷盘内存里剩下的脏页直接就丢了。解决方法是三处一起改unpinPage里只要有写操作就置脏位换出时检查脏位并调用flushPageDBManager的析构函数里遍历所有帧刷一遍。改完拿一个测试用例验证——插入 1000 行退出重开select count(*)数字对得上才算过。5.2 SELECT 少行删除后的槽复用和链表断裂现象是插入 100 行删掉中间 10 行再查询出来只有 85 行左右而且每次跑的数字还不一样。原因通常是两处。一是删除只清了记录头里的deleted标志没有正确更新槽目录扫描时把墓碑槽当成有效记录跳过了太多。二是页链表的nextPage在整理页或者分配新页时被覆盖后半段数据从链路上断开扫描走到某页就提前结束。解决方法是把扫描逻辑改成以槽目录为准遍历槽 0 到slotCount-1槽length为 0 就跳过否则按 offset 取记录。同时在每次修改nextPage的地方加断言保证它要么是有效页号要么是 -1。再加个调试命令show pages把每页的槽数和链表指针打出来断裂一眼可见。5.3 分裂后索引查不到父指针和根页号没落盘现象是插入前几千条一切正常数据量涨上去之后某些键突然查不到重插一遍又能查到。原因是 B 树分裂时有两个动作容易漏一是新页的parent指针没设后续从叶子往上回溯时走错路径二是根节点分裂产生新根后Catalog 里的indexRoot没更新下次打开还是从旧根开始查新的那半边树完全访问不到。解决方法是把分裂封装成一个函数里面固定三步分配新页、搬移右半数据、向上插入中间键。根分裂单独处理分配新根页并立刻回写 Catalog 的indexRoot。测试时用顺序递增的键插一万条再随机查一千个键全部命中才算过。5.4 索引和全表扫结果不一致索引没跟着 DML 维护现象是同一条查询走索引和走全表扫给出不同的行而且差异随删除更新次数增加而变大。原因是索引只在插入时维护了更新和删除时忘了同步。更新一个索引列的值旧键还在树里挂着指向的还是那条记录的 rowid回表取出来的值已经是新值于是出现了键和值对不上的幽灵记录。删除记录时不删索引项回表时 rowid 已经指向空槽要么崩要么返回垃圾数据。解决方法是在删除和更新路径上强制调用索引维护函数并且在测试里加一条铁律任何 DML 之后同一条查询分别用use index和忽略索引跑一遍结果必须完全相同。这条断言能抓到几乎所有索引一致性问题成本却只有几行脚本。5.5 drop table 之后段错误残留页和 pin 没清现象是连续建表删表几次之后程序在某个随机的操作上崩掉用地址检查工具一跑报的是访问已释放内存或者页号越界。原因是删表时只删了 Catalog 里的元数据页链表上的数据页还留在缓冲池里上面可能还带着 pin 计数。后续分配新页时页号被复用但旧帧还挂在pageTable_里指向同一个页号读写就串了。另一种情况是索引树的页没被回收下次建同名索引时根页号冲突。解决方法是删表时走一遍完整回收顺着firstPage的链表把每页unpinPage并标记可复用索引树从indexRoot递归析构最后才删 Catalog 条目。顺序不能反先删元数据就找不到这些页了。6. 验收与答辩用一套可复现的脚本把实现讲圆验收现场最怕的不是功能少而是刚才还能跑。演示时手敲命令顺序一变、数据一变就出问题讲不清楚是功能没做还是环境问题。我的做法是把所有验收点写成一个脚本从建库到断言全部自动化演示时直接跑脚本输出 ALL PASS 就结束整个过程不到一分钟剩下的时间留给讲设计。#!/usr/bin/env bash # 验收脚本每步独立断言失败立即停便于现场定位问题 set -euo pipefail BIN./build/minisql DB/tmp/accept.db rm -f $DB run() { echo $1 | $BIN --db $DB; } # 1. DDL 与索引创建 run create table student (id int, name char(16), score float); run create index idx_id on student (id); # 2. 灌入足够的数据量逼迫 B 树发生多次分裂 for i in $(seq 1 2000); do run insert into student values ($i, stu$i, 60.0); done # 3. 核心断言索引扫描与全表扫描必须返回同一行 a$(run select * from student where id 777; | wc -l) b$(run select * from student; | grep -c 777) [ $a -eq $b ] || { echo FAIL: index scan mismatch; exit 1; } # 4. 删除后重启验证脏页确实落盘 run delete from student where id 1000; lines$(run select * from student; | wc -l) [ $lines -eq 1001 ] || { echo FAIL: delete or flush wrong; exit 1; } echo ALL PASS脚本里set -euo pipefail这行别省它保证任何一步命令失败就立刻终止不会带着错误状态继续跑出一堆假结果。第 3 步的断言是整个脚本的核心它同时验证了索引正确性、回表逻辑和两种扫描路径的一致性这一条过了索引模块基本可以放心。第 4 步在删除之后重开进程验证的是缓冲池的落盘逻辑对应 5.1 里那个坑。除了脚本答辩时通常还会被问到几个固定问题你的页大小为什么取 4KB、B 树的阶数怎么算出来的、为什么删除不做节点合并、索引选择为什么设了阈值。这四个问题对应的就是本文第 3 章和第 4 章的设计取舍答案都在代码注释里提前把注释写清楚讲的时候直接翻代码比背稿子可信得多。验收项验证手段通过标准建表与元数据持久化建表后重启执行desc table列定义完整返回记录读写插入 2000 行后顺序扫描行数、字段值全部一致索引正确性索引扫描与全表扫结果比对完全一致数据持久化删除后重启再查询结果与删除前一致错误处理故意构造表不存在、类型不匹配返回可读错误不崩溃从那次演示前十分钟发现页号复用导致数据串掉之后我每次改完存储层都强制把这份脚本从建库开始完整跑一遍跑不通就不往下写新功能。数据库这东西功能少一点没人挑数据错了就全盘皆输。希望帮到你。本文还有配套的精品资源点击获取