☰
RucBase C++数据库源码实战:B+树并发与缓冲池解析
2026/10/13 16:35:36 网站建设 项目流程

简介:这份资源是面向计算机专业学生与数据库系统实现学习者的RucBase关系数据库管理系统C++源码工程,参考CMU15445的BusTub与Stanford CS346的Redbase设计,适合作为《数据库系统实现》课程实验框架,帮助理解并动手实现数据库核心模块。包内共185个文件,以58个h头文件、34个cpp源文件、35个txt说明、20个sql脚本为主,另含少量Python脚本、Markdown文档与图片,压缩包约1.36MB,目录结构清晰,便于按模块检索。内容覆盖数据库与表管理、索引创建删除、事务提交终止、锁管理器实现的共享锁与排他锁、日志记录与恢复、SQL语义分析与执行计划生成,以及缓冲池与磁盘管理器的页面读写替换策略,并附有B+树并发与删除测试、缓冲池管理器测试等用例。已有56人学习,适合需要完整实验框架、源码参考与测试样例的读者深入研读。

1. 从一份 C++ 源码包说起:RucBase 到底能跑出什么

如果你正在搜「C++ 数据库管理系统源码」,大概率会撞见两类东西:一类是动辄几十万行的工业级项目,clone 下来连编译都过不去;另一类是只有几百行的玩具,跑完SELECT 1就没了下文。RucBase 卡在中间——它是一个教学向的 RDBMS 原型,参考了 CMU 15-445 的 BusTub 和 Stanford CS346 的 Redbase,把数据库内核拆成存储、索引、查询、事务、日志几个可独立动手的模块。这份源码包里能看到yacc.tab.c、lex.yy.c这类词法语法分析产物,也有ix_index_handle.cpp、b_plus_tree_concurrent_test.cpp、buffer_pool_manager_test.cpp这些索引与缓冲池的实现和测试文件,说明它不是一个空壳框架,而是带着可编译、可跑测试的完整链路。适合谁?正在做数据库课程设计的学生、想补数据库内核实操的后端工程师、以及需要一份能改能调的 C++ 项目练手的人。它不承诺生产级性能,但能让你把「B+ 树怎么并发、缓冲池怎么替换、SQL 怎么变成执行计划」这些黑匣子亲手拆一遍。

2. 把源码跑起来:从 yacc/lex 到第一个可执行测试

2.1 先看清目录里有什么,再决定编译顺序

拿到压缩包解压后,别急着make。先花两分钟扫一遍文件构成,能省掉后面半小时的链接报错。这份源码里几类文件承担不同职责:

文件/目录作用编译阶段
yacc.tab.c/yacc.tab.cpp语法分析器,由 yacc/bison 生成先于业务代码
lex.yy.c/lex.yy.cpp词法分析器,由 lex/flex 生成先于业务代码
ix_index_handle.cpp索引句柄,B+ 树对外接口业务层
b_plus_tree_concurrent_test.cpp并发 B+ 树测试测试层
buffer_pool_manager_test.cpp缓冲池替换策略测试测试层
disk_manager.cpp磁盘页读写存储层
planner.cpp查询计划生成查询层

常见做法是:词法语法文件先由工具生成 C/C++ 源,再和业务.cpp一起交给编译器。如果你直接改yacc.tab.c而不改.y源文件,下次重新生成就会被覆盖——这是新手最容易翻车的地方。

2.2 环境配置:编译器、构建工具和依赖

RucBase 是 C++ 项目,对工具链版本有要求。我一般会先确认三件事:编译器支持 C++17、CMake 版本不低于 3.10、系统里有 flex 和 bison。下面这套命令在 Ubuntu 22.04 上验证过,其他发行版把包管理器换掉即可。

# 安装编译工具链与词法语法分析器 sudo apt update sudo apt install -y build-essential cmake flex bison # 确认版本,C++17 是硬门槛 g++ --version # 期望 >= 9.0 cmake --version # 期望 >= 3.10 flex --version bison --version

