☰
数据库管理系统设计赛源码深度解析:工程结构、B+树与WAL恢复
2026/9/26 23:51:44 网站建设 项目流程

简介:2024年全国大学生计算机系统能力大赛数据库管理系统设计赛第三名参赛源码及说明,面向计算机类专业学生、数据库研究人员与竞赛备赛者。源码完整覆盖数据库管理系统从底层架构搭建到上层功能实现的主要环节,包括数据存储结构设计、高效索引策略、查询处理算法与可靠事务管理机制;配套文档进一步梳理了设计思路、模块划分、关键技术应用及问题解决方案。压缩包共411个文件,主体为148个h头文件、102个cc源码文件,同时包含40个cpp、38个Python脚本、30个md文档与若干辅助配置,整体仅1.38MB,目录结构清晰,便于按需查阅。已有122人学习浏览。通过这份资料可以学习竞赛级数据库内核的设计与调优思路,理解模块解耦、测试组织与文档沉淀方式,对课程设计、项目实践或深入理解存储引擎均具有较高参考价值,可用于学习交流。

1. 数据库管理系统设计赛第三名源码:读的不是名次,而是系统能力的样本

计算机系统能力大赛的数据库管理系统设计赛,拼的不是谁的 CREATE TABLE 更花哨,而是能不能交付一个“可以被人拆开检查”的数据库管理系统:SQL 能解析、数据能持久化、并发能加锁。我审过的参赛源码里,功能堆得最多的一类队往往翻车,功能收敛得干净的队反而留到最后。这份“第三名参赛源码+说明”,不是让你抄一个名次,而是给出一组工程取舍样本:模块怎么摆、SQL 子集怎么定、B+ 树做到哪一层、日志要不要写。本文按工程结构、最小实现、存储引擎、避坑、验证五条线展开,适合准备参赛的在校生,以及想手写一次关系型数据库管理系统来补底子的从业者。

2. 看懂一份数据库管理系统参赛源码:先拆工程,再读代码

2.1 源码清单里藏着的四段式工程结构

拿到 zip 压缩包别急着解压就跑,先在脑子里过一次“数据库管理系统应该有哪些模块”。参赛源码虽然不是工业级产品,但一定得是“模块能分开讲”的工程。常见做法是把代码按四个模块摆放:解析层、执行层、存储层、事务层。如果源码包是平铺的几个同名源文件,或者全部堆在 main.c 里,说明作者当时以跑通测试优先;如果按目录分开,说明答辩前做过工程整理。

模块目录特征验证入口常见文件命名
解析层parser / analysis表达式计算、AST 打印token.c、ast.c、parse_select.c
执行层executor / operatorINSERT/SELECT 返回预期行exec_insert.c、seq_scan.c
存储层storage / page / buffer重启后数据仍在pager.c、buffer.c、btree.c
事务层tx / recovery并发事务不互相覆盖txn.c、wal.c、recovery.c

这个四段式结构不是随便分的,它对应着设计赛的三段评分方式:功能测试只验证 SQL 语义,性能测试看 TPS,答辩问内核原理。功能测试里一条单表 SELECT 能跑对,说明解析器和执行器通了;性能测试里多客户端同时写,存储层和事务层才见真章。如果源码里能画出这样一张表,答辩时评委问“多表 JOIN 你的优化器在哪一层处理”,你能直接说出模块边界,而不是翻半天代码再支支吾吾。

细节提醒一句:源文件行数分布能暴露很多问题。十几个文件里一个 btree.c 占了 60% 代码,parse_select.c 只有几十行,说明语法层面做得很薄。这样的源码可以过功能测试,但答辩环节一旦被问“子查询怎么处理的”,没有实现的模块根本答不出来。所以在读代码之前,先建立模块意识比打开任何文件都重要。

2.2 正确阅读顺序:先执行器骨架,再存储引擎细节

很多同学拿到源码,习惯从 B+ 树看起,结果一周过去还卡在叶分裂。我一般读这种源码用另一条路径:追着一条 INSERT 走完“客户端→解析→执行→落盘”的全过程,再回头啃 B+ 树。这样能快速建立高层链路,后边被树细节绕晕时,也知道自己在为哪个环节兜底。

