CMU15-445 Project3 Query Execution详解

本系列文章将以倒序的顺序回顾自己在写Spring23的Projects时的主要思路以及遇到的问题,往期文章:

CMU15-445 Spring2023 Project4 Concurrency Control个人详解 - 嘿喂GO!

前言

相比于前两个Projects,Project 3侧重点略有不同:前两个Projects的重点更多地倾向于数据库的某一个局部结构(内存、索引),而Project 3则更加侧重于全局——要求我们实现具体的sql语句的执行:完成Project 3后,在终端启动BusTub将能够顺利运行基本的sql语句。

尽管Project 3要求实现的是sql语句的执行,但课程组已经为我们搭好了大部分“脚手架”,我们要实现的仅仅是不同的算子。也因此,Project 3要实现的内容相对而言会“碎”一些,内容之间的关系更像一种并列而非递进的关系。在开始Project 3之前,建议观看Lecture10-14的视频,所有用到的算法课上都有提到。此外,在编写的过程中,可以参考官方提供的在线工具BusTub Web Shell,跟自己实现的shell对比以debug(个人实现过程中用得不多,基本都根据测试文件debug了。)

开始Project 3前,我们不用上来就急着写代码。相反,这个Project的重点更多地在于看代码——对BusTub的代码结构、执行逻辑了解得越透彻,那么实际写起来会越顺手。

再次强调,不要公开自己的作业代码,尊重Mr.Pavlo与助教们的劳动成果~本文也仅会展示主要思路,不会展示个人代码(课程组Starter Code除外)。

课程主页

Project 3 主页

BusTub-官方Github

讨论区

BusTub Web Shell

QUERY PROCESSING

本节将结合代码的断点调试,理一下SQL在BusTub中的执行流程。这里仅涉及Query层的讨论,专注于P3的内容。

在Clion中,为了能够对SQL语句的执行进行断点,需要对执行入口shell.cpp添加参数--disable-tty:
在这里插入图片描述
之后,就可以对SQL的执行进行单点调试了。我们以select * from test_simple_seq_2;语句为例,结合下图,一步步分析执行流程。
在这里插入图片描述

图片来源: CMU15-445 P3 Handout

SQL在BusTub中的执行流程如图所示,主要关注Query层的五个组件:Parser、Binder、Planner、Optimizer以及Executors。shell启动后,会在shell.cpp的line99执行SQL。

  1. Parser:解析。从shell的line99行进来后调用Binder的ParseAndSave函数进行sql的解析。Parser有点像编译,就是把sql语句解析为一个抽象语法树AST。这个不是数据库的重点,根据官方助教Chi写的文章(见参考),BusTub用的也是第三方库。
    在这里插入图片描述

    ParseAndSave函数

  2. Binder: 将AST中的词语绑定到数据库对应的实体上,例如哪张表、哪个tuple等等,生成一个Binder AST。

  3. Planner: 如下代码所示,Planner会对Binder生成的树生成初步的查询计划。这一步会生成一棵plan tree,每个节点我们称之为plan node,data的流向自底向上。

Next, the binder AST is processed by the planner, which will produce an appropriate query plan. In this case, the query plan is a tree of two nodes, with data flowing from the leaves to the root

// Optimize the query.
bustub::Optimizer optimizer(*catalog_, IsForceStarterRule());
auto optimized_plan = optimizer.Optimize(planner.plan_);
  1. Optimizer: 这一部分就是对Planner生成的计划进行优化,生成优化后的plan,如上代码所示。
  2. Executors:最后就是Executor对至第四步的planner进行执行。如下代码所示,我们进入这个函数。
is_successful &= execution_engine_->Execute(optimized_plan, &result_set, txn, exec_ctx.get());

在这里插入图片描述
可以看到,这一步会根据plan生成对应的Executor,这里我们生成的自然是SeqScanExExecutor。生成后对算子进行初始化,然后调用PollExecutor以执行算子的Next函数返回需要的tuples。这里的InitNext就是Project 3里我们需要实现的主要内容。
执行完成后,会根据是否执行成功以及对应的Schema进行结果的输出。

