唤起一天明月,
照我满怀冰雪,
浩荡百川流。
鲸引未吞海,
剑气已横秋。
——水调歌头
完整代码见:
目录
Task #3 - HashJoin Executor and Optimization
Optimizing NestedLoopJoin to HashJoin
Task #4: Sort + Limit Executors + Window Functions + Top-N Optimization
Task #3 - HashJoin Executor and Optimization
HashJoin
Q:为什么如果查询包含与两个列之间几个等值条件的连接的连接,则DBM可以使用HashJoinPlanNode呢?我不是很理解这点
A:在关系型数据库中,Hash Join 是一种非常高效的连接操作,特别适用于那些存在等值连接条件(equi-join conditions)的查询。为了帮助你理解为什么在查询中包含多个等值条件时,数据库管理系统(DBMS)可以使用 HashJoinPlanNode,我们需要更深入地了解哈希连接的工作原理和等值连接条件的特点。
哈希连接的工作原理
哈希连接算法的基本思想是:
- 构建哈希表:首先,针对左表的连接键(即连接条件中的列),构建一个哈希表。在这个哈希表中,哈希表的每个桶保存左表中具有相同哈希值的元组。
- 扫描右表:接着,针对右表的连接键,计算它的哈希值,并在哈希表中查找与之匹配的元组。如果匹配成功,就生成一个连接结果。
哈希连接的关键点是:哈希表中的每个桶存储着一个连接键的哈希值相同的元组。这意味着,哈希连接非常适用于等值连接(即连接条件是column1 = column2),因为等值连接条件使得我们可以根据哈希值将元组归类到同一个桶中进行比较。
多个等值条件的情况
当查询包含多个等值条件(如 t1.colA = t2.colA AND t1.colB = t2.colC)时,哈希连接依然可以很好地工作,原因如下:
1.多个等值条件的组合:哈希连接能够处理多个条件,只要这些条件都是等值条件。即使有多个条件(比如 colA = colE 和 colB = colC),每个条件都可以通过计算哈希值进行处理。具体来说,数据库可以通过以下方式进行:
- 对于每个连接条件,计算左表和右表连接键的哈希值。
- 将这两个连接条件的哈希值组合成一个复合键,然后在哈希表中查找匹配的元组。
例如,如果查询中有 t1.colA = t2.colA AND t1.colB = t2.colC,那么哈希连接算法会先计算 colA 和 colB 的哈希值,将它们组合起来作为一个复合哈希键,然后在哈希表中查找左表中与之匹配的元组。
2.构建复合哈希键:当查询中有多个等值连接条件时,可以将这些条件的哈希值组合成一个复合键来进行哈希查找。这意味着,数据库可以将多个列的连接条件同时考虑到哈希连接的过程,避免多次扫描。
具体示例
示例 1:t1.colA = t2.colA AND t1.colB = t2.colC
对于这种查询,哈希连接的执行过程如下:
- 计算左表(test_1)的 colA 和 colB 的哈希值。
- 根据 colA 和 colB 的哈希值,构建一个复合哈希键。
- 计算右表(test_2)的 colA 和 colC 的哈希值。
- 将右表的哈希键与左表的复合哈希键进行匹配,找到匹配的元组进行连接。
示例 2:colA = colE
对于这种简单的等值连接,哈希连接将只考虑单一列 colA = colE,并在哈希表中查找匹配的元组。
总结:为什么使用 HashJoinPlanNode
当查询中包含多个等值条件时,哈希连接仍然能够高效执行,原因是哈希连接擅长处理等值连接,并且可以通过构建复合哈希键来处理多个条件。HashJoinPlanNode 作为哈希连接的计划节点,允许数据库在遇到等值连接条件时使用哈希连接算法,构建哈希表并进行高效的连接操作。
在查询中出现多个等值条件时,哈希连接依然能够通过处理这些条件的哈希值,利用哈希表快速查找匹配的元组。因此,DBMS 可以使用 HashJoinPlanNode 来执行包含多个等值条件的连接查询,这种方式比其他连接方法(如嵌套循环连接)更加高效,尤其在大规模数据集下更为显著。
需要注意的一点是,这个task中我们应该将right table放入哈希表中。因为对于Inner Join,选择left table或者right table作为都是可以的。但是对于LEFT OUTER JOIN,我们需要遍历left table,同时在right table中寻找是否存在匹配的tuple,此时就不适合再将left table放入哈希表中了。
大体实现与NLJ差不多,还是需要注意左表的某个tuple在右表有多个对应元素的情况(这应该就是实验说明里提到的哈希冲突)。由于我们把右表放入了哈希表,假设左表tuple0的对应哈希key=0,我们可以发现key=0有多个对应的哈希value。这时就需要处理好哈希value内部的迭代过程了。
Optimizing NestedLoopJoin to HashJoin
核心是对这种复杂predicate进行递归拆分,其实也就是两步走:
- 判断当前expr是logic_expression还是comparison_expression。如果是logic_expr,继续拆分;如果是cmp_expr,进入下一步。
- 判断当前cmp_expr的两个child_expr(child[0]是左侧col_expr,child[1]是右侧col_expr)是属于左表还是右表的,将这个col_expr加入到相应表的col_expr集合中。
用递归拆分得到的左右两表的col_expr集合来构建HashJoinPlanNode。
// 拆分谓词中的表达式
// 本质上, 表达式的基本单位是column_value_expression
void Optimizer::PredParser(const AbstractExpressionRef &predicate, std::vector<AbstractExpressionRef> *left_pred,
std::vector<AbstractExpressionRef> *right_pred) {
// 拆分逻辑表达式
auto logic_expr = dynamic_cast<LogicExpression *>(predicate.get());
if (logic_expr != nullptr) {
PredParser(logic_expr->children_[0], left_pred, right_pred); // 拆分逻辑表达式的左半部分 (#0.0=#1.0)
PredParser(logic_expr->children_[1], left_pred, right_pred); // 拆分逻辑表达式的右半部分 (#0.1=#1.2)
}
// 拆分cmp表达式
// 注意这里要区分是从哪个table提取的column
auto cmp_expr = dynamic_cast<ComparisonExpression *>(predicate.get());
if (cmp_expr != nullptr) {
auto tmp = dynamic_cast<ColumnValueExpression *>(cmp_expr->children_[0].get());
if (tmp->GetTupleIdx() == 0) { // 左侧column元素正是从left table提取的
left_pred->emplace_back(cmp_expr->children_[0]);
right_pred->emplace_back(cmp_expr->children_[1]);
} else if (tmp->GetTupleIdx() == 1) { // 左侧column元素正是从right table提取的
left_pred->emplace_back(cmp_expr->children_[1]);
right_pred->emplace_back(cmp_expr->children_[0]);
}
}
}
// 这里要注意一点, cmpExpr的比较类型默认是“=”
// 要较真的话得判断每个cmpExpr的比较类型
auto Optimizer::OptimizeNLJAsHashJoin(const AbstractPlanNodeRef &plan) -> AbstractPlanNodeRef {
// TODO(student): implement NestedLoopJoin -> HashJoin optimizer rule
// 2023 秋季学期注意:你应该支持任意数量的等值条件联合作为连接键:
// 例如:<列表达式> = <列表达式> AND <列表达式> = <列表达式> AND ...
std::vector<AbstractPlanNodeRef> children;
for (auto &expr : plan->children_) { // 递归优化
children.emplace_back(OptimizeNLJAsHashJoin(expr));
}
auto optimized_plan = plan->CloneWithChildren(std::move(children));
if (optimized_plan->GetType() == PlanType::NestedLoopJoin) { // 针对NLJ进行优化
const auto &nlj_plan = dynamic_cast<NestedLoopJoinPlanNode &>(*optimized_plan); // 将plan类型转化为HashJoin
BUSTUB_ASSERT(nlj_plan.children_.size() == 2, "nlj计划应该拥有2个子节点");
// 哈希连接的核心就是谓词条件是“=”
std::vector<AbstractExpressionRef> left_expr;
std::vector<AbstractExpressionRef> right_expr;
PredParser(nlj_plan.predicate_, &left_expr, &right_expr);
return std::make_shared<HashJoinPlanNode>(optimized_plan->output_schema_, optimized_plan->children_[0],
optimized_plan->children_[1], left_expr, right_expr, nlj_plan.join_type_);
}
// return plan; // 千算万算没想到是这里给自己挖了个坑
return optimized_plan;
}
Task #4: Sort + Limit Executors + Window Functions + Top-N Optimization
Sort
正如实验说明提到的,我们需要用std::sort对table所有的tuples进行排序,这里就需要自己重写cmp函数了。建议自己实现个cmp类,在这个类内重载“()”来达到调用cmp类的实例来充当排序函数的作用。
一句话总结就是在init()中排序好所有的tuples,然后在next()遍历这数组即可。
Limit
苦了这么久,终于来了个简单的。秒了,能看到这的道友必然也是一眼秒的OvO
Top-N Optimization Rule
分为完成topn_executor和sort_limit_as_topn两个部分。
Topn_executor可以用一个priority_queue来存储前top-N个元素,同时用我们自定义的cmp用来给priority_queue排序。
sort_limit_as_topn比之前的两次优化都要简单,按部就班就可以完成,也不再赘述。Talk is cheap, show me your code.
auto Optimizer::OptimizeSortLimitAsTopN(const AbstractPlanNodeRef &plan) -> AbstractPlanNodeRef {
// TODO(student): implement sort + limit -> top N optimizer rule
std::vector<AbstractPlanNodeRef> children;
for (auto &child : plan->children_) {
children.emplace_back(OptimizeSortLimitAsTopN(child));
}
auto optimized_plan = plan->CloneWithChildren(std::move(children));
if (optimized_plan->GetType() == PlanType::Limit) {
auto limit_plan = dynamic_cast<LimitPlanNode *>(optimized_plan.get());
auto child_plan = limit_plan->GetChildPlan(); // limit只能有一个child
if (child_plan->GetType() == PlanType::Sort) {
const auto sort_plan = dynamic_cast<const SortPlanNode *>(child_plan.get());
if (sort_plan != nullptr) {
return std::make_shared<TopNPlanNode>(optimized_plan->output_schema_, child_plan->GetChildren()[0],
sort_plan->order_bys_, limit_plan->limit_);
}
}
}
return optimized_plan;
}
Window Functions
1. WindowFunctionPlanNode(SchemaRef output_schema, AbstractPlanNodeRef child, std::vector<uint32_t> window_func_indexes,
2. std::vector<AbstractExpressionRef> columns,
3. std::vector<std::vector<AbstractExpressionRef>> partition_bys,
4. std::vector<std::vector<std::pair<OrderByType, AbstractExpressionRef>>> order_bys,
5. std::vector<AbstractExpressionRef> functions,
6. std::vector<WindowFunctionType> window_func_types)
7. : AbstractPlanNode(std::move(output_schema), {std::move(child)}), columns_(std::move(columns)) {
8. for (uint32_t i = 0; i < window_func_indexes.size(); i++) {
9. window_functions_[window_func_indexes[i]] =
10. WindowFunction{functions[i], window_func_types[i], partition_bys[i], order_bys[i]};
11. }
12. }
13.
这个Window Functions就是aggregation的变体,我们要重点理解WindowFunctionPlanNode这个构造函数。WindowFunctionPlanNode包含两个成员变量:columns_数组和window_functions_这个映射。WindowFunction的列包含两种类型,一种是普通列(#0.0、#0.1)这种的,还有一种就是特殊列(就是一个SUM(0.3) OVER (PARTITION BY 0.2 ORDER BY 0.3)表达式)。每个特殊列对应一个window function。只有特殊列会被放入window_functions_这个映射。
弄清楚了window function具体是什么后,结合aggregation来写就比较容易了。唯一不同的就是rank()这个排序函数,要分是否有order by进行分类处理——如果存在rank(),那就一定会有order by语句。如果没有order by语句,那也必然没有rank()。
遇到的bug
①在init()中没有及时清空哈希表、迭代器之类的结构,导致以前的结果残留。尤其是在Aggregation和NLJ会遇到这个问题。
②又见离谱bug。就拿
select * from test_simple_seq_1 s1 inner join test_simple_seq_2 s2 on s1.col1 = s2.col1;
这个sql语句来举例,最终输出如下
按理说iter应该是正常值,但是在第一次遍历时if(iter_!= correspond_value_.end())一直卡在这个判断里,即iter莫名其妙等于correspond_value_.end()。直到我打印了correspond_value_.size(),这才终于使iter正常了。
有一说一这次的Project确实究极恶心,要看太多代码了。如果上来就写就是一团浆糊,光分析代码就得分析个两天才能彻底理清楚。而且不同以往,这次各种task过于离散了,东一榔头西一棒槌,实现起来没有一个系统性的脉络,体验也不佳。总的来说复杂度7分,工作量9分。唯一值得表扬的就是给出了所有的测试点,方便我们一步步调试,算是功过相抵了。