☰
手写数据库内核:B+树与事务并发控制实战解析
2026/11/2 17:26:40 网站建设 项目流程

简介:关系型数据库是现代软件系统的核心基础设施,其底层实现涉及存储引擎、索引结构、查询执行与事务管理等多个复杂模块。从Buffer Pool的页面替换策略到B+树的分裂合并逻辑,从火山模型算子的执行流程到MVCC多版本并发控制机制,每一环都直接影响数据库的性能与稳定性。理解这些底层原理,不仅有助于深入掌握数据库运行机制,也是进行查询优化、系统调优和故障排查的关键。对于参与系统能力大赛或从事基础软件研发的开发者而言,通过一个完整的数据库内核实现案例,可以直观地学习如何将理论与工程实践相结合,掌握从零构建高性能数据管理系统的核心方法。本文以一份全国一等奖的数据库管理系统设计作品为线索,剖析其架构设计、关键算法与工程决策,为读者提供可借鉴的实战经验。 整理移动硬盘时翻到这个压缩包:2023年全国大学生计算机系统能力大赛数据库管理系统设计赛全国一等奖作品.zip。文件名很正式,但看到它还是让我想起那个连续三周凌晨三点还在调B+树分裂逻辑的晚上。这个包不是普通的课程设计合集,里面是一个从零手写的关系型数据库管理系统,覆盖了存储引擎、索引模块、查询执行器和事务并发控制四条完整内核链路,能在比赛性能基准测试里稳定跑出百万行级数据的查询结果。这篇文章我会从压缩包里的源码出发,把整个项目从架构到实现细节掰开揉碎讲清楚,适合正在备赛系统能力大赛、想入门数据库内核开发、或者单纯想看看一个一等奖作品到底写了什么代码的同学。

1. 开箱之前,先搞懂数据库管理系统设计赛的评审逻辑

1.1 这场大赛到底在比什么能力

全国大学生计算机系统能力大赛的数据库管理系统设计赛道,跟普通的应用开发比赛完全不是一回事。普通比赛看重功能演示、界面完成度和创意,数据库设计赛要的是你亲手实现一个能跑SQL的关系数据库引擎,并且要在评委规定的硬件环境下,完成数据导入、TPC-H风格查询、ACID事务性验证这些硬核评测项。

比赛通常分为两个阶段。第一阶段是功能评测,给定一批标准SQL语句和数据集,考察你的数据库能否正确完成建表、插入、删除、更新、聚合查询、多表关联等操作。这里有个容易忽略的关键点:评测系统的SQL语法是固定的,但你不知道具体题目,所以必须实现一个足够通用的语法解析和计划生成框架,而不是对着几道题硬编码。第二个阶段是性能评测,评测程序会并发执行多个查询线程和写入线程,同时穿插事务提交和回滚,这个阶段比拼的是CPU缓存命中率、磁盘IO调度策略、锁竞争粒度这些底层功夫。

一等奖的含金量体现在哪里?复盘来看,最终评奖看三个维度:功能完整度和正确性占比四成,性能测试成绩占比四成,剩下的两成是代码质量和答辩表现。换句话说,你光把功能写通了只能拿参与奖的底子,必须把性能优化做到极致、代码结构呈现得足够专业,才有机会冲一等奖。

1.2 一等奖作品赢在哪几个关键指标

我记得当时拿到决赛评测报告时,印象最深的是三个数字:第一是导入一亿行TPC-H lineitem表数据用时不到九分钟,这个指标其实是磁盘顺序写速度和检查点策略的比拼;第二是并发16线程混合读写时,事务吞吐量还能保持在每秒八千笔以上,这依赖高效的锁管理器和MVCC多版本调度;第三是崩溃恢复场景下,kill -9杀掉进程后重新启动,五秒内恢复全部已提交事务的数据,这归功于WAL日志和模糊检查点的配合。