一些需要了解的内容

  • 火山模型:
    注意这里的火山模型不是字节那个火山模型😂。简单说,火山模型是DBMS执行SQL的其中一种,每次执行时,每个算子会使用Next来返回一个tuple,而上层则使用一个循环来调用算子。具体可见下图的解释。
    在这里插入图片描述

    图片来源: CMU15-445 Slide

  • Schema:我刚开始做的时候有点不理解schema的意思,其实结合Handout和代码就很好理解:可以简单地将Schema理解为Table的列名。某个Executor的OutPutSchema就代表了输出的tuple包含了原始tuple中的哪几列。

  • Evaluate: 我们在实现时,经常会用到这个函数。这个函数的作用就是根据表达式expression的类型以及输入的tuple、对应的schema获得对应的value。这么说可能有点抽象,举个例子:

create table t1(v1 int, v2 varchar(128), v3 int);
insert into t1 values (0, '🥰', 10), (1, '🥰🥰', 11);
select * from t1 where v1 = 1;

以上第三句SQL进行断点,可以看到where后面的语句由两个表达式构成(这里使用P4中已实现的优化算子举例,仅做理解使用):
在这里插入图片描述
可以看到,Filter包含了两个expressions,前者为ColumnValueExpression,后者为ConstantValueExpression。一个代表v1,一个代表常数1。以Column为例,这里column_exp->Evalue(tuple, schema)的作用就是输入tuple(1, '🥰🥰', 11) 以及schemav1,返回value 1。如果schema为v2,那么返回的即为'🥰🥰'

  • 注意char类型的数据要使用单引号括起来。
  • 强烈建议阅读Handout中提到的三个Sample executors源码,对后面的实现有帮助。
  • 关于BusTub内部数据的存储结构:
    每个Executor都会提供一个ExecutorContext,里面包含所有我们需要的信息,其中的Catalog存放了一张table idtable Info的map。后者包含一个table的name、id、schema以及一个指向TableHeap的指针。TableHeap中以双向链表的形式存储着TablePage,而TablePage则存储着具体的tuples。更详细的介绍可以看十一大佬的文章(见参考),写得非常好。

测试

Project 3的测试用例与线上完全一致,每写完一个算子,就会有对应的test可以运行。注意p3.13-nested-index-join.slt是不需要测试的,使用sqllogictest.cpp进行调试时,需要与前文的shell.cpp一样配置参数(文件名+--verbose)。

Project 3 QUERY PROCESSING

好了,了解了以上信息,下面我们可以开始正式实现Executor。

Task1

SeqScan

SeqScan的思路比较朴素,在Init里通过TableHeap拿到迭代器,并在Next里迭代。每次只返回一个,当迭代到End时返回false即可。注意当前遍历的tuple是否已被deleted。
这里可以把对deleted的判断放到循环里面,防止违反P4中的原则二。(详见P4总结的Task3部分以及Bug第二点)

Insert

Insert要求我们返回的是插入的tuple个数,所以在Next里面,我们要遍历完child executor的所有tuples后再返回。Init里面则只需要对child executor进行init即可。

每遍历一个child executor的tuple,我们都要调用TableHeap的InsertTuple来进行tuple的插入,该函数会返回一个new rid。

Insert中比较重要的是更新index:我们使用CatelogGetTableIndexes得到当前table的所有的索引。对每个索引,使用Handout中提到的dynamic_cast<BPlusTreeIndexForTwoIntegerColumn *>转换为P2中实现的B+Tree索引b_index,之后调用InsertEntry来进行索引的插入。

InsertEntry的参数有点多,这里简单说一下:

  1. key:使用tuple的KeyFromTuple函数构建,参数分别为table的schema、index的schema(转换前的index)以及index的Attributes。
  2. rid:Insert返回的new rid。
  3. transaction:使用Catalog获取。

最后我们构建一个Integer类型的Value至vector中,并由此构建一个tuple返回。可参考sample executors中的写法。

注意这里插入0个tuple也需要返回0,我实现时没有想到好的方法,最终只能加了一个bool成员来判断,不是很优雅。

Delete

讲Update之前,我先讲一下Delete。Delete基本与Insert相同,区别在于Delete需要新建一个is_deleted_为true的TupleMeta,并调用TableHeap的UpdateTupleMeta更新。同样记得调用对应的函数删除索引。

Update

Update的逻辑是先Delete再Insert。相当于以上两者的逻辑结合,索引同理。

IndexScan

这一个Executor可以说是P3里代码量最少的一个了。只需调用P2中实现的迭代器接口即可。

完成Task1后,我们将可以通过sqltest的1至6。一定通过Task1的所有Tests后再进行Task2的实现。

Task 2

Aggregation

