CMU_15445_P3_Part3

HashJoin Executor & Optimization

HashJoin Executor#

如果查询包含与两列之间单个或者多个等值条件的连接的连接, 则 DBMS 可以使用 HashJoinPlanNode (各个等式之间使用 AND 连接条件), 例如: 考虑以下示例查询:

Copy
SELECT * FROM __mock_table_1, __mock_table_3 WHERE colA = colE; SELECT * FROM __mock_table_1 INNER JOIN __mock_table_3 ON colA = colE; SELECT * FROM __mock_table_1 LEFT OUTER JOIN __mock_table_3 ON colA = colE; SELECT * FROM test_1 t1, test_2 t2 WHERE t1.colA = t2.colA AND t1.colB = t2.colC; SELECT * FROM test_1 t1 INNER JOIN test_2 t2 on t1.colA = t2.colA AND t2.colC = t1.colB; SELECT * FROM test_1 t1 LEFT OUTER JOIN test_2 t2 on t2.colA = t1.colA AND t2.colC = t1.colB;

我的理解是先对左边的 LeftTable 以及右边的 RightTable 进行 Hash 分组, 例如上面的语句

Copy
SELECT * FROM test_1 t1 INNER JOIN test_2 t2 on t1.colA = t2.colA AND t2.colC = t1.colB;

先对 Table t1 的 colA 与 colB 进行 GROUP BY 分组, 这个分组类似与 Aggregate GROUP BY 语句中的分组, 用于构建一个 HashTable 的 Keys, 对 Table t2 执行相同的操作, 执行完之后将两个 HashTable 进行按照条件组合即可获取 Join 之后的 tuples.

HashJoin 中 HashTable 的定义#

这里 HashTable 的主要功能是将需要 JOIN 的两张表中的 tuples 按照 column 进行分组, 例如, 上面的例子

Copy
SELECT * FROM test_1 t1 INNER JOIN test_2 t2 on t1.colA = t2.colA AND t2.colC = t1.colB;

中, 对 t1 表就使用 colAcolB 进行分组, 而 t2 表就按照 colCcolA 进行分组. 分组的本质与 GROUP BY 语句相同, 只是在构建 HashTable 的时候不同.

在 HashJoin 中定义 HashTable 的时候, 我们可以参考 AggregationPlanNode 定义类似的 HashTable, 其中 HashTable 的 Key 和 Value 的定义如下:

Copy
/** * HashJoinKey represents a key in the hash table used in the hash join. it from the ON clause of the JOIN statement. */ struct HashJoinKey { /** The group-by values */ std::vector<Value> group_bys_{}; /** * Compares two HashJoin keys for equality. * @param other the other HashJoin key to be compared with * @return `true` if both HashJoin keys have equivalent group-by expressions, `false` otherwise */ auto operator==(const HashJoinKey &other) const -> bool { for (uint32_t i = 0; i < other.group_bys_.size(); i++) { if (group_bys_[i].CompareEquals(other.group_bys_[i]) != CmpBool::CmpTrue) { return false; } } return true; } }; /** HashJoinValue represents a value for each of the running hashjoin */ struct HashJoinValue { /** The hashjoin tuples */ std::vector<Tuple> tuples_{}; };

不同点是, HashJoinValue 是一个 tuples 数组, 在 SimpleAggregationHashTable 中, 由于 Aggregate 操作会将 GROUP BY 的结果进行计算, 因此 HashJoinValue 直接记录计算的结果即可. 而在 HashJoin 中, 后续需要将数组中的每个 tuple 与另一张 HashTable 中相同的 Key 对应的 tuples 进程组合, 得到一个组合的 tuple.
HashTable 的其他部分与 SimpleAggregationHashTable 类似.

HashJoin 中 HashTable 的创建#

HashJoin 中, 需要对要 JOIN 的两张表分别创建 HashTable, 左右表的不同在 HashJoinPlanNode 存在说明, 分别创建左右 HashTable 的 HashKey 的步骤如下, 主要是充分利用 HashJoinPlanNode 的 expression 部分.

