OceanBase数据库大赛初赛:从拆包到提交的存储引擎工程实践
简介这是一份2023 OceanBase数据库大赛初赛阶段的完整资源包面向在校学生、数据库从业者及对内核技术感兴趣的初学者用于快速入门并理解数据库实现原理。内容围绕MiniOB项目展开该项目由OceanBase团队基于华中科技大学数据库课程原型打造通过简化复杂模块保留核心机制适合从零基础逐步深入。包内共637个文件以189个C/C头文件与166个cpp源码文件为主体覆盖bplus_tree、table和expression等核心模块另含143张原理图与50篇Markdown文档并配有测试用例、shell脚本、构建配置等辅助材料方便查阅逻辑、编译调试和记录实验。压缩包整体约14.52MB目录结构清晰便于按需检索。目前已有160人学习下载适合准备数据库竞赛、课程设计或面试复习的读者可直接对照源码、注释与配套文档系统掌握存储引擎、查询执行和网络通信等关键知识点积累从原型到工程的完整实践。1. 一份2023 OceanBase数据库大赛初赛.zip先别急着解压数据库内核赛道里OceanBase数据库大赛这几年的初赛包是公认的试金石一个压缩包里面装着简化后的内核骨架、一批测例以及一个被封装成黑匣子的评测入口。很多人拿到包第一反应是解压、翻README、改代码结果两天后还停在原地要么被环境依赖卡住要么自己本地测得好好的一上评测机就翻车。我把这类竞赛材料的处理方法固定成了同一条流水线先拆包盘点再跑通最小链路接着才碰实现最后按评测口径回归。这篇笔记就按“拆包-跑通-拆实现-调性能-排坑-提交前复检”的顺序展开适合准备参赛的选手也适合想借一个真实场景把存储引擎基本功补扎实的人。2. 从拆包到看懂评测材料结构、计分规则、环境依赖初赛材料的坑不在代码量而在“你以为的入口”和“评测机实际跑的入口”不一致。我见过不止一个队伍前三天都在解压、装库、换编译器版本其实先花半小时把zip内部结构盘清楚后面能省出一整天。2.1 解压之前先盘点几条命令看清目录骨架拿到压缩包我一般不会立刻执行解压。先做三件小事校验完整性、确认压缩格式、只读列出文件清单。cd /path/to/ob_prelim md5sum 2023 OceanBase数据库大赛初赛.zip file 2023 OceanBase数据库大赛初赛.zip unzip -l 2023 OceanBase数据库大赛初赛.zip | head -60md5sum用来确认下载过程没有损坏压缩包也方便和赛题发布页给出的校验值做比对如果源站给出了SHA256就用sha256sum。file输出会告诉你这个zip是用什么工具打的、是zip还是rar伪装后缀避免解压工具选择错误。unzip -l只罗列内容不解压在几十秒内就能看到顶层目录和关键文件比直接解压更稳因为有些压缩包内部路径层级很深直接解出来会把当前目录搞乱。常见情况下这类材料包的顶层会有这么几个角色一份说明文档README或题目PDF、一份内核骨架源码目录通常是C工程、一批样例输入输出、一个评测脚本目录。具体文件名每家都不一样但角色基本固定。我先确认目录里有没有judge、test、tool这类关键词再决定从哪个文件读起。2.2 先读评测脚本再读题目文档很多人习惯先读题目文档这没错但我更建议先用两条命令把评测相关的信息抓出来grep -rniE score|timeout|memory|limit|usage README.md judge/ 2/dev/null | head -60这一条的作用是快速画出计分规则的轮廓。测评脚本里写着的“分数组成”决定了你代码的优化方向有些赛题正确性占大头性能只作为同分排序另一些则是完全按执行时间排名正确性只是门槛。这两类题目对实现策略的要求完全不同前者求稳后者必须在架构上预留性能空间。再往下看评测脚本通常会按固定步骤调用你的程序加载数据、执行查询、比较输出。很多选手在这一步会忽略一个细节评测不是直接跑你的main函数而是把它编译成一个可执行文件用命令行参数控制输入输出路径。所以你的程序入口必须严格按说明实现多打印一行无关日志都可能让评测解析失败。2.3 把本地环境和评测环境钉死在同一个版本上这是最典型的“坑在压缩包之外”的地方。本地能编译、评测机崩绝大多数不是代码逻辑问题而是环境差异编译器版本不同、第三方库路径不同、动态链接库缺失。cat /etc/os-release g --version cmake --version cat CMakeLists.txt | grep -E CMAKE_CXX_STANDARD|cxx_std ldd ./build/bin/kvstore | grep not foundldd是检查动态链接依赖最快的方式not found出现任何一个都说明评测环境里少了某个运行库。我的习惯是编译时尽量静态链接第三方库或者干脆把依赖项全部打进工程目录用相对路径加载编译标准在CMakeLists.txt里显式写死C17不要依赖编译器默认值。注意如果材料包自带编译脚本优先用它的参数不要自己另搞一套构建配置。评测机大概率按材料包的脚本构建你的代码。3. 先赢下最小链路编译、启动、跑出第一份输出在动手改任何业务逻辑之前先把材料包自带的骨架完整跑一遍。这一步的目标不是拿高分而是确认从源码到可执行文件到结果输出的整个链路是通的。很多参赛者跳过这步直接开发等到提交前一天才发现构建脚本在干净环境里根本过不去。3.1 先按官方配置构建不要一上来就优化编译参数mkdir -p build cd build cmake .. -DCMAKE_BUILD_TYPERelease make -j$(nproc)这里有三点要解释。第一CMAKE_BUILD_TYPERelease必须显式指定很多默认配置是空值会让代码以未优化模式运行性能差距可能达到数倍。第二-j$(nproc)是让make按CPU核数并行编译初赛机器核数一般不多串行编译会很痛苦。第三不要在一开始加-marchnative这类针对本机CPU的指令集优化本地CPU和评测机不一定相同本地能用、评测机报illegal instruction的情况并不罕见。编译完成后先看一眼产物是否存在、是不是可执行文件ls -lh bin/ file bin/kvstore如果赛题骨架是一个完整的内核实现这一步通常可以直接生成服务端程序如果只是一个框架编译通过就说明你的开发环境已经对齐了。3.2 跑第一个样例用例确认输入输出口径跑通构建之后立刻找一个最小样例执行。假设材料包里给出的程序入口是命令行驱动./bin/kvstore --data ../data/train.csv --query ../data/train_query.sql --output ./result.out执行前后注意观察三件事返回码是否为0结果文件是否生成告警日志是不是混到了标准输出。如果返回码非0优先看stderr输出多数情况下是数据路径写错或者文件权限不对。如果结果文件生成了但内容为空可能是程序把输出写到了另一个路径。拿到第一份输出后和样例答案做对比wc -l result.out head -5 result.out diff (sort ../data/train_expected.out) (sort result.out)wc -l看行数是否对得上head扫一眼格式是否正常diff配合sort做无序比对先确认“该有的输出都有”。这里要特别注意sort隐藏了顺序信息如果评测是按顺序逐行比对这个验证方式会给出错误的安全感但它作为第一轮冒烟测试仍然足够高效。3.3 在本地补上三类自测提前暴露低级问题第一类是边界输入空文件、单行输入、超长字符串、含中文字符和转义符的字段。这些数据在初赛测例里出现概率极高很多实现一遇到空行就崩。第二类是顺序敏感的输出检查把样例结果按原序用diff直接比确保程序在输出顺序上没有隐患。第三类是资源自测用脚本临时生成一份比样例大几个数量级的数据/usr/bin/time -v ./bin/kvstore --data ../data/big.csv --query ../data/big_query.sql --output ./big_result.out 21 | grep -E Elapsed|Maximum resident/usr/bin/time不是bash内置的time-v参数会输出“最大驻留内存”和“用户/系统态耗时”这一项在初赛评测中往往是隐藏杀手评测机给的内存上限可能比你本地开发机小得多本地不炸不等于评测不炸。4. 实现层面怎么拆存储布局、索引路径、并发与持久化骨架跑通后真正的问题才浮出水面这些测例背后考的是内核的哪一块能力我把大多数数据库初赛题的实现拆成三个层面数据怎么存、查找怎么快、变更怎么落。下面按一张表的逻辑走下来顺序不对容易返工。4.1 存储布局行存还是列存先看查询长什么样初赛测例的查询模式决定了存储结构。如果测例以点查和范围查为主行存加索引就够了如果出现大量聚合计算比如按列求和、分组统计行存会在扫描时浪费大量带宽。一个简单的判断方式用几条代表性查询看它们引用了多少列。单条查询只取两三列却要扫全表时列存优势明显每次查询都回整行行存实现更简单性能也足够。我通常建议初赛用行存起步把每一行作为一个对象存进连续内存理由有三点实现快调试容易和CSV导入天然匹配。下面是一个最朴素的存储结构struct Row { std::vectorstd::string fields; // 所有列统一按字符串存 bool deleted false; // 标记删除延迟物理回收 };这里把列统一存成字符串是为了避免在导入阶段做类型转换带来的复杂度和错误源。真实类型转换延后到查询阶段按列处理代码结构会更清晰。deleted字段是给删除操作留的口子先标记不立刻搬动数据避免删除引发的内存拷贝。内存布局上我强烈建议一次性把数据文件读完而不是逐行push_back小对象。给vector做reserve预分配能省掉大量扩容拷贝std::ifstream inf(path); std::string line; std::vectorstd::string fields; while (std::getline(inf, line)) { // 按分隔符拆分到fields然后rows.emplace_back(fields) }reserve的数量如果无法提前知道可以先按文件大小估算一个粗略值比如rows.reserve(file_size / 32)减少扩容次数。4.2 索引与查找路径把全表扫描变成有序定位初赛测例通常不会让全表扫描活太久所以索引是拿分的核心。最常见的需求是主键等值查询和主键范围查询这两者用同一个数据结构解决对主键排序后二分查找。struct Entry { int64_t key; // 主键解析后的整数值 uint32_t offset; // 对应Row在数组中的下标 }; std::vectorEntry index; auto it std::lower_bound(index.begin(), index.end(), key, [](const Entry e, int64_t k) { return e.key k; });解析主键时就把字符串转成int64_t存入Entry不要再在查询时反复做字符串比较这能省下一大截时间。lower_bound返回第一个不小于目标值的位置等值查询检查该位置的主键是否相等范围查询则从这个位置顺序扫描到边界。这套写法比手写二分更不容易出界而且对有序数据的范围查询非常友好。比较容易被忽略的是索引构建方式不要边读数据边插入vector那样每次插入都是O(n)的搬移。正确做法是先全部推入读完后一次性排序std::sort(index.begin(), index.end(), [](const Entrya, const Entryb) { return a.key b.key; });排序后索引和Row数组通过offset保持关联。这里有一个优化点如果主键本身就是单调递增的可以省掉排序直接使用但不要做这个假设除非你能从数据文件里确认。4.3 并发和持久化初赛阶段别急着上事务很多选手一上来就设计锁、设计事务日志理由是“评测可能跑并发”。但在初赛阶段这条结论通常站不住脚多数测例是单线程顺序驱动你引入的锁反而成了最大的性能瓶颈。我推荐的做法是先把单线程路径做到极致再根据评测脚本判断并发场景是否存在。如果测例里有多个查询文件并行执行再考虑加线程池如果评测脚本是逐个调用就完全不用碰并发。线程池的合理线程数也不是越多越好一般取CPU核数超卖反而会因为上下文切换变慢。持久化是另一个容易踩坑的地方。初赛如果涉及写入场景最常见的性能问题是频繁fsync。比如每条插入都刷盘评测一轮跑完会慢几十倍std::ofstream out(data.log, std::ios::binary | std::ios::app); out.write(buf.data(), buf.size()); // 不要每次写完都flush攒一批再刷 out.flush();这个阶段的目标是“行为正确但不背上无谓的性能债”。日志输出也属于同样的道理调试时把日志级别调到最详细没问题但性能测试前必须调回来标准输出和日志文件都会拖慢评测进程。5. 本地和评测机的差距五个最容易翻车的排查点初赛的下半场基本是在和黑匣子较劲本地跑得飞快评测机却超时样例全过提交却零分。这一节把我踩过的和周围队友踩过的坑整理成几条排查路径每一条都按“现象-原因-解决”来写。5.1 现象一本地编译运行一切正常评测机一跑就崩现象本地Release构建编译通过样例和自测都正常提交后评测返回Segmentation fault或直接无输出。原因往往不是代码质量而是环境不一致依赖的动态库版本对不上、评测机内存上限比本机小很多、或者是编译参数里带上了本机CPU专属指令。解决方式分三步在本地开一个受限环境重新构建限制虚拟内存检查动态库依赖并去掉-marchnative类参数。ulimit -v 2097152 # 限制虚拟内存为2GB模拟评测机 ulimit -t 60 # 限制CPU时间为60秒提前暴露死循环如果真的出现了内存不足优先查存储结构里的容器是否有过大的预分配比如在知道上限的情况下把vector一次性resize到几千万行是常见的炸内存原因。5.2 现象二样例输出全对一到隐藏测例就Wrong Answer现象本地样例通过率百分百提交后大量测例输出格式错误。原因多半是输出口径问题而不是查询逻辑问题。最常见四处行尾多了一个空格、换行符是\r\n而不是\n、数字字段的精度和评测要求不一致、输出顺序和预期顺序不同。解决方式是把输出逻辑统一封装成一个函数所有查询结果都走同一段序列化代码void write_result(std::ostream os, const std::vectorstd::string row) { for (size_t i 0; i row.size(); i) { if (i) os \t; os row[i]; } os \n; }统一封装后字段分隔符、行尾符只有一个修改点。另外输出缓冲区要提前设置大一点频繁的小输出写盘会显著拖累性能std::cout.sync_with_stdio(false); std::cin.tie(nullptr);5.3 现象三本地测试不慢评测却频繁超时现象本地跑最大样例只要几百毫秒评测机返回超时。原因有两种可能评测机单核性能比本机差很多或者测试数据量比最大样例还要大一个量级。解决方式只有一个就是量化。不要对着黑匣子猜先用脚本生成多组不同规模的数据测出程序的耗时增长曲线再定位是导入慢、构建索引慢还是查询慢。for size in 10000 100000 1000000 5000000; do python3 gen_data.py -n $size data_$size.csv /usr/bin/time -v ./bin/kvstore --data data_$size.csv --query q.sql --output out.log 21 | grep -E Elapsed|Maximum resident done如果耗时随数据规模线性增长先查是不是全表扫描如果增长曲线比线性更陡查索引构建里的排序或容器扩容。5.4 现象四导入阶段内存暴涨现象程序在加载数据时就占了几个GB还没开始查询就被评测系统杀掉。原因通常是每行数据都独立分配了堆内存std::string叠加vector的分配开销非常大。解决方式是减少小对象分配把整块数据读进连续缓冲行对象只保存指向缓冲区的偏移和长度。struct RawRow { uint32_t offset; uint16_t len; uint8_t field_count; };这类紧凑结构能明显压缩内存占用。如果题目允许也可以考虑每个字段单独存字符串但主键字段和常用过滤字段单独提取成数值其余字段留着延迟解析。5.5 现象五提交后发现评测脚本被自己改过现象反复提交都报启动失败队友一查材料包里的评测脚本已经被改得面目全非。原因是在调试时为了方便手动改了评测脚本里的路径或参数打包时又把整个目录一起提交了。解决方式是严格区分“源码目录”和“材料目录”对抗检查时只提交源码目录。更稳妥的是动材料之前先做一次拷贝把原始zip留在一边不动每次改动都在自己的工作副本上操作cp 2023 OceanBase数据库大赛初赛.zip ob_backup.zip这条习惯救过我不止一次。材料包里的数据文件和评测脚本是裁判的口径任何本地修改都会让结果失真。6. 提交前最后一步用“干净副本复现”替代“本地自测”离截止时间越近越不要用已经跑过几十次的开发目录做最终验证。开发目录里可能残留着旧的编译产物、改过的配置、临时日志文件甚至评测脚本的本地补丁。我自己的习惯是做一个独立于开发目录的干净复现流程核心就三点从原始压缩包解出一份新代码、在受限环境下重跑构建、用固定脚本对全部样例做回归。下面这个脚本是我每次提交前都会跑的逻辑很简单但它能把“环境不一致”这个隐患在本地拦截下来#!/usr/bin/env bash set -euo pipefail BASE/tmp/submit_check rm -rf $BASE mkdir -p $BASE cd $BASE # 从原始包重新解压保证不掺入本地改动 unzip -q /path/to/2023 OceanBase数据库大赛初赛.zip -d src # 按官方配置构建 mkdir -p build cd build cmake ../src -DCMAKE_BUILD_TYPERelease cmake --build . -j$(nproc) # 模拟评测机的资源约束 ulimit -v 2097152 ulimit -t 120 # 跑全部可见样例 for case_dir in ../src/cases/*; do echo $(basename $case_dir) ./bin/kvstore --data $case_dir/data.csv \ --query $case_dir/q.sql \ --output $case_dir/result.out if ! diff -q $case_dir/expected.out $case_dir/result.out; then echo FAILED: $(basename $case_dir) exit 1 fi done echo ALL PASSED脚本里set -euo pipefail保证任何一个命令失败立即退出不会带着错误继续往下走ulimit放在构建之后、运行之前模拟评测机资源上限每次从zip重新解压确保提交的代码是材料包目录下的那部分源码而不是你开发目录里靠本地补丁才能跑的东西。提交之后我还会做一次检查确认提交包的文件列表里只有源码和必要配置不包含构建产物、数据文件和评测脚本。这个习惯帮我避开过好几次打包失误——把build目录整个带上提交包打了上百兆评测机构建时却因为没有原始目录结构而失败。竞赛型的数据库实现“能跑”和“能稳着跑”是两回事。本地反复验证过的东西换个环境可能完全不是那么回事。每次提交前多花十分钟跑一遍干净副本比在评测系统里浪费一次提交机会划算得多。希望这些思路能帮你在初赛阶段少走点弯路。本文还有配套的精品资源点击获取