个人感觉Aggregation算子是P3中最为复杂的一个,需要改动的代码也是最多的一个。其总体思路是哈希表。使用group by的schema作为hash的key,对于child executor的每一个input,在map中insert对应的key/value,value为tuple在某几列(aggregates_,后文会提到)对应的值,调用Evaluate获得。这里不考虑partition hash,即默认整张map可以放在内存Buffer Pool中。

首先,Handout里提到Aggregation是pipeline breaker。这是因为如果还是按照Task1中的逻辑设计Aggregation的话,我们可能在Next时无法得到我们需要遍历的所有数据。例如,sql语句select count(*)from t1; ,要得到count(*),显然我们要遍历所有的tuples才行。因此,我们需要在Init里就遍历所有的tuples,并记录相应的aggregate values。

我们先来过一遍AggregationPlanNode

/** The GROUP BY expressions */
std::vector<AbstractExpressionRef> keys_;
/** The aggregation expressions */
std::vector<AbstractExpressionRef> aggregates_;
/** The aggregation types */
std::vector<AggregationType> agg_types_;

有三个成员,第一个为group by的columns(可以为空),第二个为被aggregate的columns,第三个为aggregate的类型。举个例子:select min(v1), sum(v2)from t1 group by v5;
那么,keys_即为v5,aggregates_即为v1v2agg_types_即为minsum。可见,后两者的size对应。

之后,我们再来过一遍aggregation_executor.h里的SimpleAggregationHashTable

/** The hash table is just a map from aggregate keys to aggregate values */
std::unordered_map<AggregateKey, AggregateValue> ht_{};
/** The aggregate expressions that we have */
const std::vector<AbstractExpressionRef> &agg_exprs_;
/** The types of aggregations that we have */
const std::vector<AggregationType> &agg_types_;

包含三个成员,第一个为一个map,Key由tuple对应group by columns的值构建,Value则由tuple对应aggregates_的columns值构建。两者的构建函数均已为我们封装好。第二个与AggregationPlanNodeaggregates_相同,表示被aggregate的columns。第三个成员与AggregationPlanNodeagg_types_相同,表示aggregate的类型。

CombineAggregateValues是我们在这个类里唯一需要实现的函数。给定一个agg_key,每次向map中插入一个value时,都会调用这个函数更新map[agg_key]的值。我们需要遍历agg_types_,并根据不同的type进行不同的操作。例如对于sum,我们需要取两者之和,对于min,取两者较小值等。这里需要注意count starcount的区别:前者就计数NULL,后者不会。此外,对null值的处理需要特别注意理清一下,我自己在实现的时候这个地方出现了不少bug。

其他两个函数GenerateInitialAggregateValueInsertCombine的逻辑非常清晰,这里就不过多介绍了。最后记得在AggregationExecutor.h里加SimpleAggregationHashTable以及对应的迭代器成员。

理清以上代码,AggregationExecutor.cpp就很清晰了:在Init里面我们遍历child_executor的所有tuple以构建map,在Next里使用迭代器取出map的key和value,将key和value拼接至同一个Value类型里输出为tuple即可。

对于empty table,若Key为空,即没有group by时,需要输出default值;否则,不需要输出。这一点是根据test case得出来的,也算是一个隐藏的坑点。

NestedLoopJoin

在这里插入图片描述

图片来源: CMU15-445 Slide

NestedLoopJoin的思路很简单,就是一个二重循环。但实际实现过程中,我们需要保留上下文。考虑这样的情况,Next中遍历到一个满足条件的left tuple(上图中的r)与right tuple(上图中的s)后,如果不进行任何额外的处理,下次Next再被调用时,将重新从头开始遍历。

那么,我们应该保留那些上下文呢?

首先,肯定需要保留上次Next后left table和right table分别遍历到了哪个tuple,所以需要两个成员分别用于指向left tuple以及right tuple的位置,在Init中初始化,并且注意及时重置。其次,由于right table中的tuple会被遍历多次(具体为left tuple的个数),单纯使用right_child_executor的话需要频繁初始化,所以可以使用一个vector存储right tuples,同样在Init中初始化。(事实上,在Spring23的测试用例中,PerformChecks会强制要求每次right table遍历完后right child Init一下,所以这里不用vector也无妨。这也是为什么NestedLoopJoin没有被划入pipeline breaker的原因。)

之后需要注意的就是如何处理两个tuple是否满足predicate的情况了,可以参考Filter算子。简单说就是根据predicate里的expression,调用EvaluateJoin,将左右两个tuple及其对应的schema输入,判断两个tuple是否满足join的条件。

