CMU_15445_P3_Part3
HashJoin Executor & Optimization
HashJoin Executor#
如果查询包含与两列之间单个或者多个等值条件的连接的连接, 则 DBMS 可以使用 HashJoinPlanNode (各个等式之间使用 AND 连接条件), 例如: 考虑以下示例查询:
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 分组, 例如上面的语句
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 进行分组, 例如, 上面的例子
SELECT * FROM test_1 t1 INNER JOIN test_2 t2 on t1.colA = t2.colA AND t2.colC = t1.colB;
中, 对 t1
表就使用 colA
与 colB
进行分组, 而 t2
表就按照 colC
与 colA
进行分组. 分组的本质与 GROUP BY 语句相同, 只是在构建 HashTable 的时候不同.
在 HashJoin 中定义 HashTable 的时候, 我们可以参考 AggregationPlanNode
定义类似的 HashTable, 其中 HashTable 的 Key 和 Value 的定义如下:
/**
* 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 部分.
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 中, 如下:
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 返回.
我们还加入了判断部分, 如下:
// 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() 函数的部分实现如下:
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 中连接的条件, 例如:
SELECT * FROM test_1 t1, test_2 t2 WHERE t1.colA = t2.colA AND t1.colB = t2.colC;
未优化前的 EXPLAIN 输出如下:
NestedLoopJoin { type=Inner, predicate=((#0.0=#1.0)and(#0.1=#1.2)) }
SeqScan { table=test_1 }
SeqScan { table=test_2 }
我们期待的优化后的输出为:
HashJoin { type=Inner, left_key=[#0.0, #0.1], right_key=[#0.0, #0.2] }
SeqScan { table=test_1 }
SeqScan { table=test_2 }
通过比较 NestedLoopJoinPlanNode
与 HashJoinPlanNode
可以知道, 优化的核心是将 NestedLoopJoinPlanNode
中的 AbstractExpressionRef predicate_
优化为 HashJoinPlanNode
中的 expression, 也就是下面的部分:
/** 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 的对应不同, 例如:
-- 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 所在的表, 使用下面的函数:
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_;
因此我们实现的转化部分的函数如下:
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, 需要考虑很多的位置信息, 实际上实现过程类似.
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构