先做一次定位,把包含语句执行入口的文件找出来:

find . -type f \( -name "*.c" -o -name "*.cpp" -o -name "*.h" \) \ -exec grep -l "execute_stmt\|do_insert\|insert_tuple\|seq_scan" {} \; | head -20

这条命令的作用是按文件名过滤出代码入口。参数说明:grep 模式可以根据源码实际命名替换,不用死守这几个单词,比如执行器函数可能叫do_execute或run_stmt;head -20限制输出量,避免把无关的第三方头文件也扫进来。找到入口后,顺藤摸瓜往下追三到五层函数调用,记录每个函数负责的事。

如果源码里预留了日志或 debug 开关,把日志级别调到 info,执行一条select 1;,执行链路的每个阶段都会自己报名字。这个手段在后期排错非常管用,能把黑匣子直接打开。为什么“先执行器后存储”比“先存储后执行器”更稳?因为执行器只有几百行代码,半天看完;存储引擎里的 B+ 树上千行,涉及页分裂、磁盘 IO、父指针维护,一上来就啃硬骨头容易陷进局部,忘了整个数据库管理系统在干什么。

2.3 从注释、README、测试脚本反推设计取舍

一份比赛源码通常带着简短的 README、一两个测试 SQL 脚本和若干注释。注释量不一定大,但能反推很多取舍。比如 regress 目录下只有 basic.sql 和 concurrent.sql,没有 crash.sql,基本可以断定作者对崩溃恢复没有把握;测试 SQL 里清一色单表 INSERT/SELECT,没出现 JOIN,那 SQL 子集大概率不支持 JOIN,或者实现到一半被砍了。

把这些信息汇总成“已知能力边界”清单,比急着改代码更实用。边界清单正是重写和补强的作业计划:下一个版本先补哪个模块,是 JOIN 算子、WAL 日志、还是锁超时。第三名源码在这个角度有天然优势——功能可能不完整,但取舍收敛、边界清晰,比功能多而乱的源码更适合作为学习样本。拿到源码后顺手统计各模块体量:

find src -type f \( -name "*.c" -o -name "*.h" \) | xargs wc -l | sort -rn | head -15

这条命令按行数倒序列出源码文件前 15 名。如果执行逻辑集中在一个超大文件,说明当时写得太仓促,适合用来“改作业”;如果分布均匀,说明作者刻意控制了模块规模,更适合“上课”。把这条命令和前面的 grep 一起用,等于先给源码做了一次体检,接下来读哪份文件、跳过哪份文件,心里就有数了。

3. 最小数据库管理系统实现:从词法到 AST 到执行器

3.1 限制 SQL 子集:为什么第三名的功能列表反而很短

写竞赛源码经常能看到功能清单特别宽的项目:SELECT、INSERT、UPDATE、DELETE、JOIN、GROUP BY 全都要支持。但这类项目往往在评审现场被某个边界条件击穿——要么每种语法只跑得通最简单用例,要么底层存储根本没接上。更常见也更可靠的路线是收敛 SQL 子集,把每条被支持语句的全链路做扎实。

能力初版支持程度原因
单表 SELECT + WHERE + ORDER BY完整验证执行器与表达式求值主干
INSERT / DELETE完整验证存储写入与唯一约束
多表 JOIN暂不支持需要增加 hash join/merge join 算子,复杂度翻倍
子查询暂不支持表达式递归会让 AST 复杂化
事务简单提交/回滚先做单一写者,再推广到并发多写

不是能力越全越容易得高分。数据库管理系统设计赛的评测通常分功能正确性和答辩表现,一个把单表 SELECT 跑得飞快、脏页处理干净的队,比一个十个功能都半吊子的队更能说清楚每个模块的设计理由。功能列表短还有一个附加优势:每个功能都能配上对应的回归 SQL,出 bug 时定位范围小,代码里不会出现“这条语句能过是因为上一个 bug 正好抵消了下一个 bug”的玄学。