这三个指标折射出一个事实:一等奖不是靠某个模块惊艳,而是整体工程能力均衡。Buffer Pool管理、索引结构、查询优化器、并发控制,任何一个环节有短板,都会在某个评测项中暴露。比如你索引写得很花哨,但缓冲池缺页频繁,性能就会崩掉;你事务隔离做得很好,但日志刷盘太频繁,写入吞吐量就会难看到不行。所以备赛的核心策略是:先保证所有模块能达到及格水平,再挑选一到两个模块做到极致。

2. 从源码目录逆推一等奖作品的整体设计

2.1 解压后的第一眼:工程目录和模块划分

拿到压缩包解压后,第一眼看到的是一个非常规整的CMake工程。顶层目录结构干净利落,没有乱七八糟的一堆文件堆在根目录,这在我的评审经验里是加分项。核心目录划分如下:

dbms/ ├── CMakeLists.txt ├── src/ │ ├── common/ // 通用工具:错误码、日志、内存分配器 │ ├── storage/ // 存储引擎:缓冲池、页面管理、磁盘文件 │ ├── index/ // B+树索引与哈希索引 │ ├── executor/ // 执行器:火山模型算子、聚合、排序、Join │ ├── optimizer/ // 查询优化:逻辑计划、物理计划、代价估算 │ ├── parser/ // SQL词法/语法分析,抽象语法树 │ ├── transaction/ // 事务管理、锁管理器、MVCC版本链 │ └── server/ // 服务端协议解析与连接管理 ├── tests/ │ ├── unit/ // 单元测试 │ └── integration/ // 端到端SQL测试 └── bench/ └── tpch/ // 比赛用的数据生成脚本与查询集

存储层、索引层、执行层、事务层互相隔离,依赖方向清晰:server依赖parser和executor,executor依赖storage和index,transaction模块独立被executor调用。这样划分的好处是,后续做性能优化时可以精准锁定热点代码,不会出现牵一发动全身的连锁改动。我见过很多参赛队伍把SQL解析、执行、存储逻辑全部塞进一个几千行的main文件里,这种写法在功能演示时没问题,一进入性能调优阶段就会陷入泥潭。

2.2 核心模块的依赖关系与数据流协议

模块之间通过明确的接口协议协作,不是直接互相调用对方的内部函数。比如执行器向存储引擎请求数据时,不是直接调用fix_page这种底层接口,而是通过一个统一的TableIterator抽象,上层只关心行数据流,不关心底层是B+树扫描还是全表顺序扫描。这种设计让优化器可以自由地替换物理访问路径,是支撑后续性能优化的基石。

从数据流视角看,一条SQL的生命周期是这样的:客户端连接把SQL文本送进parser生成AST,optimizer遍历AST生成逻辑计划,再根据统计信息和代价模型选择物理算子,executor以火山模型逐行拉取数据,底层访问方法从缓冲池获取页面,经过索引定位后在堆表文件中读取目标记录,最后将结果拼装成客户端协议格式返回。整条链路中,事务模块像一张隐形的网,在插入、更新、删除时记录Undo日志,在读取时通过版本链判断当前事务的可见性。

我建议你拿到一份开源数据库代码时,第一件事不是深入某个算法细节,而是先画出模块依赖图和数据流图。理解了数据怎么流动,代码在你眼中就不再是静态的文本,而是一套有生命的机器。这一点对于备赛组队时的分工也很关键,每个人负责一个模块时,只需要约定好接口,就可以并行开发。

3. 存储引擎:从零实现一个不拖后腿的Buffer Pool

3.1 页面管理与缓冲池替换策略的工程权衡

存储引擎是数据库的地基。一个设计良好的Buffer Pool决定了数据库能承载多大的数据量,以及高并发场景下的性能表现。一等奖作品采用的页面大小是8KB,这和InnoDB的默认页大小一致,比赛中没有特殊说明的话这个值算是一个安全的选择。

