CMU_15445_P3_Part1
CMU_15445_P3_Part1
这部分主要是实现一些基本的 Plan_Node 的 Executor, 我们可以首先通过一个列子来看, 就是 Projection
Plan_Node 的例子.
我们使用一个简单的 EXPLAIN
的例子引入 PlanNode 的执行流程:
-- 在控制台使用 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 语句的根节点, 例如:
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.
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 后面的判断语句, 例如语句:
EXPLAIN SELECT * FROM __mock_table_1 WHERE colA > 1;
上述例子的 EXPLAIN
中的结果也展示了该例子, 可以看到, Filter节点的孩子节点是 Scan 节点, Scan 节点返回数据库表中的一列给 Filter 节点进行筛选, 具体实现如下:
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 中给出的例子是:
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 中使用迭代器的方式遍历数据库中的表:
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 函数之后, 返回一行之后, 下次调用不会从头开始读. 除此之外还有下面需要注意的点:
- 每次读取一行的时候, 需要使用
tuple_meta.is_deleted_
判断该 tuple 在数据库表中是否已经被删除 - 因为该 Project 实际上以及开启了
merge_filter_nlj
的优化, 因此需要使用下面的部分执行 Filter 节点的判断:plan_->filter_predicate_->Evaluate(¤t_tuple, schema).GetAs<bool>()
具体代码就不列出了.
Insert PlanNode Executor#
Insert 节点出现在 INSERT 的 SQL 语句中, 是 INSERT 类型的 SQL 语句的根节点, 将 tuples 插入到数据库表中, 在 EXPLAIN
中我们期望得到的效果如下:
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 有下列特点:
- InsertExecutor 只有一个孩子节点, 该孩子节点返回 tuples 给 Insert 节点作为插入到表中 tuple, 并且孩子节点返回的 tuples 和 Insert 要插入的数据库表的 Schema 完全一致.
- InsertExecutor 返回的 tuple 表示本次 INSERT 动作向数据库表插入的 tuples 的个数. 因此 InsertExecutor 的不同点是我们需要将所有的 tuples 全部插入到表之后, 再返回.
- InsertExecutor 会修改表中的数据, 因此与表中数据对应的 Index 也需要对应的修改, 也就是说, 我们向表中插入数据的时候, 也需要向这张表的检索中插入对应的检索信息.
- 插入 Insert 的实际操作仅需要设置
TupleMeta
的is_delete_
为 True 即可.
因此我们 InsertExecutor 的初始化如下:
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, 转化如下:
// 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 有以下特点:
- UpdateExecutor 有且只有一个孩子节点, 表示需要 Update 的tuples.
- UpdateExecutor 返回一个 tuple 表示数据库中有多少行被修改.
- UpdateExecutor 的执行流程应该是先将这个 tuples 从数据库表中删除, 这个删除对应的 tuple 使用的是孩子节点返回的
rid
, 然后在表中插入一行数据, 插入的并不直接插入孩子节点返回的 tuple, 因为这个 tuple是旧的, 而是需要进行修改, 修改的方式就是使用 Update PlanNode 中的target_expressions_
- UpdateExecutor 也需要更新 tuple 对应的检索信息, 更新的方式和前面的 Insert 过程类似.
根据旧的 tuple 使用 target_expressions_
获取心得 tuple 的方式如下:
/** ! 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 有以下特点:
- DeleteExecutor 有且只有一个孩子节点, 表示需要 Delete 的tuples.
- DeleteExecutor 返回一个 tuple 表示数据库中有多少行被删除.
- 在BUSTUB中, 或者说在我们的 Project 中, Delete PlanNode 都是作为根节点出现的.
DeleteExecutor 实现方式与 Insert 和 Update 类似, 就不再重复叙述了.
IndexScan PlanNode Executor#
首先, IndexScanExecutor 也是一种 ScanExecutor, 因此往往是作为叶子节点执行的, 但是通常是优化后的叶子节点. IndexScan 可以通过快速的检索 Index 获取需要 Scan 的tuples 的IDs, 不需要逐行遍历, 最后和 SeqScan 一样会每次返回一个 tuples.
IndexScan PlanNode 通常执行下列的 SELECT 类型的 SQL 表达式:
SELECT FROM <table> WHERE <index column> = <val>
在 BUSTUB 中使用 EXPLAIN 可以看到执行的步骤:
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 对检索添加了下列的限制, 使得我们更容易实现:
- 检索对象的类型一定是
HashTableIndexForTwoIntegerColumn
, 这个在定义检索的时候明确的, 因此可以使用下面的htable_ = dynamic_cast<HashTableIndexForTwoIntegerColumn *>(index_info_->index_.get())
类型转换获取指向 Index 的指针. - BUSTUB 中的检索保证检索列只有一列, 并且这一列的数据类型为 Integer.
- 检索列的信息不会重复, 意味着, 检索列一定可以作为主键. 并且每次检索最多返回一行检索到的数据, 这是因为在 Project2 我们实现可扩展 HashTable 的时候, 限制了每个 Key 的唯一性.
因此在IndexScanExecutor
的实现中, 在 Init() 初始化函数中可以初始化查找到的 Rids 的迭代器, 后续优化的时候可以再增加多行检索的功能.
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 节点的合并优化, 合并的代码如下:
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
的优化过程:
MergeFilterScan Optimizer
一定在SeqScanAsIndexScan Optimizer
之前完成- 我们仅需要优化谓语 WHERE 中仅存在一个等式的情况, 例如:
SELECT * FROM t1 WHERE v1 = 1;
, 而两个等式与其他的谓语情况无需进行优化, 例如:SELECT * FROM t1 WHERE v1 = 1 AND v2 = 2
无需优化.
因此实际实现的部分, 最需要关注的是 SeqScan 中的表达式部分, 用表达式的这部分构建出一个检索需要使用的常量, 以及识别是否存在检索列. 部分实现如下:
/** 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, 并且测试通过再接着往下做的, 保证前面部分的正确性. +
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通