我自己做这类项目时有一个习惯:先把 SQL 子集写成一张表贴在代码仓库顶部,每实现一个特性就划线打勾。这能避免最头痛的“做到一半发现某条语句破坏了之前的功能”的情况,评审问支持哪些 SQL 时也不用现想。

3.2 用 C 写最小 AST:tokenize 到 parse 的关键代码

参赛源码里最常出现的轻量做法是手写 tokenizer,而不是引入 flex/lex。原因很现实:比赛周期短,全功能 SQL 解析器太重,而只支持固定子集时,手写分词器和语句填充足够用。先看切分令牌的部分:

typedef struct token { char text[32]; struct token *next; } Token; /* 简化版 tokenizer:只按空格和逗号切分 */ static Token *tokenize(const char *sql) { Token *head = NULL, *tail = NULL; char buf[32]; int pos = 0; for (const char *p = sql; ; p++) { if (*p == ' ' || *p == ',' || *p == '\0') { if (pos > 0) { Token *t = (Token *)calloc(1, sizeof(Token)); buf[pos] = '\0'; strncpy(t->text, buf, 31); if (!head) head = t; else tail->next = t; tail = t; pos = 0; } if (*p == '\0') break; } else { if (pos < 31) buf[pos++] = *p; } } return head; }

逻辑说明:text[32]是列名或常量的缓冲区,32 字节限制了单字段最大长度;pos < 31防止缓冲区溢出。切分完成后,p走到'\0'退出循环。参数说明:竞赛阶段字段名长度限制在 32 字节是合理折中,但这里有个经典翻车点——没有处理 SQL 里的字符串引号。'hello world'这种带空格的字符串会被切碎,所以真正用于生产的 tokenizer 还要加一个in_quote状态位。我在自己的项目里第一版也是这么写的,后来被一条INSERT INTO t VALUES ('Zhang San')的测试用例教育了一晚上。

接下来是 AST 结构体和语句填充:

typedef enum { NODE_SELECT, NODE_INSERT, NODE_DELETE } StmtType; typedef struct expr { char col[32]; const char *op; /* "=", ">", "<", "LIKE" */ char val[32]; struct expr *next; /* WHERE 条件链表 */ } Expr; typedef struct stmt { StmtType type; char table[32]; char columns[8][32]; /* 投影列 */ Expr *where; } Stmt; Stmt *minidb_parse(const char *sql) { Token *toks = tokenize(sql); Stmt *s = (Stmt *)calloc(1, sizeof(Stmt)); Token *cur = toks; if (cur && strcasecmp(cur->text, "select") == 0) { s->type = NODE_SELECT; /* 简单实现:col1,col2 -> columns[i] 语法固定为 select c1,c2 from t where c3 = v */ } return s; }

这里的注释已经暗示了重要设计决定:parse 函数会写一个冗长的 if/else 链来填 columns 和 expr。这份代码读起来啰嗦,但逻辑直接、不依赖外部生成器。我自己更推荐这种手写方式而不是 lex/yacc,原因很朴素:出错时能直接在 C 代码里打断点,而不是在生成器产物里摸不着头脑。char columns[8][32]的限制意味着最多支持 8 个投影列,单列名最长 31 字符,这些数字都要和 tokenizer 的参数一起写进 README,保证代码的可读性。

3.3 把执行器写成一张内存表:先跑通语义,再谈磁盘

在实现初期,很多队伍先不接磁盘文件,直接建内存表,这样能让解析器和执行器快速联调。内存表结构长得像链表不是偶然的,它是最低成本的线性数据组织方式:

typedef struct row { int id; char name[32]; int age; struct row *next; } Row; typedef struct table { char name[32]; Row *rows; int next_id; /* 自增主键分配器 */ } Table; int exec_insert(Table *t, const Stmt *s) { Row *r = (Row *)calloc(1, sizeof(Row)); r->id = t->next_id++; strncpy(r->name, s->columns[1], 31); r->age = atoi(s->columns[2]); r->next = t->rows; /* 头插,避免遍历到表尾 */ t->rows = r; return r->id; }

