☰
从源码到可执行:RucBase数据库内核编译与改造实践指南
2026/9/26 13:54:23 网站建设 项目流程

简介:RucBase 是一个基于 C++ 实现的关系数据库管理系统原型,面向《数据库系统实现》课程实验,参考了 BusTub 与 Redbase 的设计思路,适合数据库内核学习者与高校学生进行源码研读和二次开发。资源共 185 个文件,压缩包仅 1.36MB,涵盖 34 个 C++ 源文件、58 个头文件、20 个 SQL 脚本以及 yacc/lex 语法文件,同时包含 8 个 Python 辅助脚本、演示图片和实验文档,代码结构清晰,覆盖存储管理、缓冲池、B+ 树索引、事务与锁管理、日志恢复、查询优化等核心模块。已有 56 人学习使用。通过研读这些源码,可以直观理解 SQL 解析、并发控制与磁盘读写等数据库底层机制的完整实现;项目自带 B+ 树并发测试与删除测试,便于对照验证算法正确性,尤其适合作为数据库系统实现类课程的配套参考与实验框架。

1. 拿到一个能编译的数据库内核源码,比看任何书都管用

很多人在“数据库系统实现”这门课上挂了,不是因为概念难,而是因为永远站在黑匣子外面:老师说B+树分裂怎么做、缓冲池LRU怎么换,你听得懂,但合上书啥也不会。RucBase就是人民大学数据库课程组拿来把这层纸捅破的东西——一个用C++写的、能编译、能跑SQL、能真实写入磁盘文件的教学数据库管理系统。它的价值不在“功能多”,而在于把磁盘管理、缓冲池、记录管理、索引、查询执行这些平时看不见的模块,全部压缩进一份你能读懂、能改、能调试的源码里。

这篇笔记想讲清楚三件事:RucBase这个源码包拆开来看长什么样,怎么在本地把它从zip编译成一个能用的rmdb可执行文件,以及改哪些代码能让它真正变成“你自己的数据库”。适合的人群也很明确:学过操作系统和数据结构、想进数据库内核方向的在校生,被课程设计或毕设逼着要写一个“小型数据库”的人,以及工作了几年却始终没看过一套完整数据库源码的C++开发。

2. RucBase的模块架构:从磁盘到SQL的C++分层设计

RucBase遵循的是经典数据库内核的分层思路。它不是像SQLite那样把一切都揉在一起的嵌入式实现,而是按“磁盘文件 → 缓冲池 → 记录管理 → 索引 → 算子 → SQL解析”这样一条链路把系统拆开。每一层只给上一层提供服务,接口收得很窄。这种拆法最大的好处是:你在调试一个bug的时候,能明确知道问题出在哪一层,不至于像无头苍蝇一样到处翻代码。

2.1 存储层:DiskManager与BufferPoolManager的分工

存储层是整个系统的地基。DiskManager负责跟操作系统文件打交道,它的核心能力就是把数据库文件看成一块块固定大小的页(page),按页号读写。RucBase里PAGE_SIZE一般取4096字节,也就是对齐文件系统块的大小。这里有一个教学系统常做的简化:直接从文件偏移去读、去写,不维护自己的“文件内空闲块列表”,删页只是标记,不真正回收。这样的简化让你能专注理解“页是数据库的最小IO单位”,而不被文件空间管理分心。

BufferPoolManager则是在内存里维护一个页面的缓存。它对外暴露的核心接口是FetchPage(page_id)、UnpinPage(page_id, is_dirty)和DeletePage(page_id)。FetchPage的语义是“把指定页放到内存里,返回指向该页内存区域的指针”;UnpinPage的语义是“我读完/改完了,你可以考虑把它换出”。这里的“pin/unpin”计数是RucBase初学者最容易忽略的机制——一个页被pin了多少次,就对应了多少个使用者在访问它。只有pin计数降到0的页,才有资格被置换算法选中换出。

