CMU15-445 Project3 Query Execution心得

发布时间 2023-06-14 19:38:43作者: byFMH

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 小时以上的源码,才有思路动手开始写,不然直接上手就是浪费时间或者糊里糊涂抄答案,没有任何意义。

最后这篇博客有两个目的:

  1. 记录我的学习心得,而不是project 的答案,我自己在开始做这个 project 时最大的难点是有一种“有劲没处使”的感觉,即我不怕智力挑战,但是怕的是我连怎么开始这个挑战都不明白,所以墙裂建议大家一定要先去读源码、跟源码!
  2. (以我自己微薄的力量尽可能地)受人以渔,所以会记录我自己怎么读源码怎么调试 bug

二、写的很好的相关博客

https://blog.eleven.wiki/posts/cmu15-445-project3-query-execution/:这位博主对表结构做了很详细的分析,同时对于每种算子的实现也做了比详细的说明

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 的控制台输出中直接显示命令的结果。这个选项可以避免终端窗口的干扰。

image-20230608112446412

比如我要跟一个 insert 语句的执行过程:

先执行建表语句 create table t1(v1 int, v2 varchar(128), v3 int);

然后执行insert into t1 values (0, '?', 10);并开始跟踪,进行单步调试,函数调用顺序基本上如下(下图是一个知乎老哥整理的,我找不到出处了,等找到补充一下),可以看到经过paser、binder、 planner 和 optimizer 之后,输出的执行计划树被送到了执行引擎,引擎中最重要的方法就是Execute()方法。

bustab 结构

这里需要理解的一个关键是 plan node 究竟是什么?

我之前一直不理解 node 和 executor 的关系,在没有认真读源码之前,我一直觉得 node 和 executor 是一个东西,无非是执行计划树上的节点,但是现在我有了新的理解:node 确实是执行计划树上的节点,但是他不是具体的算法实现,而是记录了这条聚集语句的具体列的各种属性,比如:针对哪些列进行聚集?聚集的种类是什么?聚集的表达式树什么?node 实际上是为executor 准备数据,而 executor 负责具体实现,说来说去还是解耦那一套。

node作为计划树的一个节点,这个节点有两个成员变量:schema,用来指示这个 node 吐出的 tuple 是哪几列,children:用来指示 该 node 的子节点是哪些。把节点 paln_打印出来:下图可以看到根节点是 insertPlanNode,子节点是 ProjectionPlanNode,然后再子节点是 ValuesPlanNode。(这个 plan_ 其实就是根节点,但是其中包含了 children 的信息,可以根据这些信息推出整棵计划树的样貌,所以 plan_ 既可以理解为计划树的根节点,也可以理解为整棵计划树。)

image-20230607195410256

然后把执行计划树 plan_送到优化器中,optimizer输出的执行计划树是 optimized_plan:

image-20230607200223116

可以看到 ProjectionPlanNode 被优化了,只包含根节点 insertPlanNode和其子节点 ValuesPlanNode。

上图中 ValuesPlanNode 的 values_成员变量则分别存储了 (0, '?', 10) ,每列用一个 expression 来表示,所以可以根据 values_ 构造出要插入的 tuple

这个optimized_plan就是被送入执行器的最终计划。

一定要熟悉的一个编程模式是火山模型如何不停地从子节点 pull tuple,答案就是下面这句:

while(executor->Next(&tuple, &rid)) {
	//do anything you like
}

在每一个 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

 private:
  std::vector<std::vector<AbstractExpressionRef>> values_;

ValuesExecutor 的Next()方法注释如下 :其中 AbstractExpressionRef -> Value -> Tuple 的转化太美妙了,所以ValuesExecutor 的Next()的结果就是吐出一条tuple,就是 sql 语句中自己的写的那条要插入的数据。

auto ValuesExecutor::Next(Tuple *tuple, RID *rid) -> bool {
  if (cursor_ >= plan_->GetValues().size()) {
    return false;
  }

  std::vector<Value> values{};
  //预留空间
  values.reserve(GetOutputSchema().GetColumnCount());
  //如一条 sql 中一次插入 5 条数据,则cursor_取值为 0~4
  const auto &row_expr = plan_->GetValues()[cursor_];
  for (const auto &col : row_expr) {
    //AbstractExpressionRef -> Value
    values.push_back(col->Evaluate(nullptr, dummy_schema_));
  }
  //Value -> Tuple
  *tuple = Tuple(values, &GetOutputSchema());
  cursor_ += 1;

  return true;
}