逻辑说明:exec_insert只做三件事:分配自增 id、按位置拷贝列值、头插链入。参数说明:next_id从 1 开始递增,相当于内存版 AUTO_INCREMENT;头插法让新行放链表头,插入代价是 O(1),但 SELECT 的结果顺序会与插入顺序相反。解决方式是“头插 + 需要时排 ORDER BY”,这是非常典型的内存表阶段取舍。

SELECT 的过滤逻辑同样朴素,用全表扫描加逐条条件匹配:

int exec_select(const Table *t, const Stmt *s, void (*emit)(Row *)) { int matched = 0; for (Row *r = t->rows; r; r = r->next) { int hit = 1; for (Expr *e = s->where; e; e = e->next) { if (strcmp(e->col, "id") == 0 && r->id != atoi(e->val)) hit = 0; if (strcmp(e->col, "name") == 0 && strcmp(r->name, e->val) != 0) hit = 0; if (strcmp(e->col, "age") == 0 && r->age != atoi(e->val)) hit = 0; } if (hit) { matched++; emit(r); } } return matched; }

参数说明:emit是回调函数,负责把匹配行打印出来或者填入结果集,这样命令行客户端和测试框架可以共用同一套执行器。这个阶段不要碰索引,先把语义调到全对,再在下一章把全表扫描替换成索引查找。如果内存表阶段就急着优化,很容易把 WHERE 判断逻辑和索引遍历逻辑混在一起,后期改起来痛不欲生。

4. 存储引擎与 B+ 树索引:把内存表换成页式存储

4.1 页、缓冲池与脏页刷盘

内存表阶段可以让结果正确,但设计赛评测通常不会放过“持久化”这一项,重启后数据还在才算过关。常见做法是引入页作为磁盘与内存交换的最小单位,页大小 4KB 或 8KB。为什么要先定页大小?一页内的 IO 开销小,索引节点、行记录、日志记录都按页对齐;页太大会浪费内存,页太小放大扇区 IO。

页与缓冲池的定义如下:

#define PAGE_SIZE 4096 #define FRAME_COUNT 8 typedef struct page { int page_id; char data[PAGE_SIZE]; int dirty; /* 1 表示修改后未落盘 */ int pin_count; /* 被引用的次数 */ long last_used; /* 最近访问纪年 */ } Page; static Page g_pool[FRAME_COUNT]; static long g_clock;

加载页面的函数是缓冲池的核心:

Page *fetch_page(FILE *fp, int page_id) { for (int i = 0; i < FRAME_COUNT; i++) { if (g_pool[i].page_id == page_id) { g_pool[i].pin_count++; g_pool[i].last_used = ++g_clock; return &g_pool[i]; } } /* 缺页:找一个 pin_count==0 且最久未用的槽位 */ int victim = -1; for (int i = 0; i < FRAME_COUNT; i++) { if (g_pool[i].pin_count == 0) { if (victim < 0 || g_pool[i].last_used < g_pool[victim].last_used) victim = i; } } if (victim < 0) return NULL; /* 所有页都被 pin 住,等待释放 */ if (g_pool[victim].dirty) flush_page(fp, &g_pool[victim]); fseek(fp, (long)page_id * PAGE_SIZE, SEEK_SET); fread(g_pool[victim].data, PAGE_SIZE, 1, fp); g_pool[victim].page_id = page_id; g_pool[victim].dirty = 0; g_pool[victim].pin_count = 1; g_pool[victim].last_used = ++g_clock; return &g_pool[victim]; }

逻辑说明:fetch_page先查命中,命中就直接返回并更新访问纪年,避免重复读盘。miss 之后才扫槽位找 victim,选择标准是“没有被 pin 住且最久未使用”。victim < 0表示所有页都被占用,此时返回 NULL,由调用方决定是报“缓冲池满”还是稍后重试。

