简介:面向重庆大学数据库系统课程的 Project2 完整工程包,属于数据库系统实现类 Java 项目,适用于正在修读相关课程、需要完成大作业或课程设计的本科生与研究生参考与复现。工程包含 Maven 项目配置、核心 Java 源码、单元测试、编译产物及 README 说明,目录结构清晰,便于对照学习查询执行、SQL 解析等数据库核心环节。压缩包共 51 个文件,涵盖 6 个 java 源文件、9 个 class 编译文件、20 个 xml 配置/依赖文件、10 个 jar 依赖库(含 Calcite、JUnit 等),以及 Markdown、IDE 配置等辅助文件,整体大小约 9.92MB。已有 64 人学习或下载。资料包经过严格验证可直接运行,拿到后可按 README 与现成工程快速复现,也可在现有模块基础上扩展功能,适合作为课程项目、期末设计或数据库系统入门练手的完整样例。
1. 拿到project2.zip之后,先别急着解压
如果你正在跟重庆大学数据库系统这门课的project2打交道,大概率是在一个深夜,从课程群或者教务系统里下载了一个名叫project2.zip的压缩包。顺手解压之后,里面是密密麻麻的源代码、实验说明PDF,可能还有一个要求异常严格的测试脚本。我当初第一次看到这个压缩包的时候,第一反应是:这哪里是课程项目,分明是一个小型数据库内核的雏形。
先说说这个project2到底是什么,能给不了解的同学一个定位。这门课的project序列,通常project1让你熟悉SQL、ER模型或者简单的表操作,到了project2就会明显上一个台阶——开始触碰数据库内核的核心模块。我这里说的不是某个具体学期的题目,而是基于国内高校数据库系统课程(王珊版《数据库系统概论》、CMU 15-445风格的外国教材都是主流)的常态设计:project2基本会围绕存储管理、B+树索引、查询执行或者事务并发这几个方向展开。也就是说,你手头这个zip,很可能就是要求你实现一个"迷你版数据库的某个关键器官"。
这类项目适合谁来参考?如果你是正在赶ddl的学生,这篇文章能帮你快速理清结构、避开我当年踩过的坑;如果你是自学数据库内核、想对标课程项目练手的人,这里面的模块拆解和实现思路同样可以作为一份实践路线图。读完之后你不会立刻变成数据库内核专家,但至少再打开那个zip的时候,不会觉得它是天书。
2. 内容整体设计与思路拆解
2.1 为什么数据库课程都会拿"存储+索引+执行"当project2的主角
很多同学第一次接触这个项目时会困惑:为什么project2不继续写SQL,反而要我们折腾什么缓冲池、页表、B+树?这个设计逻辑其实很直白。在真实的生产级数据库里,你写一条SELECT * FROM users WHERE age > 18,这条语句要经过的词法解析、语法树构建、逻辑优化、物理优化,最后落到执行引擎。而执行引擎的每一步,都要跟底层的存储结构打交道:数据在磁盘的哪个页?这个页在缓冲池里吗?不在的话要不要淘汰别的页?这个索引能不能帮我减少扫描的页数?这些恰恰是"数据库系统"和"数据库应用开发"最本质的区别。
拿project2最常出现的缓冲池模块来举例。设计一个LRU-K淘汰策略的缓冲池,本质上就是在回答一个问题:当内存装不下所有磁盘页时,到底该牺牲谁?如果你只学过操作系统课的页面置换,可能会觉得这就是个LRU变体,但真正实现起来还要考虑脏页标记、钉住页面(pin/unpin)、并发访问的锁粒度。不少学校把这个模块作为project2的核心,看中的就是它把一个看似老生常谈的问题,放到了数据库特有的场景里重新拷问。
2.2 框架代码的架构风格:先读懂,再动手
我见过太多拿到zip就开始往里面疯狂塞代码的同学,最后在测试脚本面前栽跟头。这里想认真提醒一句:project2的框架代码,本身就是最好的设计文档。以CMU 15-445风格的bustub为例(国内很多课程项目都借鉴了这套框架,重大这个project2如果也走这个路线,结构会很相似),它的代码分几层:
storage/目录管磁盘页、表堆、缓冲池;index/目录是B+树索引的实现骨架,通常已经给了节点类的接口,让你填空;execution/目录是一个个执行算子(seq scan、index scan、nest loop join等);concurrency/目录管事务、锁管理器、日志。
每个目录里都有header文件告诉你接口长什么样、该返回什么类型、异常怎么处理。我自己的习惯是,动手前先用一个晚上把整个目录树捋一遍,把每个类的头文件读一遍,用思维导图或者一张纸画出数据流图:一条查询从QueryExecutor进来,怎么一步步调用存储层和索引层。这个过程大约会花掉你10%的总时间,但能省掉后面50%的返工。
3. 核心细节解析与实操要点
3.1 页面与表堆:数据库最小的存储单元
很多project2的第一步是实现表堆(Table Heap)和页面(Page)。页面就是数据库在磁盘和内存之间搬运的最小单位,通常是4KB或者8KB,具体大小看框架代码的PAGE_SIZE宏定义。每个页有自己的页头,记录slot数量、空闲空间偏移等元信息,数据则按slot数组的方式组织在页内。
这里最容易出问题的点在于:页内数据是定长还是变长的?如果表里有一列是VARCHAR(255),你存"hello"和存"hello world"占的空间不一样,slot里存的应该是指向实际数据的偏移量,而不是数据本身。我在实现表堆的时候,犯过一个低级错误:把RecordId(即页号+slot编号)和页内偏移搞混,结果插入两行数据后,第二行的slot指向了被第一行覆盖的内存区域。排查了一整天,最后是打印每个页的十六进制内容才发现问题。所以给所有做这个模块的同学一个建议:先搞清楚你手里这个页面的物理布局,画出字节级别的图示,再写代码,否则你后续的索引和扫描都会建立在流沙上。
3.2 缓冲池:不被注意但决定生死的模块
缓冲池(Buffer Pool)在我的印象里是project2区分度最高的模块。它不光是维护一个页数组那么简单。一个典型的缓冲池需要提供两个核心能力:
NewPage/FetchPage:从磁盘加载页到内存,必要时淘汰页;- 脏页追踪:被修改过的页在淘汰时必须写回磁盘。
实现时最容易被忽略的是"页面钉住"机制。比如B+树在做节点分裂时,当前线程拿到的页要保证在操作期间不会被其他线程淘汰掉,否则轻则数据错乱,重则直接segment fault。框架代码里通常会给每个页加一个pin_count,FetchPage的时候加一,操作完UnpinPage的时候减一,只有pin_count为0的页才有资格被淘汰。这个机制看起来简单,可是多线程并发测试下特别容易出死锁或者漏unpin的问题。我的习惯是每个FetchPage调用,都写一个对称的UnpinPage在函数的出口处,用RAII或者defer的方式保证成对出现,从根上避免泄漏。
3.3 B+树索引:最磨人的硬骨头
如果project2让你实现B+树,那恭喜你,拿到了全项目工作量最大的一块。B+树的查询、插入、分裂、删除、合并,每一块实现都有很多边界条件。我最想分享的是"写B+树不要一上来就写删除"这个经验。删除操作要处理节点低于最小占用率的借位和合并,逻辑复杂度比插入高一个量级。绝大多数框架的测试都是"先插够数据,再查,再删一部分,再查",删除的正确性直接影响后续所有操作。
在B+树的实现里,有一个很容易绊倒人的细节:内部节点的key和指针布局。常见实现有两种——第一种是key数组和child指针数组都占max_size个槽位,key[i]和child[i]一一对应;第二种是key比child少一个,即num_keys个key对应num_keys+1个child。如果你把第二种误当成第一种来写,分裂时的边界判断、父节点key的提升逻辑全都会错。我看过不少人在课程论坛里问"为什么插入几个节点之后,查找就找不到数据了",十有八九都是这个布局问题。动手之前,先在纸上画一棵三层高的示例树,标清楚每个数组的下标和空位,再对照着写代码,效率会高很多。
另外,B+树的并发控制是加分项也是扣分项。如果project2的测试是多线程并发插入,而你只用一个全局锁锁住整棵树,性能大概率会垫底;但如果你做的是B-link树风格的锁耦合(crabbing protocol),又得保证读操作和写操作都不会死锁。我当时的策略是先实现单线程版本保证正确性,提交前再考虑并发优化,因为正确性永远是第一位的。
4. 实操过程与核心环节实现
4.1 从零搭建调试环境
拿到zip之后,第一件事不是看代码,而是让项目能在本地跑起来。这类项目一般依赖CMake和特定版本的C++标准(比如C++17)。我建议用VSCode加CMake插件,或者CLion打开整个目录,先构建一次,确保测试文件能编译。如果编译期报错,优先看是不是依赖缺失或者编译器版本过低。有一个我在实操中踩过的坑:某些框架代码会用到std::filesystem,这在GCC 8以下是不可用的,必须升级到GCC 9以上,否则你会看到一大堆莫名其妙的模板报错。
实验说明PDF里通常会给一个sqllogictest或者类似前缀的测试命令,比如./build/test/b_plus_tree_test。跑通个简单的冒烟测试,再开始写自己的代码,这样你后来每次改动都能快速验证有没有把原来好的东西弄坏。我习惯用git管理代码,每完成一个小功能就commit一次,这个习惯在project2里救过我很多次——有一次我连续改了两个小时B+树删除逻辑,最后测试全挂,差点崩溃,rollback到上一个commit后重新来,很快定位到问题在哪。
4.2 从日志和断点中读懂框架意图
这个项目的调试,比起普通应用开发,更依赖日志。因为数据库内核是在不断操作内存和磁盘,你看不到中间状态。框架代码里通常会预留LOG_INFO之类的宏,你可以在关键路径上打印当前页号、slot数量、节点类型。我实现B+树插入的时候,会在Split函数里加一个分支判断,打印分裂前后的key数组和父节点指针,再配合gdb断点到FindLeafPage,基本能把问题缩小到具体某一行代码。
另外一个实操技巧是:测试脚本里的每个测试用例名都不是随便起的。比如InsertTest1、InsertTest2、ScaleTest,它们往往对应不同的数据规模和边界条件。如果你的代码在小规模测试上全过、在大规模测试上挂掉,优先怀疑内存泄漏、未初始化变量或者pin_count没有归零。这种事看起来玄学,实际上都是可以靠打印和静态检查工具(比如AddressSanitizer)揪出来的。CMake里一般有-DENABLE_ASAN=ON这样的开关,开启后跑测试,溢出或者越界会直接报出来,强烈推荐。
4.3 性能优化:别急着炫技,先把正确性稳住
我见过一些同学,project2一上来就想搞什么排序优化、并行扫描,结果基础功能都没实现完。实际上,这类课程项目的评分大头通常是功能性测试,也就是"你的查询结果对不对、你的索引查得准不准",性能分只占一小部分。我自己实现顺序扫描算子和B+树索引扫描算子时,先保证两个算子在同样的查询条件下返回完全一致的结果集,然后再去对比性能。具体方式是用测试框架里现成的SELECT * FROM table WHERE id = xxx语句,分别强制走顺序扫描和索引扫描,把输出的记录数和内容做diff。
性能优化的一个实用切入点是:减少无谓的页复制。很多初版实现会在FetchPage之后再把页内容拷到局部变量,然后操作局部数据,其实可以直接通过页指针读写页内内存。页头部的元数据操作也要避免反复调用GetPageId()之类的方法,在热点循环里,这种函数调用会被放大到可感知的程度。不过这些都是后话,如果项目本身都能跑通测试了,再考虑这些,锦上添花;要是正确性都还没保证,先别碰性能。
5. 常见问题与排查技巧实录
5.1 编译期:模棱两可的模板报错
database系统的项目框架,普遍用了大量的C++模板和智能指针,编译期报错能把你绕晕。最常见的是unique_ptr和shared_ptr混用导致的ownership问题,例如某个接口要求返回unique_ptr,你返回了一个shared_ptr,编译器会提示"无法将shared_ptr转换为unique_ptr"。这时候别死磕报错信息,去头文件里看接口定义,搞清楚到底谁拥有这个对象的所有权。
还有一类是"undefined reference to"链接错误,多半是你声明了某个函数但没实现,或者实现文件没有被CMakeLists.txt包含进编译目标。解决办法是在src/CMakeLists.txt里查看add_library是否列入了你新加的.cpp文件。
5.2 运行期:段错误与死锁的定位
段错误十有八九是指针越界造成的。数据库内核代码里的指针,基本上都是从页基址算出来的偏移量。一旦key数组的索引超出实际分配的空间,或者页指针为nullptr,就会直接crash。我用过的三个定位手段:
- 开AddressSanitizer(ASan),它会精确告诉你越界发生在哪一行的读写;
- 在每一个页访问函数入口加assert,断言页id合法、页不为空、索引在合理范围内;
- 打印调用栈,gdb下执行
bt,看出错现场是从哪个函数调用进来的。
死锁问题则多见于并发测试。如果你在多线程测试中程序挂起不动,大概率是死锁。排查思路是把锁的获取顺序统一成全局一致的顺序,比如永远先拿左节点的锁再拿右节点的锁。B+树锁耦合本身的顺序是从根到叶,这个顺序天然避免了环路等待,如果你自己加了额外的latch,务必确保不破坏这个顺序。
5.3 测试全过但分数不高?看看这些隐形扣分点
经验之谈,课程项目评分除了功能测试,还会看代码规范和内存安全。有些同学的代码能跑过所有测试,但用了大量new和delete,没做异常安全处理,在压测中会内存泄漏或崩溃。我的建议是:尽量使用框架提供的智能指针,避免裸指针;资源获取即初始化(RAII)是C++的最优实践,数据库内核代码尤其吃这一套。
另外一个隐形扣分点是异常处理。比如插入操作写了一半,发现页满了需要分裂,如果你这时候直接抛异常,整个页的中间状态就乱了。各种数据库内核项目的隐藏测试,会故意制造这种半途失败来检测你的原子性。处理方式是:分裂和写回的过程,尽量保证在一个函数内原子完成,要么全成,要么一个字节都不改。
6. 写在最后的一些个人体会
做这个project2的过程,让我第一次真正理解”数据库系统“这四个字的重量。以前写SQL只关心结果对不对,从不关心一条查询背后要经历多少层的调度和存储操作。直到自己写完缓冲池和索引,再回头看一条简单查询的explain结果,才意识到那些看起来不起眼的页淘汰策略、索引扫描路径,恰好决定了这条查询是跑10毫秒还是10秒。
说个我自己的实操习惯,写project2那阵子,我坚持每天睡前看一下测试覆盖率报告。不是追求100%覆盖率,而是看看有没有哪个分支函数从来没被运行过。很多时候,你以为测过的情况,其实压根没走进你新写的逻辑。这种自查方式帮我抓出过两个边界bug,一个在B+树删除时的最小占用率判断,一个在扫描算子对上溢页面的处理。
如果你现在正因为某个测试用例跑不过去而烦躁,我的建议是关掉电脑,拿纸笔画一下这个用例的数据流。大多数时候,问题不出在你写代码的能力,而是你还没完全理解框架里那条隐形的数据通路。等你想明白了,代码自然就写对了。这个项目做完,你对数据库系统的理解会比上一个学期的理论课加在一起都深,这句话我拿人格担保。
本文还有配套的精品资源,点击获取