文章目录
- Task1 访问方法执行程序
- seq_scan_executor
- insert_executor
- update_executor
- delete_executor
- index_scan_executor
- Task2 聚合和连接执行器
- Aggregation
- NestedLoopJoin
- HashJoin
- 优化NestedLoopJoin到HashJoin
- Task3 排序+限制执行器和Top-N优化
- Sort
- Limit
- Top-N优化规则
BusTub架构:
可见,Project3并不负责解析SQL语句,而是在执行计划已经形成之后,填充计划结点对应的执行过程(Query Execution)并做优化(Optimizer)
在tools/shell/shell.cpp
中main()
为开始函数,调用linenoise
工具负责命令行解析,两层死循环,内层负责逐词拼装sql,外层解析执行等。之后是Parser
,使用了Postgers的libpg_query
进行SQL解析。解析产生的Postgers解析树经过binder
转换为可以被BusTub规划器明确识别的绑定树。Planner
接受绑定树,并将其转换为BusTub计划树。执行引擎将使用计划树来执行语句。Optimizer
接受计划树,然后对它进行结构优化,从而加快DBMS执行效率。
之后就是执行,执行需要执行引擎ExecutionEngine
,执行引擎的工作就是调用执行器工厂方法ExecutorFactory::CreateExecutor()
根据优化后的计划树拼接出对应的执行器,具体就是通过直接递归从计划树的根到叶,最终生产出"执行器树",之后执行引擎PollExecutor()
轮询"执行器树",说是轮询其实就是不停调用"执行器树"的根执行器的Next()
,此时的轮询是因为执行器的Next()
一次只能返回一条记录,为了获得完整的结果只能轮询根执行器,而对非根执行器的Next()
调用是在根执行器的Next()
中完成的(Init()
同理),换句话说,请求从根执行器下去,一层一层到达叶执行器,叶执行器返回一条记录,又经过一层层执行器处理,最后到达根执行器得到一条执行结果。这就是火山模型——记录不停向上冒。
这样结合架构图看,我们只需要填充Executors
中各个执行器即可。
Task1 访问方法执行程序
在本任务中,您将实现对存储系统中的表进行读写的执行器。
seq_scan_executor
SeqScanExecutor 遍历表并每次返回一个元组
//SeqScanExecutor类私有属性
/** 需要执行的顺序扫描计划节点 */
const SeqScanPlanNode *plan_;
std::unique_ptr<TableIterator> it_; // 表的迭代器
IsolationLevel isl_; // 事务隔离级别
SeqScanExecutor::Init()
SeqScanExecutor::Next()
,叶执行器不需要再执行子执行器的Next()
,先加行级读写锁,通过表迭代器获取一对数据it_->GetTuple();
,数据类型为std::pair<TupleMeta, Tuple>
,pair
可以理解成两个返回值的封装,Tuple
是记录的相关信息,TupleMeta
是记录的事务id和是否删除的标记的结构体。若未删除就取出记录信息,将记录信息和获取的RID
(即记录在硬盘中的位置)赋给传入的参数,以供父执行器使用。之后解锁,表迭代器自增,指向下一个记录,其核心就是auto next_tuple_id=rid_.GetSlotNum()+1;
,在此之前需要获取当前页并强转为TablePage
,目的是获取现存记录数,从而确定迭代器是否要换页,slot_num_
表示记录在RID
标记的页的偏移量。
*tuple = pair.second;
*rid = tuple->GetRid();
表迭代器TableIterator
有对表堆TableHeap
的引用,表堆表示磁盘上的物理表
//TableHeap的私有属性
BufferPoolManager *bpm_;
page_id_t first_page_id_{INVALID_PAGE_ID};
std::mutex latch_;
page_id_t last_page_id_{INVALID_PAGE_ID}; /* protected by latch_ */
关于加解锁的问题后面统一分析。
insert_executor
InsertExecutor 将元组插入到表中,并更新任何受影响的索引。它只有一个子节点,生成要插入到表中的值。规划器 planner 将确保这些值具有与表相同的模式。执行器将生成一个整数类型的元组作为输出,表示有多少行已插入到表中。如果有与表关联的索引,请记住在插入表时更新索引。
//InsertExecutor类私有属性
/** 要执行的插入计划节点 */
const InsertPlanNode *plan_;
std::unique_ptr<AbstractExecutor> child_; // 这个是insert节点的子节点,也就是value节点,它一次产生一个tuple
const TableInfo *table_;//表的元信息
std::vector<IndexInfo *> indexs_;
bool is_end_;
InsertExecutor::Init()
,锁表,获取表和表索引。身为中间/根执行器,需要调用子执行器的Init()
,
InsertExecutor::Next()
,进入循环,条件是子处理器(其实是ValuesExecutor
)的Next()
返回true,因为可能要插入多个记录。通过table_
调用TableHeap::InsertTuple()
进行记录插入,实际是插入到了BufferPoolManager
的页中,获取返回的插入的记录所在的RID
,下一段代码是将插入动作添加到事务的撤销表中,以防回滚
auto twr = TableWriteRecord(table_->oid_, *rid, table_->table_.get()); // 写记录
twr.wtype_ = WType::INSERT;
exec_ctx_->GetTransaction()->AppendTableWriteRecord(twr); // 插入写记录
之后更新所有的表索引,先调用Tuple::KeyFromTuple()
获取新插入的元组的key,然后调用表索引的InssertEntry()
插入新元组的索引,然后同样将插入新索引的动作添加到事务的索引写集中,以防回滚
auto iwr = IndexWriteRecord(
*rid,
table_->oid_,
WType::INSERT,
key_tuple, index->index_oid_,
exec_ctx_->GetCatalog()); // 索引记录
exec_ctx_->GetTransaction()->AppendIndexWriteRecord(iwr); // 增加索引记录
最后将插入记录数封装成一个新元组,赋给传入参数,返回结束
update_executor
UpdateExecutor 可以修改指定表中的现有元组。执行器将生成一个整数类型的元组作为输出,指示更新了多少行。记住要更新受update影响的所有索引。
UpdateExecutor的子节点一般为Filter或者SeqScan,plan_->target_expressions_
包含待更新的值
Next()
循环中先遍历plan_->target_expressions_
中所有的表达式,并根据表达式产生一行新元组values.emplace_back(expr->Evaluate(tuple, table_info_->schema_));
,这种表达式如:SET colB=10
时,target_expressions_
为[#0.0, 10, #0.2, #0.3]
。
对于 target_exprs=[#0.0, 15445, #0.2, #0.3],target_exprs 数组的第二个表达式是ConstantValue 表达式,直接产生一个整数值;其余三个都是列值表达式,从一个元组的对应列分别得到值。
这样在遍历完后先将旧记录的元信息标记删除,然后直接调用TableHeap::InsertTuple()
插入新元组,后续同插入一样:记录新元组的位置、更新所有索引(获取旧和新元组的key,先DeleteEntry()
删除再InsertEntry()
插入)、封装更新记录数返回
delete_executor
只有一个子节点,子节点包含要从表中删除的记录。您的删除执行器应该生成一个整数输出,表示它从表中删除的行数。它还需要更新所有受影响的索引。
可以假设 DeleteExecutor 总是在它出现的查询计划的根位置。DeleteExecutor 不应该修改它的结果集。
Next()
循环中拿到要删除的记录的RID
,然后table_info_->table_->GetTupleMeta(next_rid);
获得记录的元信息,将is_deleted
置true后更新到table_info_
中表示被删除了。创建TableWriteRecord
对象,表示为删除动作,然后插入到事务的表写表中。遍历indexs_
更新所有索引DeleteEntry()
,并创建IndexWriteRecord
对象插入到事务的索引写集中,封装删除条数返回。
index_scan_executor
IndexScanExecutor 遍历索引以检索元组的’ rid '。操作符然后使用这些 RID 在相应的表中检索它们的元组。然后,它一次一个地发出这些元组。
using BPlusTreeIndexForTwoIntegerColumn = BPlusTreeIndex<IntegerKeyType, IntegerValueType, IntegerComparatorType>;
// IndexScanExecutor类的私有属性
/** The index scan plan node to be executed. */
const IndexScanPlanNode *plan_;
IndexInfo *index_info_; // 具体的列的索引
BPlusTreeIndexForTwoIntegerColumn *tree_; // b+树索引
IndexIterator<IntegerKeyType, IntegerValueType, IntegerComparatorType> iter_;
IndexIterator<IntegerKeyType, IntegerValueType, IntegerComparatorType> end_;
一个索引就是一个B+树,两个索引迭代器初始化如下:
iter_ = tree_->GetBeginIterator();//取树的叶子链表的左端
end_ = tree_->GetEndIterator();//取叶子链表的右端
Next()
中两个迭代器也不过是对B+树的叶子链表进行遍历获取同一索引列的不同行RID
而已,注意外部循环的作用是跳过is_deleted_
标记的已删除行,每次调Next()
只能返回一行的元组。
Task2 聚合和连接执行器
Aggregation
实现聚合的一种常用策略是使用散列表,以group-by列作为键。在这个项目中,您可以假设聚合哈希表适合内存。这意味着您不需要实现一个多阶段、基于分区的策略,并且哈希表不需要由缓冲池页面支持。
聚合执行器为每组输入计算一个聚合函数。它只有一个子节点。输出结果由grup-by的列紧接着是聚合列组成。
//AggregationExecutor的私有属性
/** The aggregation plan node */
const AggregationPlanNode *plan_;
/** 子执行器,它产生计算聚合的元组 */
std::unique_ptr<AbstractExecutor> child_executor_;
/** 简单聚合hash表 */
SimpleAggregationHashTable aht_;
/** Simple aggregation hash table iterator */
SimpleAggregationHashTable::Iterator aht_iterator_;
SimpleAggregationHashTable::Iterator aht_iterator_end_;
AggregationExecutor::Init()
初始化子执行器、聚合哈希表,并在这里执行子执行器的Next()
,调用MakeAggregateKey()
和MakeAggregateValue()
获得子执行器获得的记录在哈希表中的k-v,然后传入InsertCombine()
将k-v插入哈希表同时和之前聚合运算的结果进行聚合运算CombineAggregateValues()
。若运算的是空表,就调用InitInsert()
初始化插入。
SimpleAggregationHashTable
实质上是一个unordered_map
,键是分组列(GROUP BY
后列名)产生的,值是聚合列(聚合函数的参数列)产生的,map中的键不重复,调用count()
就可以查到有几个分组列,而map中存的也不是聚合列产生的AggregateValue
,而是聚合结果的数组(可能有多个聚合函数),数组中某个值就是当前分组的某个聚合操作的当前结果。这样在哈希表中天然地做到了分组,在CombineAggregateValues()
中和已有的计算数据进行聚合运算。
//SimpleAggregationHashTable私有属性
/** 哈希表只是从聚合键到聚合值的映射 */
std::unordered_map<AggregateKey, AggregateValue> ht_{}; // 聚合键和其对应的桶
/** 我们有的聚合表达式,即MAX(...),SUM(...)之类的 */
const std::vector<AbstractExpressionRef> &agg_exprs_;
/** 聚合类型集合 */
const std::vector<AggregationType> &agg_types_;
CombineAggregateValues()
传入哈希表中分组列的位置&ht_[agg_key]
和聚合列产生的AggregateValue
。遍历聚合函数列表(SELECT
后有多个聚合函数)进行switch-case选择聚合类型,每进一次该函数说明有一条记录需要进行聚合操作。
AggregationExecutor::Next()
每次调用都返回一个分组和它的聚合结果插在一起的vector
std::vector<Value> values; // 注意输出模式
values.insert(values.end(), aht_iterator_.Key().group_bys_.begin(), aht_iterator_.Key().group_bys_.end());
values.insert(values.end(), aht_iterator_.Val().aggregates_.begin(), aht_iterator_.Val().aggregates_.end());
封装成Tuple后哈希表迭代器自增,指向下一个分组。
NestedLoopJoin
默认情况下,DBMS将对所有连接操作使用NestedLoopJoinPlanNode。
您需要使用类中的简单嵌套循环连接算法为 NestedLoopJoinExecutor 实现内连接和左连接。该操作符的输出模式是左表的所有列,然后是右表的所有列。对于外部表中的每个元组,考虑内部表中的每个元组,如果满足连接谓词,则发出一个输出元组。
嵌套循环连接就类似SELECT * FROM a, b;
这样,对一个表的循环中对另一个表进行循环匹配。
/** The NestedLoopJoin plan node to be executed. */
const NestedLoopJoinPlanNode *plan_;
std::unique_ptr<AbstractExecutor> left_executor_;
std::unique_ptr<AbstractExecutor> right_executor_;
Tuple left_tuple_;//左表循环时的当前行
Tuple right_tuple_;//右表循环时的当前行
bool is_match_;//标识在一次右表循环中是否至少有一次匹配
Next()
每次调用产生一个左表的一行和匹配的右表某行的一个列表,无匹配则右表部分是空值:
//左表 //右表
------------- -------------
| a | b | | c | d |
------------- -------------
| 1 | n | | 1 | x |
------------- -------------
| 1 | y |
-------------
//一次Next()的结果
| 1 | n | 1 | x |
Next()
进行一个死循环,目的是循环左表if (!left_executor_->Next(&left_tuple_, rid)) return false;
,之后进入内层循环,即右表循环,这样就是简单嵌套循环连接。内层循环中调用plan_->Predicate()->EvaluateJoin(...);
来判断两个行记录是否匹配,可见是根据执行计划中的谓词来判断的,谓词即表达式,谓词表现为一个表达式树,这样,底层结点如ColumnValueExpression
就调用GetValue()
来获得元组中特定列的值。
//ComparisonExpression::EvaluateJoin()
auto EvaluateJoin(const Tuple *left_tuple, const Schema &left_schema,
const Tuple *right_tuple,const Schema &right_schema) const -> Value override {
Value lhs = GetChildAt(0)->EvaluateJoin(left_tuple, left_schema,
right_tuple, right_schema);//左子树
Value rhs = GetChildAt(1)->EvaluateJoin(left_tuple, left_schema,
right_tuple, right_schema);//右子树
return ValueFactory::GetBooleanValue(PerformComputation(lhs, rhs));//switch比较
}
如果判断匹配,调用GetOutputTuple()
并告知已匹配,获取返回的一行数据后返回真,从而让执行引擎继续轮询。
NestedLoopJoinExecutor::GetOutputTuple()
通过执行计划获得左右表的列数,先遍历tuple的各列将左表的一行添加进vector,若匹配成功,就同样将右表的一行添加进vector,封装成Tuple,私有属性is_match_
置true。
实际上,当右表没有记录和左表的一行匹配时,即在内部循环结束时要返回的vector还没有准备好,于是在外部循环中、内部循环之后添加判断is_match_
,若为false则需要在要返回的vector的右表部分插右表列数个空值,调用GetOutputTuple(tuple, false);
,之后调用right_executor_->Init();
来初始化右表迭代器,让右表从头再来,返回true。
//SeqScanExecutor::Init()
it_ = std::make_unique<TableIterator>(table->table_->MakeEagerIterator());// 在init中初始化,后面loop join会调用
之后就是左表的下一个,即left_executor_->Next(...)
,最后初始化右表迭代器,并恢复is_match_
HashJoin
如果查询包含两个列之间相等条件结合的连接(相等条件用“AND”分隔),DBMS可以使用HashJoinPlanNode。
您需要使用类中的散列连接算法实现 HashJoinExecutor 的内连接和左连接。该操作符的输出模式是左表的所有列,然后紧接着是右表的所有列。与聚合一样,您可以假设连接使用的哈希表完全适合内存。
哈希连接主要分为两个阶段:建立阶段(build phase)和探测阶段(probe phase)
Bulid Phase
选择一个表(一般情况下是较小的那个表,以减少建立哈希表的时间和空间),对其中每个元组上的连接属性(join attribute)采用哈希函数得到哈希值,从而建立一个哈希表。
Probe Phase
对另一个表,扫描它的每一行并计算连接属性的哈希值,与bulid phase建立的哈希表对比,若有落在同一个bucket的,如果满足连接谓词(predicate)则连接成新的表。
在内存足够大的情况下建立哈希表的过程时整个表都在内存中,完成连接操作后才放到磁盘里。但这个过程也会带来很多的I/O操作。
哈希连接(hash join)原理介绍 - 知乎 (zhihu.com)
//HashJoinExecutor私有属性
/** The NestedLoopJoin plan node to be executed. */
const HashJoinPlanNode *plan_;
std::unordered_map<JoinHashKey, JoinHashValue> ht_{};//哈希表,JoinHashValue本质是一个vector
std::unique_ptr<AbstractExecutor> left_child_;
std::unique_ptr<AbstractExecutor> right_child_;
std::vector<Tuple> match_right_tuples_; // 左右哈希键都有可能重复
Tuple left_tuple_;
//HashJoinPlanNode属性
/** 用于计算左JOIN键的表达式 */
std::vector<AbstractExpressionRef> left_key_expressions_;//left join key,即左连接表达式
/** 用于计算右JOIN键的表达式 */
std::vector<AbstractExpressionRef> right_key_expressions_;
/** The join type */
JoinType join_type_;
//SELECT * FROM test_1 JOIN test_2 ON test_1.colA = test_2.colB;
//左连接表达式 test_1.colA
//右连接表达式 test_2.colB
Init()
将左右子执行器初始化,遍历右子执行器对应的表while (right_child_->Next(&tuple, &rid)) {
,我们期望它是小表,这样哈希连接的性能才能发挥出来。循环内,获取右连接表达式,遍历表达式列表,通将右表当前元组的连接列的值插入到JoinHashKey
结构中(其实就是一个vector)形成哈希表中的一个key,将当前元组插入到该key对应的JoinHashValue
(也是一个vector),这样就完成哈希表的构建了。
Next()
死循环,是为了一个左表元组匹配多个右表元组(即"遍历"JoinHashValue
)。和NestedLoopJoin一样,执行引擎每次调用Next()
都会获得一条左表与右表匹配的结果,但是为了遍历JoinHashValue
,必须在两次调用间保存这个数组,所以有了match_right_tuples_
,因此Next()
死循环开头检查match_right_tuples_
,有就直接当作下一个匹配的右表元组取出、拼接并返回,而上一个调用需要返回Next()
开头执行检查代码,所以有了这个死循环。其他逻辑与NestedLoopJoin相同。
HashJoinExecutor::GetOutputTuple()
和NestedLoopJoinExecutor::GetOutputTuple()
相似,返回模式也一样。
优化NestedLoopJoin到HashJoin
哈希连接通常比嵌套循环连接产生更好的性能。当可以使用散列连接时,您应该修改优化器,将’ NestedLoopJoinPlanNode ‘转换为’ HashJoinPlanNode '。特别是,当连接谓词是两列之间的相等条件的连接时,可以使用散列连接算法。为了这个项目的目的,处理一个相等条件,以及两个由"AND"连接的相等条件,将获得全额学分。
优化器规则实现指南
BusTub优化器是一个基于规则的优化器。大多数优化器规则以自下而上的方式构建优化计划。由于查询计划具有这种树结构,因此在将优化器规则应用于当前计划节点之前,您需要首先将这些规则递归地应用于它的子节点。在每个计划节点上,您应该确定源计划结构是否与您试图优化的计划结构匹配,然后检查该计划中的属性,以查看是否可以将其优化为目标优化的计划结构。
在公共BusTub存储库中,我们已经提供了几个优化器规则的实现。请参考一下。
可以把优化器想象成一层层的优化层,每一个执行计划结点产生都经过所有优化层:
//optimizer_custom_rules.cpp
auto Optimizer::OptimizeCustom(const AbstractPlanNodeRef &plan) -> AbstractPlanNodeRef {
auto p = plan;
p = OptimizeMergeProjection(p);
p = OptimizeMergeFilterNLJ(p);
p = OptimizeNLJAsHashJoin(p);//待实现的优化函数
p = OptimizeOrderByAsIndexScan(p);
p = OptimizeSortLimitAsTopN(p);
return p;
}
Optimizer::OptimizeNLJAsHashJoin()
支持一个比较连接条件和由AND连接的两个比较连接条件,效果是传入一个NestedLoopJoinPlanNode
传出一个分析好连接条件的HashJoinPlanNode
,所以代码的核心工作就是通过传入的计划结点的谓词分析出哈希连接所需的左计划结点、右计划结点、左连接表达式数组(test_1.colA
)、右连接表达式数组、连接类型。
先递归执行传入的计划结点的子节点们的优化,然后将传入的计划结点强转成NestedLoopJoinPlanNode
,之后两个并列的大if分别处理没有and和有and的情况,有and和没有and的判断方法是将计划结点的谓词分别强转成逻辑表达式LogicExpression
和比较表达式ComparisonExpression
是否成功。
没有and时,我们只关心等值谓词,expr->comp_type_ == ComparisonType::Equal
,取出比较表达式的左值和右值,分别构建ColumnValueExpression
类型的左右表达式的共享指针,然后加到vector中,这是为了和有and的情况下一边有多个表达式相兼容。
std::vector<AbstractExpressionRef> l{left_expr_tuple_0};
std::vector<AbstractExpressionRef> r{right_expr_tuple_0};
通过ColumnValueExpression::GetTupleIdx()
获得该表达式来自连接(“JOIN”)的哪一侧,之后返回构建的HashJoinPlanNode
型共享指针。
//left_expr来自连接左侧
if (left_expr->GetTupleIdx() == 0 && right_expr->GetTupleIdx() == 1) {
return std::make_shared<HashJoinPlanNode>(
nlj_plan.output_schema_,
nlj_plan.GetLeftPlan(),
nlj_plan.GetRightPlan(),
l,
r,
nlj_plan.GetJoinType());
}
//left_expr来自连接右侧
if (left_expr->GetTupleIdx() == 1 && right_expr->GetTupleIdx() == 0) {
return std::make_shared<HashJoinPlanNode>(
nlj_plan.output_schema_,
nlj_plan.GetLeftPlan(),/*左计划结点*/
nlj_plan.GetRightPlan(),/*右计划结点*/
r, /*左连接表达式数组*/
l, /*右连接表达式数组*/
nlj_plan.GetJoinType());/*连接类型*/
}
之后是处理and,因为只会有一个and所以可以直接取左右两个表达式强转成ComparisonExpression
,只需处理两边都是相等条件的情况,哈希连接处理等值连接条件最方便,之后的流程和无and一样:取出比较表达式的左值和右值、分别构建ColumnValueExpression
类型的左右表达式的共享指针、加到vector中、根据GetTupleIdx()
安放连接表达式数组。
Task3 排序+限制执行器和Top-N优化
Sort
如果查询的’ ORDER BY '属性与索引的键不匹配,BusTub将为如下查询生成一个’ SortPlanNode ’
该计划节点具有与其输入模式相同的输出模式。您可以从’ order_bys ‘中提取排序键,然后使用’ std::sort '和自定义比较器对子节点的元组进行排序。
/** The sort plan node to be executed */
const SortPlanNode *plan_;
std::unique_ptr<AbstractExecutor> child_executor_;
std::vector<Tuple> result_; // 保存所有元组,并排序
size_t index_; // 标记是否全部发射
//SortPlanNode属性
std::vector<std::pair<OrderByType, AbstractExpressionRef>> order_bys_;
核心代码是Init()
中使用的std::sort()
将子执行器冒上来的全部记录进行排序,其中使用lambda表达式表示比较函数。
std::sort(..., [a = x](n, m){...});
//[a=x]是lambda表达式的捕获列表,用于指定在lambda函数体内部可以访问的外部变量,a是内部变量,x是外部变量
//(n, m)是lambda函数的参数列表,指定了lambda函数可以接受的参数
Next()
就是不断地从已排序的result_
中获取元组返回。
Limit
’ LimitExecutor '限制了其子执行器输出元组的数量。如果其子执行器生成的元组数量少于计划节点中指定的限制,则此执行器不起作用,并生成它接收到的所有元组。
该计划节点具有与其输入模式相同的输出模式。您不需要支持偏移量。
/** The limit plan node to be executed */
const LimitPlanNode *plan_;
/** The child executor from which tuples are obtained */
std::unique_ptr<AbstractExecutor> child_executor_;
std::size_t sum_; // 标记已发射多少tuple
auto LimitExecutor::Next(Tuple *tuple, RID *rid) -> bool {
if (child_executor_->Next(tuple, rid) && sum_ < plan_->GetLimit()) {
++sum_;
return true;
}
return false;
}
Top-N优化规则
EXPLAIN SELECT * FROM __mock_table_1 ORDER BY colA LIMIT 10;
默认情况下,BusTub将这个查询计划为’ SortPlanNode ‘和’ LimitPlanNode '。这是低效的,因为可以使用堆来跟踪最小的10个元素,这比对整个表进行排序要有效得多。
使用topN堆优化比单纯执行排序并限制输出数量快的原因是,topN堆优化可以在查询过程中动态地维护一个大小为N的堆,只保留当前最大(或最小)的N个元素,而不需要对整个结果集进行排序。
当执行带有order by和limit关键字的SQL时,单纯执行排序并限制输出数量需要对整个结果集进行排序操作,这个操作的时间复杂度通常是O(n log n),其中n是结果集的大小。而使用topN堆优化可以将排序的操作推迟到最后,只需要在堆中维护N个元素的有序性,时间复杂度通常是O(n log N),其中N是限制的输出数量。
因此,使用topN堆优化可以减少排序的操作次数和排序的数据量,从而提高查询的效率。尤其是当结果集很大时,topN堆优化可以显著减少排序操作的时间消耗,提升查询性能。
Init()
子执行器初始化,定义一个比较函数cmp,根据比较函数定义优先队列std::priority_queue<Tuple, std::vector<Tuple>, decltype(cmp)> myqueue(cmp);
,这个优先队列经过限制大小可以起到Top N堆优化的作用,优先队列是使用堆实现的,其思想是构建一个容量固定的容器来保持局部有序,超过容量的被抛弃,这样当遍历完所有数据时就能得出容量大小的Top N结果。比较函数中包含了针对ORDER BY
的判断,这样就可以一段代码分别应对处理生成大堆还是小堆。之后循环获取子执行器的所有结果添加到优先队列。此时优先队列已将所有元组进行排序,然后根据执行计划的n_
(即LIMIT
的值)将优先队列的前(top()
)n_
个插入进results_
。
auto cmp = [order_bys = plan_->order_bys_, schema = child_executor_->GetOutputSchema()](const Tuple &left_tuple, const Tuple &right_tuple) {
for (const auto &pair : order_bys) {
// 分别取出列值
const auto &left_value = pair.second->Evaluate(&left_tuple, schema);
const auto &right_value = pair.second->Evaluate(&right_tuple, schema);
if (pair.first == OrderByType::DESC) { // 降序,使用优先队列时:若是升序则是大顶堆,大的数在前,与sort相反
if (left_value.CompareLessThan(right_value) == CmpBool::CmpTrue) {
return true;
}
if (left_value.CompareGreaterThan(right_value) == CmpBool::CmpTrue) {
return false;
}
} else {
if (left_value.CompareGreaterThan(right_value) == CmpBool::CmpTrue) {
return true;
}
if (left_value.CompareLessThan(right_value) == CmpBool::CmpTrue) {
return false;
}
}
// 若相等,继续比较
}
return false; // 全部反过来
};
Next()
从results_
中获取元组。
优化是在Optimizer::OptimizeSortLimitAsTopN()
中实现的,同样是先取出执行计划的子结点进行递归优化,将优化好的孩子列表调用CloneWithChildren()
新克隆一个结点,强转为LimitPlanNode
取出计划类型是Sort
的子计划结点,强转为SortPlanNode
,返回TopNPlanNode
型的共享指针。