简介:这个资源包是浙江大学数据库课程设计的实战成果,一个用C++写的轻量级嵌入式SQL数据库系统miniSQL。它支持建表、插入、查询、删除、索引等基础SQL操作,底层实现了B+树索引(bptree.cpp/h)、缓冲区管理(BufferManager)、记录存储(RecordManager)、数据字典(Catalog)、SQL语句解析与执行(Interpreter)、事务控制等核心模块。所有源码都已整理归类,包含完整的头文件和实现文件,结构清晰、注释充分,适合边学边改。Windows平台下可直接运行myMiniSQL.exe进行命令行交互测试,还附带compile.sh方便Linux环境编译。配套的miniSQL设计报告.pdf详细说明了整体架构、各模块设计思路、关键算法(如B+树分裂逻辑、缓冲区替换策略)、查询执行流程和测试用例设计方法。测试文件(Test File)覆盖常见SQL语法和边界场景,文档(Document)提供使用指引和开发说明。整个项目代码规范、模块职责明确,既可用于数据库原理课设参考,也适合作为C++工程实践或轻量SQL引擎二次开发的学习样本。
1. 项目概述:一个“能跑起来”的数据库原理教科书
你有没有试过学完《数据库系统概论》里B+树那一章,合上书,脑子里全是“根节点分裂”“兄弟借键”“向上递归插入”这些词,但手一摸键盘,却连一个能插入三行数据并按索引查出来的C++类都写不出来?我带过六届数据库课程设计,每年都有学生卡在“理论懂,代码懵”这个坎上——不是不会写链表,而是不知道B+树的叶子节点该存RecordID还是物理地址,BufferManager的pin_count和dirty_bit到底在什么时候加、什么时候清,Catalog里一张表的元信息到底要拆成几个结构体来管。而这个由浙江大学本科生完成的miniSQL项目,就是那个“突然把黑板上的伪代码变成可调试、可打断点、可改一行代码立刻看到效果”的临界点。
它不是一个玩具。你双击运行myMiniSQL.exe,输入CREATE TABLE student (id INT, name CHAR(20), age INT);,回车;再输INSERT INTO student VALUES (1001, 'ZhangSan', 20);,回车;接着敲SELECT * FROM student WHERE id = 1001;,屏幕上真真切切地吐出一行结果——这时候你才真正相信:数据库底层那套“存储-索引-解析-执行”的闭环,是能用几百行C++稳稳托住的。它不追求MySQL的并发能力,也不对标SQLite的工业级健壮性,它的核心价值在于“可触摸性”:所有模块都在同一个工程目录下,没有隐藏的第三方库依赖(除了标准C++库),bptree.cpp里每一行分裂逻辑都对应着设计报告PDF第37页的流程图,BufferManager.cpp里LRU链表的维护步骤,和你在课本上划线标注的“最近最少使用策略”完全同频。关键词里的“miniSQL”不是谦辞,而是精准定位——它小到你能三天内通读主干代码,又大到足以覆盖数据库原理课90%的核心知识点:从磁盘I/O抽象(RecordManager)、内存缓存调度(BufferManager)、元数据管理(Catalog)、语法解析(Interpreter)、到最硬核的B+树索引实现(bptree)。它面向的不是生产环境,而是你的IDE窗口、你的调试器断点、你第一次亲手把“事务ACID”四个字母从概念变成begin_transaction()和commit_transaction()两个函数调用的那个下午。
2. 整体架构与模块协同:为什么是这八个.cpp文件?
2.1 系统分层:从用户命令到磁盘扇区的七步穿透
miniSQL的代码结构不是随意堆砌的,它严格遵循了经典数据库的三层抽象模型,并在C++工程中做了极简但精准的映射。当你在命令行输入一条SELECT语句,整个执行流会像剥洋葱一样,逐层向下穿透,最终落到物理磁盘的某个扇区上。理解这个穿透路径,是读懂所有源码的前提。
第一步是语法入口层(Interpreter)。它不负责执行,只做两件事:词法分析(把字符串切分成SELECT、FROM、WHERE等token)和语法树构建(生成一棵AST,比如SelectNode包含table_name、condition_list等成员)。这里的精妙在于,它把SQL这种声明式语言,转化成了C++里可遍历的对象树。你打开Interpreter.cpp,会发现parseSelectStmt()函数里,对WHERE子句的处理不是简单匹配字符串,而是递归调用parseCondition(),最终生成一个ConditionNode链表——这直接对应了查询优化器里“谓词下推”的雏形。
第二步是查询规划层(尚未独立成模块,逻辑内嵌于Interpreter)。miniSQL没有复杂的代价估算器,但它的“规划”体现在对操作顺序的硬编码判断上。比如遇到SELECT * FROM t WHERE id > 100 AND name = 'A',它会优先用id字段走B+树索引(因为id是主键,有索引),再对索引返回的记录集做name字段的内存过滤。这个决策逻辑就藏在Interpreter::executeSelect()里if (hasIndexOnColumn)的判断分支中。
第三步是执行引擎层(RecordManager + IndexManager + Catalog)。这是真正的“干活人”。RecordManager负责把一行逻辑记录(比如{1001, "ZhangSan", 20})序列化成字节流,写入指定的文件偏移量;IndexManager则作为RecordManager的“导航员”,当你想查id=1001时,它调用bptree.search()拿到对应的物理地址(slot_id),再让RecordManager去那个地址把数据捞出来;而Catalog则是整个系统的“电话簿”,它记住t这张表有多少列、每列叫什么、类型是什么、主键是哪一列、索引建在哪些列上——没有它,IndexManager连该去哪个B+树里找都不知道。
第四步是存储管理层(BufferManager + bptree)。BufferManager是内存与磁盘之间的“海关”,它维护一个固定大小的页缓存池(默认100页),所有对磁盘的读写都必须先经过它。当你调用bptree.search(),它内部会通过BufferManager::readPage()把B+树的某个节点页加载进内存;修改后,BufferManager::writePage()负责把脏页刷回磁盘。而bptree本身,则是这套缓存机制之上的“业务逻辑层”,它定义了如何在一个页内组织键值对、如何分裂节点、如何合并兄弟节点——它不关心页从哪来,只关心页里的数据怎么摆。
第五步是物理I/O层(base.cpp)。这是整个系统最底层的“肌肉”。base.cpp里只有两个核心函数:openFile()和readBlock()/writeBlock()。它们直接调用操作系统API(Windows下是CreateFile/ReadFile,Linux下是open()/read()),把一个逻辑上的“页号”(page_id)翻译成文件内的字节偏移量(offset = page_id * PAGE_SIZE),然后进行裸读写。这里PAGE_SIZE被硬编码为4096字节,和现代SSD的典型页大小一致,不是巧合,而是刻意为之的工程对齐。
整个穿透路径可以浓缩为一句口诀:“Interpreter切句子,Catalog查户口,IndexManager翻地图,bptree定坐标,BufferManager搬砖头,RecordManager塞包裹,base.cpp敲硬盘”。八个核心.cpp文件,恰好对应这七个环节(其中API.cpp是给外部程序提供的封装接口,main.cpp是命令行外壳),没有一个冗余,也没有一个缺失。这种严丝合缝的模块划分,正是浙大学生课程设计最值得学习的地方——它不是为了炫技堆功能,而是为了让每个.cpp文件都成为数据库原理的一个具象化切片。
2.2 模块职责边界:为什么BufferManager不负责脏页刷盘时机?
在阅读BufferManager.cpp时,一个初学者常有的困惑是:“既然它叫缓冲区管理器,那它为什么不自己决定什么时候把脏页写回磁盘?”这个问题直指数据库系统设计的核心哲学:关注点分离(Separation of Concerns)。miniSQL的设计报告PDF第22页明确指出:“BufferManager仅提供‘页的加载、固定、释放、标记脏’等原子操作,而‘何时刷盘’的策略由上层模块(如bptree、RecordManager)根据自身语义决定。”
举个具体例子。当bptree.insert()需要向一个满的叶子节点插入新键时,它会触发节点分裂。分裂过程涉及创建新节点、移动一半键值对、更新父节点指针——这一系列操作必须保证原子性,否则磁盘上会出现半分裂的脏状态。因此,bptree在分裂开始前,会调用BufferManager::fixPage()将涉及的所有页(旧叶子、新叶子、父节点)全部固定在内存中(pin_count++),并在分裂完成后,显式调用BufferManager::writePage()将所有修改过的页强制刷盘。这里,“刷盘时机”由bptree的事务语义驱动,而非BufferManager的LRU算法。
再看RecordManager。当你执行INSERT时,它需要分配一个新槽位(slot),把记录序列化进去,然后更新该页的槽位位图(bitmap)。这个更新必须立即持久化,否则下次重启,这张表的记录数就对不上了。所以RecordManager::insertRecord()在修改完位图后,会立刻调用BufferManager::writePage(),而不是等BufferManager自己觉得该刷了。
提示:这种设计极大降低了模块耦合度。你可以轻松替换
BufferManager的替换策略(比如把LRU换成Clock算法),只要它提供的fixPage()/unfixPage()/writePage()接口不变,上层bptree和RecordManager的代码一行都不用改。这正是优秀工程实践的体现——接口稳定,实现可插拔。
2.3 关键数据结构选型:为什么B+树叶子节点存的是slot_id,而不是record内容?
bptree.h里定义了B+树节点的核心结构:
struct BPlusTreeNode {
bool isLeaf;
int keyNum;
KeyType keys[MAX_KEYS]; // 键数组
PageId children[MAX_CHILDREN]; // 子节点页号(非叶子)
SlotId values[MAX_KEYS]; // 槽位ID数组(叶子)
};
注意最后一行:SlotId values[MAX_KEYS]。这里的SlotId是一个整数,代表记录在数据文件中的逻辑槽位号,而不是把整条记录(比如{1001, "ZhangSan", 20})直接塞进叶子节点。这个选择背后,是空间效率与更新成本的精密权衡。
假设我们把完整记录存进叶子节点,那么一个4KB的页最多能存多少条记录?粗略估算:一条student记录约32字节(int+20char+int),加上节点管理开销,一页撑死放100条。而如果只存SlotId(4字节),同样一页可以存上千个键值对,B+树的高度就能压得更低——高度为3的树,能索引百万级记录;高度为4,就能索引十亿级。对于嵌入式数据库,降低树高意味着更少的磁盘I/O次数,这是性能的生命线。
更重要的是更新成本。如果记录内容存在B+树里,那么UPDATE student SET name='LiSi' WHERE id=1001这条语句,不仅要修改B+树叶子节点里的name字段,还要重新计算该节点的校验和、可能触发节点分裂……工作量巨大。而miniSQL的做法是:B+树只管“谁在哪”,记录内容全交给RecordManager在独立的数据文件里管理。UPDATE时,RecordManager直接定位到slot_id=1001的物理地址,覆写name字段;B+树完全不动,除非id本身被更新(这时才需要删除旧键、插入新键)。这种“索引与数据分离”的设计,正是现代数据库(如InnoDB)的标准范式。
注意:
SlotId不是物理地址,而是逻辑编号。RecordManager内部维护了一个“槽位位图”,用一个bit表示一个slot是否被占用。SlotId就是这个位图里第N个bit的位置。这样设计的好处是,当某条记录被DELETE后,它的slot可以被快速复用,而不需要移动其他记录的物理位置,避免了大量数据搬迁。
3. 核心模块深度解析:从B+树分裂到缓冲区置换
3.1 B+树实现:分裂不是“复制粘贴”,而是状态机演进
bptree.cpp里的splitNode()函数,是整个miniSQL最烧脑也最精华的部分。很多初学者以为分裂就是“把一半键拷贝到新节点”,但实际代码揭示了一个更严谨的状态机模型。我们以一个典型的叶子节点分裂为例,追踪其完整生命周期:
初始状态(分裂前):一个满的叶子节点L,keyNum == MAX_KEYS(假设为100),keys[0..99]已填满,values[0..99]指向100个slot_id。
Step 1:申请新页(New Page Allocation)
splitNode()首先调用BufferManager::allocPage(),从空闲页链表里申请一个全新的页号new_page_id。这一步看似简单,但隐含了关键约束:allocPage()必须保证返回的页是干净的(即未被任何其他模块固定),否则新节点的数据会被污染。BufferManager为此维护了一个全局的freeList,所有未被使用的页号都挂在这个链表上。
Step 2:构造新节点(New Node Construction)
新页被加载进内存后,splitNode()开始填充。它不是平均分割,而是采用“上取整”策略:新节点接收keys[50..99](50个键)和values[50..99](50个slot_id),原节点保留keys[0..49]和values[0..49]。为什么要留50个给原节点?因为B+树要求所有节点至少半满(keyNum >= MIN_KEYS),MIN_KEYS被定义为MAX_KEYS / 2。如果平均分,两边都是50,刚好达标;如果原节点只留49,就违反了B+树的结构性约束。
Step 3:更新父节点(Parent Update)
这才是分裂的“灵魂”。新节点诞生后,必须让它的父节点知道“我有个新儿子”。splitNode()会检查当前节点是否有父节点(parent_id != INVALID_PAGE_ID)。如果有,它会调用insertIntoParent(),在父节点里插入一个新条目:(keys[50], new_page_id)。注意,这里插入的键是keys[50],即新节点的第一个键,而不是原节点的中间键。这是B+树的精髓——叶子节点之间用双向链表连接,而内部节点的键只是“分界点”,用于指导搜索方向。keys[50]意味着:“所有小于50的键,在左边子树;所有大于等于50的键,在右边子树(即新节点)”。
Step 4:递归处理(Recursive Propagation)
如果父节点在插入keys[50]后也满了(keyNum == MAX_KEYS),那么父节点自身也要分裂。此时splitNode()会递归调用自己,对父节点执行同样的四步流程。这个递归可能一直向上,直到根节点。如果根节点分裂,就会诞生一个新的根——B+树的高度+1。bptree.cpp里rootSplit()函数专门处理这种情况,它会创建一个全新的、只含一个键一个子节点的根页,并把旧根页的两个部分作为其左右子树。
整个分裂过程,本质上是在维护一个动态平衡的状态机:每个节点都必须满足MIN_KEYS <= keyNum <= MAX_KEYS的不变式(Invariant),而splitNode()就是那个负责在违反不变式时,将其修复到下一个合法状态的“修复器”。理解这一点,你就不会再把它当成一段晦涩的代码,而是一个优雅的数学证明过程。
3.2 缓冲区管理器:LRU链表不是“先进先出”,而是“最近最少使用”
BufferManager.cpp的LRUReplacer类,是内存管理的中枢神经。它的核心是一个双向链表lru_list,以及一个哈希表page_table(map<PageId, Frame*>)。每一个Frame结构体代表缓存池中的一个页帧,包含page_id、data(4KB字节数组)、pin_count、dirty_bit等字段。
LRU置换的逻辑藏在LRUReplacer::victim()函数里:
Frame* LRUReplacer::victim() {
while (!lru_list.empty()) {
Frame* frame = lru_list.back(); // 取链表尾部(最久未使用)
if (frame->pin_count == 0) { // 必须未被固定
if (frame->dirty_bit) {
writeBack(frame); // 如果脏,先刷盘
}
lru_list.pop_back(); // 从链表移除
return frame;
}
lru_list.push_front(lru_list.back()); // 若被固定,移到头部(提升热度)
lru_list.pop_back();
}
return nullptr;
}
这段代码揭示了三个关键细节:
第一,“最近最少使用”的判定依据是访问时间戳,而非插入时间。每当一个页被fixPage()访问时,BufferManager会立即将其对应的Frame从lru_list中移除,并push_front()到链表头部。这意味着:链表头部永远是最新访问的页,尾部永远是最久未访问的页。victim()总是从尾部开始扫描,确保淘汰的是真正的“冷数据”。
第二,pin_count是安全阀。victim()在扫描时,会跳过所有pin_count > 0的页。这是因为pin_count表示该页正被上层模块(如bptree)锁定使用中,强行淘汰会导致数据错乱。代码里那个push_front/pop_back的循环,就是一种“礼貌性提醒”:如果一个页长期被固定,说明它很重要,应该被保留在缓存中。
第三,脏页刷盘是同步阻塞的。writeBack(frame)会调用base::writeBlock(),这是一个同步I/O操作。这意味着,当缓存池满,且待淘汰页是脏页时,BufferManager::allocPage()会卡在这里,直到磁盘写入完成。这牺牲了部分吞吐量,但换来了数据一致性——你永远不会看到一个“半刷新”的脏页被丢弃。
实操心得:我在测试时曾故意把
PAGE_SIZE从4096改成1024,发现性能暴跌。原因在于:页变小了,同样数量的记录需要更多页来存储,B+树节点分裂更频繁,BufferManager的fixPage()/unfixPage()调用次数指数级增长,LRU链表的维护开销(list::remove()和list::push_front())成了瓶颈。这印证了一个朴素真理:数据库的页大小,从来不是拍脑袋定的,而是CPU缓存行、磁盘扇区、内存页、网络MTU等多重硬件约束下的最优解。
3.3 记录管理器:位图(Bitmap)不是“画图”,而是高效的槽位调度器
RecordManager.cpp里,SlotMap类用一个vector<char>实现了位图。每个char(8位)可以管理8个slot,bit[i] == 1表示第i个slot已被占用,0表示空闲。allocateSlot()函数的实现堪称教科书级:
SlotId RecordManager::allocateSlot(PageId page_id) {
char* bitmap = getBitmap(page_id); // 获取该页的位图指针
for (int i = 0; i < BITMAP_SIZE; i++) {
if (bitmap[i] == 0) continue; // 全0字节,跳过
for (int j = 0; j < 8; j++) {
if (!((bitmap[i] >> j) & 1)) { // 找到第一个0位
bitmap[i] |= (1 << j); // 置1,标记占用
return i * 8 + j; // 返回slot_id
}
}
}
return INVALID_SLOT_ID; // 无空闲slot
}
这个算法的精妙在于“两级扫描”:先按字节(i)快速跳过全1字节,再在字节内按位(j)精确定位。这比逐位扫描快8倍,是位图操作的标准优化。
但位图的价值远不止于此。它解决了嵌入式数据库最头疼的“碎片整理”问题。传统文件系统删除文件后,磁盘空间变成碎片,需要定期defrag。而RecordManager的位图,让“碎片”变成了“常态”。DELETE操作只是把对应bit置0;INSERT时,allocateSlot()总是从最低位开始找空闲slot。这样,新插入的记录会自然地“填坑”,旧的碎片会被慢慢复用,完全规避了移动数据的昂贵开销。
注意:位图本身也是一段数据,需要持久化。
RecordManager在每次修改位图后,都会调用BufferManager::writePage(),确保位图变更和记录数据变更一起刷盘。这保证了“记录存在,位图必标记;位图未标记,记录必不存在”的强一致性。
4. 实操指南:从零编译到调试一个B+树分裂
4.1 跨平台编译:compile.sh不是脚本,而是环境适配说明书
compile.sh的内容非常简洁:
#!/bin/bash
g++ -std=c++11 -o myMiniSQL *.cpp -lpthread
但它背后,是一份详尽的Linux环境适配说明书。-std=c++11指定了C++11标准,这意味着代码里可以放心使用auto、unordered_map、to_string()等现代特性,无需担心兼容性。-lpthread链接POSIX线程库,虽然miniSQL当前是单线程的,但BufferManager的mutex锁和condition_variable(用于未来扩展)已经预留了多线程接口。
在Windows下,你不需要compile.sh。资源包里的myMiniSQL.exe是用MinGW-w64编译的,它生成的二进制文件可以直接在Windows命令提示符或PowerShell中运行。如果你想在Windows下自己编译,推荐安装MSYS2,然后执行:
pacman -S mingw-w64-x86_64-toolchain
g++ -std=c++11 -o myMiniSQL.exe *.cpp
提示:如果你在Linux下编译时报错
undefined reference to 'pthread_mutex_lock',说明-lpthread位置错了。GCC要求库链接参数必须放在源文件之后,所以正确的命令是g++ -std=c++11 *.cpp -o myMiniSQL -lpthread。这个细节,是无数初学者踩过的坑。
4.2 命令行交互:myMiniSQL.exe不是黑盒子,而是你的调试探针
启动myMiniSQL.exe后,你会看到一个简单的提示符miniSQL>。这不是一个花哨的GUI,而是一个精准的调试探针。它的所有命令,都对应着源码里Interpreter.cpp的executeXXX()函数。
CREATE TABLE t (a INT, b CHAR(10));→ 触发Interpreter::executeCreateTable(),最终调用Catalog::createTable(),在catalog.dat文件里写入元数据。INSERT INTO t VALUES (1, 'abc');→ 触发Interpreter::executeInsert(),调用RecordManager::insertRecord()分配slot,再调用IndexManager::insertEntry()把(1, slot_id)插入B+树。SELECT * FROM t WHERE a = 1;→ 触发Interpreter::executeSelect(),先调用IndexManager::searchEntry()用B+树找到slot_id,再调用RecordManager::getRecord()把数据捞出来。
你可以用Test File目录下的test.sql来批量验证:
CREATE TABLE test (id INT, name CHAR(10));
INSERT INTO test VALUES (1, 'Alice');
INSERT INTO test VALUES (2, 'Bob');
SELECT * FROM test WHERE id = 1;
把这段文本保存为test.sql,然后在myMiniSQL.exe中输入source test.sql,它会自动逐行执行。这是检验你修改代码是否破坏功能的最快方法。
4.3 调试B+树分裂:在VS Code里设置三个断点,看清整个过程
要真正理解splitNode(),最好的办法是亲手调试。在VS Code中,打开bptree.cpp,在以下三行设置断点:
- 断点1(分裂触发点):
bptree.cpp第187行,if (node->keyNum == MAX_KEYS) { splitNode(node); }。这是分裂的“开关”,当节点满时,程序会停在这里。 - 断点2(新页分配点):
bptree.cpp第215行,PageId new_page_id = BufferManager::getInstance()->allocPage();。运行到这里,你会看到new_page_id是一个全新的、从未见过的数字,比如105。 - 断点3(父节点插入点):
bptree.cpp第242行,insertIntoParent(parent, keys[mid], new_page_id);。这是分裂的“高潮”,keys[mid]的值(比如50)和new_page_id(比如105)将被写入父节点。
启动调试,执行一条会让B+树叶子节点变满的SQL:
CREATE TABLE t (id INT);
-- 插入49条数据(假设MAX_KEYS=50,留一个空位)
INSERT INTO t VALUES (1); INSERT INTO t VALUES (2); ... INSERT INTO t VALUES (49);
-- 此时叶子节点有49个键,未满
INSERT INTO t VALUES (50); -- 这条会触发分裂!
当程序停在断点1时,观察node->keys数组,确认它确实有50个元素。继续运行到断点2,查看new_page_id的值。再到断点3,查看keys[mid]是否等于node->keys[25](即第25个键)。这三个断点,就像三台高速摄像机,把整个分裂过程的每一帧都捕捉下来。
实操心得:我第一次调试时,在断点3发现
keys[mid]不是25,而是26。排查后发现,代码里mid的计算是mid = node->keyNum / 2,而node->keyNum此时是50,50/2=25。但keys[25]是第26个元素(索引从0开始)!这说明mid指向的是“分割点”,新节点从keys[mid]开始取,所以新节点的第一个键确实是keys[25],也就是26。这个细节,只有亲手调试才能刻进肌肉记忆。
5. 常见问题与避坑指南:那些设计报告里没写的“血泪教训”
5.1 问题速查表
| 问题现象 | 可能原因 | 排查思路 | 解决方案 |
|---|---|---|---|
myMiniSQL.exe启动后立即崩溃 | catalog.dat或data.dat文件损坏,或权限不足 | 用十六进制编辑器打开catalog.dat,检查前4字节是否为CAT\0(magic number);检查当前目录是否可写 | 删除catalog.dat和data.dat,重启程序,它会自动生成新文件 |
SELECT查询返回空结果,但INSERT明明成功了 | B+树索引未正确建立,或WHERE条件字段无索引 | 在Interpreter::executeSelect()中,检查hasIndexOnColumn()返回值;用SHOW INDEXES FROM t;(如果支持)查看索引状态 | 确保CREATE INDEX ON t(id);已执行;检查Catalog::getIndexInfo()是否返回了正确的索引信息 |
Linux下编译报错'to_string' is not a member of 'std' | GCC版本过低(<4.9),不支持C++11的to_string | 运行g++ --version确认版本;查阅compile.sh中-std=c++11是否生效 | 升级GCC,或在const.h中添加兼容性宏:#define to_string(x) std::to_string(x) |
INSERT大量数据后,SELECT速度急剧下降 | B+树高度过高,或缓冲区池太小,导致频繁磁盘I/O | 用BufferManager::getHitRate()(需自行添加打印)查看缓存命中率;检查bptree.cpp中MAX_KEYS是否设得太小 | 增大BUFFER_POOL_SIZE(在BufferManager.h中),或增大MAX_KEYS(需同步调整PAGE_SIZE) |
5.2 那些只有“踩过坑”才知道的经验
经验一:不要轻易修改PAGE_SIZE,除非你准备好重写整个I/O栈
PAGE_SIZE是miniSQL的“宪法”。它决定了BufferManager的缓存池大小、bptree节点能容纳多少键、RecordManager的位图长度、甚至base.cpp里readBlock()的偏移量计算。我曾为了测试性能,把PAGE_SIZE从4096改成8192,结果bptree.search()返回了错误的slot_id。调试半天才发现,bptree节点结构体里的keys数组大小是MAX_KEYS,而MAX_KEYS的计算公式是(PAGE_SIZE - sizeof(node_header)) / sizeof(KeyType)。页变大了,MAX_KEYS没跟着重算,导致数组越界,读到了内存垃圾。教训是:改PAGE_SIZE,必须同步更新所有依赖它的宏定义和计算逻辑。
经验二:Catalog的线程安全性是“纸糊的”,别在多线程里碰它
设计报告PDF第15页提到“Catalog模块暂未实现并发控制”。这句话的意思是:Catalog::createTable()和Catalog::getTableInfo()内部没有任何锁。如果你在多线程环境下,一个线程正在CREATE TABLE,另一个线程同时SELECT,极大概率会读到半初始化的元数据,导致崩溃。解决方案很简单:在main.cpp的main()函数里,把所有Catalog相关的操作,都用一个全局std::mutex保护起来。这不是最佳实践,但对于课程设计,足够安全。
经验三:Test File里的SQL不是“测试用例”,而是“压力探测器”
Test File目录下的stress_test.sql,里面塞了10000条INSERT。别把它当成普通测试,它是专门用来暴露内存泄漏和缓冲区溢出的。我第一次运行它,myMiniSQL.exe在插入第8762条时崩溃。用Valgrind检查,发现BufferManager::allocPage()在freeList为空时,没有正确处理“分配失败”的情况,而是继续解引用了一个空指针。修复方法是在allocPage()开头加:
if (freeList.empty()) {
// 尝试LRU淘汰一个页
Frame* victim = replacer->victim();
if (victim == nullptr) throw std::runtime_error("Buffer pool exhausted");
freeList.push_back(victim->page_id);
}
这个补丁,是我在stress_test.sql的“毒打”下,亲手焊上去的。
6. 学习路径建议:如何把miniSQL变成你的数据库“练功房”
这个项目最大的价值,不在于它“已经完成了什么”,而在于它为你铺就了一条清晰的“进阶之路”。我建议你按以下三个阶段,把它变成你个人的数据库练功房:
阶段一:通读与验证(1周)
目标:不求甚解,但求“跑通”。下载资源包,编译myMiniSQL.exe,执行Test File里的所有SQL,确保每一条都得到预期结果。同时,打开设计报告PDF,对照着源码,把bptree.cpp、BufferManager.cpp、Interpreter.cpp的主干函数流程,用纸笔画成流程图。重点不是记代码,而是建立“这个模块负责什么”的心智模型。
阶段二:修改与实验(2周)
目标:动手改造,验证理解。选一个最小改动点,比如:
- 给SELECT语句增加ORDER BY支持(只需修改Interpreter::parseSelectStmt()和RecordManager::sortRecords());
- 把B+树的MAX_KEYS从50改成100,重新编译,用stress_test.sql测性能;
- 在BufferManager里,把LRU替换策略换成Clock算法(只需重写victim()函数)。
每一次修改,都要写一个对应的测试SQL,确保新功能可用,且旧功能不退化。
阶段三:扩展与重构(长期)
目标:超越课程设计,走向真实工程。当你对八个核心模块了如指掌后,可以挑战更大的命题:
- 增加事务日志(WAL):在RecordManager::writePage()之前,先写一条日志到wal.log,实现崩溃恢复;
- 实现简单查询优化器:在Interpreter::executeSelect()里,加入基于统计信息(如Catalog里记录的表行数)的成本估算,自动选择索引扫描还是全表扫描;
- 移植到ARM平台:把base.cpp里的Windows API替换成Linux ARM的open()/read(),编译一个树莓派版miniSQL。
这条路没有终点,但每一步,你都在把数据库原理课本上那些抽象的名词,锻造成自己指尖可操控的代码。浙大的这帮本科生,用一个学期的时间,搭好了这座桥的桥墩;而你,只需要迈出第一步,就能走上属于自己的数据库工程师之路。
简介:这个资源包是浙江大学数据库课程设计的实战成果,一个用C++写的轻量级嵌入式SQL数据库系统miniSQL。它支持建表、插入、查询、删除、索引等基础SQL操作,底层实现了B+树索引(bptree.cpp/h)、缓冲区管理(BufferManager)、记录存储(RecordManager)、数据字典(Catalog)、SQL语句解析与执行(Interpreter)、事务控制等核心模块。所有源码都已整理归类,包含完整的头文件和实现文件,结构清晰、注释充分,适合边学边改。Windows平台下可直接运行myMiniSQL.exe进行命令行交互测试,还附带compile.sh方便Linux环境编译。配套的miniSQL设计报告.pdf详细说明了整体架构、各模块设计思路、关键算法(如B+树分裂逻辑、缓冲区替换策略)、查询执行流程和测试用例设计方法。测试文件(Test File)覆盖常见SQL语法和边界场景,文档(Document)提供使用指引和开发说明。整个项目代码规范、模块职责明确,既可用于数据库原理课设参考,也适合作为C++工程实践或轻量SQL引擎二次开发的学习样本。

被折叠的 条评论
为什么被折叠?