参数说明:FRAME_COUNT取 8 是演示用的最小值,真实系统通常按可用内存的 10% 配缓冲池。dirty标志位非常关键——它决定了淘汰脏页时是否必须先写回文件。很多参赛源码直接申请一块大内存做链表,没有页概念,也就没有脏页问题,但一旦进程被强杀,未落盘的数据就全部丢失。页式存储的意义不只是性能,更重要的是给数据落盘提供了一个明确的“换出点”。

4.2 B+ 树叶插入:重复键探测与页分裂

内存表跑通语义后,索引就该上场了。常见做法是用 B+ 树做聚簇索引,叶子页存“键值 + 行指针”。最核心的插入路径暴露了几乎所有 B+ 树实现的质量:

typedef struct node { int is_leaf; int keys[PAGE_KEY_MAX]; int child_ptr[PAGE_KEY_MAX + 1]; /* 叶子页里存 row 指针 */ int n; /* 当前键数量 */ int page_id; struct node *parent; } Node; int btree_insert(Node *leaf, int key, int row_ptr) { int i; /* 唯一索引约束:先找后插 */ for (i = 0; i < leaf->n; i++) { if (leaf->keys[i] == key) return ERR_DUP_KEY; if (leaf->keys[i] > key) break; } /* 从后往前搬移 */ for (int j = leaf->n; j > i; j--) { leaf->keys[j] = leaf->keys[j - 1]; leaf->child_ptr[j] = leaf->child_ptr[j - 1]; } leaf->keys[i] = key; leaf->child_ptr[i] = row_ptr; leaf->n++; if (leaf->n == PAGE_KEY_MAX) split_leaf(leaf); return OK; }

逻辑说明:for 循环同时完成两件事:唯一性探测和插入位置查找。key 已存在时直接返回ERR_DUP_KEY,不会把重复键写进叶子页。后半段从后往前移位保证有序,n 加 1 后若到达页容量上限触发split_leaf。参数说明:PAGE_KEY_MAX由 PAGE_SIZE 推导而来,4KB 页装 8 字节键加 8 字节指针,约 256 个键值对。

split_leaf要做三件事:新开叶子页、搬一半键值过去、把中间键上升。常见误用是等叶子 100% 满才分裂,这样父节点分裂的连锁反应会集中爆发。我的做法是把分裂阈值设成PAGE_KEY_MAX - 1,给新插入留一格余量,能明显减少父节点分裂次数。另一个容易错的地方是父指针的回填——分裂后 parent 指向的页号如果没有同步更新,后续查找会走到错误的子页。这个 bug 在功能测试里不容易暴露,压力测试一上来就原形毕露。

4.3 事务日志:崩溃恢复靠的不是“多写一把”

内存表和页式存储都做完后,很多队伍会在“重启后数据还在不在”上翻车。最朴素的恢复方案是 WAL:事务提交前先把操作追加到日志文件,再改数据页。日志恢复的关键要义是幂等——重放同样的 INSERT 两次,不能得到两条重复记录。

void do_recovery(FILE *log) { char op[16]; int txn_id, page_id, offset; char val[32]; while (fscanf(log, "%s %d %d %d %s", op, &txn_id, &page_id, &offset, val) == 5) { if (strcmp(op, "INSERT") == 0) { /* 先检查该位置是否已写,避免重放插入重复 */ if (!page_has_key(page_id, offset)) { apply_insert(page_id, offset, val); } } else if (strcmp(op, "COMMIT") == 0) { mark_committed(txn_id); } } }

逻辑说明:恢复循环的核心是“先查再写”,和前面叶子插入的唯一性检查是同一套逻辑。page_id+offset定位行的物理位置,val记录键值;COMMIT记录不产生数据页修改,只把事务标记为成功。参数说明:真实系统会把日志拆成 redo 和 undo 两类,但竞赛源码里很多只实现 redo 就足够应付“kill -9 后数据不丢”这条评测场景。

