一个本该十分钟跑完的离线作业,九分钟都耗在最后三个 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. 自测
- 一个 Stage 有 1000 个 task,其中 5 个跑了绝大部分时间,最先查什么?
- 为什么"调大
spark.sql.shuffle.partitions"对热点键无效? - 加盐两阶段聚合时,
COUNT(DISTINCT user_id)为什么不能直接照搬? - null 值集中导致的倾斜,用什么表达式打散?
- 分库分表选键时,"高基数"和"查询必带"分别解决什么问题?
- AQE 的
skewJoin为什么救不了groupBy的倾斜?