了解了 ValuesExecutor 的 Next() 方法之后,再写 InsertExecutor 就有些感觉了:

  1. 拿到要插入的 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)

  2. 插入到表中,这就要拿出这张图了,图源:(https://blog.eleven.wiki/posts/cmu15-445-project3-query-execution/#insert--delete),所以插入表就要我们再看源码,table heap、table page 好像都可以有插入表的方法,仔细看一下就知道table heap 中有已经实现了的方法:InsertTuple()

    image-20230608164922416

  /**
   * Insert a tuple into the table. If the tuple is too large (>= page_size), return false.
   * @param tuple tuple to insert
   * @param[out] rid the rid of the inserted tuple
   * @param txn the transaction performing the insert
   * @return true iff the insert is successful
   */
  auto InsertTuple(const Tuple &tuple, RID *rid, Transaction *txn) -> bool;
  1. 更新索引,索引都存储在在 Catalog 中,而且 Catalog 已经实现了GetTableIndexes() 方法,所以拿到索引后直接使用InsertEntry()方法,就可以更新索引。
  2. 注意输出,要求 insertPlanNode 吐出一条 tuple,用来指示多少行被插入了,注意这个吐出的 tuple 和用户要插入的 tuple 不是一个东西,你要自己构造一个 tuple,把插入的行数(比如 5)塞入这个 tuple 中,这就需要认真阅读 Tuple 的构造函数,如何把 5 塞进 Tuple 中,所谓返回 tuple。

实现以上4 点,就实现了 task1 中的 InsertExecutor,后面的几个 task 也是这种套路,但是有了这种阅读源码的思路,接下来不会有劲没处使了,祝大家顺利通关!

五、记录一次实际 debug 的过程

debug 步骤:

  1. 使用测试文件中的 sql 语句复现 bug
  2. 精确跟踪到报错语句
  3. 阅读源码并思考

我在写完delete 之后执行相应的测试,发现有以下错误:

image-20230609153128416

复现 bug

可以看到上面的报错信息中给出了函数调用栈,所以可以很快确定错误位置在 DletePlanNode 中,于是首先要复现bug,先运行前置 sql 语句:

create table t1(v1 int, v2 varchar(128), v3 int);

insert into t1 values (0, '?', 10), (1, '??', 11), (2, '???', 12), (3, '????', 13), (4, '?????', 14);

select * from t1;

然后打断点(这里有一个小技巧:根据函数的调用栈打断点,比如根据上图,一次在 ExecuteSql、Execute、delete_executor::Init()等函数处依次打断点,一方面对于函数的执行流程更加了解,而且也可以迅速找到出错的位置),运行出错的语句:

delete from t1 where v1 >= 3

精确找到报错语句

单步跟踪后发现执行下面的语句就会报错,而且 idea 也在行的左边给出了错误提示感叹号,在下方的Debugger 显示界面可以看到是 table_oid_没有值导致的错误。
image-20230609153746667

阅读源码并思考

进一步查询源码,会发现 table_oid_DeletePlanNode的成员变量,给他赋值的语句应该是DeleteExecutor的构造函数,转到DeleteExecutor的构造函数:

image-20230609154047502

发现果然没有在初始化列表中给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 算子的实现效果,比如一张表如下:

| 姓名 | 年龄 | 年级 |
| ---- | ---- | ---- |
| 张三 | 10   | 一   |
| 李四 | 20   | 二   |
| 王二 | 30   | 三   |
| 赵五 | 40   | 一   |
| 刘丹 | 50   | 二   |

使用 sql 语句 agg:

select grade, sum(age) as sum_age from table group by grade;

得到输出结果:

+-----------+-----------+
|   grade   |  sum_age  |
+-----------+-----------+
| 一         | 50        |
| 二         | 70        |
| 三         | 30        | 
+-----------+-----------+

结果解释:

就是以 grade 种类分组,由于grade 只有{一、二、三}三种情况,所以 grade 为“一”有两条数据,sum_age就是 10+40 = 50,以此类推

agg 算子是如何实现的?

  1. Init() : 在 AggregationExecutor 的 Init() 函数中机上会构建一张 hash table,这张 hash table 构建完成后长这样:(hashmap 的内容就是最终的输出)
+-----------+-----------+
|  agg_key  | agg_value |
+-----------+-----------+
| 一         | {一,50}  |
| 二         | {二,70}  |
| 三         | {三,30}  | 
+-----------+-----------+

其中 key 就是 group by 列可能的所有情况,agg_value 就是表达式树进行聚集类型运算后的结果值,比如这条 sql 中有两颗表达树式:{grade, sum(age)}(因为AggregationExecutor的子节点是 SeqscanExecutor,所以SeqscanExecutor每吐一行新数据,AggregationExecutor->Init()就可以更新自己的 hash table,最终 Init()的结果就是构建一个完全体版本的 hash table)

  1. Next() : AggregationExecutor 的 Next() 函数中会维护一个 hash_table_iterator,每次吐一个 kv 对,返回给最上层 projecttionPlanNode,然后他会根据 schem 显示sql 的执行结果

2. NestedLoopJoin虽然逻辑简单,但是有坑

默认情况下,DBMS将对所有 join 操作使用NestedLoopJoinPlanNode(当然,要区分 inner join 和 left join)。NestedLoopJoin在 ppt 中也被称为 StupidNestedLoopJoin,逻辑非常简单,就是两层循环:拿着左表的 tuple 去遍历右表,找到了就返回。

易错点

  1. 双层循环结构中,左表需要遍历一次,右表需要遍历(左表条数)次,如果使用火山模型,直接写代码:

    while (left_child->Next(&left_tuple)){
        while (right_child->Next(&right_tuple)){
            if (left_tuple matches right_tuple){
                *tuple = ...;   // assemble left & right together
                return true;
            }
        }
    }
    return false;
    

    根据SeqScanExecutor,看当内层的 while (right_child->Next(&right_tuple))遍历完一次后,right_child->Next(&right_tuple)就会返回 false,所以右表只能遍历一次,为了解决这个问题,可以把右表数据保存到内存中(如vector),这样右表就可以遍历任意次数了。

  2. 如果左表的 left_tuple 已经匹配了右表的某一行:right_tuple,但是右表还没有扫描完成,NestedLoopJoinExecutor 吐出匹配的结果(left_tuple + right_tuple)后,下次调用 NestedLoopJoinExecutor 还要用这个左表行:left_tuple 接着扫描右表 right_tuple 的下一行(即:既不能取左表行的下一行,又不能将右表从头开始扫描)

    所以就需要有一个全局游标记录扫描到了右表的哪一行,如果这个游标指向右表开头,才可以取左表一行新的数据,否则就要用旧的左表的tuple继续扫描右表

所以在代码实现中,要加入两个新变量:

  1. 存储右表数据的 vector
  2. 存储右表遍历到何处的游标 index

3. NestedIndexJoin 的优化规则

如果查询包含等值条件的连接,且连接的右表具有该列的索引,则DBMS将使用NestedlndexJoinPlanNode

CREATE TABLE t1(v1 int, v2 int);
CREATE TABLE t2(v3 int, v4 int);
CREATE INDEX t2v3 on t2(v3);
EXPLAIN SELECT * FROM t1 INNER JOIN t2 ON v1 = v3; //包含等值、且右表 t2 的 等值列:v3 的索引

 === PLANNER ===                                                                                                                                       
 Projection { exprs=[#0.0, #0.1, #0.2, #0.3] } | (t1.v1:INTEGER, t1.v2:INTEGER, t2.v3:INTEGER, t2.v4:INTEGER)                                          
   NestedLoopJoin { type=Inner, predicate=(#0.0=#1.0) } | (t1.v1:INTEGER, t1.v2:INTEGER, t2.v3:INTEGER, t2.v4:INTEGER)                                 
     SeqScan { table=t1 } | (t1.v1:INTEGER, t1.v2:INTEGER)                                                                                             
     SeqScan { table=t2 } | (t2.v3:INTEGER, t2.v4:INTEGER)                                                                                             
 === OPTIMIZER ===                                                                                                                                     
 NestedIndexJoin { type=Inner, key_predicate=#0.0, index=t2v3, index_table=t2 } | (t1.v1:INTEGER, t1.v2:INTEGER, t2.v3:INTEGER, t2.v4:INTEGER)         
   SeqScan { table=t1 } | (t1.v1:INTEGER, t1.v2:INTEGER)   

NestedIndexJoin 执行过程:

左表 (inner/left) JOIN 右表 ON 左表.col1 = 右表.col3;

右表的 col3 有索引,流程全部在 NestedIndexJoinExecutor 的 Next() 函数中处理

  1. 首先拿到左表的 col1 的值,然后在右表的索引中寻找这个值
  2. 一般情况下,索引中可以找到这个值对应的 rid,然后回表,把左表的 tuple 和右表的 tuple 连接起来,吐出去,结束。(假设索引中没有相同的值)

4. 初窥优化器

在task3 中需要实现 topn,就是把 limit + sort 节点优化为一个 topn 节点,具体来看,未优化之前:

bustub> EXPLAIN SELECT * FROM __mock_table_1 ORDER BY colA LIMIT 10;                                                                                                   
 === PLANNER ===                                                                                      
 Limit { limit=10 } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER)                      
   Sort { order_bys=[(Default, #0.0)] } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER)  
     Projection { exprs=[#0.0, #0.1] } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER)   
       MockScan { table=__mock_table_1 } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER) 
 === OPTIMIZER ===                                                                                    
 Limit { limit=10 } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER)                      
   Sort { order_bys=[(Default, #0.0)] } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER)  
     MockScan { table=__mock_table_1 } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER)   

优化之后,相同的 sql:

bustub> EXPLAIN SELECT * FROM __mock_table_1 ORDER BY colA LIMIT 10;   
 === PLANNER ===                                                                                        
 Limit { limit=10 } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER)                        
   Sort { order_bys=[(Default, #0.0)] } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER)    
     Projection { exprs=[#0.0, #0.1] } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER)     
       MockScan { table=__mock_table_1 } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER)   
 === OPTIMIZER ===                                                                                      
 TopN { n=10, order_bys=[(Default, #0.0)]} | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER) 
   MockScan { table=__mock_table_1 } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER)    

可以看到 limit + sort 节点已经优化为一个 topn 节点

优化器的实现框架是很简单的,如下所示,把planner 输出的初始计划输入到优化器中,优化器会遍历所有的优化规则,命中哪一个规则,就执行相应的优化

auto Optimizer::OptimizeCustom(const AbstractPlanNodeRef &plan) -> AbstractPlanNodeRef {
  auto p = plan;
  //所有的优化规则
  p = OptimizeMergeProjection(p);
  p = OptimizeMergeFilterNLJ(p);
  p = OptimizeNLJAsIndexJoin(p);
  // p = OptimizeNLJAsHashJoin(p);  // Enable this rule after you have implemented hash join.
  p = OptimizeOrderByAsIndexScan(p);
  p = OptimizeSortLimitAsTopN(p);
  return p;
}

而且优化的执行过程也很有趣,比如我们的原始计划是树状: LimitPlanNode->SortPlanNode->SeqScanPlanNode,参考其他优化器的代码可以发现,优化器执行优化的逻辑是:使用递归后序遍历这棵树,遍历过程中,每次得到的子树都会去判断是否满足( LimitPlanNode->SortPlanNode)条件,满足则进行替换,最终得到 optimize_plan~

所以 topn 的有趣的地方不在于写 limit+sort 逻辑,而是在如何写优化器这里的替换逻辑,但只要记住:后序遍历原始计划树,然后判断子树是否满足优化条件就可以完成~

七、测试

提醒一下,大家写注释的时候 // 之后一定要跟空格,否则make check-lint会报错!

andy 终于大气了一次,给出了所有的测试用例,不用在 gradescope 上找报错信息了

make -j$(nproc) sqllogictest
./bin/bustub-sqllogictest ../test/sql/p3.00-primer.slt --verbose

将 p3.00-primer.slt 改成 /test/sql 文件夹下的其他测试文件,然后单独测试每个算子即可。

ps:虽然 p3 没有隐藏测试用例,但是线上测试会检查内存泄漏问题,被查到依旧无法通过线上测试,所以编码时要注意这一点,比如不要随意使用 new 来构造对象。

最后贴张通过的图吧,好歹纪念下哈哈~

image-20230614192727474