Copy
private: /** @return The left child HashKey */ auto GetLeftJoinKey(const Tuple *tuple) -> HashJoinKey { std::vector<Value> values; /** Build the left child HashKey from the Expression and tuple */ for (const auto &expr : plan_->LeftJoinKeyExpressions()) { values.push_back(expr->Evaluate(tuple, left_executor_->GetOutputSchema())); } return HashJoinKey{values}; } /** @return The right child HashKey */ auto GetRightJoinKey(const Tuple *tuple) -> HashJoinKey { std::vector<Value> values; /** Build the right child HashKey from the Expression and tuple */ for (const auto &expr : plan_->RightJoinKeyExpressions()) { values.push_back(expr->Evaluate(tuple, left_executor_->GetOutputSchema())); } return HashJoinKey{values}; }

然后我们分别从 left_executor_right_executor_ 读取 tuples 作为 HashValue 插入到 HashTable 中, 如下:

Copy
Tuple left_tuple; RID left_rid; /** Build the left HashTable */ while (left_executor_->Next(&left_tuple, &left_rid)) { HashJoinKey key = GetLeftJoinKey(&left_tuple); left_hash_table_.Insert(key, left_tuple); } Tuple right_tuple; RID right_rid; /** Build the right HashTable */ while (right_executor_->Next(&right_tuple, &right_rid)) { HashJoinKey key = GetRightJoinKey(&right_tuple); right_hash_table_.Insert(key, right_tuple); } // Initi

HashJoin 的 Next() 函数#

Next() 函数的主要功能是将左表与右表的对应的 tuples 合并, 然后返回. 在实现的过程中, 我们在 Init() 函数中已经构建好了 left_hash_table_right_hash_table_. Next() 函数中只需要将 left_hash_table_right_hash_table_ 相同的 HashKey 对应的 HashValue 中的所有 tuples 组合起来, 作为结果即可. 其实这一步骤很简单, 稍微复杂一点的是, 由于我们使用的 Iterator Model, 因此 Next() 函数每次仅返回一个 tuple, 需要在 Next() 中添加判断与控制.

这里的实现与 NestedLoopJoinExecutor 中的实现类似, 对于左表使用迭代器, 对于每一个 Key, 需要判断右表的 HashTable 中有没有一致的 HashKey, 然后从 HashValue 中遍历, 组合为新的 tuple 返回.
我们还加入了判断部分, 如下:

Copy
// Initialize the iterators left_hash_table_iterator_ = left_hash_table_.Begin(); // 初始化的时候需要从头开始, left_tuples_end_ = true; right_tuples_end_ = true; join_without_null_ = false;

left_tuples_end_ 用于判断左表中的某个 HashValue 的 tuples 数组是否遍历完成, right_tuples_end_ 用于判断右表中的某个 HashValue 的 tuples 数组是否遍历完成, join_without_null_NestedLoopJoinExecutor 相同, 用于判断 LEFT JOIN 的情况.

HashJoin 的 Next() 函数的部分实现如下:

