本系列文章将以倒序的顺序回顾自己在写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除外)。
QUERY PROCESSING
本节将结合代码的断点调试,理一下SQL在BusTub中的执行流程。这里仅涉及Query层的讨论,专注于P3的内容。
在Clion中,为了能够对SQL语句的执行进行断点,需要对执行入口shell.cpp
添加参数--disable-tty
:
之后,就可以对SQL的执行进行单点调试了。我们以select * from test_simple_seq_2;
语句为例,结合下图,一步步分析执行流程。
SQL在BusTub中的执行流程如图所示,主要关注Query层的五个组件:Parser、Binder、Planner、Optimizer以及Executors。shell启动后,会在shell.cpp
的line99执行SQL。
-
Parser:解析。从shell的line99行进来后调用Binder的
ParseAndSave
函数进行sql的解析。Parser有点像编译,就是把sql语句解析为一个抽象语法树AST。这个不是数据库的重点,根据官方助教Chi写的文章(见参考),BusTub用的也是第三方库。
ParseAndSave函数 -
Binder: 将AST中的词语绑定到数据库对应的实体上,例如哪张表、哪个tuple等等,生成一个Binder AST。
-
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_);
- Optimizer: 这一部分就是对Planner生成的计划进行优化,生成优化后的plan,如上代码所示。
- Executors:最后就是Executor对至第四步的planner进行执行。如下代码所示,我们进入这个函数。
is_successful &= execution_engine_->Execute(optimized_plan, &result_set, txn, exec_ctx.get());
可以看到,这一步会根据plan生成对应的Executor,这里我们生成的自然是SeqScanExExecutor
。生成后对算子进行初始化,然后调用PollExecutor
以执行算子的Next
函数返回需要的tuples。这里的Init
与Next
就是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 id
与table 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:我们使用Catelog
的GetTableIndexes
得到当前table的所有的索引。对每个索引,使用Handout中提到的dynamic_cast<BPlusTreeIndexForTwoIntegerColumn *>
转换为P2中实现的B+Tree索引b_index
,之后调用InsertEntry
来进行索引的插入。
InsertEntry
的参数有点多,这里简单说一下:
key
:使用tuple的KeyFromTuple
函数构建,参数分别为table的schema、index的schema(转换前的index)以及index的Attributes。rid
:Insert返回的new rid。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_
即为v1
、v2
,agg_types_
即为min
、sum
。可见,后两者的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值构建。两者的构建函数均已为我们封装好。第二个与AggregationPlanNode
中aggregates_
相同,表示被aggregate的columns。第三个成员与AggregationPlanNode
中agg_types_
相同,表示aggregate的类型。
CombineAggregateValues
是我们在这个类里唯一需要实现的函数。给定一个agg_key
,每次向map中插入一个value
时,都会调用这个函数更新map[agg_key]
的值。我们需要遍历agg_types_
,并根据不同的type进行不同的操作。例如对于sum,我们需要取两者之和,对于min,取两者较小值等。这里需要注意count star
与 count
的区别:前者就计数NULL,后者不会。此外,对null值的处理需要特别注意理清一下,我自己在实现的时候这个地方出现了不少bug。
其他两个函数GenerateInitialAggregateValue
和InsertCombine
的逻辑非常清晰,这里就不过多介绍了。最后记得在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
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
,且Predicate
为And
类型的LogicExpression
或Equal
类型的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进行push
和pop
。
小坑
- 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 - 十一的文章