逻辑说明:build-essential提供 g++ 和 make,cmake负责生成构建文件,flex/bison用来从.l/.y重新生成词法语法分析器。参数上唯一要盯的是 g++ 版本,低于 9 会在std::filesystem和结构化绑定上直接报错。如果你在 Windows 上,常见做法是用 WSL2 或 MSYS2,不要硬扛 MSVC——这份源码的构建脚本默认按类 Unix 环境写。

2.3 编译与运行第一个测试

环境就绪后,标准流程是建 build 目录、cmake 生成、make 编译。不要在原目录里直接 cmake,产物和源码混在一起,清理时很痛苦。

# 在项目根目录下创建独立构建目录 mkdir -p build && cd build # 生成构建文件,Debug 模式便于调试 cmake -DCMAKE_BUILD_TYPE=Debug .. # 编译,-j 后面跟 CPU 核心数,加快速度 make -j$(nproc)

逻辑说明:CMAKE_BUILD_TYPE=Debug会带上-g和关闭优化,方便你用 gdb 跟栈;如果只想跑测试看结果,可以换成Release。make -j$(nproc)用满所有核心,但第一次编译建议先不加-j,让报错顺序清晰,定位缺失依赖更快。

编译完成后,测试可执行文件通常在build/test/或build/bin/下。先跑缓冲池测试,它依赖最少:

# 运行缓冲池管理器测试 ./test/buffer_pool_manager_test # 运行并发 B+ 树测试 ./test/b_plus_tree_concurrent_test

如果buffer_pool_manager_test全绿,说明存储层和页管理没问题;b_plus_tree_concurrent_test通过,说明索引层的锁和并发控制逻辑是通的。这两个测试是判断「这份源码是否完整可用」的最快信号。

2.4 用 SQL 走一遍完整链路

测试跑通只证明模块能独立工作,真正要验证的是 SQL 从输入到执行计划的整条链路。RucBase 自带交互式 shell,启动方式一般是:

# 启动数据库 shell,具体路径以编译产物为准 ./bin/rucbase

进入后可以执行建库建表、插入、查询:

-- 创建数据库并切换 CREATE DATABASE testdb; USE testdb; -- 建表,带主键和索引 CREATE TABLE student (id INT PRIMARY KEY, name VARCHAR(32), score INT); CREATE INDEX idx_score ON student(score); -- 插入数据 INSERT INTO student VALUES (1, 'alice', 90); INSERT INTO student VALUES (2, 'bob', 85); -- 查询,观察是否走索引 SELECT * FROM student WHERE score > 86;

逻辑说明:CREATE INDEX会触发ix_index_handle.cpp里的 B+ 树插入逻辑;SELECT ... WHERE score > 86会经过planner.cpp生成执行计划。如果你在输出里看到计划选择了索引扫描而非全表扫描,说明查询优化模块是活的。参数上注意VARCHAR(32)的长度要和存储页大小匹配,超长字符串会被截断或报错。

3. 拆开 B+ 树与缓冲池:索引和存储层怎么改、怎么验

3.1 B+ 树的并发控制:锁的粒度决定测试能不能过

b_plus_tree_concurrent_test.cpp和b_plus_tree_delete_test.cpp是这份源码里含金量最高的两个测试文件。它们验证的不是「B+ 树能不能插」,而是「多线程同时插删时树结构会不会崩」。B+ 树的并发难点在于:分裂和合并会修改父节点,如果只锁当前节点,父节点可能被另一个线程同时改掉。

常见做法是 crabbing 协议——加锁时先锁子节点再释放父节点,或者用 latch coupling 从根一路锁到叶子。RucBase 的ix_index_handle.cpp里能看到对应的锁获取和释放逻辑。你改这块代码时,要盯住三个点:

  • 读操作是否用了共享锁,写操作用排他锁;
  • 节点分裂时父节点的锁是否在子节点锁获取后才释放;
  • 删除导致合并时,是否处理了「兄弟节点也被锁住」的死锁场景。
