2724 字
约 9 分钟
14
S11-X07 · 数据倾斜:识别、成因与六种解法
2024-12-31
2026-11-08

一个本该十分钟跑完的离线作业,九分钟都耗在最后三个 task 上,剩下九百多个 task 早就 FINISHED。这不是"集群慢",是数据倾斜:少数分区承担了绝大部分数据,并行度形同虚设。

数据倾斜不是某一个组件的问题,而是分区键分布不均这一个根因在三种计算模型里的三种表现。解法也有六种,但每一种都有代价——先把识别做对,再谈治法。


1. 定义:三种场景,一个根因

倾斜的判定标准只有一条:任务分到的数据量与平均值差一个数量级以上。不同的执行引擎把它暴露成不同现象:

场景 表现 观察入口
MapReduce / Hive 少数 reduce task 耗时远超其他,Reduce shuffle bytes 极不均匀 JobHistory 的 task 列表、Counter
Spark 少数 task 的 Shuffle Read Size 是其他 task 的几十倍,Stage 卡在最后几个 task Spark UI 的 Stage 页 → Summary Metrics
分库分表 单库/单表先写满,其他分片还很空 各分片磁盘与 QPS 监控
Flink / 流式 个别 subtask 的 inPoolUsage 打满,checkpoint 被拖长 Flink UI 的 backpressure 面板

注意最后一行:流式任务的倾斜不体现在"耗时分布",而体现在背压。用批处理的思路去看流任务,很容易漏掉。


2. 识别:四条互相印证的证据

单看一条证据容易误判,标准做法是四条对齐:

手段 具体做法 能回答什么
任务耗时分布 Spark UI 按 Duration 排序 task 是不是真有长尾
分区行数统计 df.groupBy(spark_partition_id()).count() 每个分区多少行
EXPLAIN 行数估算 对比 rows 估算与实际 count(*) 优化器是否被统计信息骗了
慢 SQL 扫描行数 performance_schema / 慢日志的 Rows_examined 单条语句是否在扫全表
from pyspark.sql import functions as F
df.groupBy(F.spark_partition_id().alias("pid")).count().orderBy(F.desc("count")).show(20)

这条命令是 Spark 侧最快的体检。若前几个分区行数之和接近总量,倾斜就确认了,不需要再看别的。

SQL 侧则直接统计键分布:

-- 找出头部键占总量的比例(MySQL 8.0)
SELECT merchant_id, COUNT(*) AS cnt
FROM t_order
WHERE created_at >= '2026-01-01'
GROUP BY merchant_id
ORDER BY cnt DESC
LIMIT 20;

拿头部 20 个键的 cnt 之和除以总量,得到的比例就是倾斜程度。这个比例超过一半时,任何"调大并行度"的操作都无效——因为数据本身就在同几个键上。

还有一种隐蔽情形:EXPLAIN 的 rows 与实际差几个数量级。这通常是统计信息过期,ANALYZE TABLE 之后计划会变。此时倾斜是"假"的,先修统计信息。


3. 成因:五类,按出现频率排

成因 典型场景 判断依据
热点键 大商家、大 V、默认店铺的订单集中 头部键占比极高
null 值集中 关联键为空的行全落一个分区 WHERE k IS NULL 的行数异常大
关联键分布不均 事实表 join 维表,维表键少 维表行数远小于事实表
分区键选错 用低基数字段(状态、类型)做分片键 分片数远小于取值数
数据本身长尾 天然幂律分布(用户活跃度、文件大小) 分布曲线长尾,无法靠调参消掉

第三行值得展开:join 键分布不均时,小表一侧被广播是好事(Broadcast Join 把倾斜消掉了);真正的问题是大表对大表且关联键集中在少数值上,此时倾斜发生在 shuffle 阶段,广播救不了。


4. 六种解法与各自的代价

解法 机制 适用 代价
加随机前缀打散 键后拼 0..N-1,把热点键拆成 N 份 热点键明确、聚合可两阶段 多一次 shuffle,结果要再合并
热点键单独处理 把 top N 键抽出来走独立链路,其余走正常链路 热点数量少且稳定 代码分叉,两套逻辑要维护
map 端聚合 先本地 combine 再 shuffle 聚合类操作、重复键多 只减少传输量,不改变键分布
调整并行度 增大 shuffle 分区数 分区数明显少于数据量 对热点键无效,只稀释长尾
换分区键 / 加盐 用高基数字段或组合键 分库分表、建表阶段 需要改路由与历史数据迁移
两阶段聚合 局部聚合 → 再按原键全局聚合 GROUP BY 类倾斜的标准解 两次作业,延迟翻倍

4.1 加盐 + 两阶段聚合(最通用)

-- 第一阶段:给键加 0..9 的随机后缀,局部聚合
SELECT CONCAT(merchant_id, '_', CAST(FLOOR(RAND() * 10) AS CHAR)) AS salt_key,
       SUM(amount) AS part_amount,
       COUNT(*)    AS part_cnt
FROM t_order
WHERE created_at >= '2026-01-01'
GROUP BY salt_key;

-- 第二阶段:剥掉后缀,按原键再聚合一次
SELECT SUBSTRING_INDEX(salt_key, '_', 1) AS merchant_id,
       SUM(part_amount) AS total_amount,
       SUM(part_cnt)    AS total_cnt
FROM stage1_result
GROUP BY merchant_id;

