CMU_15445_P3_Part1

CMU_15445_P3_Part1

这部分主要是实现一些基本的 Plan_Node 的 Executor, 我们可以首先通过一个列子来看, 就是 Projection Plan_Node 的例子.

我们使用一个简单的 EXPLAIN 的例子引入 PlanNode 的执行流程:

Copy
-- 在控制台使用 EXPLAIN 解释下面的语句, 结果输出如下 EXPLAIN SELECT * FROM __mock_table_1 WHERE colA > 1; === BINDER === BoundSelect { table=BoundBaseTableRef { table=__mock_table_1, oid=0 }, columns=["__mock_table_1.colA", "__mock_table_1.colB"], groupBy=[], having=, where=(__mock_table_1.colA>1), limit=, offset=, order_by=[], is_distinct=false, ctes=, } === PLANNER === Projection { exprs=["#0.0", "#0.1"] } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER) Filter { predicate=(#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 === Filter { predicate=(#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)

Projection PlanNode Executor#

Projection 类型的 PLAN_NODE 是作为有条件的 SELECT语句 或者嵌套的 SELECT 语句的根节点, 例如:

Copy
SELECT a, b FROM t1 WHERE c > 10; SELECT a + b FROM (SELECT a, b FROM t1 WHERE c > 10) AS sub;

但是没有条件的 SELECT 语句通常不使用 Projection PLAN_NODE 节点.
并且 Projection 类型的 PLAN_NODE 只有一个孩子节点返回读取到的 Tuples, Projection 的 PLAN_NODE 中还包含各种表达式, 这些表达式定义了如何从孩子节点返回的 tuple 获取 Projection 的 tuple.

Copy
auto ProjectionExecutor::Next(Tuple *tuple, RID *rid) -> bool { Tuple child_tuple{}; /** * 从孩子节点获取 tuple, 也就是孩子节点返回 tuple. * 并且, 这里实现了一个递归的调用机制, 从上到下, 先调用孩子节点的 Next() 函数 */ const auto status = child_executor_->Next(&child_tuple, rid); if (!status) { return false; } /** * 实际这部分是调用孩子节点的 Next() 函数之后的回溯部分, 孩子节点返回 tuple 到父节点之后 * 父节点根据自身的逻辑修改与判断这个 tuple 是否符合条件, 继续返回至根节点 */ std::vector<Value> values{}; // 每一列的 Column 对应着从 孩子节点的 tuple 中取一个值 values.reserve(GetOutputSchema().GetColumnCount()); // Projection 节点通常有多个表达式, 获取孩子节点的 tuple 之后, 在回溯节点使用表达式的逻辑得到该节点的 tuple 输出 for (const auto &expr : plan_->GetExpressions()) { values.push_back(expr->Evaluate(&child_tuple, child_executor_->GetOutputSchema())); } // 组合成一个 tuple 返回 *tuple = Tuple{values, &GetOutputSchema()}; return true; }

Iterator Model 的 Executor 执行的过程类似于一个递归回溯的过程, 但是并不完全是递归回溯, 上面的代码就展示了该调用的过程, 首先会递归的调用 Next() 函数, 然后调用完之后会使用该节点的处理逻辑, 返回该节点的 tuple. 这里还有一个问题, 常见的递归回溯会将递归的过程全部执行完, 再进行回溯, 而 Iterator Model 每次只返回一个 tuple, 下次调用该节点的时候, 继续返回 tuple, 这里的实现方式在后续会揭开.

Filter PlanNode Executor#

Filter 节点通常作用为关系表达式中的 WHERE 后面的判断语句, 例如语句:

Copy
EXPLAIN SELECT * FROM __mock_table_1 WHERE colA > 1;

上述例子的 EXPLAIN 中的结果也展示了该例子, 可以看到, Filter节点的孩子节点是 Scan 节点, Scan 节点返回数据库表中的一列给 Filter 节点进行筛选, 具体实现如下:

Copy
auto FilterExecutor::Next(Tuple *tuple, RID *rid) -> bool { auto filter_expr = plan_->GetPredicate(); // 遍历孩子节点返回的所有的 tuples while (true) { // Get the next tuple const auto status = child_executor_->Next(tuple, rid); if (!status) { return false; } /** Filter_expr is a predicate, it should return a boolean * 使用表达式的逻辑判断筛选孩子节点返回的 tuples, 最终返回最后最后的 tuple */ auto value = filter_expr->Evaluate(tuple, child_executor_->GetOutputSchema()); if (!value.IsNull() && value.GetAs<bool>()) { return true; } } }

SeqScan PlanNode Executor#

接下来我们开始实现Project3中需要实现的节点, 第一个是 SeqScan 节点.
Introduction 中给出的例子是:

Copy
bustub> CREATE TABLE t1(v1 INT, v2 VARCHAR(100)); Table created with id = 15 bustub> EXPLAIN (o,s) SELECT * FROM t1; === OPTIMIZER === SeqScan { table=t1 } | (t1.v1:INTEGER, t1.v2:VARCHAR)

SeqScan 可以理解为, 每次从数据库表中读取一行, 它往往作为 PlanNode 树的叶子节点, 返回数据库表中读取的最原始的 tuples, 也就是一行的数据. 理解 SeqScan 不难, 但是实际实现的过程中, 我们需要注意的一点是, SeqScan Executor
每次返回一行, 下次调用的时候继续返回下一行, 而不是从开头开始, 这里应该如何实现呢?
这里的核心是初始化时候的 Init() 函数, 以及在 SeqScan 的 PlanNode 中使用迭代器的方式遍历数据库中的表:

Copy
void SeqScanExecutor::Init() { // use exec_ctx to get the current using Table, and yield the tuple in the table Catalog *catalog = exec_ctx_->GetCatalog(); if (plan_->GetType() != PlanType::SeqScan) { throw Exception("Plan cannot be nullptr in SeqScanExecutor"); } // get the table_infomation from the catalog TableInfo *table_info = catalog->GetTable(this->plan_->GetTableOid()); // create a table iterator to find the tuple in the table table_iterator_ = std::make_unique<TableIterator>(table_info->table_->MakeIterator()); } } // namespace bustub

TableIterator 迭代器会记录每次调用 Next() 函数的位置, 因此每次调用 SeqScan 的Next 函数之后, 返回一行之后, 下次调用不会从头开始读. 除此之外还有下面需要注意的点:

  1. 每次读取一行的时候, 需要使用 tuple_meta.is_deleted_ 判断该 tuple 在数据库表中是否已经被删除
  2. 因为该 Project 实际上以及开启了 merge_filter_nlj 的优化, 因此需要使用下面的部分执行 Filter 节点的判断: plan_->filter_predicate_->Evaluate(&current_tuple, schema).GetAs<bool>()

具体代码就不列出了.

Insert PlanNode Executor#

Insert 节点出现在 INSERT 的 SQL 语句中, 是 INSERT 类型的 SQL 语句的根节点, 将 tuples 插入到数据库表中, 在 EXPLAIN 中我们期望得到的效果如下:

Copy
bustub> EXPLAIN (o,s) INSERT INTO t1 VALUES (1, 'a'), (2, 'b'); === OPTIMIZER === Insert { table_oid=15 } | (__bustub_internal.insert_rows:INTEGER) Values { rows=2 } | (__values#0.0:INTEGER, __values#0.1:VARCHAR)

InsertExecutor 有下列特点:

  1. InsertExecutor 只有一个孩子节点, 该孩子节点返回 tuples 给 Insert 节点作为插入到表中 tuple, 并且孩子节点返回的 tuples 和 Insert 要插入的数据库表的 Schema 完全一致.
  2. InsertExecutor 返回的 tuple 表示本次 INSERT 动作向数据库表插入的 tuples 的个数. 因此 InsertExecutor 的不同点是我们需要将所有的 tuples 全部插入到表之后, 再返回.
  3. InsertExecutor 会修改表中的数据, 因此与表中数据对应的 Index 也需要对应的修改, 也就是说, 我们向表中插入数据的时候, 也需要向这张表的检索中插入对应的检索信息.
  4. 插入 Insert 的实际操作仅需要设置 TupleMetais_delete_ 为 True 即可.

因此我们 InsertExecutor 的初始化如下:

Copy
InsertExecutor::InsertExecutor(ExecutorContext *exec_ctx, const InsertPlanNode *plan, std::unique_ptr<AbstractExecutor> &&child_executor) : AbstractExecutor(exec_ctx), plan_(plan), child_executor_(std::move(child_executor)) {} void InsertExecutor::Init() { // use exec_ctx to get the current using Table, and yield the tuple in the table Catalog *catalog = exec_ctx_->GetCatalog(); // get the table_infomation from the catalog table_info_ = catalog->GetTable(this->plan_->GetTableOid()); // get the table's indexes table_indexes_ = catalog->GetTableIndexes(table_info_->name_); child_executor_->Init(); inserted_rows_ = 0; }

实际流程不难实现, 我当时做的时候的一个BUG是在插入表之后, 插入对应检索的时候, 应该将 tuples 转化为检索使用的 tuples, 转化如下:

Copy
// update all the indexes of this table for(auto& index_info:table_indexes_) { /** we can't directly use the tuple as the key of the index * we should use the child_tuple data to get the new_insert_index tuple */ Tuple look_up_tuple = child_tuple.KeyFromTuple(table_info_->schema_, index_info->key_schema_, index_info->index_->GetKeyAttrs()); index_info->index_->InsertEntry(look_up_tuple, *rid, exec_ctx_->GetTransaction()); }

此外还有一个需要注意的地方, InsertExecutor 的 Next() 函数也可能被多次调用, 但是实际上 InsertExecutor 作为根节点的 Insert PlanNode仅需要执行一次, 因此需要在函数入口判断是否已经完成 Insert, 避免重复 INSERT.

Update PlanNode Executor#

Update 的 PlanNode 出现在 UPDATE 的 SQL 语句的根节点, UpdateExecutor 有以下特点:

  1. UpdateExecutor 有且只有一个孩子节点, 表示需要 Update 的tuples.
  2. UpdateExecutor 返回一个 tuple 表示数据库中有多少行被修改.
  3. UpdateExecutor 的执行流程应该是先将这个 tuples 从数据库表中删除, 这个删除对应的 tuple 使用的是孩子节点返回的 rid, 然后在表中插入一行数据, 插入的并不直接插入孩子节点返回的 tuple, 因为这个 tuple是旧的, 而是需要进行修改, 修改的方式就是使用 Update PlanNode 中的 target_expressions_
  4. UpdateExecutor 也需要更新 tuple 对应的检索信息, 更新的方式和前面的 Insert 过程类似.

根据旧的 tuple 使用 target_expressions_ 获取心得 tuple 的方式如下:

Copy
/** ! save the updated value */ std::vector<Value> updated_values; for(auto& target_expression:this->plan_->target_expressions_) { updated_values.emplace_back(target_expression->Evaluate(&update_tuple, table_info_->schema_)); }

Delete PlanNode Executor#

Delete 的 PlanNode 仅出现在 Delete 的 SQL 语句的根节点, DeleteExecutor 有以下特点:

  1. DeleteExecutor 有且只有一个孩子节点, 表示需要 Delete 的tuples.
  2. DeleteExecutor 返回一个 tuple 表示数据库中有多少行被删除.
  3. 在BUSTUB中, 或者说在我们的 Project 中, Delete PlanNode 都是作为根节点出现的.
    DeleteExecutor 实现方式与 Insert 和 Update 类似, 就不再重复叙述了.

IndexScan PlanNode Executor#

首先, IndexScanExecutor 也是一种 ScanExecutor, 因此往往是作为叶子节点执行的, 但是通常是优化后的叶子节点. IndexScan 可以通过快速的检索 Index 获取需要 Scan 的tuples 的IDs, 不需要逐行遍历, 最后和 SeqScan 一样会每次返回一个 tuples.

IndexScan PlanNode 通常执行下列的 SELECT 类型的 SQL 表达式:

Copy
SELECT FROM <table> WHERE <index column> = <val>

在 BUSTUB 中使用 EXPLAIN 可以看到执行的步骤:

Copy
bustub> CREATE TABLE t1(v1 int, v2 int); Table created with id = 22 bustub> CREATE INDEX t1v1 ON t1(v1); Index created with id = 0 bustub> EXPLAIN (o,s) SELECT * FROM t1 WHERE v1 = 1; === OPTIMIZER === IndexScan { index_oid=0, filter=(#0.0=1) } | (t1.v1:INTEGER, t1.v2:INTEGER)

可以看到在 IndexScan 中, 有两部分关键的信息, index_oid 以及 filter 的表达式. 在数据库中使用检索要比直接 SeqScan 更快的一个原因是, SeqScan 只能在行中一行一行的读取, 但是检索是单独存储的数据结构, 例如 B+树, 可扩展 HashTable, 这些检索会直接存储某个 tuple 的检索列信息, 以及该tuple对应的 RID, 在检索中, 检索列对应的信息就是 Key, 而 RID 就是 Value. 检索列往往很小, 所占的空间小, 并且在这张表中检索往往像主键一样唯一, 不会冲突.
在该 Porject 中, BUSTUB 对检索添加了下列的限制, 使得我们更容易实现:

  1. 检索对象的类型一定是 HashTableIndexForTwoIntegerColumn, 这个在定义检索的时候明确的, 因此可以使用下面的 htable_ = dynamic_cast<HashTableIndexForTwoIntegerColumn *>(index_info_->index_.get()) 类型转换获取指向 Index 的指针.
  2. BUSTUB 中的检索保证检索列只有一列, 并且这一列的数据类型为 Integer.
  3. 检索列的信息不会重复, 意味着, 检索列一定可以作为主键. 并且每次检索最多返回一行检索到的数据, 这是因为在 Project2 我们实现可扩展 HashTable 的时候, 限制了每个 Key 的唯一性.

因此在IndexScanExecutor 的实现中, 在 Init() 初始化函数中可以初始化查找到的 Rids 的迭代器, 后续优化的时候可以再增加多行检索的功能.

Copy
void IndexScanExecutor::Init() { // use exec_ctx to get the current using Table, and yield the tuple in the table Catalog *catalog = exec_ctx_->GetCatalog(); // get the index info first, and then get the table info index_info_ = catalog->GetIndex(plan_->GetIndexOid()); table_info_ = catalog->GetTable(index_info_->table_name_); // get the acutal index for indexing htable_ = dynamic_cast<HashTableIndexForTwoIntegerColumn *>(index_info_->index_.get()); /** use the ConstantValueExpression to get the constant value key to lookup */ Value look_up_key = this->plan_->pred_key_->Evaluate(nullptr, plan_->OutputSchema()); Tuple look_up_tuple({look_up_key}, &(index_info_->key_schema_)); /** Find all the tuple with Index as look_up_key */ htable_->ScanKey(look_up_tuple, &look_up_rids_, exec_ctx_->GetTransaction()); rids_iter_ = look_up_rids_.begin(); }

Optimizing SeqScan to IndexScan#

SeqScan 和 IndexScan 都是从数据库表中按行读取数据, 但是 SeqScan 只能遍历表的方式来读取, 但是 IndexScan 可以直接定位到具体的 RID, 要比 SeqScan 快得多, 因此再某些特定的 SQL 语句中, 我们可以将 SeqScan 优化为 IndexScan 的 PlanNode.
merge_filter_scan.cpp 中已经完成了 Filter PlanNode 与 SeqScan Node 节点的合并优化, 合并的代码如下:

Copy
namespace bustub { /** * 每一个优化器都会从 PlanNode 节点树的根节点开始向下递归的调用, 如果需要符合条件的节点, 就对节点进行优化 * 递归的第一步将所有优化的孩子节点的指针存储到临时变量中 */ auto Optimizer::OptimizeMergeFilterScan(const AbstractPlanNodeRef &plan) -> AbstractPlanNodeRef { std::vector<AbstractPlanNodeRef> children; // recursively works on the children Nodes for (const auto &child : plan->GetChildren()) { children.emplace_back(OptimizeMergeFilterScan(child)); } /** * Clone 复制一个新节点, 用这个节点作为优化后的节点 * 将已经优化的孩子节点作为该节点的孩子节点 */ auto optimized_plan = plan->CloneWithChildren(std::move(children)); // 如果当前节点的2类型是 Filter if (optimized_plan->GetType() == PlanType::Filter) { const auto &filter_plan = dynamic_cast<const FilterPlanNode &>(*optimized_plan); // Filter 节点有且只有一个孩子节点 BUSTUB_ASSERT(optimized_plan->children_.size() == 1, "must have exactly one children"); const auto &child_plan = *optimized_plan->children_[0]; // 如果 Filter 节点的孩子节点是 SeqScan 节点, 并且该 SeqScan 节点没有 Filter 谓语表达式, 就将这个 // Filter的谓语表达式写进 SeqScan 节点中 if (child_plan.GetType() == PlanType::SeqScan) { const auto &seq_scan_plan = dynamic_cast<const SeqScanPlanNode &>(child_plan); if (seq_scan_plan.filter_predicate_ == nullptr) { return std::make_shared<SeqScanPlanNode>(filter_plan.output_schema_, seq_scan_plan.table_oid_, seq_scan_plan.table_name_, filter_plan.GetPredicate()); } } } return optimized_plan }

SeqScanAsIndexScan Optimizer 优化中, 由于在优化的步骤中, MergeFilterScan Optimizer 优化是在 SeqScanAsIndexScan Optimizer 之前的, 因此在 SeqScanAsIndexScan Optimizer 中不需要考虑 Filter PlanNode, 只需要考虑 SeqScan 节点即可.
BUSTUB 中对 SeqScanAsIndexScan Optimizer 增加了下列的限制, 有助于我们更加简单的完成 SeqScanAsIndexScan Optimizer 的优化过程:

  1. MergeFilterScan Optimizer 一定在 SeqScanAsIndexScan Optimizer之前完成
  2. 我们仅需要优化谓语 WHERE 中仅存在一个等式的情况, 例如: SELECT * FROM t1 WHERE v1 = 1;, 而两个等式与其他的谓语情况无需进行优化, 例如: SELECT * FROM t1 WHERE v1 = 1 AND v2 = 2 无需优化.
    因此实际实现的部分, 最需要关注的是 SeqScan 中的表达式部分, 用表达式的这部分构建出一个检索需要使用的常量, 以及识别是否存在检索列. 部分实现如下:
Copy
/** Judge the Cloumn in the SeqScan (filter_predicate_ part) could be used as the Index */ const auto &seqscan_plan = dynamic_cast<const SeqScanPlanNode &>(*optimized_plan); const auto *table_info = catalog_.GetTable(seqscan_plan.GetTableOid()); const auto indices = catalog_.GetTableIndexes(table_info->name_); /** when optimize a seqscan to indexscan, only support predicate such as `WHERE v1 = 1` * you only need to support optimization for a single equality predicate on the indexed column. */ auto *comparison_expr = dynamic_cast<ComparisonExpression *>(seqscan_plan.filter_predicate_.get()); if (comparison_expr == nullptr || comparison_expr->comp_type_ != ComparisonType::Equal) { return optimized_plan; } /** get the left and right of the ComparisonExpression, there are columns and constant value */ auto *column_value_expr = dynamic_cast<ColumnValueExpression *>(comparison_expr->children_[0].get()); auto *constant_value_expr = dynamic_cast<ConstantValueExpression *>(comparison_expr->children_[1].get()); if(column_value_expr == nullptr || constant_value_expr == nullptr) { return optimized_plan; }

总结#

这部分刚开始实现的时候脑子里面全是天书, 有点看不懂, 从单个地方很容易理解每个步骤是干什么的, 以及每个步骤应该怎么实现, 但是将这些 MySQL 的 PlanNode, Expression, Executor, Optimizer 组合起来, 还是有些懵逼, 后来再回头看看了网站的说明, 将代码多看几遍, 发现还是很有收获的, 我是做完了 Project1, 并且测试通过再接着往下做的, 保证前面部分的正确性. +

posted @   虾野百鹤  阅读(36)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通
点击右上角即可分享
微信分享提示
CONTENTS