ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

手写数据库内核:B+树与事务并发控制实战解析

手写数据库内核:B+树与事务并发控制实战解析 简介关系型数据库是现代软件系统的核心基础设施其底层实现涉及存储引擎、索引结构、查询执行与事务管理等多个复杂模块。从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和executorexecutor依赖storage和indextransaction模块独立被executor调用。这样划分的好处是后续做性能优化时可以精准锁定热点代码不会出现牵一发动全身的连锁改动。我见过很多参赛队伍把SQL解析、执行、存储逻辑全部塞进一个几千行的main文件里这种写法在功能演示时没问题一进入性能调优阶段就会陷入泥潭。2.2 核心模块的依赖关系与数据流协议模块之间通过明确的接口协议协作不是直接互相调用对方的内部函数。比如执行器向存储引擎请求数据时不是直接调用fix_page这种底层接口而是通过一个统一的TableIterator抽象上层只关心行数据流不关心底层是B树扫描还是全表顺序扫描。这种设计让优化器可以自由地替换物理访问路径是支撑后续性能优化的基石。从数据流视角看一条SQL的生命周期是这样的客户端连接把SQL文本送进parser生成ASToptimizer遍历AST生成逻辑计划再根据统计信息和代价模型选择物理算子executor以火山模型逐行拉取数据底层访问方法从缓冲池获取页面经过索引定位后在堆表文件中读取目标记录最后将结果拼装成客户端协议格式返回。整条链路中事务模块像一张隐形的网在插入、更新、删除时记录Undo日志在读取时通过版本链判断当前事务的可见性。我建议你拿到一份开源数据库代码时第一件事不是深入某个算法细节而是先画出模块依赖图和数据流图。理解了数据怎么流动代码在你眼中就不再是静态的文本而是一套有生命的机器。这一点对于备赛组队时的分工也很关键每个人负责一个模块时只需要约定好接口就可以并行开发。3. 存储引擎从零实现一个不拖后腿的Buffer Pool3.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::atomicsize_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 技术栈选型为什么用C17而不是Rust或Go代码仓库里能看到清晰的工程决策痕迹。首先语言选择了C17而不是当下热门的Rust或Go。原因很简单比赛评测机只提供标准gcc编译环境C标准库在内存管理上更可控团队参赛者普遍也最熟悉。C的内存管理优势在做Buffer Pool时特别明显——你可以精确控制对象的分配与释放位置避免GC垃圾回收停顿造成查询延迟尖刺。Rust虽然是内存安全的优秀选择但在快速迭代码的三周备赛周期内借用检查器反而是绊脚石。构建系统选择了CMake而非简单的Makefile并启用了以下编译选项set(CMAKE_CXX_FLAGS_RELEASE -O3 -marchnative -flto -DNDEBUG) set(CMAKE_CXX_FLAGS_DEBUG -O0 -g3 -fsanitizeaddress,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和解析器跑通后面的执行器、事务模块再逐步迭代。不要指望一次把整个系统编写完毕再调试数据库内核这种基础设施级别的代码几乎不可能一次写对迭代式开发才是正解。本文还有配套的精品资源点击获取
返回列表