Hive是基于Hadoop的一个数据仓库系统,在各大公司都有广泛的应用。美团数据仓库也是基于Hive搭建,每天执行近万次的Hive ETL计算流程,负责每天数百GB的数据存储和分析。Hive的稳定性和性能对我们的数据分析非常关键。
在几次升级Hive的过程中,我们遇到了一些大大小小的问题。通过向社区的咨询和自己的努力,在解决这些问题的同时我们对Hive将SQL编译为MapReduce的过程有了比较深入的理解。对这一过程的理解不仅帮助我们解决了一些Hive的bug,也有利于我们优化Hive SQL,提升我们对Hive的掌控力,同时有能力去定制一些需要的功能。
详细讲解SQL编译为MapReduce之前,我们先来看看MapReduce框架实现SQL基本操作的原理
select u.name, o.orderid from order o join user u on o.uid = u.uid;
在map的输出value中为不同表的数据打上tag标记,在reduce阶段根据tag判断数据来源。MapReduce的过程如下(这里只是说明最基本的Join的实现,还有其他的实现方式)
MapReduce CommonJoin的实现
select rank, isonline, count(*) from city group by rank, isonline;
将GroupBy的字段组合为map的输出key值,利用MapReduce的排序,在reduce阶段保存LastKey区分不同的key。MapReduce的过程如下(当然这里只是说明Reduce端的非Hash聚合过程)
MapReduce Group By的实现
select dealid, count(distinct uid) num from order group by dealid;
当只有一个distinct字段时,如果不考虑Map阶段的Hash GroupBy,只需要将GroupBy字段和Distinct字段组合为map输出key,利用mapreduce的排序,同时将GroupBy字段作为reduce的key,在reduce阶段保存LastKey即可完成去重
MapReduce Distinct的实现
如果有多个distinct字段呢,如下面的SQL
select dealid, count(distinct uid), count(distinct date) from order group by dealid;
实现方式有两种:
(1)如果仍然按照上面一个distinct字段的方法,即下图这种实现方式,无法跟据uid和date分别排序,也就无法通过LastKey去重,仍然需要在reduce阶段在内存中通过Hash去重
MapReduce Multi Distinct的实现
(2)第二种实现方式,可以对所有的distinct字段编号,每行数据生成n行数据,那么相同字段就会分别排序,这时只需要在reduce阶段记录LastKey即可去重。
这种实现方式很好的利用了MapReduce的排序,节省了reduce阶段去重的内存消耗,但是缺点是增加了shuffle的数据量。
需要注意的是,在生成reduce value时,除第一个distinct字段所在行需要保留value值,其余distinct数据行value字段均可为空。
MapReduce Multi Distinct的实现
了解了MapReduce实现SQL基本操作之后,我们来看看Hive是如何将SQL转化为MapReduce任务的,整个编译过程分为六个阶段:
下面分别对这六个阶段进行介绍
Antlr
Hive使用Antlr实现SQL的词法和语法解析。Antlr是一种语言识别的工具,可以用来构造领域语言。 这里不详细介绍Antlr,只需要了解使用Antlr构造特定的语言只需要编写一个语法文件,定义词法和语法替换规则即可,Antlr完成了词法分析、语法分析、语义分析、中间代码生成的过程。
Hive中语法规则的定义文件在0.10版本以前是Hive.g一个文件,随着语法规则越来越复杂,由语法规则生成的Java解析类可能超过Java类文件的最大上限,0.11版本将Hive.g拆成了5个文件,词法规则HiveLexer.g和语法规则的4个文件SelectClauseParser.g,FromClauseParser.g,IdentifiersParser.g,HiveParser.g。
抽象语法树AST Tree
经过词法和语法解析后,如果需要对表达式做进一步的处理,使用 Antlr 的抽象语法树语法Abstract Syntax Tree,在语法分析的同时将输入语句转换成抽象语法树,后续在遍历语法树时完成进一步的处理。
下面的一段语法是Hive SQL中SelectStatement的语法规则,从中可以看出,SelectStatement包含select, from, where, groupby, having, orderby等子句。 (在下面的语法规则中,箭头表示对于原语句的改写,改写后会加入一些特殊词标示特定语法,比如TOK_QUERY标示一个查询块)
selectStatement
:
selectClause
fromClause
whereClause?
groupByClause?
havingClause?
orderByClause?
clusterByClause?
distributeByClause?
sortByClause?
limitClause? -> ^(TOK_QUERY fromClause ^(TOK_INSERT ^(TOK_DESTINATION ^(TOK_DIR TOK_TMP_FILE))
selectClause whereClause? groupByClause? havingClause? orderByClause? clusterByClause?
distributeByClause? sortByClause? limitClause?))
;
样例SQL
为了详细说明SQL翻译为MapReduce的过程,这里以一条简单的SQL为例,SQL中包含一个子查询,最终将数据写入到一张表中
FROM
(
SELECT
p.datekey datekey,
p.userid userid,
c.clienttype
FROM
detail.usersequence_client c
JOIN fact.orderpayment p ON p.orderid = c.orderid
JOIN default.user du ON du.userid = p.userid
WHERE p.datekey = 20131118
) base
INSERT OVERWRITE TABLE `test`.`customer_kpi`
SELECT
base.datekey,
base.clienttype,
count(distinct base.userid) buyer_count
GROUP BY base.datekey, base.clienttype
SQL生成AST Tree
Antlr对Hive SQL解析的代码如下,HiveLexerX,HiveParser分别是Antlr对语法文件Hive.g编译后自动生成的词法解析和语法解析类,在这两个类中进行复杂的解析。
HiveLexerX lexer = new HiveLexerX(new ANTLRNoCaseStringStream(command)); //词法解析,忽略关键词的大小写
TokenRewriteStream tokens = new TokenRewriteStream(lexer);
if (ctx != null) {
ctx.setTokenRewriteStream(tokens);
}
HiveParser parser = new HiveParser(tokens); //语法解析
parser.setTreeAdaptor(adaptor);
HiveParser.statement_return r = null;
try {
r = parser.statement(); //转化为AST Tree
} catch (RecognitionException e) {
e.printStackTrace();
throw new ParseException(parser.errors);
}
最终生成的AST Tree如下图右侧(使用Antlr Works生成,Antlr Works是Antlr提供的编写语法文件的编辑器),图中只是展开了骨架的几个节点,没有完全展开。 子查询1/2,分别对应右侧第1/2两个部分。
SQL生成AST Tree
这里注意一下内层子查询也会生成一个TOK_DESTINATION节点。请看上面SelectStatement的语法规则,这个节点是在语法改写中特意增加了的一个节点。原因是Hive中所有查询的数据均会保存在HDFS临时的文件中,无论是中间的子查询还是查询最终的结果,Insert语句最终会将数据写入表所在的HDFS目录下。
详细来看,将内存子查询的from子句展开后,得到如下AST Tree,每个表生成一个TOK_TABREF节点,Join条件生成一个“=”节点。其他SQL部分类似,不一一详述。
AST Tree
AST Tree仍然非常复杂,不够结构化,不方便直接翻译为MapReduce程序,AST Tree转化为QueryBlock就是将SQL进一部抽象和结构化。
QueryBlock
QueryBlock是一条SQL最基本的组成单元,包括三个部分:输入源,计算过程,输出。简单来讲一个QueryBlock就是一个子查询。
下图为Hive中QueryBlock相关对象的类图,解释图中几个重要的属性
QueryBlock
AST Tree生成QueryBlock
AST Tree生成QueryBlock的过程是一个递归的过程,先序遍历AST Tree,遇到不同的Token节点,保存到相应的属性中,主要包含以下几个过程
aliasToTabs
等属性中destToSelExpr
、destToAggregationExprs
、destToDistinctFuncExprs
三个属性中最终样例SQL生成两个QB对象,QB对象的关系如下,QB1是外层查询,QB2是子查询
QB1
\
QB2
Operator
Hive最终生成的MapReduce任务,Map阶段和Reduce阶段均由OperatorTree组成。逻辑操作符,就是在Map阶段或者Reduce阶段完成单一特定的操作。
基本的操作符包括TableScanOperator,SelectOperator,FilterOperator,JoinOperator,GroupByOperator,ReduceSinkOperator
从名字就能猜出各个操作符完成的功能,TableScanOperator从MapReduce框架的Map接口原始输入表的数据,控制扫描表的数据行数,标记是从原表中取数据。JoinOperator完成Join操作。FilterOperator完成过滤操作
ReduceSinkOperator将Map端的字段组合序列化为Reduce Key/value, Partition Key,只可能出现在Map阶段,同时也标志着Hive生成的MapReduce程序中Map阶段的结束。
Operator在Map Reduce阶段之间的数据传递都是一个流式的过程。每一个Operator对一行数据完成操作后之后将数据传递给childOperator计算。
Operator类的主要属性和方法如下
QueryBlock
QueryBlock生成Operator Tree
QueryBlock生成Operator Tree就是遍历上一个过程中生成的QB和QBParseInfo对象的保存语法的属性,包含如下几个步骤:
由于Join/GroupBy/OrderBy均需要在Reduce阶段完成,所以在生成相应操作的Operator之前都会先生成一个ReduceSinkOperator,将字段组合并序列化为Reduce Key/value, Partition Key
接下来详细分析样例SQL生成OperatorTree的过程
先序遍历上一个阶段生成的QB对象
首先根据子QueryBlock QB2#aliasToTabs {du=dim.user, c=detail.usersequence_client, p=fact.orderpayment}
生成TableScanOperator
TableScanOperator(“dim.user”) TS[0]
TableScanOperator(“detail.usersequence_client”) TS[1] TableScanOperator(“fact.orderpayment”) TS[2]
先序遍历QBParseInfo#joinExpr
生成QBJoinTree
,类QBJoinTree
也是一个树状结构,QBJoinTree
保存左右表的ASTNode和这个查询的别名,最终生成的查询树如下
base
/ \
p du
/ \
c p
前序遍历QBJoinTree
,先生成detail.usersequence_client
和fact.orderpayment
的Join操作树
Join to Operator
图中 TS=TableScanOperator RS=ReduceSinkOperator JOIN=JoinOperator
Join to Operator
QBParseInfo#destToWhereExpr
生成FilterOperator
。此时QB2遍历完成。下图中SelectOperator在某些场景下会根据一些条件判断是否需要解析字段。
Where to Operator
图中 FIL= FilterOperator SEL= SelectOperator
GroupBy to Operator
图中 GBY= GroupByOperator GBY[12]是HASH聚合,即在内存中通过Hash进行聚合运算
FileSinkOperator
图中FS=FileSinkOperator
大部分逻辑层优化器通过变换OperatorTree,合并操作符,达到减少MapReduce Job,减少shuffle数据量的目的。
名称 |
作用 |
---|---|
② SimpleFetchOptimizer |
优化没有GroupBy表达式的聚合查询 |
② MapJoinProcessor |
MapJoin,需要SQL中提供hint,0.11版本已不用 |
② BucketMapJoinOptimizer |
BucketMapJoin |
② GroupByOptimizer |
Map端聚合 |
① ReduceSinkDeDuplication |
合并线性的OperatorTree中partition/sort key相同的reduce |
① PredicatePushDown |
谓词前置 |
① CorrelationOptimizer |
利用查询中的相关性,合并有相关性的Job,HIVE-2206 |
ColumnPruner |
字段剪枝 |
表格中①的优化器均是一个Job干尽可能多的事情/合并。②的都是减少shuffle数据量,甚至不做Reduce。
CorrelationOptimizer优化器非常复杂,都能利用查询中的相关性,合并有相关性的Job,参考 Hive Correlation Optimizer
对于样例SQL,有两个优化器对其进行优化。下面分别介绍这两个优化器的作用,并补充一个优化器ReduceSinkDeDuplication的作用
PredicatePushDown优化器
断言判断提前优化器将OperatorTree中的FilterOperator提前到TableScanOperator之后
PredicatePushDown
NonBlockingOpDeDupProc优化器
NonBlockingOpDeDupProc
优化器合并SEL-SEL 或者 FIL-FIL 为一个Operator
NonBlockingOpDeDupProc
ReduceSinkDeDuplication优化器
ReduceSinkDeDuplication可以合并线性相连的两个RS。实际上CorrelationOptimizer是ReduceSinkDeDuplication的超集,能合并线性和非线性的操作RS,但是Hive先实现的ReduceSinkDeDuplication
譬如下面这条SQL语句
from (select key, value from src group by key, value) s select s.key group by s.key;
经过前面几个阶段之后,会生成如下的OperatorTree,两个Tree是相连的,这里没有画到一起
ReduceSinkDeDuplication
这时候遍历OperatorTree后能发现前前后两个RS输出的Key值和PartitionKey如下
|
Key |
PartitionKey |
---|---|---|
childRS |
key |
key |
parentRS |
key,value |
key,value |
ReduceSinkDeDuplication优化器检测到:1. pRS Key完全包含cRS Key,且排序顺序一致;2. pRS PartitionKey完全包含cRS PartitionKey。符合优化条件,会对执行计划进行优化。
ReduceSinkDeDuplication将childRS和parentheRS与childRS之间的Operator删掉,保留的RS的Key为key,value字段,PartitionKey为key字段。合并后的OperatorTree如下:
ReduceSinkDeDuplication
OperatorTree转化为MapReduce Job的过程分为下面几个阶段
对输出表生成MoveTask
由上一步OperatorTree只生成了一个FileSinkOperator,直接生成一个MoveTask,完成将最终生成的HDFS临时文件移动到目标表目录下
MoveTask[Stage-0]
Move Operator
开始遍历
将OperatorTree中的所有根节点保存在一个toWalk的数组中,循环取出数组中的元素(省略QB1,未画出)
开始遍历
取出最后一个元素TS[p]放入栈 opStack{TS[p]}中
Rule #1 TS% 生成MapReduceTask对象,确定MapWork
发现栈中的元素符合下面规则R1(这里用python代码简单表示)
"".join([t + "%" for t in opStack]) == "TS%"
生成一个MapReduceTask[Stage-1]
对象,MapReduceTask[Stage-1]
对象的MapWork
属性保存Operator根节点的引用。由于OperatorTree之间之间的Parent Child关系,这个时候MapReduceTask[Stage-1]
包含了以TS[p]
为根的所有Operator
Stage-1 生成Map阶段
Rule #2 TS%.*RS% 确定ReduceWork
继续遍历TS[p]的子Operator,将子Operator存入栈opStack中 当第一个RS进栈后,即栈opStack = {TS[p], FIL[18], RS[4]}时,就会满足下面的规则R2
"".join([t + "%" for t in opStack]) == "TS%.*RS%"
这时候在MapReduceTask[Stage-1]
对象的ReduceWork
属性保存JOIN[5]
的引用
Stage-1 生成Reduce阶段
Rule #3 RS%.*RS% 生成新MapReduceTask对象,切分MapReduceTask
继续遍历JOIN[5]的子Operator,将子Operator存入栈opStack中
当第二个RS放入栈时,即当栈opStack = {TS[p], FIL[18], RS[4], JOIN[5], RS[6]}
时,就会满足下面的规则R3
"".join([t + "%" for t in opStack]) == “RS%.*RS%” //循环遍历opStack的每一个后缀数组
这时候创建一个新的MapReduceTask[Stage-2]
对象,将OperatorTree从JOIN[5]
和RS[6]
之间剪开,并为JOIN[5]
生成一个子Operator FS[19]
,RS[6]
生成一个TS[20]
,MapReduceTask[Stage-2]
对象的MapWork
属性保存TS[20]
的引用。
新生成的FS[19]
将中间数据落地,存储在HDFS临时文件中。
Stage-2
继续遍历RS[6]的子Operator,将子Operator存入栈opStack中
当opStack = {TS[p], FIL[18], RS[4], JOIN[5], RS[6], JOIN[8], SEL[10], GBY[12], RS[13]}
时,又会满足R3规则
同理生成MapReduceTask[Stage-3]
对象,并切开 Stage-2 和 Stage-3 的OperatorTree
Stage-3
R4 FS% 连接MapReduceTask与MoveTask
最终将所有子Operator存入栈中之后,opStack = {TS[p], FIL[18], RS[4], JOIN[5], RS[6], JOIN[8], SEL[10], GBY[12], RS[13], GBY[14], SEL[15], FS[17]}
满足规则R4
"".join([t + "%" for t in opStack]) == “FS%”
这时候将MoveTask
与MapReduceTask[Stage-3]
连接起来,并生成一个StatsTask
,修改表的元信息
MoveTask
合并Stage
此时并没有结束,还有两个根节点没有遍历。
将opStack栈清空,将toWalk的第二个元素加入栈。会发现opStack = {TS[du]}
继续满足R1 TS%,生成MapReduceTask[Stage-5]
Stage-5
继续从TS[du]
向下遍历,当opStack={TS[du], RS[7]}
时,满足规则R2 TS%.*RS%
此时将JOIN[8]
保存为MapReduceTask[Stage-5]
的ReduceWork
时,发现在一个Map对象保存的Operator与MapReduceWork对象关系的Map
对象中发现,JOIN[8]
已经存在。此时将MapReduceTask[Stage-2]
和MapReduceTask[Stage-5]
合并为一个MapReduceTask
合并 Stage-2 和 Stage-5
同理从最后一个根节点TS[c]
开始遍历,也会对MapReduceTask进行合并
合并 Stage-1 和 Stage-6
切分Map Reduce阶段
最后一个阶段,将MapWork和ReduceWork中的OperatorTree以RS为界限剪开
切分Map Reduce阶段
OperatorTree生成MapReduceTask全貌
最终共生成3个MapReduceTask,如下图
OperatorTree生成MapReduceTask全貌
这里不详细介绍每个优化器的原理,单独介绍一下MapJoin的优化器
名称 |
作用 |
---|---|
Vectorizer |
HIVE-4160,将在0.13中发布 |
SortMergeJoinResolver |
与bucket配合,类似于归并排序 |
SamplingOptimizer |
并行order by优化器,在0.12中发布 |
CommonJoinResolver + MapJoinResolver |
MapJoin优化器 |
MapJoin原理
mapjoin原理
MapJoin简单说就是在Map阶段将小表读入内存,顺序扫描大表完成Join。
上图是Hive MapJoin的原理图,出自Facebook工程师Liyin Tang的一篇介绍Join优化的slice,从图中可以看出MapJoin分为两个阶段:
通过MapReduce Local Task,将小表读入内存,生成HashTableFiles上传至Distributed Cache中,这里会对HashTableFiles进行压缩。
MapReduce Job在Map阶段,每个Mapper从Distributed Cache读取HashTableFiles到内存中,顺序扫描大表,在Map阶段直接进行Join,将数据传递给下一个MapReduce任务。
conditionaltask
如果Join的两张表一张表是临时表,就会生成一个ConditionalTask,在运行期间判断是否使用MapJoin
CommonJoinResolver优化器
CommonJoinResolver优化器就是将CommonJoin转化为MapJoin,转化过程如下
遍历上一个阶段生成的MapReduce任务,发现MapReduceTask[Stage-2]
JOIN[8]
中有一张表为临时表,先对Stage-2进行深度拷贝(由于需要保留原始执行计划为Backup Plan,所以这里将执行计划拷贝了一份),生成一个MapJoinOperator替代JoinOperator,然后生成一个MapReduceLocalWork读取小表生成HashTableFiles上传至DistributedCache中。
mapjoin变换
MapReduceTask经过变换后的执行计划如下图所示
mapjoin变换
MapJoinResolver优化器
MapJoinResolver优化器遍历Task Tree,将所有有local work的MapReduceTask拆成两个Task
MapJoinResolver
最终MapJoinResolver处理完之后,执行计划如下图所示
MapJoinResolver
从上述整个SQL编译的过程,可以看出编译过程的设计有几个优点值得学习和借鉴
Hive依然在迅速的发展中,为了提升Hive的性能,hortonworks公司主导的Stinger计划提出了一系列对Hive的改进,比较重要的改进有:
我们也将跟进社区的发展,结合自身的业务需要,提升Hive型ETL流程的性能
Antlr: http://www.antlr.org/ Wiki Antlr介绍: http://en.wikipedia.org/wiki/ANTLR Hive Wiki: https://cwiki.apache.org/confluence/display/Hive/Home HiveSQL编译过程: http://www.slideshare.net/recruitcojp/internal-hive Join Optimization in Hive: Join Strategies in Hive from the 2011 Hadoop Summit (Liyin Tang, Namit Jain) Hive Design Docs: https://cwiki.apache.org/confluence/display/Hive/DesignDocs
Hive, MapReduce
原文链接:https://tech.meituan.com/2014/02/12/hive-sql-to-mapreduce.html