页面的核心管理结构是PageID到内存帧的映射表,配套一个精简的LRU替换策略。但很多初学者实现LRU时会掉入一个陷阱:直接用标准库的list保存访问历史,每次访问都要在O(n)的遍历中查找页面。这个实现在数据量小的时候没问题,一旦数据量到达百万条记录,缓冲池的命中率会急剧下降。更好的做法是用哈希表加双向链表的组合,哈希表负责O(1)查找,双向链表记录访问顺序,淘汰时直接从链表尾部摘下,命中时通过哈希表快速定位节点然后移到头部。

// 帧管理结构示意 class Frame { page_id_t page_id_; char* data_; bool is_dirty_; uint32_t pin_count_; std::atomic<size_t> last_access_; };

这里面有个容易被忽视的坑:is_dirty_标志位。页面在内存中被修改后不能立刻丢掉,必须在合适的时机写回磁盘。但如果每次修改都立刻写回,性能又会很差,所以要在脏页淘汰时批量刷盘。一等奖代码里专门实现了一个后台刷盘线程,每隔一段时间将最近最少使用的脏页批量写回,同时记录重做日志,这样既保证了数据不会丢失,又不会阻塞前台查询线程。

3.2 磁盘文件与日志系统:崩溃恢复的定海神针

存储层另外一块硬骨头是堆表文件管理与日志系统。比赛要求数据库必须支持事务的持久性,也就是说,哪怕程序在事务中途被强制杀死,重启后已提交的数据也不能丢。为了实现这个保证,代码实现了标准的WAL机制:事务提交时,先把Redo日志刷入磁盘,再修改数据页,这样崩溃后可以通过日志重放恢复数据。

日志模块的核心是日志缓冲区加后台刷盘协作者的配合。每次事务提交时,不需要把整个数据页写回磁盘,只需要写一条几十字节的逻辑日志记录,这在高并发写入场景下能大幅降低磁盘IO压力。比赛实际测试中,每秒钟执行数千条插入事务时,日志落盘成为最大瓶颈,一等奖代码通过批量组提交的方式解决了这个问题:多个事务同时进入提交阶段时,合并成一次fsync操作,吞吐量提升非常明显。

这里补充一个排查经验:如果你的数据库在fork/exec方式下启动后,发现数据文件频繁损坏,先别怀疑磁盘有坏道,大概率是日志模块只刷入了Log Buffer但没有调用fsync把日志数据真正落到磁盘。操作系统的page cache不是可靠存储,只依赖write而不是fsync的话,断电瞬间数据就丢了。

4. 索引、执行器与事务:数据库内核最硬的三块骨头

4.1 B+树索引实现里的关键细节

索引模块选型上,一等奖作品选择了B+树作为主索引结构。这个选择不意外,B+树在范围查询、顺序扫描场景下的表现完胜哈希索引和跳表,而TPC-H这类分析型查询中恰好充满了大量范围条件和排序操作。

B+树实现的难点集中在分裂和合并操作上。比赛时我调试最多的就是这个函数:InsertIntoLeaf。当叶子节点插入数据后容量超限,需要将一半条目分裂到新节点,并更新父节点的指针。这里有一个非常隐蔽的bug场景:如果插入顺序是递增的,叶子节点的分裂会引发父节点分裂,再引发祖父节点分裂,逐层向上传导,需要递归处理。如果某个中间层节点的父节点为NULL,说明要创建新的根节点,树的高度加一。这个逻辑听起来简单,但实现时很容易出现父节点指针未更新、兄弟节点链表断裂、孩子节点数量统计错误三种情况。

索引并发控制的实现也值得一提。比赛采用的方案是乐观锁加闩定的组合:读路径上先不加锁,读取过程中校验节点的版本号,如果发现节点正在分裂或合并导致版本号变化,就重试当前操作。写路径上则使用读写闩从根节点到叶子节点逐层加锁,这种方式在读写比例高的场景下非常高效,读线程不会被写线程长期阻塞。