// 伪代码示意:B+ 树插入时的 crabbing 加锁顺序 // 从根开始,先锁子再放父,避免持有父锁等待子锁 auto child = root; root->RLatch(); // 根节点加读锁 while (!child->IsLeaf()) { auto next = child->Lookup(key); next->RLatch(); // 先锁子节点 child->RUnlatch(); // 再释放父节点 child = next; } child->WLatch(); // 叶子节点加写锁,准备插入 // ... 执行插入与可能的分裂 ... child->WUnlatch();

逻辑说明:这段不是让你照抄,而是说明加锁顺序。RLatch/WLatch是读写锁,Lookup根据 key 找下一层。关键参数是锁的持有时间——持有越短并发越高,但太短会导致结构不一致。跑b_plus_tree_concurrent_test时如果出现段错误或断言失败,九成是加锁顺序反了。

3.2 缓冲池替换策略:LRU-K 的 K 怎么选

buffer_pool_manager_test.cpp验证的是页面替换。RucBase 的缓冲池管理器负责把磁盘页读进内存帧,满了就按策略淘汰。教学项目里常见的是 LRU 或 LRU-K,K 表示参考最近 K 次访问。K 越大,越能抵抗「扫描一遍大表把热页全冲掉」的问题,但实现复杂度和内存开销也上去。

改这块代码时,参数选择直接影响测试通过率:

参数含义常见取值影响
KLRU-K 的历史窗口2K=1 退化成 LRU,K 太大冷启动慢
帧数缓冲池大小测试里通常 10~50太小频繁淘汰,太大测不出策略差异
淘汰阈值历史访问次数门槛2低于阈值优先淘汰
// 缓冲池取页的核心逻辑示意 Page* BufferPoolManager::FetchPage(page_id_t page_id) { std::lock_guard<std::mutex> lock(latch_); // 1. 先在页表里找,命中直接返回并更新访问记录 auto it = page_table_.find(page_id); if (it != page_table_.end()) { frame_id_t fid = it->second; replacer_->RecordAccess(fid); // 更新 LRU-K 历史 return &pages_[fid]; } // 2. 未命中,找一个空闲帧或淘汰一个 victim frame_id_t fid; if (!free_list_.empty()) { fid = free_list_.front(); free_list_.pop_front(); } else if (!replacer_->Evict(&fid)) { return nullptr; // 所有帧都被钉住,取页失败 } // 3. 把磁盘页读进帧,更新页表 disk_manager_->ReadPage(page_id, pages_[fid].GetData()); page_table_[page_id] = fid; replacer_->RecordAccess(fid); return &pages_[fid]; }

逻辑说明:RecordAccess是替换策略的输入,Evict是输出。参数上要特别注意「钉住」的页不能被淘汰——如果测试里出现FetchPage返回nullptr,先检查是不是有页忘了UnpinPage。这是血泪经验:缓冲池测试挂掉,一半是替换策略写错,另一半是 pin 计数没配对。

3.3 磁盘管理器:页读写与持久化边界

disk_manager.cpp是存储层最底下的那块,负责把页写到文件、从文件读回来。它不复杂,但边界条件多:文件偏移怎么算、页号越界怎么处理、写失败要不要回滚。常见做法是每个页固定大小(比如 4KB),页号乘以页大小就是文件偏移。

// 磁盘页读写示意 void DiskManager::WritePage(page_id_t page_id, const char* data) { // 页号转文件偏移,页大小固定 off_t offset = static_cast<off_t>(page_id) * PAGE_SIZE; // 定位并写入整页 if (lseek(fd_, offset, SEEK_SET) == -1) { throw std::runtime_error("lseek failed"); } ssize_t n = write(fd_, data, PAGE_SIZE); if (n != PAGE_SIZE) { throw std::runtime_error("short write"); } }

