一、引言
在大数据处理中,数据倾斜(Data Skew)是困扰开发者的常见问题。想象一下:当你运行一个Hive Join作业时,99%的Reduce任务都在1分钟内完成,但有1个Reduce却运行了2小时——这很可能是因为某几个Key的分布极端不均(比如某个用户ID出现了1000万次),导致该Reduce节点处理的数据量远超其他节点。
为了解决这个问题,Hive提供了自动数据倾斜优化(Skew Join Optimization)。本文将结合Hive源码(SkewJoinOptimizer
类),深入解析其底层实现逻辑,带你看清Hive是如何“智能”处理数据倾斜的。
二、数据倾斜的本质与Hive的解决思路
1. 数据倾斜的根源
数据倾斜通常发生在Shuffle阶段,当某个Key的出现频率远高于其他Key时,所有包含该Key的数据都会被发送到同一个Reduce节点,导致该节点成为“瓶颈”。常见场景包括:
- Key分布不均:比如某电商平台的“双11”订单中,某个热门商品的订单量占比90%。
- Join操作中的小表倾斜:小表的某个Key匹配大表的大量数据(比如小表的“null”值匹配大表的所有数据)。
- 聚合操作倾斜:比如
COUNT(DISTINCT)
对高基数Key的聚合(比如统计用户的唯一访问次数)。
2. Hive的Skew Join优化思路
Hive的核心解决思路是**“拆分倾斜数据,分别处理”**:
- 步骤1:检测倾斜Key:通过统计信息或动态采样,识别出Join操作中的倾斜Key(比如出现次数超过阈值的Key)。
- 步骤2:拆分执行计划:将原Join作业拆分为两个部分:
- 非倾斜数据处理:过滤掉倾斜Key,按正常Join流程处理(Map → Shuffle → Reduce)。
- 倾斜数据处理:对倾斜Key的数据单独处理(比如使用Map Join广播小表数据,或拆分到多个Reduce节点)。
- 步骤3:合并结果:将两部分的结果通过Union合并,得到最终结果。
三、源码深度解析:SkewJoinOptimizer的实现逻辑
Hive的Skew Join优化由org.apache.hadoop.hive.ql.optimizer.SkewJoinOptimizer
类实现,该类是Hive优化器的一部分,负责在物理计划阶段重写执行计划。以下是其核心逻辑的分步解析:
1. 优化触发条件:哪些Join会被处理?
SkewJoinOptimizer通过规则匹配(RuleRegExp
)触发优化,规则为"TS%.*RS%JOIN%"
,即匹配表扫描(TS)→ 任意算子 → ReduceSink(RS)→ Join的执行计划。
在transform
方法中,优化器会遍历所有Top Operator(比如表扫描算子),并触发SkewJoinProc
处理器:
@Override
public ParseContext transform(ParseContext pctx) throws SemanticException {
Map<SemanticRule, SemanticNodeProcessor> opRules = new LinkedHashMap<>();
opRules.put(new RuleRegExp("R1", "TS%.*RS%JOIN%"), getSkewJoinProc(pctx));
// 初始化上下文,启动遍历
SkewJoinOptProcCtx ctx = new SkewJoinOptProcCtx(pctx);
SemanticDispatcher disp = new DefaultRuleDispatcher(null, opRules, ctx);
SemanticGraphWalker walker = new DefaultGraphWalker(disp);
walker.startWalking(new ArrayList<>(pctx.getTopOps().values()), null);
return pctx;
}
2. 核心处理器:SkewJoinProc的process方法
SkewJoinProc
是处理Skew Join的核心处理器,其process
方法负责:
- 检测Join操作是否包含倾斜Key;
- 重写执行计划,拆分倾斜数据和非倾斜数据;
- 插入过滤算子和Union算子,合并结果。
(1)第一步:获取表扫描算子(getTableScanOpsForJoin)
为了检测倾斜Key,需要先获取Join操作涉及的表扫描算子(TableScanOperator)。getTableScanOpsForJoin
方法递归遍历Join算子的父节点,收集所有表扫描算子:
private boolean getTableScanOpsForJoin(JoinOperator op, List<TableScanOperator> tsOps) {
for (Operator<? extends OperatorDesc> parent : op.getParentOperators()) {
if (!getTableScanOps(parent, tsOps)) {
return false;
}
}
return true;
}
private boolean getTableScanOps(Operator<? extends OperatorDesc> op, List<TableScanOperator> tsOps) {
for (Operator<? extends OperatorDesc> parent : op.getParentOperators()) {
if (!parent.supportSkewJoinOptimization()) { // 检查是否支持倾斜优化
return false;
}
if (parent instanceof TableScanOperator) {
tsOps.add((TableScanOperator) parent);
} else if (!getTableScanOps(parent, tsOps)) {
return false;
}
}
return true;
}
注意:只有支持supportSkewJoinOptimization
的算子(比如简单的过滤、选择算子)才会被处理,复杂算子(比如自定义UDF)会跳过。
(2)第二步:检测倾斜Key(getSkewedValues)
获取表扫描算子后,getSkewedValues
方法会从表的元数据中获取倾斜Key和对应的倾斜值。例如,若表user_order
的user_id
列有倾斜值[1001, 1002]
,则该方法会返回这些值:
private Map<List<ExprNodeDesc>, List<List<String>>> getSkewedValues(
Operator<? extends OperatorDesc> op, List<TableScanOperator> tableScanOpsForJoin) throws SemanticException {
Map<List<ExprNodeDescEqualityWrapper>, List<List<String>>> skewData = new HashMap<>();
for (Operator<? extends OperatorDesc> reduceSinkOp : op.getParentOperators()) {
ReduceSinkDesc rsDesc = ((ReduceSinkOperator) reduceSinkOp).getConf();
for (ExprNodeDesc keyColDesc : rsDesc.getKeyCols()) {
if (keyColDesc instanceof ExprNodeColumnDesc) {
ExprNodeColumnDesc keyCol = (ExprNodeColumnDesc) keyColDesc;
Table table = getTableFromTableScan(reduceSinkOp, tableScanOpsForJoin);
List<String> skewedColumns = table.getSkewedColNames();
List<List<String>> skewedValues = table.getSkewedColValues();
// 匹配Join Key与倾斜列,收集倾斜值
int pos = skewedColumns.indexOf(keyCol.getColumn());
if (pos >= 0) {
List<String> skewedValue = skewedValues.get(pos);
// 将倾斜值存入skewData
List<ExprNodeDescEqualityWrapper> joinKeys = new ArrayList<>();
joinKeys.add(new ExprNodeDescEqualityWrapper(keyCol));
skewData.put(joinKeys, skewedValue);
}
}
}
}
// 转换为返回格式
Map<List<ExprNodeDesc>, List<List<String>>> result = new HashMap<>();
for (Entry<List<ExprNodeDescEqualityWrapper>, List<List<String>>> entry : skewData.entrySet()) {
List<ExprNodeDesc> keys = entry.getKey().stream().map(ExprNodeDescEqualityWrapper::getExprNodeDesc).collect(Collectors.toList());
result.put(keys, entry.getValue());
}
return result;
}
关键逻辑:
- 从ReduceSink算子的Key列中提取Join Key;
- 从表的元数据中获取倾斜列和倾斜值;
- 匹配Join Key与倾斜列,收集倾斜值。
(3)第三步:重写执行计划(rewriteSkewJoinPlan)
若检测到倾斜Key,rewriteSkewJoinPlan
方法会重写执行计划,将原Join拆分为非倾斜部分和倾斜部分:
private void rewriteSkewJoinPlan(JoinOperator joinOp, SkewJoinOptProcCtx ctx) throws SemanticException {
// 1. 克隆原Join算子及其子算子(比如Select算子)
Operator<? extends OperatorDesc> currOp = joinOp;
if (joinOp.getChildOperators().get(0) instanceof SelectOperator) {
currOp = joinOp.getChildOperators().get(0);
}
Operator<? extends OperatorDesc> currOpClone = currOp.clone();
// 2. 插入过滤算子,分离倾斜数据和非倾斜数据
// 非倾斜部分:过滤掉倾斜Key,正常Join
insertSkewFilter(joinOp, skewedValues, false); // false表示过滤倾斜数据
// 倾斜部分:保留倾斜Key,单独处理
insertSkewFilter(currOpClone, skewedValues, true); // true表示保留倾斜数据
// 3. 合并结果:使用Union算子合并两部分结果
Operator<? extends OperatorDesc> unionOp = createUnionOperator(currOp, currOpClone);
// 将Union算子插入到执行计划中
replaceOriginalOperatorWithUnion(joinOp, unionOp);
}
关键步骤:
- 克隆算子:克隆原Join算子及其子算子,用于处理倾斜数据;
- 插入过滤算子:通过
insertSkewFilter
方法,在原Join算子前插入过滤条件(过滤倾斜Key),在克隆的Join算子前插入过滤条件(保留倾斜Key); - 合并结果:使用Union算子合并两部分的结果,得到最终执行计划。
(4)第四步:插入过滤条件(insertSkewFilter)
insertSkewFilter
方法负责构建过滤条件,并将其插入到表扫描算子之后。例如,若倾斜Key是user_id = 1001
,则过滤条件为user_id != 1001
(非倾斜部分)或user_id = 1001
(倾斜部分):
private void insertSkewFilter(
Operator<? extends OperatorDesc> op,
Map<List<ExprNodeDesc>, List<List<String>>> skewedValues,
boolean keepSkewed) {
ExprNodeDesc filterExpr = constructFilterExpr(skewedValues, keepSkewed);
TableScanOperator tableScanOp = getTableScanOperator(op);
// 插入过滤算子
Operator<FilterDesc> filterOp = OperatorFactory.getAndMakeChild(
new FilterDesc(filterExpr, false),
new RowSchema(tableScanOp.getSchema().getSignature()),
tableScanOp
);
// 更新算子关系:TableScan → Filter → 原算子
tableScanOp.setChildOperators(Collections.singletonList(filterOp));
filterOp.setChildOperators(Collections.singletonList(op));
}
private ExprNodeDesc constructFilterExpr(
Map<List<ExprNodeDesc>, List<List<String>>> skewedValues,
boolean keepSkewed) {
ExprNodeDesc filterExpr = null;
for (Entry<List<ExprNodeDesc>, List<List<String>>> entry : skewedValues.entrySet()) {
List<ExprNodeDesc> keyCols = entry.getKey();
List<List<String>> skewedValueList = entry.getValue();
// 构建OR条件:(key1 = val1) OR (key1 = val2)
ExprNodeDesc orExpr = constructOrCondition(keyCols, skewedValueList);
// 若keepSkewed为false,则添加NOT条件:NOT (OR条件)
if (!keepSkewed) {
orExpr = ExprNodeGenericFuncDesc.newInstance(new GenericUDFOPNot(), Collections.singletonList(orExpr));
}
// 合并所有条件为AND
if (filterExpr == null) {
filterExpr = orExpr;
} else {
filterExpr = ExprNodeGenericFuncDesc.newInstance(new GenericUDFOPAnd(), Arrays.asList(filterExpr, orExpr));
}
}
return filterExpr;
}
核心逻辑:
- 构建OR条件:将倾斜Key的所有值用OR连接(比如
user_id = 1001 OR user_id = 1002
); - 添加NOT条件:若处理非倾斜数据,则在OR条件前添加NOT(比如
NOT (user_id = 1001 OR user_id = 1002)
); - 插入过滤算子:将过滤条件插入到表扫描算子之后,确保数据在进入Join之前被过滤。
四、实际案例:Skew Join优化的执行计划变化
为了更直观地理解Skew Join优化的效果,我们举一个实际案例:
1. 原执行计划(未优化)
假设我们有两个表:
user
表:存储用户信息,user_id
列有倾斜值[1001, 1002]
;order
表:存储订单信息,user_id
列是Join Key。
原执行计划为:
TableScan(user) → ReduceSink → Join → Select → Result
TableScan(order) → ReduceSink → /
此时,user_id = 1001
的订单会被发送到同一个Reduce节点,导致数据倾斜。
2. 优化后的执行计划(Skew Join)
优化后,执行计划被拆分为两部分:
- 非倾斜部分:过滤掉
user_id = 1001
和user_id = 1002
的记录,正常Join; - 倾斜部分:保留
user_id = 1001
和user_id = 1002
的记录,使用Map Join(广播user
表的倾斜数据)处理。
优化后的执行计划为:
// 非倾斜部分:过滤倾斜数据,正常Join
TableScan(user) → Filter(user_id NOT IN (1001, 1002)) → ReduceSink → Join → Select → Union → Result
TableScan(order) → Filter(user_id NOT IN (1001, 1002)) → ReduceSink → /
// 倾斜部分:保留倾斜数据,Map Join处理
TableScan(user) → Filter(user_id IN (1001, 1002)) → MapJoin → Select → Union → /
TableScan(order) → Filter(user_id IN (1001, 1002)) → /
效果:
- 非倾斜数据按正常流程处理,避免了倾斜;
- 倾斜数据通过Map Join处理,无需Shuffle,减少了Reduce节点的压力。
五、调优建议:如何充分利用Skew Join优化?
1. 开启优化
通过以下参数开启Skew Join优化:
SET hive.optimize.skewjoin = true; -- 开启Skew Join优化(默认false)
SET hive.skewjoin.key = 100000; -- 倾斜Key的阈值(默认100000,即Key出现次数超过10万则视为倾斜)
SET hive.skewjoin.mapjoin.min.split = 33554432; -- 倾斜数据的最小拆分大小(默认32MB,超过则使用Map Join)
2. 收集统计信息
Hive的Skew Join优化依赖表的统计信息(比如SKEWED_COLUMNS
和SKEWED_VALUES
),因此需要提前收集统计信息:
ANALYZE TABLE user_order COMPUTE STATISTICS FOR COLUMNS user_id;
注意:若未收集统计信息,Hive会进行动态采样(默认采样10000条记录),估算Key的分布,但准确性可能较低。
3. 处理复杂倾斜场景
- 自定义UDF导致的倾斜:若倾斜是由自定义UDF的输出分布不均导致的,可以在UDF中加入随机前缀(比如
CONCAT(RAND(), '_', user_id)
),将倾斜Key拆分到多个Reduce节点; - null值倾斜:若
null
值导致倾斜,可以将null
值替换为随机值(比如CASE WHEN user_id IS NULL THEN RAND() ELSE user_id END
),避免所有null
值都发送到同一个Reduce节点。
六、总结
Hive的Skew Join优化是解决数据倾斜的有效手段,其核心逻辑是拆分倾斜数据,分别处理:通过检测倾斜Key,重写执行计划,将倾斜数据和非倾斜数据分开处理,最后合并结果。通过源码分析,我们可以看到Hive如何通过规则匹配、倾斜Key检测、执行计划重写等步骤,实现自动优化。
在实际应用中,开发者需要开启优化参数、收集统计信息、处理复杂场景,才能充分发挥Skew Join优化的效果。同时,对于极端倾斜的场景,可能需要结合手动调优(比如拆分Key、使用Map Join),才能彻底解决数据倾斜问题。