4.2 火山模型执行器与三种典型算子

执行器采用经典的火山模型,每个物理算子实现一个Next方法,返回下一条符合条件的记录。这种模型的优点是简单、直观、便于实现组合逻辑,缺点是虚函数调用开销较高。一等奖作品在性能压测阶段做了一个优化:对热点算子模板化,避免虚函数调用,用一个宏生成不同数据类型的特化版本。例如整数比较的谓词下推算子和浮点数比较算子分别生成不同的代码路径,这样在CPU流水线层面能获得更好的分支预测效果。

最让我印象深刻的是三个算子的实现质量。第一个是HashJoin算子,它采用两阶段方法:先用小表构建哈希表,再用大表逐行探测。构建阶段针对每一行调用哈希函数计算Bucket位置,如果冲突就插入对应链表中。探测阶段对每一行同样计算哈希值,在链表中查找匹配键。这里有个优化细节:合理设置哈希桶的初始大小,如果桶数量太小会导致链表过长,探测性能下降;如果桶数量太大又会浪费内存。一等奖代码里的经验值是桶数量设为小表记录数的1.2到1.5倍,实测效果最优。

第二个是聚合算子。TPC-H中大量存在group by语句,聚合算子的实现往往成为查询性能的胜负手。一等奖作品的实现是哈希聚合加内存排序:先将分组键计算哈希值,在哈希表中维护每个分组的计数和累加值,内存放不下时再溢写磁盘临时文件。这里有个重要的优化是提前裁剪:在扫描底层数据时就将不满足where条件的行丢弃,而不是先把所有行送入聚合算子再过滤,这个简单变换能让聚合查询性能提升数倍。

第三个是排序算子。比赛数据集远大于内存容量,不能简单把数据load到内存后调用qsort。一等奖作品实现了外部归并排序:第一阶段将数据分成多个有序的归并段写入临时文件,第二阶段多路归并生成最终有序结果。这里的多路归并用败者树加速,比简单比较堆排序减少了一半的逐次比较IO次数,代码量不大但是收益明显。

4.3 事务并发控制:MVCC版本链与两阶段锁的取舍

事务模块是数据库内核中概念抽象程度最高的一部分。一等奖作品实现了Read Committed和Repeatable Read两种隔离级别,底层核心是两个机制:MVCC多版本并发控制和两阶段锁。

MVCC的核心是Undo日志版本链。每一行数据上保存一个指向旧版本的指针,旧版本中又保存更旧版本的指针,由此形成一个单向链表。读操作通过判断版本号与当前事务的快照时间点关系,决定是读取最新版本还是回溯旧版本。这个设计让读操作完全不阻塞写操作,是数据库高并发性能的关键支撑。

但MVCC并不万能,写操作之间仍然需要互斥。所以代码同时实现了一套标准的行级锁管理器,采用经典的读写锁加意向锁协议。事务在更新一行数据之前,必须持有该行对应的独占锁;多个事务同时操作同一行时,后到的写事务会被阻塞在锁等待队列中。这里有一个值得注意的设计决策:为什么不直接全部用悲观锁,或者全部用乐观锁?答案是在比赛评测的高竞争场景下,纯乐观锁会让大量事务无谓地重新执行;纯悲观锁又会让读操作被写操作阻塞,导致吞吐量上不去。MVCC解决读写冲突,行锁解决写写冲突,各管一段、互不干扰,最终达到平衡。

5. 关键工程决策与性能调优的复盘

5.1 技术栈选型:为什么用C++17而不是Rust或Go

代码仓库里能看到清晰的工程决策痕迹。首先语言选择了C++17,而不是当下热门的Rust或Go。原因很简单:比赛评测机只提供标准gcc编译环境,C++标准库在内存管理上更可控,团队参赛者普遍也最熟悉。C++的内存管理优势在做Buffer Pool时特别明显——你可以精确控制对象的分配与释放位置,避免GC(垃圾回收)停顿造成查询延迟尖刺。Rust虽然是内存安全的优秀选择,但在快速迭代码的三周备赛周期内,借用检查器反而是绊脚石。