逻辑说明:PAGE_SIZE要和缓冲池、B+ 树节点大小保持一致,改一处就得全改。lseek的返回值必须检查,write的短写也必须处理——这两个是文件 IO 最常见的翻车点。如果你在测试里看到数据读回来是乱码,先确认写的时候偏移算对了没有。

4. 查询链路与事务日志:planner 和恢复怎么串起来

4.1 从 SQL 文本到执行计划:planner 的输入输出

planner.cpp处在查询层的核心位置,它接收语法分析后的抽象语法树,输出可执行的计划树。RucBase 作为教学原型,计划节点通常包括顺序扫描、索引扫描、投影、过滤、连接这几类。你要改优化规则,就在这一层动手。

一个典型的链路是:lex.yy.c把 SQL 切成 token,yacc.tab.c按语法规则组装成 AST,planner 遍历 AST 生成计划,执行器再递归执行计划节点。如果你发现某条 SQL 报「语法错误」但 SQL 本身没问题,先去看.y文件里的语法规则是不是没覆盖这种写法。

// 计划生成示意:把 WHERE 条件转成过滤节点 std::unique_ptr<AbstractPlanNode> Planner::PlanSelect( const SelectStatement* stmt) { // 1. 先为 FROM 子句生成扫描节点 auto scan = PlanTableRef(stmt->from_); // 2. 如果有 WHERE,包一层过滤 if (stmt->where_ != nullptr) { auto filter = std::make_unique<FilterPlanNode>( PlanExpression(stmt->where_), std::move(scan)); scan = std::move(filter); } // 3. 最后套投影,决定输出哪些列 return std::make_unique<ProjectionPlanNode>( stmt->select_, std::move(scan)); }

逻辑说明:PlanTableRef决定用全表扫描还是索引扫描,PlanExpression把条件表达式转成可求值的节点。参数上要注意投影和过滤的顺序——先过滤再投影能减少中间数据量,如果写反了,性能会差但结果正确,测试不一定抓得出来。

4.2 事务与锁管理器:行级锁和表级锁怎么配合

RucBase 的并发控制通过锁管理器实现,支持行级和表级的共享锁、排他锁。事务开始、提交、终止对应锁的获取和释放。这块最容易出的问题是锁升级和死锁:一个事务先拿行锁再要表锁,另一个反过来,就死锁了。

常见做法是给锁请求排个全序,或者用死锁检测定期扫等待图。教学项目里通常简化成「按固定顺序加锁」。你写事务测试时,可以构造两个事务交叉访问同一批行,观察是否有一个被回滚。

-- 事务交叉访问示意 -- 事务 A BEGIN; UPDATE student SET score = 91 WHERE id = 1; -- 此时不提交,事务 B 尝试更新同一行会阻塞或失败

逻辑说明:BEGIN之后锁才生效,COMMIT或ABORT释放。参数上注意隔离级别——RucBase 教学版通常只实现到可串行化或可重复读,不要假设它有完整的 MVCC。

4.3 日志管理与恢复:WAL 的顺序不能乱

日志管理支持记录和恢复,核心是 WAL(预写日志):先写日志再写数据页,崩溃后才能重放。disk_manager.cpp写页之前,日志必须已经落盘。如果你改恢复逻辑,要保证 redo 和 undo 的顺序正确。

日志类型作用恢复阶段
BEGIN标记事务开始分析阶段
UPDATE记录前像和后像redo/undo
COMMIT标记事务提交分析阶段
ABORT标记事务回滚undo

逻辑说明:分析阶段确定哪些事务要 redo、哪些要 undo;redo 阶段重放已提交的修改;undo 阶段回滚未提交的修改。参数上注意 LSN(日志序列号)的单调递增,乱了就恢复错乱。