// 简化的BufferPoolManager核心签名 class BufferPoolManager { public: // 从磁盘加载指定页,若已在内存则直接返回;返回的页处于pinned状态 Page* FetchPage(page_id_t page_id); // 将页的pin计数减一;is_dirty标记该页是否被修改过 bool UnpinPage(page_id_t page_id, bool is_dirty); // 真正删除一个页,并从缓冲池与磁盘文件中移除 bool DeletePage(page_id_t page_id); private: Page* pages_; // 缓冲池的frame数组 std::list<page_id_t> free_list_; // 空闲frame链表 // 常见的简化实现用std::list模拟LRU队列 };

参数说明里有一个关键点:代码里用std::list模拟LRU队列是教学实现里最常见的做法,把被访问的页移到队尾、淘汰队头。真实数据库(比如InnoDB)一般用分段LRU或时钟算法,因为标准LRU在遍历脏页和顺序扫描场景下表现不好。但你刚上手时不要急着换算法——先把这个“list模拟LRU”跑通,理解置换的触发时机,再动算法不迟。

2.2 记录层与索引:定长Tuple与B+树的C++表达

再往上一层是记录管理。RucBase对记录做了很强硬的规定:一条记录(Tuple)的字段个数和类型在建表时就固定了,而且是定长存储。这样设计省掉了变长字段的页内偏移管理,让初学者不用在“一条记录可能跨页”这种地狱里挣扎。TableHeap负责把tuple写到页里,每个页维护一个slot数组来标记槽位占用情况。删除记录时,只把slot标记为空,不压缩空间——这个设计会在第五章里讲它带来的坑。

索引层用的是B+树,这也是RucBase里最有含金量的一部分。课程代码把B+树实现拆成了三个节点类:LeafNode、InternalNode和根节点的封装。最核心的操作是Insert,它需要处理节点分裂;比较难写的是Delete,它涉及节点合并和借位。看这一部分代码时,不要直接从头读到尾,建议读Insert时先在纸上画一棵深度为3的B+树,然后跟踪代码里的Search、SplitChild两个函数。

// B+树分裂时,内部节点的处理逻辑示意 // child需要分裂时,把child的一半键搬到一个新节点sibling上 // 然后向上层插入“把child一分为二”的分隔键 template <typename KeyType, typename ValueType> void InternalNode::SplitChild(int child_index) { auto* child = GetChild(child_index); auto* sibling = CreateNewNode(child->IsLeaf()? NodeType::Leaf : NodeType::Internal); // 将child的后半部分key与value搬到sibling child->MoveHalfTo(sibling); // 提取child的新最小key作为分隔键,插入当前内部节点 KeyType split_key = sibling->GetFirstKey(); InsertPairAfter(child_index, split_key, sibling); }

这段代码不是让你直接用,而是要你看懂“分裂后父亲节点发生了什么”。数据库的B+树不像教科书里那种整棵树重建,它的所有操作都是局部的:分裂一个满的叶子页,然后向父亲插一个键;父亲满了继续往上分裂,一直到根。参数上有一个细节:B+树的阶数(fanout)在这里不是通过模板参数设定的,而是根据页大小和key大小算出来的——这也提醒你,改PAGE_SIZE会直接影响B+树的形状和性能。

2.3 执行层:从TableScanner到火山模型算子

在RucBase里,执行层并不像PostgreSQL那样有一整套完整的优化器。它的SQL语句经过flex/bison生成的parser之后,会直接转成对应的执行器类:CreateTableExecutor、InsertExecutor、DeleteExecutor和SelectExecutor。Select的执行逻辑最简单粗暴:拿到表扫描器TableScanner,从头到尾把tuple捞一遍,逐条判断where条件。这个行为是教学数据库的标准解法,但你要明白它与生产数据库的巨大差距——没有索引选择,没有谓词下推,没有连接顺序优化。

火山模型的“Next()返回一个tuple或空”模式在RucBase里以更简单的方式存在:每个Executor的Execute()直接返回一个结果集。这是个合理的选择,因为单表查询不需要流式迭代。但你如果照着这个结构去加Join,就会觉得别扭——A表扫出来的每一条要跟B表做条件判断,最自然的写法是“两层循环”,而两层循环需要能嵌套地pull数据。这就是为什么我到后面建议你自己写一个简单算子框架,而不是在原来的Execute()返回值上硬改。

