CMU15-445 Project3 Query Execution心得
Project3 Query Execution 心得
一、概述
首先要说:这个 project很有趣很硬核!从这个 project 开始才感觉自己在数据库方面真正成长了!
第一个 project :buffer pool manager 相对独立且简单,说白了就是使用 LRU-K 算法维护一个 page 数组,2022 fall 又加了一点内容:使用可扩展哈希来将对外暴露的 page_id 和数组下标映射起来。 2023 sping 又添加了 COW 的功能,不过 MITOS 已经写过相关 lab 了,也不眼馋了。
第二个 project 虽然烧脑,但是更加独立,有种 干货是有但 trick 更多的意味(个人感觉,手写 b+tree有一点浪费时间)。 虽然对 b+tree 有了全面的认识,但除了知道这是数据库的存储引擎外,和 bustub 也没有更多的互动。
但是第三个 project!我太喜欢了!这才是学数据库的感觉!太爽了!大量的源码阅读带来极致的好奇心的满足,如果说前两个 project 都是数据库的组件,这个 project 开始,终于要从宏观的角度观察数据库了!
同时想要独立完成 project 的同学不能心急,我自己的经验是:如果是第一次接触 project3 的内容,至少先沉下心来读 +调试 10 小时以上的源码,才有思路动手开始写,不然直接上手就是浪费时间或者糊里糊涂抄答案,没有任何意义。
最后这篇博客有两个目的:
- 记录我的学习心得,而不是project 的答案,我自己在开始做这个 project 时最大的难点是有一种“有劲没处使”的感觉,即我不怕智力挑战,但是怕的是我连怎么开始这个挑战都不明白,所以墙裂建议大家一定要先去读源码、跟源码!
- (以我自己微薄的力量尽可能地)受人以渔,所以会记录我自己怎么读源码,怎么调试 bug。
二、写的很好的相关博客
https://blog.csdn.net/Kprogram/article/details/125837906 :这位博主对源码的分析比较多
https://zhuanlan.zhihu.com/p/570917775:迟先生(bustub 源码重要贡献者)对 bustub 的说明,建议自己读完源码之后有了自己的感悟再来看这篇文章,才能有更深的体会
三、如何读 bustub源码
最好的方式就是跟一个 sql 语句的执行过程:
建议使用 clion在 tools/shell.cpp 中使用 debug 模式进行调试,如果使用 CLion,记得加参数:--disable-tty,它的含义是含义是禁用终端窗口(TTY)。这意味着在执行命令时,不会弹出一个新的终端窗口,而是在 CLion 的控制台输出中直接显示命令的结果。这个选项可以避免终端窗口的干扰。
比如我要跟一个 insert 语句的执行过程:
先执行建表语句 create table t1(v1 int, v2 varchar(128), v3 int);
然后执行insert into t1 values (0, '🥰', 10);
并开始跟踪,进行单步调试,函数调用顺序基本上如下(下图是一个知乎老哥整理的,我找不到出处了,等找到补充一下),可以看到经过paser、binder、 planner 和 optimizer 之后,输出的执行计划树被送到了执行引擎,引擎中最重要的方法就是Execute()方法。
这里需要理解的一个关键是 plan node 究竟是什么?
我之前一直不理解 node 和 executor 的关系,在没有认真读源码之前,我一直觉得 node 和 executor 是一个东西,无非是执行计划树上的节点,但是现在我有了新的理解:node 确实是执行计划树上的节点,但是他不是具体的算法实现,以 aggregate 算子为例,aggregationPlanNode 记录了这条聚集语句的具体列的各种属性,比如:针对哪些列进行聚集?聚集的种类是什么?聚集的表达式树什么?node 实际上是为executor 准备数据,而 executor 负责具体实现,说来说去还是解耦那一套。
node作为计划树的一个节点,这个节点有两个成员变量:schema,用来指示这个 node 吐出的 tuple 是哪几列,children:用来指示 该 node 的子节点是哪些。把节点 paln_打印出来:下图可以看到根节点是 insertPlanNode,子节点是 ProjectionPlanNode,然后再子节点是 ValuesPlanNode。(这个 plan_ 其实就是根节点,但是其中包含了 children 的信息,可以根据这些信息推出整棵计划树的样貌,所以 plan_ 既可以理解为计划树的根节点,也可以理解为整棵计划树。)
然后把执行计划树 plan_送到优化器中,optimizer输出的执行计划树是 optimized_plan:
可以看到 ProjectionPlanNode 被优化了,只包含根节点 insertPlanNode和其子节点 ValuesPlanNode。
上图中 ValuesPlanNode 的 values_成员变量则分别存储了 (0, '🥰', 10) ,每列用一个 expression 来表示,所以可以根据 values_ 构造出要插入的 tuple
这个optimized_plan就是被送入执行器的最终计划。
一定要熟悉的一个编程模式是火山模型如何不停地从子节点 pull tuple,答案就是下面这句:
在每一个 executor 中(叶子节点除外,因为他没有 child,只能自己生产 tuple),一定会包含这句代码,正常情况下,Next()会返回 true,同时在 tuple 和 rid 中填充child 返回的那条 tuple。(一般情况下是在 Next()函数中调用while(executor->Next(&tuple, &rid))
,但有些算子是“pipe breaker”,比如 aggregate 和 sort,就需要在 Init()函数中调用while(executor->Next(&tuple, &rid))
)
四、以 Insert 为例,实现细节
前面已经讲了,一个 insert 语句优化出的结果是一个 InsertPlanNode 节点和 ValuesPlanNode 节点,我们的目标是实现 InsertExecutor 中的 Init 和 Next 方法,首先得把 ValuesPlanNode 和 ValuesExecutor 的源码看了吧
ValuesPlanNode 比较简单,最核心的数据结构是下面的values_,用来存储 rows of values,即行数据,根据values_就可以构造出 tuple
ValuesExecutor 的Next()方法注释如下 :其中 AbstractExpressionRef -> Value -> Tuple 的转化太美妙了,所以ValuesExecutor 的Next()的结果就是吐出一条tuple,就是 sql 语句中自己的写的那条要插入的数据。
了解了 ValuesExecutor 的 Next() 方法之后,再写 InsertExecutor 就有些感觉了:
-
拿到要插入的 tuple:row 本来在 sql 中,经过 paser、binder、plaaner、optimizer 后 row 变成了 row_expr 并且存储到了 ValuesPlanNode 的 values_数据结构中(再一次体会到 node 是为 executor 准备数据),所以可以使用 ValuesPlanNode->GetValues() 方法拿到 row_expr,然后使用AbstractExpression->Evaluate() 方法将 row_expr 转换为 Value,Value是 Tuple 的重要参数,然后使用 Tuple 的构造函数可以构造出 Tuple,但是以上所有流程都是 bustub 已经实现好了,学生只需要使用 ValuesExecutor 的 Next()方法就可以拿到要插入的 tuple:
child_executor_->Next(tuple, rid)
-
插入到表中,这就要拿出这张图了,图源:(https://blog.eleven.wiki/posts/cmu15-445-project3-query-execution/#insert--delete),所以插入表就要我们再看源码,table heap、table page 好像都可以有插入表的方法,仔细看一下就知道table heap 中有已经实现了的方法:
InsertTuple()
- 更新索引,索引都存储在在 Catalog 中,而且 Catalog 已经实现了GetTableIndexes() 方法,所以拿到索引后直接使用
InsertEntry()
方法,就可以更新索引。 - 注意输出,要求 insertPlanNode 吐出一条 tuple,用来指示多少行被插入了,注意这个吐出的 tuple 和用户要插入的 tuple 不是一个东西,你要自己构造一个 tuple,把插入的行数(比如 5)塞入这个 tuple 中,这就需要认真阅读 Tuple 的构造函数,如何把 5 塞进 Tuple 中,所谓返回 tuple。
实现以上4 点,就实现了 task1 中的 InsertExecutor,后面的几个 task 也是这种套路,但是有了这种阅读源码的思路,接下来不会有劲没处使了,祝大家顺利通关!
五、记录一次实际 debug 的过程
debug 步骤:
- 使用测试文件中的 sql 语句复现 bug
- 精确跟踪到报错语句
- 阅读源码并思考
我在写完delete 之后执行相应的测试,发现有以下错误:
复现 bug
可以看到上面的报错信息中给出了函数调用栈,所以可以很快确定错误位置在 DletePlanNode 中,于是首先要复现bug,先运行前置 sql 语句:
然后打断点(这里有一个小技巧:根据函数的调用栈打断点,比如根据上图,一次在 ExecuteSql、Execute、delete_executor::Init()等函数处依次打断点,一方面对于函数的执行流程更加了解,而且也可以迅速找到出错的位置),运行出错的语句:
精确找到报错语句
单步跟踪后发现执行下面的语句就会报错,而且 idea 也在行的左边给出了错误提示感叹号,在下方的Debugger 显示界面可以看到是 table_oid_没有值导致的错误。
阅读源码并思考
进一步查询源码,会发现 table_oid_
是DeletePlanNode
的成员变量,给他赋值的语句应该是DeleteExecutor
的构造函数,转到DeleteExecutor
的构造函数:
发现果然没有在初始化列表中给DeletePlanNode
对象赋值,所以在初始换列表中添加:plan_(plan)
之后, bug 就可以顺利解决,当然还可能有其他 bug,但是根据我 30 小时+做project3 的经验,依据以上的步骤,目前还没有解决不了的 bug,所以加油吧!
六、project 中有趣的/开眼界/有坑的点
具体每个算子的实现我就不一一赘述了,上面的博客中的老哥写的很好了,我只记录一下自己印象深刻的几个点:
1. Aggregate 真的是用 hashmap 实现的
group by 语句使用的 Aggregate 算子竟然真的是用 hashmap 实现的。 Aggregate 算子的子算子就是普通的 SeqScanExecutor,在 AggregationExecutor 的Init()函数中,每次都会从 SeqScanExecutor中 pull 一条新的 tuple,然后根据 AgggationPlanNode 中的聚集类型、表达树式、聚集列去更新 hashmap 中的数据。
首先要明白 agg 算子的实现效果,比如一张表如下:
使用 sql 语句 agg:
得到输出结果:
结果解释:
就是以 grade 种类分组,由于grade 只有{一、二、三}三种情况,所以 grade 为“一”有两条数据,sum_age就是 10+40 = 50,以此类推
agg 算子是如何实现的?
- Init() : 在 AggregationExecutor 的 Init() 函数中机上会构建一张 hash table,这张 hash table 构建完成后长这样:(hashmap 的内容就是最终的输出)
其中 key 就是 group by 列可能的所有情况,agg_value 就是表达式树进行聚集类型运算后的结果值,比如这条 sql 中有两颗表达树式:{grade, sum(age)}(因为AggregationExecutor的子节点是 SeqscanExecutor,所以SeqscanExecutor每吐一行新数据,AggregationExecutor->Init()就可以更新自己的 hash table,最终 Init()的结果就是构建一个完全体版本的 hash table)
- Next() : AggregationExecutor 的 Next() 函数中会维护一个 hash_table_iterator,每次吐一个 kv 对,返回给最上层 projecttionPlanNode,然后他会根据 schem 显示sql 的执行结果
2. NestedLoopJoin虽然逻辑简单,但是有坑
默认情况下,DBMS将对所有 join 操作使用NestedLoopJoinPlanNode(当然,要区分 inner join 和 left join)。NestedLoopJoin在 ppt 中也被称为 StupidNestedLoopJoin,逻辑非常简单,就是两层循环:拿着左表的 tuple 去遍历右表,找到了就返回。
易错点
-
双层循环结构中,左表需要遍历一次,右表需要遍历(左表条数)次,如果使用火山模型,直接写代码:
根据SeqScanExecutor,看当内层的
while (right_child->Next(&right_tuple))
遍历完一次后,right_child->Next(&right_tuple)
就会返回 false,所以右表只能遍历一次,为了解决这个问题,可以把右表数据保存到内存中(如vector),这样右表就可以遍历任意次数了。 -
如果左表的 left_tuple 已经匹配了右表的某一行:right_tuple,但是右表还没有扫描完成,NestedLoopJoinExecutor 吐出匹配的结果(left_tuple + right_tuple)后,下次调用 NestedLoopJoinExecutor 还要用这个左表行:left_tuple 接着扫描右表 right_tuple 的下一行(即:既不能取左表行的下一行,又不能将右表从头开始扫描)
所以就需要有一个全局游标记录扫描到了右表的哪一行,如果这个游标指向右表开头,才可以取左表一行新的数据,否则就要用旧的左表的tuple继续扫描右表
所以在代码实现中,要加入两个新变量:
- 存储右表数据的 vector
- 存储右表遍历到何处的游标 index
3. NestedIndexJoin 的优化规则
如果查询包含等值条件的连接,且连接的右表具有该列的索引,则DBMS将使用NestedlndexJoinPlanNode
NestedIndexJoin 执行过程:
左表 (inner/left) JOIN 右表 ON 左表.col1 = 右表.col3;
右表的 col3 有索引,流程全部在 NestedIndexJoinExecutor 的 Next() 函数中处理
- 首先拿到左表的 col1 的值,然后在右表的索引中寻找这个值
- 一般情况下,索引中可以找到这个值对应的 rid,然后回表,把左表的 tuple 和右表的 tuple 连接起来,吐出去,结束。(假设索引中没有相同的值)
4. 初窥优化器
在task3 中需要实现 topn,就是把 limit + sort 节点优化为一个 topn 节点,具体来看,未优化之前:
优化之后,相同的 sql:
可以看到 limit + sort 节点已经优化为一个 topn 节点
优化器的实现框架是很简单的,如下所示,把planner 输出的初始计划输入到优化器中,优化器会遍历所有的优化规则,命中哪一个规则,就执行相应的优化
而且优化的执行过程也很有趣,比如我们的原始计划是树状: LimitPlanNode->SortPlanNode->SeqScanPlanNode,参考其他优化器的代码可以发现,优化器执行优化的逻辑是:使用递归后序遍历这棵树,遍历过程中,每次得到的子树都会去判断是否满足( LimitPlanNode->SortPlanNode)条件,满足则进行替换,最终得到 optimize_plan~
所以 topn 的有趣的地方不在于写 limit+sort 逻辑,而是在如何写优化器这里的替换逻辑,但只要记住:后序遍历原始计划树,然后判断子树是否满足优化条件就可以完成~
七、测试
提醒一下,大家写注释的时候 // 之后一定要跟空格,否则make check-lint
会报错!
andy 终于大气了一次,给出了所有的测试用例,不用在 gradescope 上找报错信息了
将 p3.00-primer.slt 改成 /test/sql 文件夹下的其他测试文件,然后单独测试每个算子即可。
ps:虽然 p3 没有隐藏测试用例,但是线上测试会检查内存泄漏问题,被查到依旧无法通过线上测试,所以编码时要注意这一点,比如不要随意使用 new 来构造对象。
最后贴张通过的图吧,好歹纪念下哈哈~
__EOF__

本文链接:https://www.cnblogs.com/looking-for-zihuatanejo/p/17481180.html
关于博主:评论和私信会在第一时间回复。或者直接私信我。
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角【推荐】一下。您的鼓励是博主的最大动力!
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· [翻译] 为什么 Tracebit 用 C# 开发
· 腾讯ima接入deepseek-r1,借用别人脑子用用成真了~
· Deepseek官网太卡,教你白嫖阿里云的Deepseek-R1满血版
· DeepSeek崛起:程序员“饭碗”被抢,还是职业进化新起点?
· RFID实践——.NET IoT程序读取高频RFID卡/标签