5. 避坑与排查:编译、链接、运行时的五类翻车现场

5.1 现象:undefined reference to yyparse或yylex

原因:词法语法分析器的生成文件没参与链接,或者生成顺序不对。yacc.tab.c和lex.yy.c是工具产物,如果 CMakeLists 里只 glob 了.cpp,就会漏掉它们。

解决:在 CMake 里显式把生成文件加入源列表,或者用add_custom_command在构建前重新生成。确认flex/bison已安装,且.l/.y源文件存在。

5.2 现象:编译通过,运行测试时Segmentation fault

原因:B+ 树并发测试里加锁顺序错误,或者缓冲池 pin 计数不配对导致页被提前淘汰。也可能是空指针解引用——教学代码里指针管理不严格。

解决:用 gdb 跑gdb ./test/b_plus_tree_concurrent_test,run后bt看栈。重点检查ix_index_handle.cpp的锁获取释放路径,以及buffer_pool_manager_test.cpp里UnpinPage是否和FetchPage/NewPage成对。

5.3 现象:buffer_pool_manager_test断言失败,提示淘汰页不对

原因:LRU-K 的RecordAccess没在命中时调用,或者Evict选 victim 时没排除被 pin 住的帧。

解决:在FetchPage命中分支补上RecordAccess;在Evict里检查pin_count_ == 0才允许淘汰。参数上确认 K 值和测试预期一致。

5.4 现象:SQL 执行报「table not found」但表确实建了

原因:数据库切换或元数据没持久化。CREATE TABLE写进了系统表,但USE没切换对,或者元数据页没落盘。

解决:先SHOW TABLES确认当前库;检查disk_manager.cpp的写页是否真的刷到文件。教学项目里元数据可能只在内存,重启就丢,这是设计边界不是 bug。

5.5 现象:Windows 下编译报fopen安全错误

原因:MSVC 默认把fopen标为不安全,要求用fopen_s。这份源码按类 Unix 写,直接上 MSVC 会撞。

解决:要么加_CRT_SECURE_NO_WARNINGS宏,要么换 WSL2/MSYS2 用 g++。我一般直接上 WSL2,省得和平台差异纠缠。

6. 进阶玩法:给 RucBase 加一条自定义 SQL 函数

跑通测试只是起点,真正能验证你理解了链路的方式,是加一条自己的 SQL 函数,从词法、语法、计划到执行全走一遍。我拿「求平方」举例,SELECT square(score) FROM student。

第一步,在词法文件里加关键字或函数名识别。如果square当普通标识符处理,就跳过这步,在语法里当函数调用解析。

第二步,在语法规则里加函数调用表达式。找到表达式相关的产生式,加一条expr: ID '(' expr ')',动作里构造函数调用节点。

第三步,在 planner 里把函数调用节点转成计划表达式。常见做法是加一个FunctionExpression,执行时根据函数名分发。

// 函数表达式求值示意 Value FunctionExpression::Evaluate(const Tuple* tuple) const { // 先求参数 Value arg = child_->Evaluate(tuple); // 按函数名分发 if (func_name_ == "square") { int v = arg.GetAsInteger(); return Value(v * v); } throw std::runtime_error("unknown function: " + func_name_); }

逻辑说明:child_->Evaluate递归求参数,func_name_决定行为。参数上注意类型检查——square只接受整数,传字符串要报错而不是崩。加完后重新生成词法语法文件、编译、跑一条SELECT square(score) FROM student,如果输出是平方值,说明整条链路你都打通了。

验证方法上,我习惯先跑原有测试确保没改坏,再加新测试覆盖新函数。从那以后我每次改 RucBase 的解析或计划层,都强制先跑一遍buffer_pool_manager_test和b_plus_tree_concurrent_test,确认底层没被波及,再验证新功能。这个习惯帮我省过好几次「改了上层崩了底层」的后悔药。希望帮到你。

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

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

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

立即咨询