理解了这三层之后,你对“数据库是怎么跑起来的”就有了一个完整的链条概念:SQL进来,parser生成语法树,执行器调度,执行器访问索引或表堆,表堆通过缓冲池拿页,缓冲池通过磁盘管理器落盘。接下来要做的,就是把这条链子在本地真正拉起来跑一遍。

3. 在本地跑通RucBase:CMake编译与最小启动流程

从zip包到能跑SQL,最怕的不是代码难,而是环境没配对。RucBase的教学代码大部分是按Linux环境组织的,依赖很简单:一个支持C++17的编译器、CMake、flex和bison(用于从parser.y和lexer.l生成SQL解析代码)。在Windows上也能编,但常见的坑是flex/bison不好装,所以我的建议是:有Linux就用Linux,没有Linux就用WSL,不要跟Windows环境较劲。

3.1 拿到源码后的目录结构与编译命令

解压之后,先把目录结构看一遍。正常情况下会看到这几个关键目录:src/disk_manager/是磁盘管理、src/buffer_pool_manager/是缓冲池、src/record/是记录与表堆、src/index/是B+树、src/execution/是执行器、src/parser/是SQL解析器、src/system/是系统启动和元数据。你可能还会看到一个colt或test目录,那是配套的测试工具。

编译的第一步是确认g++版本,然后执行CMake流程:

# 确认编译器支持C++17 g++ --version # 需要8.0以上,至少支持-std=c++17 # 在项目根目录下 mkdir -p build cd build cmake .. make -j$(nproc) # 并行编译,nproc拿到CPU核心数

这里有一个常见问题:cmake生成配置时,如果输出里出现“Could NOT find FLEX”或“Could NOT find BISON”,说明系统没装这两个工具。Ubuntu/Debian下安装命令是sudo apt install flex bison,装完重新执行cmake即可。make过程如果报错,绝大多数是语法错误或者STL用法不兼容,记住一个技巧:make -j后面的进程数不要超过16,否则内存不够会直接把编译卡死。

编译完成后,在build目录下会生成bin/rmdb这个可执行文件。先不用急着执行任何SQL,先跑一下不带参数的命令,确认它能把系统启动起来。

3.2 启动rmdb并执行第一批SQL

RucBase启动时需要指定数据库文件路径,这个文件可以不存在——系统第一次启动时会帮你创建。按照课程代码的习惯,启动参数一般长这样:

cd build # 以test.db作为数据库文件启动 ./bin/rmdb test.db # 看到 ruBase> 或 rucbase> 提示符即启动成功

等提示符出现后,你就可以跟它交互了。注意RucBase的SQL语法是非常严格的,不支持分号、不支持小写自动转大写,每条语句必须写在一个物理行里。第一批语句建议按这个顺序执行:

create table student(id int, name varchar(64), score float); insert into student values(1, 'zhangsan', 92.5); insert into student values(2, 'lisi', 87.0); select * from student;

这里有两个参数层面的坑要提前说:varchar(64)的64在这个教学系统里往往会被解析成64字节,所以你可以放心把名字写满一点;float在存储时用的是C++的float类型,不要用double的精度去验证结果,否则对不上是正常的。insert语句的语法必须是“insert into 表名 values(...)”,不支持列名列表。

如果select能打印出两条记录,恭喜你,整条链路已经通了。此时你可以顺手做一个关于数据库落盘的验证:退出rmdb(输入exit),用命令ls -l test.db看一下文件大小,再重新启动rmdb test.db,执行select * from student,看看数据是否还在。这一步看起来不起眼,但它是区分“数据库”和“内存玩具”的试金石——数据能持久化,说明DiskManager的写盘逻辑真的在工作。

3.3 用VSCode配置C/C++调试环境:从print到断点

