ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

数据库管理系统设计赛源码深度解析:工程结构、B+树与WAL恢复

数据库管理系统设计赛源码深度解析:工程结构、B+树与WAL恢复 简介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_stmthead -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 写最小 ASTtokenize 到 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_count0 且最久未用的槽位 */ 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_idoffset定位行的物理位置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 BOMLinux 下 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 唯一索引插入重复键不报错现象对唯一索引重复插入 id5第一次成功第二次也“成功”导致查询时同一个 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.sqlselect_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 id1是不是真的走了索引。把打印内容再扩展一步加入每个算子的读页数和比较次数跑完一遍就有了第一手性能证据。评委问“这条查询性能瓶颈在哪”你可以直接指着终端输出讲。我在做这类底层系统时养成了一个固定习惯先想“我怎么向一个陌生人解释这个系统做了什么”再动手写代码。数据库管理系统这种底层项目代码里如果没留自检等于把心脏病埋到答辩前夜才发作。这份第三名源码的最大价值不在于名次而在于它给后来者展示了“能做多少、做到什么程度、留下什么缺口”的完整答案。希望这篇笔记能帮你把源码里的工程取舍真正读进去而不是停留在一个 zip 文件的目录列表上。本文还有配套的精品资源点击获取
返回列表