CMU15-445 Project3
这个project主要考验了阅读工程代码的能力。像这种大型工程代码,推荐的阅读顺序是:先看测试代码,大概能够了解这个项目会用到哪些部分,用了哪些类,然后分层次去看这些类的头文件,搞清楚每个类的作用,而我们看类重点还是先看这些类中的成员变量
接下来先梳理一下bustub数据库中的一些类:
Catalog
下去了解一下emplace和move和智能指针方面的知识,还有atomic
这个类主要是用来创建/得到table和index的,在创建一个catalog的时候,我们需要把底层的一些基础架构传入,例如bufferpool,lockmanager,logmanager等等。
那么在Catalog中就要管理table和index,这就又会涉及到其他类。如果我们在Catalog中要存管理的table和index,我们不去直接存实例化对象,而是存指向这些对象的指针。而且不是普通的指针,是智能指针!用unique_ptr来存,在构造的时候用移动语义,也能解决效率问题。
当然table和index中会有一些元数据,因此我们实际存的是tableinfo和indexinfo,即在真正的table和index上面加了一层。
在GetIndex和GetTable中有多种可行的查询方式,这里可以直接重载!而不是重新命名例如GetIndexById和GetIndexByName,这也是一种设计模式。
TableInfo
存这个table的schema,name,tableheap实例(用unique_ptr)和这个table的id。
IndexInfo
存的信息和TableInfo差不多,只是这里的schema变成了key_schema,专门为key设计。
Schema
Schema是在Catalog中的结构,用来存Column和这些Column组合的信息。注意这里的Column不是指具体的这一列上的值,而是值这一列的值应该具有的某些属性。
Schema类中有四个成员变量:length_
表示所有column总共占了多大空间。columns_
作为存column的vector,还有uninlined_columns_
作为存哪些column是uninlined的vector。
这里schema会存column,注意这两个都是元数据,不会保存真正的数据。真正的数据保存在tuple类中。
Column
存了关于这一列所存元素的元信息,例如列的名字,这一列元素的类型,这一列在tuple中的offset,还有一个对应的AbstractExpression,表示用这个表达式来生成了这一列。
几个设计点:
1.存元数据的这种类大部分的成员函数都是取一些信息啊之类的,尽量把成员函数的定义写在类里面。
2.涉及到STL的尽量用不要直接拷贝复制,Column类中,就算是String也是直接move初始化。
Table_heap
这个类就会接触到更低层的东西了,我们将table存在若干个page上,这些page用链表串联起来。table_heap主要负责查找我们要操作的page是哪一个以及进行什么操作,至于具体的操作就要留给page来实现。
在table_heap中,删除操作是markDelete的,也就是不会立即删除。
一个设计点:
因为可能涉及到遍历table_heap的操作,我们可以设计另外一个table_heap的迭代器类,而在table_heap中主要负责返回begin和end迭代器。
Table_iterator
因为iterator会有解引用操作,为了避免频繁进行磁盘IO,我们要将目前的tuple存下来。而且因为table_heap是以page的形式存储的,所以每次在++操作的时候要先看是否到了page的边界。
后置++运算符可以复用前置++运算符:
1 | auto TableIterator::operator++(int) -> TableIterator { |
Tuple
1.这个Tuple设计的很有意思。我们的Tuple分为临时tuple和永久tuple。所谓的临时tuple,指的是用完就会销毁的,常用于创建一个临时的tuple来进行返回、传参。在成员中用allocated_
来标记这个tuple是不是临时的,用于在拷贝、构造和析构时选择深拷贝还是浅拷贝、是否释放内存。
2.Tuple上是要存实际数据的,但是我们的数据可能有很多种,而且有的数据可能太大了在tuple上存不下。解决的方法是将所有的数据序列化后存到tuple上,即在tuple上定义一个char数组,对每种类型,在其内部定义序列化和反序列化函数。除此之外,还会有一些数据太大不能直接放到tuple上的,我们把这种数据成为uninlined,我们的做法是:在char数组的对应位置上(这个位置本来该存这个实际的数据)不再存放实际数据,而是存放这个实际数据的偏移,即我可以在哪个位置上找到这个数据。注意到char本身的特殊性(表示一个字节),可以用强制类型转换来做到这一点:
1 | *reinterpret_cast<uint32_t *>(data_ + col.GetOffset()) = offset; |
3.和schema以及column类联合起来,column中会存自身在tuple中的偏移,这也是面向对象设计的一个点,每个类存自身的信息。
Expression
用来执行运算的类,其中abstract_expression是一个抽象基类。计算表达式的时候将表达式组织成一棵表达式树。这里用:std::vector<const AbstractExpression *> children_
来存。所有的表达式的返回结果都是一个Value。
column_value_expression
这个运算主要用于求一个tuple的某个特定列或者是join的join key。成员有tuple_idx_
和col_idx_
,分别表示join的时候这个tuple在左边还是右边,以及我们要取的是哪一列。也就是说,我们在创建这个表达式的时候,我们其实就知道了要取哪个位置上的Value,这个位置并不是在表达式中计算出来的。
constant_value_expression
这个就是直接返回一个Value。已经设定好的。
comparison_expression
递归执行左右儿子的表达式,然后将结果进行比较,返回一个bool值。比较的类型和结果类型都封装在一个Type类中。也就是所谓的类型封装。这里的思想是:我们完全可以把比较的类型看做是常量,在程序中尽量不要直接用常量,而是用名字去表示常量,并且把这些常量都封装到一个类中。
aggregate_value_expression
和column_value_expression差不多,返回指定列上的值,适用于aggregate操作。这里不进行aggregate操作的原因是在对应的aggregate_executor中,已经用hash table计算好了。
Execution
查询计划的执行可以看成由两部分组成,一部分是plan,另一部分是executor。这两部分都组成树结构。
当我们要执行一个查询计划的时候,步骤如下:
1.外部代码传给plan对应的信息,plan包含了对应的executor执行必须要有的特定信息。
2.初始化executor_context,包含了执行的上下文信息,例如transaction,catalog,bufferpool和lockmanager等。
3.executor_engine开始执行,先调用对应的executor的init函数,然后不停调用对应executor的next函数,如果返回结果是true,就把绑定参数的tuple和rid给存到结果中能够,否则结束操作。
接下来到我们的任务了,实现9个executor:
seq_scan_executor
每个executor要获得table,index等要操作的对象的信息(如果需要的话)。seq_scan_executor将从一个表中遍历,并按照自己指定的schema的形式输出tuple,因此我们要先对照output_schema来存最后的结果中每一列对应着原来schema中的哪一列。如果有predicate的话还要先检验一下是否满足条件。
1 | SeqScanExecutor::SeqScanExecutor(ExecutorContext *exec_ctx, const SeqScanPlanNode *plan) |
注意区分Init和构造函数的作用。因为一个表可能会多次扫描,所以Init是用来进行重置状态的,而构造函数才是用来进行初始化的。
insert_executor
insert只有可能出现在executor树的最上层,并且不会改变result vector,所以它应该一次性执行完所有的插入操作,并且只会返回false。
主要是要注意index的插入,这里的tuple是要从原来的tuple中抽一些key value出来组成key_tuple,注意函数参数名的区别。
1 | InsertExecutor::InsertExecutor(ExecutorContext *exec_ctx, const InsertPlanNode *plan, |
delete_executor
和insert差不多,就是这里的删除操作并不是真正的删除,而是markdelete,只有当事务提交后才会被真正地删除掉。
1 | DeleteExecutor::DeleteExecutor(ExecutorContext *exec_ctx, const DeletePlanNode *plan, |
update_executor
和insert以及delete差不多,这里需要注意的一点是:因为我们每次从子节点next一个tuple上来,底层是用迭代器实现的,而在迭代器迭代的时候我们是不能够对一个表进行增删操作的。因此一开始删除我们要把这些tuple存下来,改完后一起插入。
1 | UpdateExecutor::UpdateExecutor(ExecutorContext *exec_ctx, const UpdatePlanNode *plan, |
nested_loop_join
注意这里nested_loop_join是可以实现流水线化的!我们保留outer tuple的状态,每次inner获取一个tuple。先判断能不能join,如果能join的话需要重组成一个新的tuple来输出。依据output_schema中每一column的tuple_id和col_id来决定每个value的位置。
1 | NestedLoopJoinExecutor::NestedLoopJoinExecutor(ExecutorContext *exec_ctx, const NestedLoopJoinPlanNode *plan, |
hash_join_executor
选用unordered_map来作为hash表,这里与map不同的是,前者是hashmap,后者是treemap,因此如果要让一种类作为key,必须要给这个类重载==运算符和定义hash函数。我们把hash_key(实质上是一个Value)封装成一个类。
1 | namespace bustub { |
必须要在一开始的bustub作用域中定义hashjoinkey类,然后在std作用域中定义偏特化hash类。这里偏特化的意思是指只有bustub::HashJoinKey作为模板参数时,这个类才能被创建。在其中定义调用运算符,返回一个hash值。
接着在后面的bustub作用域中正常实现HashJoinExecutor的定义。顺序是:bustub–std–bustub。而不能是bustub–std,即将std放在最后。因为我们会在HashJoinExecutor中定义一个unordered_map,它会去查找hash类的实现,因此我们要把hash类的定义放在unorderde_map的定义前面。
hash_join是pipeline blocker,我们在init阶段为outer table构建hash table,每次next我们会定位到一个hash table中的一个条目中,这个条目中可能会有多个tuple,因此我们的实现中会保存指向这个条目的指针和一个此条目的迭代器。
1 | HashJoinExecutor::HashJoinExecutor(ExecutorContext *exec_ctx, const HashJoinPlanNode *plan, |
aggregation_executor
和hash_join_executor差不多,当我们把一个元素插入到hash_table的时候,hash_table的条目维护的aggregation的结果,每当我要往这个条目中插入一个新的元素的时候,就会去更新这个条目的信息。
aggregation可能会有having语句,这里的having语句是先根据group_by划分出组后,再来判断每组是否满足条件。
而aggregation是可以pipeline的,因此先把hash_table建好,保存好迭代器信息。
1 | AggregationExecutor::AggregationExecutor(ExecutorContext *exec_ctx, const AggregationPlanNode *plan, |