代价很明确:多一次落表与一次全量扫描,且 SUM/COUNT 这类可分解聚合才能这么干。COUNT(DISTINCT) 不能直接拆——它需要先按 (salt_key, distinct_col) 去重再全局去重,中间结果会比原数据更大。

4.2 热点键单独处理

// 路由:头部商家走专用通道,其余走通用通道
public String route(long merchantId) {
    return HOT_MERCHANTS.contains(merchantId) ? "hot-lane" : "normal-lane";
}

HOT_MERCHANTS 必须来自配置而不是硬编码,且要有过期机制——热点是会变的,写死在代码里等于制造下一个事故。

4.3 map 端聚合

Spark 里对应 reduceByKey 与 groupByKey 的选择差异:前者会先在 map 端做本地合并,后者把全部原始记录 shuffle 过去。同一份数据,groupByKey 的 shuffle 量可能是 reduceByKey 的数倍,在倾斜键上这个倍数还会放大。

Hive 侧对应 hive.groupby.skewindata=true,它把一次 GROUP BY 拆成两个 MR 作业:第一个按随机键分发做局部聚合,第二个按真实键做最终聚合。这本质上是引擎内置的两阶段聚合。

# Spark 侧
spark.sql.adaptive.enabled=true
spark.sql.adaptive.skewJoin.enabled=true
spark.sql.shuffle.partitions=400

# Hive 侧
set hive.groupby.skewindata=true;
set hive.exec.reducers.bytes.per.reducer=268435456;

4.4 自适应倾斜处理

Spark 3.x 的 AQE 有 spark.sql.adaptive.skewJoin.enabled,能在运行时把过大的 shuffle 分区再切分。开启它比手写加盐省事,但只对 shuffle join 生效,且切分粒度受 advisoryPartitionSizeInBytes 控制,具体行为按 Spark 3.x 对应小版本核对。它的局限是:倾斜如果发生在 groupBy 而不是 join,AQE 帮不上。


5. 分库分表场景:倾斜的后果是"单库先满"

计算引擎的倾斜浪费的是时间,分库分表的倾斜浪费的是容量——而容量不可回收。

后果 机制 缓解
单分片磁盘先满 大商家订单全落同一分片 大客户独立分片(路由表)
单分片 QPS 打满 热点读集中 热点数据本地缓存
扩容无效 加机器但热点键仍落原分片 换键或做二次拆分
迁移成本高 历史数据按旧键分布 建表阶段就选对键

选键的两条实用规则:高基数、查询必带。单用哈希在纯热点下没用,单用范围在写入上会打爆尾部,所以常见做法是组合:

// 范围 + 哈希组合:先按时间范围定库,再按用户哈希定表
public String locate(long userId, LocalDate bizDate) {
    int db  = Math.floorMod(bizDate.getYear() * 12 + bizDate.getMonthValue(), 4); // 4 库
    int tbl = Math.floorMod(Long.hashCode(userId), 64);                            // 每库 64 表
    return "ds_" + db + ".t_order_" + tbl;
}

这段路由的隐患是跨月查询:WHERE created_at BETWEEN '2026-01-15' AND '2026-02-15' 要扫两个库。所以时间维度只适合"按月归档"这类查询模式,不适合高频范围查询。


6. 排错

现象 原因 处理
调大并行度后没变化 数据本身集中在少数键 加盐打散或热点单列
加盐后结果翻倍 忘了第二阶段按原键聚合回去 补全局聚合,校验总量
null 键全落一个分区 关联键为空的记录哈希相同 COALESCE(k, CAST(RAND()*100 AS BIGINT)) 打散
作业比原来更慢 加盐倍数过大,小文件与调度开销上升 降低盐的粒度,或只对热点键加盐
COUNT(DISTINCT) 结果错 两阶段聚合拆了不可分解的聚合 改为先去重再计数
分片磁盘不均 分片键基数太低 换高基数字段或引入路由表
AQE 开了仍倾斜 倾斜发生在 groupBy 而非 join 走两阶段聚合

7. 边界

场景 建议 理由
单机数据量小、作业分钟级 不处理 长尾几分钟,治它的成本更高
热点键集合稳定且少 热点单独链路 比通用加盐更精准,代价可控
热点键动态变化 加盐 + 两阶段聚合 不依赖热点清单
聚合不可分解(中位数、去重) 采样估算或换算法 拆开会改变语义
分库分表已上线 路由表 + 二次拆分 换键要迁数据,路由表可增量
流式任务背压 先看 keyBy 的键分布 批处理的调参手段不适用

一句话:倾斜的根因在键的分布,不在并行度。所有不解分布、只调参数的动作,都只是在把长尾摊薄一点点。


8. 自测

  1. 一个 Stage 有 1000 个 task,其中 5 个跑了绝大部分时间,最先查什么?
  2. 为什么"调大 spark.sql.shuffle.partitions"对热点键无效?
  3. 加盐两阶段聚合时,COUNT(DISTINCT user_id) 为什么不能直接照搬?
  4. null 值集中导致的倾斜,用什么表达式打散?
  5. 分库分表选键时,"高基数"和"查询必带"分别解决什么问题?
  6. AQE 的 skewJoin 为什么救不了 groupBy 的倾斜?
S11-X07 · 数据倾斜:识别、成因与六种解法
https://www.clxhxhhr.top/posts/5799/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。