CLI翻来覆去跑通之后,下一步就是调试代码。很多初学者拿到源码第一反应是到处加printf,这没错,但效率太低。正确的做法是配置好VSCode的C++调试环境,直接在关键函数下断点。

在项目根目录.vscode/launch.json里,配置的核心是program字段指向编译生成的rmdb可执行文件,args字段填入数据库文件名:

{ "version": "0.2.0", "configurations": [ { "name": "debug rmdb", "type": "cppdbg", "request": "launch", "program": "${workspaceFolder}/build/bin/rmdb", "args": ["debug.db"], "cwd": "${workspaceFolder}/build", "preLaunchTask": "build" } ] }

同时还要配置好tasks.json里的build任务,让F5既能编译又能启动。这个配置里最容易被忽略的参数是“cwd”和“args”——如果args里填的是test.db,而cwd指向的是build目录,那么数据库文件会生成在build目录下,不是项目根目录。很多人的数据库文件“莫名丢失”,就是工作目录不一致导致的。

调试的时候我建议你重点下三个断点:BufferPoolManager::FetchPage函数入口、B+树的Insert函数入口和TableHeap的InsertTuple函数入口。这三个断点能让你观察一条insert语句到底触发了哪些层级的行为。

4. 把RucBase改成你自己的系统:三个值得动手的改造点

跑通只是起点。真正让你理解数据库内核的是改代码——不是改语法,而是改机制。我在这里给出三个改造点,从易到难,每一个都能让你对系统某一部分的理解加深一个档次。

4.1 把朴素LRU换成CLOCK置换算法

先说第一个改造点。RucBase的缓冲池置换如果你看代码,会发现它是用std::list实现的近似LRU。具体做法是:新页拉到队尾,每次访问把页从中间移到队尾,淘汰时取队头。这个实现在访问模式规律(比如全表扫描)时表现很差,因为扫描会把整个表的所有页都“摸”一遍,导致LRU队列被完全刷掉。

现代数据库更倾向用CLOCK算法:页面上维护一个use位,淘汰时循环扫描,use位为1的置0跳过,为0的淘汰。在RucBase的BufferPoolManager里改这个,核心就是把free_list替换成一个环形数组指针:

// CLOCK算法替代LRU的核心逻辑 // victim_index_是时钟指针,每次淘汰从它开始扫描 Page* BufferPoolManager::ClockEvict() { while (true) { Page* frame = &pages_[victim_index_]; if (frame->pin_count_ > 0) { // 被钉住的页跳过,指针前移 victim_index_ = (victim_index_ + 1) % pool_size_; continue; } if (frame->use_flag_) { // 第二次机会:置0但不立刻淘汰 frame->use_flag_ = false; victim_index_ = (victim_index_ + 1) % pool_size_; } else { // use位为0,淘汰这一页 if (frame->is_dirty_) { disk_manager_->WritePage(frame->page_id_, frame->data_); } victim_index_ = (victim_index_ + 1) % pool_size_; return frame; } } }

改造时要注意一个配套变化:原来FetchPage里“命中页就把它移到list末尾”的逻辑,要改成“命中页就把它use_flag_置为1”。这个细节是全部代码改写里最容易漏的。漏掉的结果是:算法退化成FIFO,全表扫描依然会把整个缓冲池刷掉,只是表现形式不同。改完以后,你可以用10万条insert观察缓冲池的未命中次数对比,CLOCK的未命中率在随机访问模式下会略低于原来的LRU模拟。

4.2 给B+树补一个真正的Delete:节点合并与借位

第二个改造点是B+树的删除。你看到源码里如果有BPlusTree::Insert,但Delete只标记不物理删除,那这个树在频繁删除后就会严重浪费空间、增加树高。生产B+树要求删除时做“先借位、再合并”的操作——如果删除后叶子节点剩余条目少于一半,先尝试从兄弟节点借一个条目过来;兄弟也少于一半,则把两个节点合并成一个。

这个改造比Insert难,难在边界条件多。我把叶子节点借位的核心逻辑写在这里,你可以对照源码自己补充内部节点的借位版本:

