ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

数据库内核工程化:Bazel构建、gtest测试与事务索引实践解析

数据库内核工程化:Bazel构建、gtest测试与事务索引实践解析 简介这是2024年全国大学生计算机系统能力大赛数据库管理系统设计赛第三名的参赛源码及说明文档合集面向数据库方向的竞赛选手、在校学生与系统开发者有助于深入理解数据库内核的实现路径。压缩包共411个文件大小约1.38MB以h、cc、cpp源码为主体辅以py脚本、md/txt说明、yml/cmake/bazel构建配置以及基于gtest的单元测试文件构成一套完整的工程样例。已有122人浏览学习配套说明梳理了设计思路、模块划分、关键技术选型与排错过程源码中包含存储结构、索引策略、查询处理与事务管理等模块并附有构建与测试脚本便于本地复现实验。通过研读这套第三名方案可借鉴竞赛级数据库管理系统的整体架构与编码组织方式积累从功能设计到性能调优的实战经验适合用于备赛演练和课程项目进阶。1. 数据库管理系统设计赛的第三名源码包别盯算法先盯构建和测试2024 年全国大学生计算机系统能力大赛-数据库管理系统设计赛的这份第三名参赛源码解压之后第一眼看到的不是堆满优化算法的核心模块而是好几个 BUILD.bazel 和一大片 gtest、gmock 的测试相关源码。这个反直觉的目录结构恰恰说明拿名次的队伍不是靠一个漂亮算法而是把工程构建、单元测试和异常路径处理做到了评测机能反复刷的水平。这份资源适合正在备战数据库管理系统设计赛的学生也适合想看看数据库内核工程化如何组织的从业者——你能从中学到的不只是存储和索引怎么写更是一整套可复现的构建与验证方法。2. 拆解这套数据库内核赛题考什么BUILD.bazel 和组织边界2.1 赛题考察的能力闭环存储、索引、查询、事务四件套这类数据库管理系统设计赛通常不允许直接套用 SQLite 这类现成引擎评测机只认你自己写出来的内核。把比赛评分点拆开看核心是四个子系统底层文件管理、存储索引、查询执行、事务恢复。第三名的源码里这些模块都要能独立工作又要通过统一的 SQL 入口联动。附带的说明文档一般会先画一张总架构图把磁盘页管理、缓冲池、BTree、火山模型算子、WAL 日志分成若干独立目录每个目录有清晰的头文件接口这是评测环境下最好维护的组织方式。我在读这类参赛源码时有个固定动作先找 storage、index、txn 这三个目录的头文件直接跳过 CC 实现文件。头文件里能看到基类和核心数据结构比如页大小常量、文件句柄封装、BTree 的节点分裂接口。第三名的源码在这一点上做得比较清楚头文件里的注释直接解释了每个接口被哪个上层模块调用说明队伍在设计阶段就把模块边界划好了。赛题评测一般分功能正确性和性能两组。功能正确性会跑随机 SQL、批量插入、重启后数据恢复、并发事务冲突性能组会卡死循环跑点查和范围查的耗时。为了撑住这些用例源码里必须有三个能力一是页式文件管理支持按页读写并正确维护空闲页二是索引结构至少能在唯一键上做不丢数据的插入删除三是事务日志崩溃后能把未提交的写操作回滚掉。下面这个表是我从这类赛题里归纳出的模块职责基本上源码包的目录结构都能对上号。模块核心接口评测关注点文件管理器CreateFile、ReadPage、WritePage随机读写正确性、文件预分配缓冲池FetchPage、UnpinPage、FlushPage页淘汰策略、脏页回写BTree 索引Insert、Delete、Scan分裂合并的正确性、范围查询执行器SeqScan、IndexScan、Join算子结果的类型匹配事务模块Commit、Abort、RedoLog、UndoLog崩溃恢复、并发死锁2.2 为什么用 Bazel 而不是 CMakeBUILD.bazel 的工程化逻辑比赛源码里铺满 BUILD.bazel 不是偶然。数据库内核这种项目既有底层页模块又有 SQL 层对象天然适合拆成多个编译单元。Bazel 的粒度和缓存能力比 CMake 更适合这种多目标项目每个 cc_library 有独立的编译单元改动一个页文件只会重编页模块和依赖它的模块评测前反复改 bug 时的编译时间成本低不少。而且 Bazel 默认的沙箱构建能保证换一台机器后编译结果一致这对比赛环境来说很重要。源码包里的 BUILD.bazel 基本遵循一个规则实现文件与单测文件分离成两个目标业务模块只依赖自己的直接下级模块。比如存储页相关的 BUILD.bazel 看起来像这样# src/storage/BUILD.bazel load(rules_cc//cc:defs.bzl, cc_library, cc_test) cc_library( name page, srcs [page.cc, file.cc], hdrs [page.h, file.h], copts [-stdc17, -DDEBUG_PAGE], visibility [//src:__pkg__], ) cc_test( name page_test, srcs [page_test.cc], deps [ :page, com_google_googletest//:gtest_main, ], size small, )这里的逻辑要拆开看page 库只暴露 srcs 和 hdrs不包含测试文件这样生产代码不会带上任何测试符号page_test 通过 deps 链接 page 库和 gtest_main保证测试入口独立。visibility 限制了这个库只能被 src 包引用防止上层执行器误依赖存储实现细节。copts 里的 -DDEBUG_PAGE 是最后的后悔药遇到页对齐问题可以开调试宏打印关键偏移。我一般会用 Bazel 而不是 CMake 来搭这类项目还有一个原因依赖外部代码时比较直白。压缩包里的 gtest 相关源码无论是 googletest 官方自带的测试文件还是参赛者写的 gtest 扩展只需要在根目录 WORKSPACE 里用 new_local_repository 指一下路径就能在任意 BUILD.bazel 的目标里引用不用像 CMake 那样处理层层 include 目录。这也是为什么第一眼看到 BUILD.bazel 时不要急着跳过它其实是整个工程最真实的架构文档。2.3 测试文件透露的功能边界gtest 源码不是凑数的打开源码包的测试文件清单能看到 gtest.cc、gtest_unittest.cc、gmock-matchers_test.cc、gtest-death-test.cc 这类文件。很多人误以为这些是凑体积的第三方代码实际上从工程角度这些文件说明参赛者把 GoogleTest 框架源码完整纳入了构建体系并且在业务测试里用到了框架的三类能力普通断言、gmock 模拟对象、death test 崩溃断言。其中 death test 值得特别说。数据库内核最容易翻车的地方是空指针解引用和坏文件句柄比如页缓存里拿不到目标页、索引节点指针指向非法内存。用 gtest 的死亡测试可以反过来验证这些异常路径确实会崩溃而不是静默返回错误数据。代码包里出现 gtest-death-test.cc说明业务测试中大概率用到了 ASSERT_DEATH 来断言存储模块在坏输入下不会产出半正常状态。要快速摸清这套源码的功能边界最直接的办法是做一次目标查询bazel query //src/...:* --outputlabel运行结果会列出所有库、二进制和测试目标。我习惯先看带_test后缀的标签如果看到 parser_test、index_btree_test、wal_recovery_test 之类的目标就能反推出系统有哪些组件。同一个源码包在盲目读代码之前先用这个命令把模块地图画出来读代码时就能直接跳到核心实现不会被零散的辅助文件带偏。这也是这段源码最难能可贵的地方测试目标和业务目标一起被 Bazel 管理一条命令就能看到系统的完整边界。3. 把源码在本地跑起来Bazel 配置、构建、单测与覆盖率3.1 环境准备与 .bazelrc 参数设置拿到源码第一步先把环境固定下来。这个包是基于 Bazel 构建的 C17 项目建议在 Ubuntu 22.04 上用 GCC 11 或从源码装好 Bazel 7。不要一上来就 bazel build先看一眼根目录同时存在 WORKSPACE 还是 MODULE.bazel。如果只有 WORKSPACE而本机 Bazel 默认打开了 bzlmod需要显式关掉否则第三方依赖解析会和你较劲半天。我在复现这类项目时会先按下面这份 .bazelrc 建好基准配置不同的调试需求再通过命令行参数覆盖# .bazelrc common --noenable_bzlmod common --cxxopt-stdc17 common --cxxopt-Wall common --cxxopt-Wno-unused-variable build --cxxopt-O2 build --cxxopt-g test --test_outputerrors test --test_timeout120 coverage --combined_reportlcov逐项说明common 开头的配置对 build、test、coverage 所有命令都生效-stdc17 保证语言版本一致-O2 和 -g 同时打开代表既要性能又要可调试比赛调试期我一般改成 -O0跑性能压测再切回 -O2test_outputerrors 让失败时才输出日志否则单个用例失败会把几千行终端刷满test_timeout120 防止死亡测试和并发测试挂起拖慢整个回归。这里的 common --noenable_bzlmod 是关键前提如果你的 Bazel 版本默认启用了 bzlmod而包里没有 MODULE.bazel构建阶段会直接报错找不到 WORKSPACE 里的依赖。3.2 构建核心目标从编译到启动数据库外壳环境就绪后先构建全部目标验证基础工程完整性。不要先跑测试因为测试目标数量多编译失败时会一次性暴露几十个错误不利于排查。整个包的构建顺序我个人习惯分两步走bazel build //src:all这条命令会编译所有库和可执行文件但不会编译测试。执行成功后输出会集中在 bazel-bin 目录数据库外壳一般对应一个名为 db_shell 或 sql_cli 的二进制。用 bazel run 可以直接启动并传入数据目录参数bazel run //src:db_shell -- --data_dir./mydata在 -- 后面的参数会原样传给运行的程序。data_dir 是数据库文件目录第一次运行时存储模块会创建页文件第二次连同一目录时系统要能从上一次提交状态恢复。这一步能快速检验源码里文件管理器是否写完整了。如果启动后命令行能正常执行建表和插入语句构建层面的链路就通了。这里有个容易误用的点bazel run 默认只链接二进制本身如果数据库外壳依赖的数据文件放在工作区里需要在 BUILD.bazel 的 data 字段里声明或者直接像我上面这样用绝对路径传入 --data_dir。不要以为程序启动后能自动找到当前目录的资源文件。3.3 用 gtest 跑通单测并生成覆盖率报告编译通过后是最重要的测试验证环节。Bazel 优势在这里就体现出来了一条命令可以把整包的测试目标并行跑完并自动汇总失败信息。bazel test //src/...这条命令会执行所有 _test 后缀目标。比赛周期紧张时我会用更细的过滤方式只跑某个模块的测试而不是全量回归。比如只验证存储页模块bazel test //src/storage:page_test \ --test_arg--gtest_filterPagedFileTest.ReadWriteBackToBack--test_arg 后面的内容会追加给测试进程--gtest_filter用例名.模式 是 GoogleTest 官方的用例筛选语法。这个组合参数在 Bazel 里写法固定先定位到具体测试目标再过滤到具体用例。注意如果测试目标名写错Bazel 会提示找不到标签不会自动帮你模糊匹配。想确认一个模块里有哪些测试用例直接看对应目录下的 *_test.cc 文件里的 TEST_F 宏即可。覆盖率是判断这份源码是否值得精读的重要指标。Bazel 自带 coverage 命令结合项目里的 lcov 配置一条命令就能拿到报告bazel coverage //src/storage/... --combined_reportlcov命令执行完会在 bazel-out 下生成合并的 lcov 文件。覆盖率不是越高越好但对参赛源码来说存储模块的覆盖率如果在 85% 以下说明很多异常路径还没测到后续评测压测大概率会在边界条件翻车。我拿到这套源码时先跑的是事务模块的覆盖率事务是数据库正确性的黑匣子覆盖率低的地方往往藏着死锁或日志顺序缺陷。4. 这些坑我帮你踩过了构建、索引与事务的常见问题排查4.1 重复的 BUILD.bazel 导致标签漂移现象解压源码后直接 bazel query报错 Conflicting labels 或者提示找不到 //src/storage:page_test明明文件列表里能看到 BUILD.bazel。原因压缩包正文里出现多个同名 BUILD.bazel通常是参赛者在根目录、子目录和备份目录各留了一份导入时被 IDE 或解压工具重复展开导致同一个标签被定义多次或移动到错误路径。Bazel 对标签冲突是零容忍的直接拒绝加载。解决清理重复文件。以包内说明文档为准保留构建脚本指向的那一份其余移出工作区。操作上先看 BUILD.bazel 开头的 load 语句引用了哪个路径顺着路径检查目标是否存在。从那以后我每次解压源码包第一件事永远是查重并记录文件树避免构建阶段被这种低级问题卡住。4.2 把 gtest 源码直接编进业务库链接阶段符号冲突现象bazel test 编译通过但在链接阶段报错错误信息里有 duplicate symbol 或 undefined reference to testing::internal::Foo有时直接指到 gtest.cc 里的某个函数。原因参赛源码把 gtest 相关源码直接放在业务目录下测试目标的 srcs 里用 glob 把该目录所有 .cc 都收编了于是 gtest.cc 同时被多个测试目标链接。GoogleTest 框架源码的全局符号一旦被重复编译进不同目标链接器就会抱怨符号冲突。另一个常见诱因是测试目标只链接了 gtest 库却漏了 gtest_main导致 main 符号缺失。解决把 gtest 源码单独封装成一个 cc_library业务测试只通过 deps 引用。我在本地修复时按这个模板处理# third_party/googletest/BUILD.bazel cc_library( name gtest, srcs [ gtest.cc, gtest_main.cc, gmock.cc, ], hdrs glob([ include/**/*.h, *.h, ]), copts [-stdc17], visibility [//visibility:public], )然后再建测试目标时deps 里写 :gtest 而不是把源码文件塞进 srcs。这个改动能消除大多数链接冲突。血泪经验就是不要偷懒用 glob 编译整个测试目录你永远不知道目录里哪些 .cc 属于框架源码。修复到能跑的时候记得全量重新 bazel test 一遍缓存会掩盖部分目标没更新的问题。4.3 页偏移计算溢出数据超过阈值后写入错位现象插入几千行数据一切正常但插入到几十万行时某些行查询出来的值和插入时不一样甚至直接读到别的表的数据。单线程执行时偶尔复现加并发后必现。原因页文件偏移公式是 offset page_id * PAGE_SIZE参赛代码里如果 page_id 是 uint32_t乘法结果默认按 int 计算再转换成更大类型一旦乘积超过 int 上限就发生溢出。更隐蔽的是 fseek 这类接口接收 long 参数在 32 位偏移逻辑下同样会被截断。小数据量时 page_id 很小问题被掩盖数据量上来才爆炸。解决统一用 uint64_t 计算字节偏移并对页大小加静态断言。我在代码里习惯加一行 static_assert(PAGE_SIZE % 512 0)既满足磁盘对齐要求又防止页大小被意外改动。同时 OpenFile 时按 page_nums * PAGE_SIZE 预分配文件大小避免运行时动态扩展带来的偏移歧义。修完以后构造一个跨溢出边界的写入用例插入超过 1 万页的数据再回读逐页校验内容这个坑才算是彻底填平。4.4 WAL 日志刷盘与页面锁互相等待导致死锁现象开启事务持久化后多线程压测某种混合写读场景服务日志打出一排死锁检测错误事务提交成功率骤降性能直接掉到接近零。原因源码里事务提交时先拿页面锁再写 WAL 日志而刷盘线程为了减少 fsync 次数把日志缓冲集中到一个后台线程后台线程需要拿日志锁进行 group commit形成事务线程持有页面锁等待日志锁、后台线程持有日志锁等待页面锁的循环等待。解决统一锁顺序事务提交时先提交日志、再操作数据页或者反一反所有路径都先拿日志锁后拿页面锁。我在处理这类问题时就一个原则把锁顺序写进提交代码的注释里避免后人改动时再次引入交叉。如果评测机对性能有要求把每条 WAL 的 fsync 移到提交临界区外面用周期性批量刷盘但这样做要以正确性为先每次刷盘后必须让后续读取能看到已提交数据否则就是拿正确性换性能不值得。4.5 死亡测试在 Bazel sandbox 里挂起直接跑二进制却正常现象bazel test 只要跑带 DeathTest 字样的用例就长时间不返回最后超时被杀但进入 bazel-bin 手动执行同一个测试二进制所有用例秒过。原因死亡测试的实现依赖 fork 子进程并等待子进程崩溃。Bazel 的 Linux sandbox 会对文件系统做隔离并给测试进程设置资源限制和私有临时目录子进程继承这些受限环境后崩溃行为或信号传递方式和裸机环境不一致导致父进程等不到预期的 SIGCHLD。解决这是构建环境问题不是代码问题。先确认死亡测试本身没有真正 bug再给对应的 cc_test 加 size medium 并指定更长超时。命令行里比较有效的做法是执行时加上 --test_timeout300 和 --spawn_strategylocal绕过沙箱隔离跑该目标。我在比赛前复现这类问题时会直接 bazel run 对应二进制用 gtest 自带的 --gtest_filterDeathTest参数单独验证。从那以后我把这项检查固定为环境预案凡是包里有 death test 的工程先把沙箱策略写进 README省得换台机器又踩一遍。5. 在这份源码上做二次开发用测试驱动索引性能验证拿到这套数据库管理系统设计赛参赛源码后最值得做的二次开发不是加新功能而是把它的测试资产变成你的开发抓手。第一步先用 bazel query 找出索引模块的测试目标在测试文件里看它构造了哪些数据分布。通常索引测试只覆盖顺序插入和随机查询你可以补一个大批量乱序插入的回归测试专门验证 BTree 在节点分裂时的指针重连。第二步是给索引模块加一个可观测的行为窗口我一般会在存储页写入路径上留一个 debug 计数器记录每次 Insert 触发的节点分裂次数然后把批量插入脚本跑一遍# regression/bulk_insert_test.sh for i in $(seq 1 5000); do echo INSERT INTO t_key(k, v) VALUES ($i, $(($i * 3))); done | ./bazel-bin/src/db_shell --data_dir./tmp /dev/null跑完看两个指标第一个是节点分裂次数是否随数据量线性增长第二个是单条插入的耗时曲线是否出现阶梯式跳变。如果耗时不均匀多半是页写回策略在触发频繁刷盘这时候可以对比调整缓冲池脏页阈值再跑一轮。这套源码的价值正在于此——它把理论上的索引结构落成了可以量化观测的工程实体。最后把验证结果固化成一个一键脚本跑通后整个二次开发闭环就建立起来了。每次改动索引代码先执行脚本确认分裂次数和耗时可复现再提交。从那以后我每次拿到一个数据库内核项目都要先做一件事把它的测试资产和压测脚本绑成一套回归基线用数据说话而不是拍脑袋调参数。这比精读所有源码更能在比赛前救你一命希望帮到你。本文还有配套的精品资源点击获取
返回列表