这个方案为什么值得做:不少参赛源码的问题是“缓冲池有脏页却没日志”。只要补上 WAL,用一个带 transaction_id 的日志文件,就能在重启后重放未刷盘的操作。这比“正常退出前手动 flush 所有脏页”可靠得多,因为你永远不知道评测程序什么时候会 kill 进程。再补一句设计判断:比赛阶段优先做 redo 而不做完整 undo,原因是评测场景通常是“提交后崩溃,重启校验数据”,较少测“回滚后撤销未提交事务”。如果评测里出现了隔离性测试,再补锁和 undo 也不迟。

5. 避坑:从参赛源码里最容易踩中的 5 个泥潭

5.1 压缩包解压失败:zip 伪加密与解压工具差异

现象:双击 zip 能看到文件名,解压时要求输密码,输入什么都不对;或者解压到一半提示“无法解密,已中止”。换成 7-Zip 却能正常打开。

原因:这是 zip 伪加密。文件目录区的通用位标志被标记为“已加密”,但数据区并没有真正加密。Windows 资源管理器按目录标志要求用户输密码,而 7-Zip 只检测实际数据区,能直接解出内容。

解决:换用 7-Zip 解压:

7z x 2024-dbms-third.zip -o./dbms-src

参数说明:-o指定解压目标目录,目录不存在会自动创建。如果 7-Zip 也需要密码且不是伪加密,那就说明资料确实带密码,先看说明文档或原链接有没有提示。这个坑和代码无关,但每年都在“下载→解压→编译”第一步劝退一堆人。顺手提一句,离线环境下 7-Zip 的压缩工具装好后可以直接命令行调用,避免右键菜单里的解压行为在伪加密文件上打转。

5.2 Windows 下能编译,Linux 下报 stray

现象:同一份源码在 Windows 的 MSVC 下编译通过,在 Linux 下 gcc 报错,报错信息是stray '\r' in program,或者中文注释乱码成一片。

原因:源码用 CRLF 换行并且带 UTF-8 BOM;Linux 下 gcc 把\r当成字符,BOM 被当作非法标识符。

解决:批量转换:

find src -type f \( -name "*.c" -o -name "*.h" \) -exec sed -i 's/\r$//' {} \; find src -type f \( -name "*.c" -o -name "*.h" \) -exec sed -i '1s/^\xef\xbb\xbf//' {} \;

第一条命令去掉回车符,第二条命令去掉文件开头的 UTF-8 BOM。参数说明:sed -i直接改源文件,没有备份,批量操作前建议先复制一份目录。这种现象在 Windows 本地开发、Linux 评测环境的组合下极其容易出现,血泪经验就是提交评测前先跑一遍file命令检查格式,把换行符问题留在本地方便解决。

5.3 两个事务互相卡死:锁顺序与 no-wait 策略

现象:并发测试中,事务 A 更新行 1、事务 B 更新行 2,随后 A 想更新行 2、B 想更新行 1,两边互相等锁,评测超时崩掉。

原因:行锁获取顺序没有全局约定,也没有锁超时。每个事务直接抢锁,形成 ABBA 死锁环。

解决:常见做法是把锁接口改成“拿不到就立刻返回冲突”,即 no-wait 策略:

lock_t *try_lock(Table *t, int row_id, Txn *cur) { if (t->row_locks[row_id].owner == cur) return &t->row_locks[row_id]; if (t->row_locks[row_id].owner != NULL) return NULL; /* 冲突立即返回 */ t->row_locks[row_id].owner = cur; return &t->row_locks[row_id]; }

逻辑说明:try_lock不再阻塞,拿不到锁就返回 NULL,由调用方决定回滚重试还是报错。参数说明:重试次数一般设 3 次,间隔随机 1-5 毫秒,避免重试风暴。no-wait 比死锁检测省事得多,也完全符合竞赛场景的数据规模。重点是别在报表或锁管理里引入“等待队列”,一旦有等待就会重新引入死锁问题。

5.4 kill -9 后表文件打不开

现象:正常关闭数据库时一切正常;用 kill -9 强制杀掉进程,重启后表文件还在,但查询结果少了几行,或直接报“page header corrupted”。

原因:缓冲池里的脏页只在正常关闭时统一 flush,被强杀时这些页没落盘,文件头部的元数据还写着“页数=100”,实际有效页只有 80。

