执行计划
Impala执行DML查询的执行计划与普通SELECT相同,从EXPLAIN的结果中可以看出,执行计划基本没有区别,左边为普通SELECT查询的执行计划,右边为CTAS建表的执行计划,只是多了一个写入hdfs的部分。
执行过程
分析代码可以发现,Impala在接收查询的入口处将查询分为多种,大致如以下伪代码所示:
switch (exec_request_.stmt_type) {
case TStmtType::QUERY: //普通查询和DML一起处理
case TStmtType::DML: //DML查询
RETURN_IF_ERROR(ExecAsyncQueryOrDmlRequest(exec_request_.query_exec_request));
break;
case TStmtType::EXPLAIN: //只展示执行计划的EXPLAIN查询
set_explain_results();break;
case TStmtType::TESTCASE: //根据输入数据进行测试
do_test_case();break;
case TStmtType::DDL: //DDL查询
do_ddl();break;
case TStmtType::LOAD: //LOAD操作
do_load();break;
case TStmtType::SET: //query option执行
do_set();break;
case TStmtType::ADMIN_FN: //管理员操作,目前只有granceful shutdown功能
do_shutdown();break;
default:
return errmsg; //未知查询类型,返回报错
}
在这里,CTAS和INSERT OVERWRITE的处理稍有不同,CTAS其实是作为DDL执行的,即作为TStmtType::DDL传入,但在具体的处理逻辑中,完成元数据初始化相关操作后Impala还是会调用到DML处理中的ExecAsyncQueryOrDmlRequest函数,相当于CTAS多了一个建表的过程,但后续操作与INSERT OVERWRITE或INSERT INTO相同,具体可见DDL操作调用的ExecDdlRequest函数实现伪代码:
Status ClientRequestState::ExecDdlRequest() {
`Print(op_type); //打印操作类型
if (TCatalogOpType::RESET_METADATA) {
do_reset_metadata(); //执行元数据重置(刷新)操作,一般为refresh或invalidate metadata
return;
}
if (ddl_type() == TDdlType::COMPUTE_STATS) {
do_compute_stats(); //执行统计信息计算
return;
}
Status status = catalog_op_executor_->Exec(exec_request_.catalog_op_request); //在执行过上面的操作后更新当前元数据状态
if (TDdlType::CREATE_TABLE_AS_SELECT
&& !catalog_op_executor_->ddl_exec_response()->new_table_created) {
do_nothing(); //当用户提交的CTAS包含了IF NOT EXISTS,且表已存在时,不进行任何操作
return;
}
if (TDdlType::CREATE_TABLE_AS_SELECT) {
// CTAS真正执行的位置
RETURN_IF_ERROR(ExecAsyncQueryOrDmlRequest(exec_request_.query_exec_request));
}
return;
}
从以上代码可以看出,Impala其实把DML的操作实现统一在一个函数内实现了,然后通过一个统一的query_exec_request对象来保存查询信息,查询如何执行要根据这个对象的成员来进行判断。上面我们提到了DML最终都调用了同一个函数ExecAsyncQueryOrDmlRequest,我们来看下这个函数内部的伪代码实现:
Status ClientRequestState::ExecAsyncQueryOrDmlRequest(
const TQueryExecRequest& query_exec_request) {
query_plan_init(); //进行一些参数判断,并初始化执行计划
if_stats_missing_or_corrupt(); //确定哪些表的统计信息缺失或崩溃,以及是否有scan range的block元数据丢失
if (is_cancelled_) return Status::CANCELLED; //如果此时用户cancel查询,则退出
//对于每个查询,起一个处理线程用来执行查询,直到查询完成或退出
RETURN_IF_ERROR(Thread::Create("query-exec-state", "async-exec-thread",
&ClientRequestState::FinishExecQueryOrDmlRequest, this, &async_exec_thread_, true));
return Status::OK();
}
由上可见,ExecAsyncQueryOrDmlRequest函数其实是初始化了一些统计信息相关的信息到查询profile中,这就是我们在profile的执行计划中看到的Impala提示统计信息缺失等提示的来源,如下图所示:
在统计信息相关处理完成后,Impala会对每个查询单独起一个线程用来处理这个查询,直到查询完成。线程函数的实现在FinishExecQueryOrDmlRequest中。这个函数我们可以简化来看,除一些参数检查、状态注册、executor注册外,主要有两个比较重要的地方,一个是SubmitForAdmission(),另一个是coord_->Exec(),即队列准入和正式开始coordinator上的查询执行。随后开始一些be端和执行计划分片的初始化,在开始执行之前把Runtime filter广播到其他impalad上。至此,所有执行前的准备工作就执行完成了,后面通过StartBackendExec()函数正式开始执行,其中通过调用ExecAsync()函数中的ExecQueryFInstancesAsync()函数来rpc调用control-service.cc中的ExecQueryFInstances()函数,ExecQueryFInstancesAsync函数是通过protobuf生成出来的,具体逻辑在control_service.proto中定义,这个protobuf文件中还定义了一些rpc相关的状态变量、DML相关的状态及统计函数等等。其中调用了QueryExecMgr类中的StartQuery函数,QueryExecMgr是一个管理查询执行和执行状态的类,所有查询的注册均会通过此类中的方法来进入到impalad中执行。StartQuery函数中会起一个线程用来管理此次查询的执行,线程会调用ExecuteQueryHelper函数,其中调用了QueryState类的StartFInstances函数,针对每个执行计划分片,Impala都会启动一个线程来进行执行,调用ExecFInstance函数,并最终调用到FragmentInstanceState类中的Exec()和ExecInternal函数。其中核心的执行部分如下所示:
do {
Status status;
row_batch_->Reset();
{
SCOPED_TIMER(plan_exec_timer);
RETURN_IF_ERROR(
exec_tree_->GetNext(runtime_state_, row_batch_.get(), &exec_tree_complete));
event_sequence_->MarkEvent("Row batch got");
}
UpdateState(StateEvent::BATCH_PRODUCED);
if (VLOG_ROW_IS_ON) row_batch_->VLogRows("FragmentInstanceState::ExecInternal()");
COUNTER_ADD(rows_produced_counter_, row_batch_->num_rows());
RETURN_IF_ERROR(sink_->Send(runtime_state_, row_batch_.get()));
event_sequence_->MarkEvent("Row batch written");
UpdateState(StateEvent::BATCH_SENT);
} while (!exec_tree_complete);
执行过程中,Impala会不停地调用GetNext来扫描数据,存储到row_batch_对象中,每个rowbatch扫描完成后都会执行Send()函数,根据查询类型的不同,Send()函数有不同的实现(均继承了DataSink类),在HDFS上的DML查询一般会用到HdfsTableSink::Send(),执行过程中每个executor会将每个执行计划分片扫描到的row_batch_写入临时文件,直到全部执行计划执行完成。在Send()函数中,Impala根据不同情况会使用不同的写入方式,具体实现在hdfs-table-sink.cc(写kudu表则为kudu-table-sink.cc),大致可分为三种情况:1)静态分区写入;2)动态分区写入;3)聚簇写入(Clustered Insert)。每种方式其实最终调用了同一个函数进行写入,但参数有所不同,下面分别介绍:
静态/动态分区写入
Impala通过dynamic_partition_key_expr_evals_这个vector是否为空来判断是否进行静态分区写入,这个参数的初始化根据SQL中的分区表达式是否指定了常量值来进行。举例来讲,如果是如下SQL,则dynamic_partition_key_expr_evals_中就会有dt='2021-01-01’的分区表达式;
INSERT INTO DB.TABLE PARTITION (dt='2021-01-01') SELECT * FROM DB.TABLE2
如果像如下SQL这样没有指定分区值,则dynamic_partition_key_expr_evals_就是空的,会进入静态分区写入的处理:
INSERT INTO DB.TABLE PARTITION (dt) SELECT * FROM DB.TABLE2
这种动态分区的情况下,INSERT最后跟着的SELECT语句中select列表的字段数或VALUES子句中的字段数必须完全匹配被INSERT表的字段数(包括未指定分区值的分区字段),未指定分区值的分区字段将以select或values子句列表的最后一列作为分区值进行插入。
聚簇写入(Clustered Insert)
当INSERT语句中加入了/* +CLUSTERED */的HINT时,会执行聚簇写入的逻辑。在写入前,Impala会先对数据按分区进行排序,确保写入时是按照一个分区一个分区的顺序进行写入的,在写入Parquet文件时这样可以在一定程度上降低文件数。
无论哪种写入方式,最终都会分别调用两个函数进行写入操作:GetOutputPartition和WriteRowsToPartition。GetOutputPartition会先在Impala的临时写入目录(默认在表目录)创建并保持打开要写入的文件,然后根据存储类型来初始化TEXT或Parquet的writer;WriteRowsToPartition会经过多层调用,最终调用到Write函数进行文件的写入(同样根据TEXT和Parquet有两种写入方式)。所有写入完成后,Impala将之前打开的文件逐一关闭。此时,所有SQL执行基本结束,剩余的处理基本就是变量的释放、metric数据记录等等。最终返回到执行入口处的处理函数Execute()执行完成后,会调用request_state->Wait()以及GetCoordinator()->Wait()函数进行后续处理。在coordinator中,如果查询为DML,那么会等待所有executor执行完成并返回结果,然后coordinator开始进行文件搬运,将写入的临时文件移动到最终的表目录中,这里操作完成后,查询就可以说执行完毕了。整体SQL的执行过程可见下图:
总结
Impala的DML大致可分为三个阶段:1)coordinator下发执行计划到executor(CTAS这种DDL也会执行类似DML的逻辑);2)executor执行执行计划中的SELECT部分,并将row batch结果写入临时文件;3)所有executor执行完成,coordinator搬运数据文件到最终的表目录。DML查询的执行计划基本与普通查询相同,只是在处理过程中根据查询类型会额外执行一些操作,并在所有executor全部执行完才会开始文件的移动操作,所以coordinator会有一个同步等待executor的过程。需要注意的是,当查询(包括DML)规模很小时(如带有LIMIT 1子句),Impala会优化执行计划,不将执行计划下发到executor,而是直接在coordinator上执行。