// 叶子节点借位:从右兄弟借一个key-value到本条目的末尾 // 完成后要更新父节点里的分隔键 void LeafNode::BorrowFromRightSibling(int sibling_index) { auto* sibling = GetSibling(sibling_index); // 把右兄弟的第一个条目插到本节点最后 KeyType borrow_key = sibling->GetKey(0); ValueType borrow_val = sibling->GetValue(0); InsertEntry(borrow_key, borrow_val); // 右兄弟整体左移,删除被借走的条目 sibling->RemoveEntry(0); // 父亲节点中分隔本节点与右兄弟的键要改成右兄弟的新第一个key // 这一步必须由内部节点调用,叶子节点自己拿不到父亲的引用 }

我给你的这个函数只是为了让你理解方向:借位不是一个节点的操作,而是“左节点+右节点+父节点”三方协作。源码里如果没有提供递归删除框架,我建议你先从叶子借位写起,然后把测试样例对齐:连续插入200个不同key,再按随机顺序删除100个,检查每层节点数是否符合“除根外每个节点至少半满”的约束。这一步如果用断言暴力检测,能逼出大部分边界bug。

4.3 给执行器加一个NestLoopJoin,体会算子如何串联

第三个改造点是最有成就感的:让RucBase支持两表连接。原版系统通常只支持单表select,加Join的执行器是你真正理解火山模型扩展性的最好练习。

实现思路是:新增一个JoinExecutor类,把它串在SelectExecutor的下游。执行时先创建左表Scanner,对每一条左表tuple,再创建右表Scanner,逐条匹配连接条件:

// 极简版的NestLoopJoin执行逻辑 void JoinExecutor::Execute() { while (left_scanner_->Next(&left_tuple_)) { right_scanner_ = table_heap_->BeginScan(); while (right_scanner_->Next(&right_tuple_)) { if (MatchJoinCondition(left_tuple_, right_tuple_)) { // 投影:把需要的列拼接成一条新tuple放入结果集 AppendJoinedTuple(left_tuple_, right_tuple_); } } } }

写这个执行器的时候,最容易踩的坑是“右表Scanner每次都要重新创建”。很多人的第一版代码把它创建在循环外面,导致右表只扫了一遍,Join结果比实际少了很多行。这个错误会非常隐蔽——小数据量时看起来没问题,一旦左表有多条记录就翻车。你可以在执行器里加入一个记录数的断言:Join结果行数必须等于“左表行数 × 右表匹配行数之和”,发现不满足就报错退出。

改完这三个点,你对RucBase的理解已经超过八成拿它做课程设计的人了。但改造必然带来新问题,接下来就是硬碰硬的排错环节。

5. RucBase避坑实录:编译、运行与调试的五个常见问题

做任何一个源码项目,“改前能跑,改后必挂”是常态。这一章把我在跟踪RucBase以及类似教学数据库时遇到的最高频问题按“现象→原因→解决”整理出来,每一条都有用。

5.1 g++版本过旧,C++17特性和STL容器报一堆模板错误

现象是编译时出现几百行看不懂的模板报错,错误信息集中在std::optional或std::string_view上,看起来和你的代码完全无关。原因是系统自带的g++版本停留在4.8或5.x,不支持C++17。解决:先用g++ --version确认版本,低于8就升级或安装新版,Ubuntu下可以用ppa源安装。

5.2 BufferPoolManager的pin计数没减,页面被永久钉死

现象是程序运行十几分钟后突然卡住,或者报“buffer pool full”错误,无论LRU还是CLOCK都换不出页面。原因很常见:你在FetchPage之后,由于提前return或者分支判断写错,导致UnpinPage没有执行。解决:在FetchPage后养成“同作用域里必须Unpin”的肌肉记忆,写代码时用RAII封装一个PageGuard类,析构时自动Unpin。

5.3 删除记录后不回收空间,插入性能越来越差