最后记得处理某个child 为空的情况以及Left Join和 Inner Join的区别,前者对于不满足的right table补null(ValueFactory::GetNullValueByType),后者取交集。

HashJoin

与Aggregation一样,HashJoin也是一个pipeline breaker。因此,在Init里我们就需要构建好对应的map。

这里的map我们可以参考Aggregation里的SimpleAggregationHashTable写,并且实际实现上会比SimpleAggregationHashTable更简单:我们不需要写迭代器,不需要写value的初始化与aggregate。此外,为了解决hash冲突问题,这里map的value我定义为了Tuple类型的vector

这里我自己在实现的时候稍微卡了一阵:namespace的顺序老是出问题。unordered_map对于自定义的类,需要封装==和hash,而这两者分别属于两个namespace:前者为bustub,后者为std。同时要保证使用map时hash已封装,而hash又需要保证==已封装,所以最终的实现是两个bustub中间夹着一个std

实现map后记得添加构造key的成员函数。由于要支持Left Join,所以我在Init时对right table构建了hash table,这样在Next里当left tuple无法命中map时,可以直接补null。

Optimizing NestedLoopJoin to HashJoin

实现HashJoin后我们无法进行测试,需要实现了nlj as hashjoin优化器后才会生成对应的HashJoin Plan Node。这里先简单介绍一下优化器是如何优化plan的。

auto Optimizer::OptimizeCustom(const AbstractPlanNodeRef &plan) -> AbstractPlanNodeRef {
  auto p = plan;
  p = OptimizeMergeProjection(p);
  p = OptimizeMergeFilterNLJ(p);
  p = OptimizeNLJAsHashJoin(p);
  p = OptimizeOrderByAsIndexScan(p);
  p = OptimizeSortLimitAsTopN(p);
  p = OptimizeMergeFilterScan(p);
  return p;
}

如上代码所示,在生成原始的plan后,BusTub会将其一一传入特定的优化器,如果命中,则会根据优化规则优化。这里的命中指是否满足能过优化的结构,例如对于NestedLoopJoin to HashJoin,其命中规则即为当前plan为NestedLoopJoin,且PredicateAnd类型的LogicExpressionEqual类型的ComparisonExpression

初上手时我们可以参考已有的优化器代码。注意NestedLoopJoin to HashJoin需要处理两个通过And连接的equi-condition,即上文的And类型的LogicExpression。此外,在expr的等号两侧与两个table并不是严格对应的,也就是说,等号右边的column可能对应left table。因此,我们需要调用GetTupleIdx来判断对应的tuple属于哪个table,0属于left,1属于right。

Task 3

Task 3相对而言朴素一些,这里就放在一起讲了。对于sort,我们直接在Init里调用std::sort进行排序即可。这里需要根据order_bys_自定义一下排序函数。对于Limit,加一个count即可。对于TopN,维护了一个堆,根据order_bys_以及N进行pushpop

小坑

  • Aggregation Executor中,对于empty 的table,当group by为空时,需要输出default值;否则,不需要输出。(上文提到)
  • 由于GradeScope上的评测代码用的是23年6月22号前的版本,所以如果拉取的原始代码为23年6月22号后的版本,建议不要使用plan中的GetTableOid函数。

个人BUG

本节记录一下个人代码实现中的bug,可直接跳过。

  • nlj第一次写的时候直接写成笛卡尔积了,哭笑不得🤣。
  • nlj中的右指针要及时重置,否则会陷入死循环。
  • Join相关的executor,需要考虑空表的情况。

后记

总体而言,Project 3由于给了所有的测试用例,所以提交后很快就满分了。不过由于Project 3中的每个模块思路不尽相同,不向P1、P2那样“一脉相承”,需要频频转换思路,所以P3做下来并没有感觉非常轻松。但无论如何,收获依旧是巨大的。

此外,自P2开始,自己debug的时候都会遵循一个“最小复现”原则,即出现bug的时候先根据测试用例构建一个新的更为简单的、可复现bug的测试用例,这样debug起来事半功倍。

这篇文章写了有将近六个小时,如果能够帮助到你,那么这篇文章就写得值了~
在这里插入图片描述
如果行文中有错的地方或者不理解的地方,欢迎讨论~

参考

BusTub 养成记:从课程项目到 SQL 数据库 - 迟策
做个数据库:2022 CMU15-445 Project3 Query Execution - 十一的文章

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值