简介:这份 miniSQL 项目是浙江大学数据库设计课程期末大程的完整成果,面向正在学习数据库系统原理、希望从零理解关系型数据库内部机制的高校学生与 C/C++ 开发者。压缩包共 29 个文件,约 885KB,以 9 个 cpp 源文件、9 个 h 头文件和 9 个 txt 测试与说明文本为主,配合 1 份 PDF 设计报告和 1 个可直接运行的 exe,覆盖缓冲池管理、B+ 树索引、记录管理、系统目录与 SQL 解释器等核心模块,源码与可执行程序齐全。读者可借助设计报告梳理项目目标、设计取舍与测试思路,再对照源码理解 SELECT、INSERT、UPDATE、DELETE 及连接、事务等语句如何被解析执行,并通过测试用例验证系统在并发场景下的正确性与稳定性。对于课程大作业、数据库内核入门或面试复习,都能提供可复用的实现参考与排错线索。目前已有 1532 人学习。
1. 从“能跑”到“敢改”:miniSQL 数据库课程设计先啃哪块硬骨头
第一次打开这份 miniSQL 数据库课程设计源码,很多人会直接翻 B+ 树,结果在页号越界和重启丢数据上连卡两天。它不是把几条 SQL 语句翻译成打印结果就完事,而是用 C/C++ 把 SQL 解析、系统目录、页式存储、索引和执行器串成一条能落盘、能回滚的链路。
这份资源适合正在做数据库课程设计、想从 CRUD 往存储引擎走一步的 C/C++ 开发者,也适合面试前需要讲清楚“一条 select 到底经过哪些层”的人。它能帮你把零散的数据库概念压成一个可编译、可调试的小系统。
先别急着改索引。把建表、插入、重启、再查询这条最土的路径跑通,后面改 B+ 树才不至于反复返工。踩坑最多的地方通常不是算法,而是页格式、目录元数据和刷盘时机。
2. 存储层选型:页大小、记录布局和缓冲池的三个硬参数
存储层是 miniSQL 数据库课程设计里最容易被低估的一层。SQL 解析写错顶多报语法错误,存储层写错会表现成“查询偶发丢行”“重启后表打不开”“索引页和表页对不上”,排查时像在黑匣子里摸线。先把页式文件、记录编码和缓冲池这三件事定下来,后面的 B+ 树、系统目录、日志才有稳定的地基。
2.1 为什么先定页大小和记录布局
数据库文件不是按行读写,而是按页读写。页大小决定 B+ 树节点能放多少 key、系统目录页怎么切、日志页怎么对齐,也决定你调试时一次能看到多少字节。常见做法是 4KB 或 8KB:4KB 和大多数文件系统块对齐,用pread/pwrite按偏移读写比较顺手,xxd一屏能看完;8KB 能减少 I/O 次数,适合记录较大、范围扫描较多的场景,但缓冲池内存压力会变大。
记录布局要跟页布局一起定。定长记录简单,但字符串列会浪费空间;变长记录灵活,却需要槽页把偏移和长度管起来。我一般会用一个定长记录头加变长 payload:记录头存删除标记、空值位图和 payload 长度,槽数组从页尾向前长,记录体从页头向后长。这样删除只改标记,不立刻压缩页,插入时找空闲槽或追加到 free_ptr 后面。
| 参数 | 建议值 | 影响 |
|---|---|---|
PAGE_SIZE | 4096 | B+ 树节点容量、I/O 粒度、调试可读性 |
BUFFER_POOL_SIZE | 64~256 页 | 内存占用与命中率,测试期可临时调大 |
RECORD_HEADER_SIZE | 8~16 字节 | 删除标记、空值位图、长度字段 |
SLOT_SIZE | 4 字节 | 槽页偏移和长度的存储成本 |
选型理由不复杂:页大小一旦落盘,改它等于改文件格式。B+ 树节点容量、系统目录页、日志页都要同步重算,不能只改一个宏。记录头也要一次定稳,后面加列、加索引都靠它做兼容。
2.2 页式文件与缓冲池的最小实现
先写页结构和页文件读写,再写缓冲池。不要一上来就把 B+ 树、执行器全塞进去,先用一个page_test把“写 100 页再读回”跑通。页结构里pin_count和dirty是缓冲池淘汰的关键:pin_count表示这页正被多少执行器引用,只有为 0 才能淘汰;dirty表示页被修改过,淘汰前必须刷盘。
// storage/page.h #pragma once #include <cstdint> constexpr int PAGE_SIZE = 4096; // 与文件系统块对齐,便于预读和调试 constexpr int INVALID_PAGE = -1; struct Page { int page_id = INVALID_PAGE; int pin_count = 0; // 被多少执行器引用,0 才可淘汰 bool dirty = false; // 修改过,淘汰前必须刷盘 char data[PAGE_SIZE] = {0}; };页文件读写用pread/pwrite,偏移用 64 位,避免大文件下int溢出。短读不要当成正常情况,通常意味着页号越界、文件被截断或者上一次写没落盘。
// storage/page_file.cpp #include "page.h" #include <fcntl.h> #include <unistd.h> bool PageFile::readPage(int page_id, Page* page) { off_t offset = static_cast<off_t>(page_id) * PAGE_SIZE; ssize_t n = pread(fd_, page->data, PAGE_SIZE, offset); if (n != PAGE_SIZE) return false; // 短读通常意味着页号越界或文件截断 page->page_id = page_id; return true; } bool PageFile::writePage(int page_id, const Page* page) { off_t offset = static_cast<off_t>(page_id) * PAGE_SIZE; ssize_t n = pwrite(fd_, page->data, PAGE_SIZE, offset); return n == PAGE_SIZE; }缓冲池的fetchPage先查页表,命中就加pin_count;未命中选一个pin_count == 0的淘汰页,脏页先写回,再把新页读进来替换映射。pickVictim可以用 clock 或 LRU,测试阶段先别选正在被引用的页。
Page* BufferPool::fetchPage(int page_id) { auto it = page_table_.find(page_id); if (it != page_table_.end()) { it->second->pin_count++; return it->second; } Page* victim = pickVictim(); // 只选 pin_count == 0 的页 if (victim->dirty) page_file_.writePage(victim->page_id, victim); page_file_.readPage(page_id, victim); page_table_.erase(victim->page_id); victim->page_id = page_id; victim->pin_count = 1; victim->dirty = false; page_table_[page_id] = victim; return victim; }参数说明:page_id从 0 开始连续分配;fd_是页文件描述符;pin_count在unpinPage里减一;dirty在记录插入、删除、索引修改后置 true。测试时把BUFFER_POOL_SIZE设得比测试页数大,先排除淘汰逻辑干扰,再逐步缩小池子看脏页刷盘是否正确。
g++ -std=c++17 -O0 -g -Iinclude \ src/storage/page_file.cpp src/storage/buffer_pool.cpp tests/page_test.cpp \ -o page_test ./page_test-O0 -g方便用 gdb 看页号和偏移,-Iinclude指定头文件目录。先跑通页读写,再接管表记录。记录头可以这样定:
struct RecordHeader { uint16_t delete_flag; // 0 正常,1 已删除 uint16_t null_bitmap; // 可空列的位图 uint16_t payload_len; // 变长 payload 长度 uint16_t reserved; // 对齐用,后续可放版本号 };槽页页头存slot_count和free_ptr,槽数组从页尾向前长,记录体从页头向后长。删除记录只改delete_flag,不要立刻压缩页;等页内碎片超过阈值再做整理。这样做的好处是回滚和索引删除都简单,代价是页内空间利用率会下降。
2.3 参数怎么改、失败时看什么
改PAGE_SIZE是牵一发动全身的操作。B+ 树叶子节点容量要按(PAGE_SIZE - header_size) / sizeof(Key + Rid)重算,内部节点容量要按 key 和 child page id 重算,系统目录页和日志页也要同步。只改一个宏,最常见的表现是建索引时节点分裂越界,或者系统目录写到一半覆盖了表页。
失败时先看三个地方:文件大小是不是PAGE_SIZE的整数倍、页 0 的头部有没有被覆盖、dirty页有没有在关闭前刷盘。用xxd看前 64 字节,能快速判断页 0 是元数据页还是被记录数据踩了。
ls -l test.db xxd -l 64 test.db如果文件大小不是页大小的整数倍,说明某次写偏移算错或者写越界;如果前 64 字节全 0,可能是打开文件后没初始化元数据页;如果page_test单跑通过、接上缓冲池就失败,先把池子调大,确认不是淘汰逻辑把脏页丢了。errno也要打出来,pread返回 -1 和短读是两类问题,前者看系统调用,后者看页号范围。
提示:测试存储层时先关掉并发和索引,只跑单线程顺序插入,确认页文件稳定后再叠 B+ 树。
3. 索引与查询执行:B+ 树落盘、范围扫描和连接顺序
索引层是 miniSQL 数据库课程设计里最像“数据库”的部分,也是最容易只写对插入、没写对分裂和范围扫描的部分。B+ 树不是内存里的std::map,它的每个节点都要落盘,节点分裂会改父节点,叶子链表断了会让范围查询少行或者死循环。查询执行器则要把扫描、过滤、连接按代价排好,不然索引建了也用不上。
3.1 B+ 树节点分裂的边界条件
B+ 树的阶数不要拍脑袋定,按页容量算。叶子节点存 key 和 rid,内部节点存 key 和 child page id,节点头存is_leaf、key_count、next_leaf、parent。插入时先找到目标叶子,插入后如果key_count > max_keys就分裂;分裂把右半部分复制到新节点,叶子节点要维护next指针,内部节点要把中间 key 上推给父节点。
void BPlusTree::splitLeaf(LeafNode* leaf) { LeafNode* right = new LeafNode(); int mid = leaf->key_count / 2; right->key_count = leaf->key_count - mid; for (int i = 0; i < right->key_count; ++i) { right->keys[i] = leaf->keys[mid + i]; right->rids[i] = leaf->rids[mid + i]; } leaf->key_count = mid; right->next = leaf->next; // 先接右邻居,再让左节点指向新节点 leaf->next = right->page_id; // 父节点插入 right 的最小 key,必要时递归分裂内部节点 }mid取key_count / 2是常见做法,但叶子节点和内部节点的max_keys不同,不能共用同一个分裂阈值。叶子节点分裂后,父节点要插入右节点的最小 key;内部节点分裂时,中间 key 上推,不留在子节点。删除时合并阈值一般设为半满,别等到节点空了才合并,否则树高会抖。
3.2 从 SQL 到执行计划:扫描、过滤、连接的最小闭环
执行器先做最小闭环:SeqScan顺序扫表,IndexScan按索引取 rid 再回表,Filter做条件判断,NestedLoopJoin做双循环连接。选择索引的条件不要写得太玄学,等值条件优先,范围条件看选择率;如果范围太大,顺序扫描反而更稳。连接顺序上,小表驱动大表是常见做法,至少别让大表在外层反复扫。
// executor/select_executor.cpp Row* SelectExecutor::next() { while (true) { Row* row = child_->next(); // child_ 可能是 SeqScan、IndexScan 或 Join if (row == nullptr) return nullptr; if (!filter_->match(row)) continue; // 过滤条件下推后仍要在这里兜底 return row; } } // 简化版 IndexScan::next:先定位叶子,再沿 next_leaf 走范围 Row* IndexScan::next() { if (!started_) { leaf_ = tree_->findLeaf(lower_key_); slot_ = tree_->lowerBound(leaf_, lower_key_); started_ = true; } if (leaf_ == nullptr || slot_ >= leaf_->key_count) return nullptr; Rid rid = leaf_->rids[slot_++]; if (slot_ >= leaf_->key_count) { leaf_ = tree_->fetchLeaf(leaf_->next); slot_ = 0; } return table_->getRow(rid); }child_->next()是迭代器模型,避免一次把结果全物化;filter_->match做类型转换后的比较,注意int和char的边界;lowerBound返回第一个大于等于lower_key_的槽位;next_leaf为INVALID_PAGE时结束范围扫描。连接执行器里,外表每取一行,内表要能重置扫描位置,否则第二轮连接会从空结果开始。
3.3 索引参数与执行计划验证
建索引后不要只看“能不能查”,要看“走了哪条路”。如果项目里有EXPLAIN,让它输出计划节点;没有就加一个最小打印,把SeqScan、IndexScan、Filter的节点类型和预估行数打出来。验证时用三组 SQL:等值命中、范围命中、低选择率条件。
CREATE INDEX idx_student_id ON student(id); SELECT * FROM student WHERE id = 2; SELECT * FROM student WHERE id >= 10 AND id < 20; SELECT * FROM student WHERE score > 60;第一组应该走IndexScan,第二组走索引范围扫描,第三组如果score没索引就走SeqScan,别硬套索引。对比表可以这样记:
| 场景 | 预期计划 | 检查点 |
|---|---|---|
| 主键等值 | IndexScan | 是否只回表一次 |
| 主键范围 | IndexScan + next_leaf | 叶子链表是否连续 |
| 无索引列过滤 | SeqScan + Filter | 是否全表扫但结果正确 |
| 小表连大表 | NestedLoopJoin,小表在外 | 内表扫描是否可重置 |
失败时先看索引页和表页的 rid 是否一致,再看范围扫描有没有在叶子链表上打转。叶子next指向自己、指向已释放页、或者分裂时忘了改右邻居,都会表现成范围查询死循环或少行。建索引后跑一遍全表SELECT和索引SELECT,比对行数和主键集合,是最便宜的对账方式。
注意:B+ 树节点分裂后要立刻把新页号写回父节点,别只改内存不落盘,否则重启后索引树会断。
4. SQL 解析与系统目录:词法、语法树和元数据落库
SQL 解析和系统目录是 miniSQL 数据库课程设计的“入口层”。解析器把字符串变成语法树,系统目录把表名、列类型、索引根页号记下来,执行器才知道去哪张表、取哪些列、走哪棵索引。很多实现能跑简单SELECT,一加CREATE TABLE或重启就崩,问题多半在目录元数据没有真正落库。
4.1 词法/语法分析的最小可用路径
词法分析手写就够:跳过空白,识别关键字、标识符、数字、字符串和符号。关键字用unordered_map做大小写不敏感匹配,标识符允许字母、数字、下划线,数字支持整数和简单小数。语法分析用递归下降,按CREATE、INSERT、SELECT、DELETE、UPDATE、DROP分派,不要一上来做完整 SQL 标准。
Token Lexer::next() { skipSpace(); if (pos_ >= input_.size()) return {TokenType::END, ""}; char c = input_[pos_]; if (isalpha(c) || c == '_') { size_t start = pos_; while (pos_ < input_.size() && (isalnum(input_[pos_]) || input_[pos_] == '_')) pos_++; std::string word = input_.substr(start, pos_ - start); return {keywordOrIdent(word), word}; // 先查关键字表,再当标识符 } if (isdigit(c)) { size_t start = pos_; while (pos_ < input_.size() && isdigit(input_[pos_])) pos_++; return {TokenType::NUMBER, input_.substr(start, pos_ - start)}; } ++pos_; return {TokenType::SYMBOL, std::string(1, c)}; }skipSpace要处理空格、制表符和换行;keywordOrIdent把select、from、where统一转大写后查表;pos_是游标,遇到非法字符要返回错误 token,不要静默跳过。递归下降的入口按首关键字分派,解析失败时带上位置和期望 token,调试会省很多时间。
4.2 系统目录表怎么设计
系统目录本身也用表存,启动时加载到内存。最小三张表够用:sys_tables记表 id、表名、根页、首数据页;sys_columns记列序号、列名、类型、长度、是否可空;sys_indexes记索引 id、索引名、表 id、列序号、索引根页。表名和索引名加唯一约束,删除表时按table_id级联清理。
CREATE TABLE sys_tables ( table_id INT PRIMARY KEY, table_name VARCHAR(64) UNIQUE, root_page INT, first_page INT ); CREATE TABLE sys_columns ( table_id INT, col_index INT, col_name VARCHAR(64), col_type INT, col_len INT, not_null INT ); CREATE TABLE sys_indexes ( index_id INT PRIMARY KEY, index_name VARCHAR(64), table_id INT, column_index INT, root_page INT );table_id和index_id用递增分配,不要复用已删除 id;root_page对堆表可以放元数据页,对索引表放 B+ 树根页;col_type用枚举值区分INT、CHAR、VARCHAR;col_len对定长和变长都要有上限。启动时先读sys_tables,再按table_id读列和索引,加载到内存的 catalog 结构里。
4.3 DDL 与 DML 的执行边界
DDL 要同时改目录和物理文件。CREATE TABLE先分配table_id,创建数据文件头页,写sys_tables和sys_columns,最后提交目录事务;CREATE INDEX先扫表建索引,再写sys_indexes和索引根页。DML 要检查列数、类型、非空约束和唯一约束,插入前确认页空间够,删除时同步删索引项。
bool Catalog::createTable(const CreateTableStmt& stmt) { int table_id = next_table_id_++; Page header; page_file_.readPage(0, &header); TableMeta meta{table_id, stmt.table_name, header.page_id, header.page_id}; if (!sys_tables_->insert(meta)) return false; // 目录插入失败要回滚文件头 for (int i = 0; i < stmt.columns.size(); ++i) { if (!sys_columns_->insert(table_id, i, stmt.columns[i])) { sys_tables_->remove(table_id); return false; } } return true; }next_table_id_要持久化,别每次启动从 0 开始;header.page_id是数据文件头页;目录插入失败要回滚已经写入的元数据,否则会出现“表不存在但列还在”的半残状态。DML 执行前先查 catalog 拿列定义,再做类型转换,最后才碰存储层。DROP TABLE先删索引和列,再删表记录,最后释放页;释放页可以标记空闲,不要立刻复用,方便回滚和排查。
提示:系统目录写入要和用户表数据分开事务边界,目录写失败时不要让数据页已经落盘。
5. 避坑与排查:从编译链接到事务回滚的 5 个翻车现场
miniSQL 数据库课程设计的很多问题不是算法不会,而是工程边界没守住。下面这 5 个翻车现场,基本都是 C/C++ 项目里最常见的那类:编译链接、刷盘、叶子链表、范围扫描、日志顺序。每个都按“现象 → 原因 → 解决”拆开,照着排查能少走不少弯路。
5.1 编译链接报 undefined reference
现象:page_file.cpp单独编译能过,链接测试程序时报undefined reference to PageFile::readPage(int, Page*),或者BufferPool的构造函数找不到。
原因:静态库链接顺序不对,或者头文件声明和源文件定义签名不一致。C++ 对const、命名空间、默认参数很敏感,声明里写了const Page*,定义里写成Page*就会变成两个函数。
解决:先把所有源文件直接编进可执行文件,排除库顺序问题;确认能跑后,再把libminisql.a放到链接命令最后。用nm -C看符号是否真的存在。
g++ -std=c++17 -O0 -g -Iinclude \ src/storage/page_file.cpp src/storage/buffer_pool.cpp tests/page_test.cpp \ -o page_test nm -C libminisql.a | grep PageFile::readPagenm -C能把 C++ 符号还原成可读名字;如果符号带const或参数类型不一致,一眼能看出来。链接顺序上,调用方目标文件放前面,被调用库放后面。
5.2 插入成功但重启后数据没了
现象:交互界面里INSERT返回成功,SELECT也能查到,关闭程序再启动,表还在但行没了。
原因:记录只改了缓冲池里的页,dirty置了 true,但程序退出时没有flushAll,或者close直接_exit没走析构。另一种情况是目录页刷了,数据页没刷,导致“表存在、数据为空”。
解决:在正常退出路径显式刷所有脏页,并在事务提交时按策略刷盘。测试时可以在INSERT后立刻kill -9,看恢复逻辑是否成立;没有日志就先做“提交即刷数据页”的保守版本。
void BufferPool::flushAll() { for (auto& [page_id, page] : page_table_) { if (page->dirty) { page_file_.writePage(page_id, page); page->dirty = false; } } page_file_.sync(); // 确保元数据和数据落到文件系统 }flushAll只刷脏页,sync保证文件系统缓存落盘;退出前先unpin所有页,再flushAll,最后关文件描述符。不要依赖进程正常退出,测试里要覆盖异常退出。
5.3 建索引后查询结果重复
现象:不建索引时SELECT结果正常,建完索引后同一行出现两次,或者范围查询多出几条。
原因:B+ 树叶子分裂时next指针没接对,右节点的next还指向旧邻居,范围扫描沿链表走的时候绕回去了;另一种是索引项 rid 写错,回表取到相邻行。
解决:分裂时先保存leaf->next,再让right->next指向它,最后改leaf->next;每次插入后跑一遍“全表主键集合”和“索引主键集合”的比对。范围扫描加一个已访问页集合,测试期发现环就报错。
void BPlusTree::splitLeaf(LeafNode* leaf, LeafNode* right) { int old_next = leaf->next; leaf->next = right->page_id; right->next = old_next; // 父节点插入 right 的最小 key,别在这里改 leaf 的 key_count }old_next必须先存,否则右节点会指向自己;right->next初始化为INVALID_PAGE,不要默认 0,页 0 通常是元数据页。
5.4 范围查询卡死或只返回一行
现象:WHERE id >= 10执行后程序不返回,或者只返回第一行就结束。
原因:lowerBound返回槽位后,next没推进;叶子链表最后一页的next不是INVALID_PAGE,而是 0,导致又跳回页 0;或者IndexScan::next在slot_ >= key_count时没有切到下一页。
解决:叶子节点初始化时next = INVALID_PAGE,根节点和所有新分裂节点都显式赋值;IndexScan每次取完当前槽后检查是否需要切页;加一个最大页访问计数,超过表页数就报“索引链表成环”。
if (slot_ >= leaf_->key_count) { if (leaf_->next == INVALID_PAGE) return nullptr; leaf_ = tree_->fetchLeaf(leaf_->next); slot_ = 0; }INVALID_PAGE用 -1,不要用 0;fetchLeaf要校验页类型,防止把内部节点当叶子读。
5.5 事务回滚后数据页还是脏的
现象:事务里插入一行后回滚,SELECT查不到,但重启后行又出现了;或者回滚后索引里还留着 rid。
原因:日志顺序错了,先刷数据页再写日志,崩溃恢复时无法判断哪些修改已提交;或者回滚只改了表页,没删索引项。
解决:按 WAL 顺序,先写日志并flush,再刷数据页;回滚时用 undo 日志反向执行,表页和索引页都要回滚。提交时写 commit 日志,恢复时只重放已提交事务。
void Transaction::commit() { log_manager_->append(CommitLog{txn_id_}); log_manager_->flush(); // 先保证日志落盘 buffer_pool_->flushAll(); // 再刷数据页 state_ = COMMITTED; }txn_id_要单调递增;CommitLog里至少带事务 id 和最后一条日志序号;flush失败要直接报错,不能继续提交。
| 现象 | 高频原因 | 先看哪里 |
|---|---|---|
| 链接报未定义符号 | 签名不一致、库顺序 | nm -C、头文件声明 |
| 重启丢数据 | 脏页未刷、异常退出 | flushAll、退出路径 |
| 索引结果重复 | 叶子next成环 | 分裂代码、页 0 默认值 |
| 范围查询卡死 | 槽位不推进、next指回 0 | IndexScan::next、INVALID_PAGE |
| 回滚不干净 | 日志顺序、索引未回滚 | WAL 顺序、undo 记录 |
注意:排查先复现最小 SQL,不要一上来开并发;单线程都过不了,加锁只会把问题藏得更深。
6. 验收与压测:用 3 组 SQL 和 1 个脚本把 miniSQL 跑穿
验收不要靠手工点几条 SQL,容易漏掉“重启后”“建索引后”“删除后”的组合。我一般用一个脚本跑三组:建表插入、索引等值与范围、删除后重查。脚本每次删库重建,保证从干净状态开始;如果某组失败,先看返回码,再看数据文件大小和目录页,别急着改执行器。
#!/usr/bin/env bash set -e BIN=./minisql DB=./test.db rm -f "$DB" $BIN <<'SQL' CREATE TABLE student (id INT, name CHAR(16), score INT); INSERT INTO student VALUES (1, 'A', 90); INSERT INTO student VALUES (2, 'B', 85); INSERT INTO student VALUES (3, 'C', 70); CREATE INDEX idx_student_id ON student(id); SELECT * FROM student WHERE id = 2; SELECT * FROM student WHERE id >= 2 AND id < 4; DELETE FROM student WHERE id = 1; SELECT * FROM student; SQLset -e让任何一步失败就停;rm -f保证每次从空库开始
本文还有配套的精品资源,点击获取