现象是连续插入5000条记录后一切正常,但删除2000条后再插入,文件大小越来越大,插入延迟明显上涨。原因是TableHeap删除时只标记slot为空,InsertTuple优先找空slot,如果找不到才追加新页——但空slot回收逻辑没有实现,追加的新页越来越多。解决:在InsertTuple里先遍历现有页面找空闲slot,如果都满再新建页。

5.4 SQL文件的换行与空行导致解析失败

现象是用脚本批量导入SQL时,最后一条语句报语法错误,但在CLI里手动输入同一句话却没问题。原因是文件末尾没有换行符,或者某条语句中间被空行断开。解决:写脚本时用echo语句在结尾补一个换行,用tr -d '\r'把Windows的CRLF转成LF。

5.5 Debug与Release构建行为不一致

现象是Debug版正常,Release版跑特定SQL时崩溃。原因是Release构建里STL的迭代器检查被关闭,一些在Debug下被提前拦截的越界访问在Release下直接踩内存。解决:不是忽略问题,而是用ASAN重新编译——在CMake命令里追加-DCMAKE_CXX_FLAGS="-fsanitize=address"重新构建,让内存错误浮出水面。

6. 验证改动:先写测试,再跑回归,最后重建数据

改完代码最怕的不是没有结果,而是结果“看起来对但其实不对”。这一章讲怎么用一套低成本、高可信的验证方式,把改出来的系统钉在“正确”这条线上。

6.1 给B+树和BufferPool补单元测试

RucBase的改动集中在内核层,而内核层的bug用SQL语句很难定位。我一般会给BufferPool写一个“pin/unpin配对测试”,给B+树写一个“随机插入+全部查询”的一致性测试。关键技巧是不测具体顺序,只测不变量:

测试对象不变量断言触发方式
BufferPool所有页的pin_count之和等于在途使用数随机Fetch/Unpin/Delete一百次后断言无泄漏
B+树叶子节点除根外每个节点key数量在半满与全满之间插入1000个key后遍历所有叶子
表堆插入插入N条记录后select返回N条插入500条后select *统计行数

这些测试写多了你会发现一条血泪经验:断言写在哪里,bug就能在哪里被拦下来。与其依赖SQL的结果比对,不如在数据结构的层与层之间加assert。

6.2 用SQL脚本做端到端回归

单测只证明局部正确,端到端测试证明整条链路还通。做法很简单:准备一个test.sql文件,包含建表、插入、删除、查询的完整序列,然后用rmdb批处理模式跑它,把输出保存下来;改动前和改动后各跑一遍,用diff对比输出差异。

# 把SQL文件喂给rmdb,输出落盘 ./bin/rmdb regress.db < test.sql > before.out # 在另一个新库上跑修改后的版本 ./bin/rmdb regress2.db < test.sql > after.out # 逐行对比 diff before.out after.out

这一步能抓到的bug类型是“修改B+树删除后,普通insert受影响”这种副作用问题。你如果改的是BufferPool置换算法,务必在回归脚本里加一段顺序扫描后随机点查的测试——旧算法跑得好不代表新算法也跑得好。

6.3 重建数据文件,避免存量数据污染测试结果

最后一个验证习惯:每次跑完回归测试,把rmdb生成的.db文件删光,让测试从空库开始。很多人调试的时候,数据库文件里残留着旧版本的脏页、旧格式的记录,导致新版本跑起来行为诡异。删除数据库文件、从头create table再insert,这个习惯能省掉大量定位时间。

做数据库内核就是这样:每修一个bug,都要重新问一遍“这个改动会不会影响其他模块”。我在改完RucBase的B+树之后,曾经因为只注意了叶子节点分裂,没注意到内部节点也要同步修改,结果测试跑了三个小时,最后靠一条“树深度变化”的断言才定位到问题。从那以后我养成了一个习惯——每个结构体改动,都顺手写一条关于它属性的断言。用最小的成本把风险钉死。

希望这些从编译到改造再到验证的过程,能帮你在RucBase这个源码包里真正走通一条属于自己的“数据库之路”。一个能跑能改的数据库内核,比十本教科书都值。

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

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

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

立即咨询