解决:加一层 WAL,并且保证“提交点=日志 fsync 完成”,而不是“数据页写回完成”。写入事务时先追加日志,再改数据页;每次写页更新文件头部元数据时也要先写日志。如果时间不够,最低限度是在每次事务提交后把相关脏页异步写回,把崩溃窗口缩小到“最近一个事务的差值”。这条坑几乎每个手写存储引擎的队都会踩一次,区别只在于崩溃后有没有后悔药可吃。

5.5 唯一索引插入重复键不报错

现象:对唯一索引重复插入 id=5,第一次成功,第二次也“成功”,导致查询时同一个 id 返回两行,甚至索引页分裂后错乱。

原因:插入路径没有做键存在性探测,直接在叶子页末尾追加。不少实现把“唯一性检查”忘了,或只检查了根节点没往叶子层走。

解决:按照前面 4.2 节的写法,插入前先在叶节点内做一次查找,命中就返回ERR_DUP_KEY。如果同一个叶子页有并发插入,需要在持有叶锁的情况下重做一次探测。这个坑在八成手写 B+ 树里都会出现,几乎成了数据库管理系统设计赛源码里的标志性毛病。本地测试时多写一条“插入重复主键应当报错”的回归用例,能防住它。

6. 验证能力:用三个“白盒自检”让源码在答辩现场站得住

6.1 三跑哈希:用可重复性检验未初始化内存

能否证明系统没有未初始化内存?最简单的方法是同一批 SQL 跑三遍,比较输出哈希:

./minidb < test.sql | sha256sum ./minidb < test.sql | sha256sum ./minidb < test.sql | sha256sum

如果三次结果完全一样,说明执行路径里没有读到没赋值的局部变量,也没有在不确定状态下做分支。参数说明:test.sql里要覆盖 INSERT、UPDATE、DELETE、SELECT 主键记录,写路径和读路径都要碰到。这个测试在答辩前夜特别有用,它能把偶现 bug 变成必现 bug。三次哈希一致,至少证明了执行链路的确定性。

6.2 崩溃重放:把评测机的 kill -9 变成你的考试题

在脚本里人为用 kill -9 杀掉进程,重启后再执行聚合查询,比较结果:

./minidb < insert_100.sql kill -9 $(pgrep minidb) ./minidb < select_sum.sql

select_sum.sql里执行SELECT SUM(id)并打印,和崩溃前的预期值做对比。这个方法能把随机的崩溃现象变成可重复的恢复测试,直接检验 WAL 逻辑是否真的在起作用。我自己的习惯是把这条命令写进每天的构建脚本,任何一次改动导致恢复失败,第二天早上第一眼就能发现。

6.3 强制打印执行计划,让评委一眼读懂系统能力

抽象的能力需要可视化来支撑,执行前的内部计划打印是一个成本很低的小技巧:

void print_plan(const Stmt *s) { if (s->type == NODE_SELECT) { printf("seq_scan(table=%s, pred=%s)\n", s->table, s->where ? s->where->col : "nil"); } }

让命令行客户端在每次执行前调用一次print_plan,外面跑一条 SQL 就能看到用的是seq_scan还是btree_index_scan。同一套机制还能用来验证WHERE id=1是不是真的走了索引。把打印内容再扩展一步,加入每个算子的读页数和比较次数,跑完一遍就有了第一手性能证据。评委问“这条查询性能瓶颈在哪”,你可以直接指着终端输出讲。

我在做这类底层系统时,养成了一个固定习惯:先想“我怎么向一个陌生人解释这个系统做了什么”,再动手写代码。数据库管理系统这种底层项目,代码里如果没留自检,等于把心脏病埋到答辩前夜才发作。这份第三名源码的最大价值不在于名次,而在于它给后来者展示了“能做多少、做到什么程度、留下什么缺口”的完整答案。希望这篇笔记能帮你把源码里的工程取舍真正读进去,而不是停留在一个 zip 文件的目录列表上。

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

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

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

立即咨询