Copy
auto HashJoinExecutor::Next(Tuple *tuple, RID *rid) -> bool { /** * 与 nest_join 一样, 需要考虑到重复进入到 Next() 函数的问题, 每次进入的时候需要考虑是否需要重新初始化 * 我们使用 left_tuples_end_ 和 right_tuples_end_ 分别表示是否遍历完一个 HashKey 对应的 HashValue(std::vector<Tuple>) * 如果遍历完, 重新初始化, 遍历下一个 HashKey 中的所有 tuples * */ while (left_hash_table_iterator_ != left_hash_table_.End()) { const auto &left_key = left_hash_table_iterator_.Key(); const auto &left_value = left_hash_table_iterator_.Val(); auto right_key = left_key; auto right_value = right_hash_table_.Find(left_key); /** 如果左边的 tuples 已经遍历完了, 表示已经组合完成了 */ if (left_tuples_end_) { left_tuples_end_ = false; left_tuple_index_ = 0; join_without_null_ = false; } if (right_value.has_value()) { while (left_tuple_index_ < left_value.tuples_.size()) { /** 如果右边的一个 HashKey 对应的所有 tuples 遍历完了, 重新开始遍历右边的 */ if (right_tuples_end_) { right_tuples_end_ = false; right_tuple_index_ = 0; join_without_null_ = false; } const auto left_tuple = left_value.tuples_[left_tuple_index_]; /** 存在 Left_Table 与 Right_Table 对应的 HashKey 一致的情况, 表示可以 JOIN, 将这两个 tuples 合并成一个 tuple */ while (right_tuple_index_ < right_value->tuples_.size()) { // 使用左表与右表中的 tuple 构建一个新的 tuple *tuple = Tuple(values, &plan_->OutputSchema()); right_tuple_index_++; join_without_null_ = true; return true; } right_tuples_end_ = true; left_tuple_index_++; } } else { /** * 如果是 LEFT JOIN 即使右孩子节点没有匹配的 tuple, 会使用 NULL 作为输出 * 但是如果有匹配的 tuple, 并且已经匹配过, 那么不需要输出, 使用后 join_without_null_ 判断 */ if (!join_without_null_ && plan_->GetJoinType() == JoinType::LEFT) { while (left_tuple_index_ < left_value.tuples_.size()) { // 使用左表与右表中的 tuple 构建一个新的 tuple. *tuple = Tuple(values, &plan_->OutputSchema()); left_tuple_index_++; return true; } } } /** 左边的一个 HashJoinKey 下的 tuples 全部遍历完成 */ left_tuples_end_ = true; /** 如果对于 left_tuple 中的某个 Key, right_tuple 没有相等的 Key, 表示无法连接, 下一个 */ ++left_hash_table_iterator_; } return false; }

HashJoin Optimization#

在HashJoin 中, 我们使用 HashTable 的方式在左表与右表中找到对应的 HashKey, 这种方式可以避免在 NestedLoopJoinExecutor 中使用枚举的方式一一对应, 要快很多.
HashJoin Optimization 需要我们完成 BUSTUB 中的部分优化, 支持针对特殊的 JOIN 语句将其原来的 NestedLoopJoinPlanNode 优化为 HashJoinPlanNode.

这里需要注意, BUSTUB 中已经完成了 OptimizeMergeFilterNLJ 的优化, 这部分已经完成了, 这部分会将带有 WHERE 语句, 而不带有 JOIN ON 语句的 JOIN 语句的 NestedLoopJoinPlanNode + FilterPlanNode 优化为单个的 NestedLoopJoinPlanNode.

优化思路#

BUSTUB 中需要支持的是当有多个等式在 JOIN 中连接的条件, 例如:

Copy
SELECT * FROM test_1 t1, test_2 t2 WHERE t1.colA = t2.colA AND t1.colB = t2.colC;

未优化前的 EXPLAIN 输出如下:

Copy
NestedLoopJoin { type=Inner, predicate=((#0.0=#1.0)and(#0.1=#1.2)) } SeqScan { table=test_1 } SeqScan { table=test_2 }

我们期待的优化后的输出为:

Copy
HashJoin { type=Inner, left_key=[#0.0, #0.1], right_key=[#0.0, #0.2] } SeqScan { table=test_1 } SeqScan { table=test_2 }

通过比较 NestedLoopJoinPlanNodeHashJoinPlanNode 可以知道, 优化的核心是将 NestedLoopJoinPlanNode 中的 AbstractExpressionRef predicate_ 优化为 HashJoinPlanNode 中的 expression, 也就是下面的部分:

Copy
/** The expression to compute the left JOIN key */ std::vector<AbstractExpressionRef> left_key_expressions_; /** The expression to compute the right JOIN key */ std::vector<AbstractExpressionRef> right_key_expressions_;

所以实际上是表达式的转化, 只需要将表达式的结构解析即可.

表达式类型的转化#

我们用下面的函数递归的处理 predicate_ 表达式, 需要注意的是, MySQL 语句的写法的不同会导致 predicate_ 表达式 与 HashJoinPlanNode 中的左右 Expression 的对应不同, 例如:

Copy
-- predicate_ 是 WHERE 部分的后半部分, 记录表达式的时候从左到右 SELECT * FROM test_1 t1, test_2 t2 WHERE t1.colA = t2.colA AND t1.colB = t2.colC; -- 在 ColumnValue 表达式中, 左右边的不同会导致 NestedLoopJoinPlanNode 中的 predicate_ 的表达式的不同, 在 Optimize 的时候应该是不同的位置 SELECT * FROM test_1 t1, test_2 t2 WHERE t2.colA = t1.colA AND t2.colC = t1.colB;

但是在 ColumnValueExpression 中, 在 SQL 语句解析的节点, 记录了 JOIN 表达式中的 Column 所在的表, 使用下面的函数:

Copy
auto GetTupleIdx() const -> uint32_t { return tuple_idx_; } /** Tuple index 0 = left side of join, tuple index 1 = right side of join */ uint32_t tuple_idx_;

因此我们实现的转化部分的函数如下:

Copy
void Optimizer::PredicateSplitAsHashJoin(const AbstractExpressionRef &predicate, std::vector<AbstractExpressionRef> &left_expression, std::vector<AbstractExpressionRef> &right_expression) { // 如果表达式是逻辑类型, 说明是一个复合表达式, 需要递归的将其分解为左右两个表达式 if (const auto *logic_expr = dynamic_cast<const LogicExpression *>(predicate.get()); logic_expr != nullptr) { PredicateSplitAsHashJoin(logic_expr->GetChildAt(0), left_expression, right_expression); PredicateSplitAsHashJoin(logic_expr->GetChildAt(1), left_expression, right_expression); } // 如果表达式是一个等式类型, 左右孩子表达式应该是 ColumnValueExpression 类型 if (const auto *comparison_expr = dynamic_cast<const ComparisonExpression *>(predicate.get()); comparison_expr != nullptr) { auto first_expr = dynamic_cast<ColumnValueExpression *>(comparison_expr->GetChildAt(0).get()); auto second_expr = dynamic_cast<ColumnValueExpression *>(comparison_expr->GetChildAt(1).get()); /** 在我们 */ if (first_expr->GetTupleIdx() == 0) { left_expression.emplace_back(comparison_expr->GetChildAt(0)); } if (second_expr->GetTupleIdx() == 0) { left_expression.emplace_back(comparison_expr->GetChildAt(1)); } if (first_expr->GetTupleIdx() == 1) { right_expression.emplace_back(comparison_expr->GetChildAt(0)); } if (second_expr->GetTupleIdx() == 1) { right_expression.emplace_back(comparison_expr->GetChildAt(1)); } } }

总结#

这个 JOIN 由于前面实现了 NestedLoopJoinExecutor, 因此实际实现过程中要简单很多, 主要还是 Next() 函数的每次输出一个 tuple 的判断, 这部分感觉自己写的还是和屎一样, 没有优化的很好. 为什么在这个 Project 中只实现了 INNER JOIN 与 LEFT JOIN 呢, 这是涉及到查询优化以及遍历检索的时的 INNER TABLE 与 OUTER TABLE, 我在 JOIN 的 Executor 中实现的时候都是将 Left_Table 作为迭代器的选择的部分, 而 Right_Table 作为 Next() 函数中的部分, 如果再涉及到 RIGHT JOIN, 需要考虑很多的位置信息, 实际上实现过程类似.

posted @   虾野百鹤  阅读(15)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构
点击右上角即可分享
微信分享提示
CONTENTS