CMU15445 数据库项目3 Executor & Optimizer

复杂任务的分解是一项技术活


这次 Project3,没想到那么多天才做完。倒也不是说花了那么多时间,而是在找时间写(什么鬼课程安排,早上一节课下午一节课),中间也因为面试而停下一段时间。不过总算是通过了。

image

Project3 的难度和 Project1 比较接近,但是任务量大了很多,并且对不知道怎么阅读代码的人极度不友好——它需要你会用调试工具,边调试,边理解,边写代码,边修bug。

根据 Andy 大佬的要求,我不能够在此公开任何源代码,所以只能够写一些思路,以及自己踩到的坑。

基本执行过程

一句 sql 的执行过程,在 Project3 的 Specification 中已经给明了:

Sql process

传递给 Executor 的参数,是一个执行计划(Planner),它是一棵树的根指针,AbstractPlanNode,所以你使用 Explain 来解释 Sql 的时候,会看到这些东西:

bustub> 
... explain select * from
...     __mock_table_tas_2023_fall inner join __mock_table_schedule_2023
...     on office_hour = day_of_week;

......

=== PLANNER ===
Projection { exprs=["#0.0", "#0.1", "#0.2", "#0.3"] }
  NestedLoopJoin { type=Inner, predicate=(#0.1=#1.0) }
    MockScan { table=__mock_table_tas_2023_fall }
    MockScan { table=__mock_table_schedule_2023 }
=== OPTIMIZER ===
HashJoin { type=Inner, left_key=["#0.1"], right_key=["#1.0"] }
  MockScan { table=__mock_table_tas_2023_fall }
  MockScan { table=__mock_table_schedule_2023 }

那么怎么根据这个树来执行的?一般是由叶节点,向其父节点发送数据(Tuple,这是一个二进制数据,里面有数据长度信息,但是没有类型信息,存储的是数据库中的一行数据),父节点根据子节点的 Schema(真正存储了类型信息的),来解析 Tuple,反向解析为 vector<Value>,之后进行计算。

上述的例子,MockScan 读取到一行后,以 Tuple 的形式发送给 NestedLoopJoin,它再进行 Join。

其中的编号,比如predicate=(#0.1=#1.0)中的#0.1#1.0,其含义分别是左表的第 1 列,右表的第 0 列。下面是我写的一段注释,本质上左右两个表的行,只是简单地拼接到一起了。你读了 InferJoinSchema,用 GDB 确定了会调用它,你就明白为什么是这样的了:

  // In plan_node.h:
  // auto NestedLoopJoinPlanNode::InferJoinSchema(const AbstractPlanNode &left,
  //                                              const AbstractPlanNode &right)
  //                                              -> Schema;
  // Go through the codes, it just connect left and right schema together.

自动机警告

大一的时候,学校开了自动机的课程,这是整个大学里唯一有用的课程。很多同学以为它没用。这里就用上它了,在 NestedLoopJoinExecutor,HashJoinExecutor 中,本来挺好写的两层循环,你要改写为迭代器形式。也就是说类似于这样的代码:

result = []
for i in left_tuple:
    for j in right_tuple:
        if predicate(i, j) == True:
            result.append((i, j))

你要改写成迭代器,每一次获得一个 result 中的结果。

# 想什么嘞?怎么可能放 C++ 代码
def find_matched_right(Tuple left):
    while right = rexec->next():
        if predicate(left, right):
            return True, connect(left, right)
    return False, None

left = None

def next():
    while True:
        if 左表遍历完成:
            return False, None
        elif 右表已经遍历完成:
            left = lexec->next()
            rexec->init()
        found, result = find_matched_right(left)
        if found == False:
            continue
        else: return True, result

如何表示左右表遍历的情况?直接上自动机吧。实际上我在实现中,next 调用一个函数,它在去调用前文说的 find_matched_right。

为什么要改写成迭代器形式?如果是双层循环,那么这个 Executor 将会成为一个 Pipeline breaker,必须等他执行完成之后,其父节点才可以执行,那万一父节点也是个 Pipeline breaker?这样做,节省内存,还避免了 Pipeline breaker,不用阻塞执行流程。

这种自动机改写两层循环,我还真是第一次写,有机会写个改写的通式来。

Update

本以为 UpdateExecutor 是直接替换原来的 Tuple 即可,我也看到了一个这样的函数,以为确实可以这么做:

  /**
   * Update a tuple in place. Should NOT be used in project 3. Implement your project 3 update executor as delete and
   * insert. You will need to use this function in project 4.
   * @param meta new tuple meta
   * @param tuple  new tuple
   * @param rid the rid of the tuple to be updated
   * @param check the check to run before actually update.
   */
  auto UpdateTupleInPlace(const TupleMeta &meta, const Tuple &tuple, RID rid,
                          std::function<bool(const TupleMeta &meta,
                                             const Tuple &table,
                                             RID rid)> &&check = nullptr) -> bool;

当我测试不通过的时候才发现那句Should NOT be used in project 3。所以正确的做法是标记为删除,然后新插入。为什么这么做?就是为了后面的事务管理。

rank() of Window functions

window function 的功能和 group by 相似,但是也有区别,特别是 rank()。一般用rank() over (order by v1),输出结果,如果 order by 指定的值(这里是 v1)不变,输出与上一个输出相同的值。否则从输出的总行数开始输出。也就是说:

select v1, rank() over (order by v1) from t1;
----
-99999 1
0 2
1 3
1 3
2 5
3 6
3 6
99999 8

而不是

select v1, rank() over (order by v1) from t1;
----
-99999 1
0 2
1 3
1 3
2 4
3 5
3 5
99999 8

我的想法是,反正有 order by,那就在聚合的时候,对 rank() 的记录改写为(该聚合键上一次选中的 tuple id,上一次输出的 rank 值。如果不同,就输出该 tuple,以及输出的总 tuple 数量,并更新聚合信息。

Optimizer 的二叉树花式遍历

以前听说过:程序猿每天喜欢在两棵树上跳来跳去,一颗是二叉树,另一棵,还是二叉树。

直到写 Optimizer 的时候我才明白为什么,因为这就是在花式遍历二叉树。

遍历的是表达式树,用 AbstractExpression 指针代表根节点。遍历的时候统计信息,来进行一些优化。比如:

  1. 是否有 or 表达式
  2. 是否有 not 表达式
  3. 有哪些 and 表达式
  4. 有哪些 = 表达式
  5. 哪些表达式可以让子 Executor 处理
  6. 有哪些地方可能可以用索引

没有 or 和 not,意味着你可以放心地提取所有的比较表达式,<=, >, !=之类的表达式你提取成 vector,可以方便地构建一个新的表达式。第 0 个和第 1 个组成一个 and 表达式,然后该 and 表达式和第 2 个组成 and 表达式...

测试用例中有一个对三张 1000k 行 join 的 sql,默认用 NestedLoopJoin,但是如果过滤条件是 =,且是 and 表达式,那么你的执行时间可以优化到 6 分钟。也就是说:

select count(*), max(__mock_t4_1m.x), max(__mock_t4_1m.y), max(__mock_t5_1m.x), max(__mock_t5_1m.y), max(__mock_t6_1m.x), max(__mock_t6_1m.y)
    from __mock_t4_1m, __mock_t5_1m, __mock_t6_1m
        where (__mock_t4_1m.x = __mock_t5_1m.x)
            and (__mock_t6_1m.y = __mock_t5_1m.y)
            and (__mock_t4_1m.y >= 1000000)
            and (__mock_t4_1m.y < 1500000)
            and (__mock_t6_1m.x < 150000)
            and (__mock_t6_1m.x >= 100000);

改写为:

select count(*), max(__mock_t4_1m.x), max(__mock_t4_1m.y), max(__mock_t5_1m.x), max(__mock_t5_1m.y), max(__mock_t6_1m.x), max(__mock_t6_1m.y)
    from (
              select * from __mock_t4_1m, __mock_t5_1m on (
                  __mock_t4_1m.x = __mock_t5_1m.x
              ) where (__mock_t4_1m.y >= 1000000) and (__mock_t4_1m.y < 1500000)
         ), __mock_t6_1m on (__mock_t6_1m.y = __mock_t5_1m.y)
    where (__mock_t6_1m.x < 150000) and (__mock_t6_1m.x >= 100000);

所以节点为:

Agg { types=["count_star", "max", "max", "max", "max", "max", "max"], aggregates=["1", "#0.0", "#0.1", "#0.2", "#0.3", "#0.4", "#0.5"], group_by=[] }
  Filter { predicate=((#0.4<150000)and(#0.4>=100000)) }
    HashJoin { type=Inner, left_key=["#0.3"], right_key=["#1.1"] }
      Filter { predicate=((#0.1>=1000000)and(#0.1<1500000)) }
        HashJoin { type=Inner, left_key=["#0.0"], right_key=["#1.0"] }
          MockScan { table=__mock_t4_1m }
          MockScan { table=__mock_t5_1m }
      MockScan { table=__mock_t6_1m }

因为整个 Sql optimize rule 有应用顺序的。Filter 会尝试和 NestedLoopJoin 合并,所以你写完了优化函数后,顺序还得考虑。不过这个是小问题了。