构建系统选择了CMake而非简单的Makefile,并启用了以下编译选项:

set(CMAKE_CXX_FLAGS_RELEASE "-O3 -march=native -flto -DNDEBUG") set(CMAKE_CXX_FLAGS_DEBUG "-O0 -g3 -fsanitize=address,undefined")

手动开满优化选项,同时在Debug版本开启ASan(地址消毒器)和UBSan(未定义行为消毒器)。这两个消毒器在开发阶段帮了大忙,B+树分裂时偶尔越界访问、并发场景下数据竞争读野指针,全靠ASan提前暴露,否则这些bug跑到性能评测阶段会变成毫无头绪的内存崩溃。

5.2 性能剖析:用Perf找到瓶颈,而不是凭感觉优化

性能调优阶段最有价值的一课是:不要用猜的方式定位瓶颈。队伍里两名同学曾经花了两天疯狂优化哈希函数和哈希桶数组内存布局,试图提升HashJoin性能,结果perf统计显示热点根本不在这里——接近60%的CPU时间都花在了内存分配器上。

问题出在大量小对象的频繁构造析构上。每一行数据的中间表示都通过new创建一个对象,查询完又delete,这些零散的内存分配不仅耗时,还会导致严重的堆碎片。一等奖代码的解决方案是实现了一个简单的内存池:预分配一大块连续内存,按固定大小切成块,用空闲链表管理。自定义malloc和free将内存分配的CPU开销几乎消除,整个查询性能因此提升了一倍多。

进一步用perf top分析时还发现,大量指令花费在字符串比较上。这是SQL处理中非常核心的底层操作,字符串列的分组、排序、连接都需要反复比较键值。代码采用了一项优化:为每个字符串建一个哈希指纹,先比较哈希值,哈希值不同就认定字符串不同,只有哈希值相同时才做逐字节比较。因为大多数情况下字符串是不同的,这个技巧能在内存中少访问大量字节,CPU缓存命中率明显上升。

5.3 比赛时间线复盘:哪些阶段最容易翻车

从四月中旬备赛到八月底决赛,整个参赛周期大致经历了五个阶段。第一个阶段是词法分析、语法分析到逻辑计划的搭建,这个阶段大约花了两周。第二阶段是存储引擎和B+树实现,花了三周,其间数据结构设计反复推倒重来了两轮。第三阶段是执行器和查询优化,这是整个项目中最漫长的阶段,前后花费五周。第四阶段是事务和并发控制,用了大约两周,因为缓冲区管理器和锁管理器要协同调试,这阶段的bug最隐蔽、最难复现。最后一个阶段是系统联调、优化和压测,又花了两周,期间每天都跑一遍TPC-H测试集,记录各项查询耗时。

复盘下来最容易翻车的其实不是技术难点本身,而是接口约定不一致导致模块之间反复适配。比如存储引擎最初返回的是整行数据的裸字节表示,执行器需要根据表结构解析字段;但后来为了支持索引下推,存储引擎直接返回处理后的结果,导致执行器适配逻辑全部重写。所以我强烈建议,组队开发时先花至少两天时间设计好模块间的接口文档,再开始写代码。接口不要追求大而全,要先简单清晰,后续按需扩展。

6. 比赛中踩过的坑与问题排查实录

6.1 崩溃恢复后查询结果错乱的经典案例

在比赛前的模拟评测中遇到过一个问题:每次在压力测试中途用kill -9杀掉进程后重启,某些行的数据就丢失了。排查了很久才定位到根因:不是Buffer Pool的脏页没有写回,也不是Redo日志没有落盘,而是检查点执行时存在竞态条件。

检查点逻辑是扫描所有脏页并写入磁盘,但扫描过程中某些脏页正被后台刷盘线程写回,两个线程同时写同一个页面,最终磁盘上的页面可能是一个混合了新旧数据的中间状态。修复方案是给刷盘操作加一个互斥锁,保证每个数据页在同一时刻只能有一个写回线程在操作。这个bug非常隐蔽,因为正常运行时几乎触发不了,只有在kill -9这个极端场景下才会暴露。排查的时候建议多看看日志文件和页面文件的字节变化,必要时写一个页级校验和的脚本,能极大缩小定位范围。

6.2 高并发下的死锁:从日志中挖掘锁等待图

另一个高发问题就是死锁。多线程并发更新同一张表的不同行时,如果两个事务都先拿第一行的锁再请求第二行的锁,就会出现循环等待,数据库直接卡死。一开始我们完全没有处理这个情况,压测跑了一个小时就彻底进入假死状态。

一等奖代码的做法是实现了一个死锁检测线程,每隔几百毫秒遍历锁管理器中的等待关系图,检测是否出现环。发现环的存在后,选择一个代价最小的事务作为牺牲者回滚,并释放它持有的所有锁,让其他事务继续推进。这个机制本质上是数据库的“越狱机制”,在实际工程中非常重要。

排查死锁的现场经验是:在锁管理器里加一个打点日志,记录每个事务获取了哪些锁、正在等待哪些锁。死锁发生的时候,立刻分析日志还原锁等待图,一眼就能看出是不是经典的两个事务互等模式,还是因为锁粒度设置过大导致的伪死锁。如果等待图中节点很多、环很复杂,通常要考虑是否能用一次加多个锁的方式减少锁请求次数。

6.3 高频问题速查表

现象可能原因解决思路
导入数据后查询结果少了几行缓冲池淘汰时丢失脏页检查刷盘线程是否被正确调用,新增页面校验和机制
并发压测时吞吐量突然降为零出现死锁且无检测机制实现等待图检测与牺牲者回滚
查询性能在数据量翻倍后断崖下降索引分裂过度或缓冲池命中期失效观察是否发生大量随机IO,合理设置缓冲池容量
崩溃重启后已提交事务数据丢失WAL日志未在提交时刷新到磁盘检查fsync调用位置,考虑组提交优化
同一条SQL每次执行性能波动大系统页缓存干扰或并发执行时锁竞争多次冷/热执行分离统计,用perf确认瓶颈
group by查询内存飙升打崩进程聚合算子没有处理内存溢写实现基于磁盘的临时表溢写机制
单条点查快但范围查极慢索引结构只适合等值匹配检查是否用B+树而非哈希索引,以及叶子节点链表是否完整

6.4 独家避坑技巧:先写测试再写实现

整个备赛阶段如果只总结一条经验,我会选择“先写端到端测试再写功能代码”。很多人写数据库内核时习惯自上而下地实现模块,最后再补测试,结果就是各类边界条件在比赛现场才暴露。我们队伍的做法是每实现一个模块时,先写一组覆盖正例、反例、边界条件的集成测试,再开始写真正的功能代码。

比如实现B+树时,测试用例会覆盖空树插入、非叶节点分裂、树高增长、并发读写冲突这四类场景。实现之后立刻跑测试,不用等到所有模块写完再联调。这个方法看起来会多花一些时间,但节省下来的联调时间远多于写测试的时间。这次比赛调试Buffer Pool和索引模块之间接口混乱的问题时,就是靠回归测试快速定位到了哪一行代码引发了异常。

如果你也准备参加下一届大赛,建议从暑假前一个月就开始搭建骨架代码,先把Buffer Pool和解析器跑通,后面的执行器、事务模块再逐步迭代。不要指望一次把整个系统编写完毕再调试,数据库内核这种基础设施级别的代码,几乎不可能一次写对,迭代式开发才是正解。

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

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

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

立即咨询