94% 的集群在空等------数据倾斜实测
200 万条记录 shuffle 到 64 个 reducer。同样的代码,只换数据分布:
markdown
数据分布 最少 平均 最多 最大/平均 最大占比
--------------------------------------------------------------------------
均匀分布的 key 30,006 31,250 32,735 1.05x 1.6%
Zipf 分布的 key (真实数据) 4,458 31,250 531,944 17.02x 26.6%
一个 reducer 吃掉了 26.6% 的数据,是平均值的 17 倍。
而分布式作业的完成时间由最慢的那个任务决定(木桶效应)。所以这个作业的实际耗时是平均负载的 17 倍------
换句话说,94% 的集群算力在空等。
你开 64 台机器,实际上只有 3.8 台在干活。
为什么真实数据一定是倾斜的
Zipf 分布不是我刻意构造的极端情况,它是现实世界的默认形态:
ini
Zipf 数据的 key 频次: top1 = 519,894 条 (26.0%), top10 合计 59.4%
总共 54,015 个不同的 key
想想你的业务:
- 按用户 ID 聚合 → 有几个超级用户/爬虫/测试账号的数据量是普通用户的万倍
- 按商品 ID 聚合 → 爆款商品的订单量碾压长尾
- 按城市聚合 → 北上广深 vs 县城
- 按错误码 聚合 → 99% 是
200 - 按日期聚合 → 大促那天
还有最阴的一种:NULL 和空字符串。所有缺失值哈希到同一个 key,而缺失值往往占比极高。
均匀分布是需要刻意制造的,倾斜才是自然状态。
加机器完全无效
这是我觉得最该贴在墙上的一组数据:
markdown
reducer 数 平均负载 最大负载 最大/平均 理论耗时
------------------------------------------------------------
16 125,000 567,508 4.54x 567,508 条
64 31,250 536,617 17.17x 536,617 条
256 7,812 524,952 67.19x 524,952 条
1024 1,953 520,084 266.28x 520,084 条
4096 488 519,994 1064.95x 519,994 条
reducer 从 16 加到 4096(256 倍),最大负载从 567,508 降到 519,994------降了 8%。
因为那个最热的 key 有 52 万条记录,而同一个 key 必然落在同一个 reducer 上。你加再多机器,它还是那一台在扛。
倾斜的本质是「单个 key 的数据量 」,而 reducer 数量只影响「key 怎么分组」。这是两个维度的事,加机器解决不了。
更糟的是:reducer 加多了,调度开销、小文件数量、shuffle 的元数据都在涨------成本上升,耗时不变。
三种解法,效果差很多
markdown
方案 最大/平均 相比不处理 额外代价
-------------------------------------------------------------------------
不处理 17.02x 基准 ---
Map 端预聚合 (Combiner) 1.31x 13.01x 需要可结合的聚合函数
加盐 8 份 + 二次聚合 4.85x 3.51x 多一轮 shuffle, 数据 ×8
加盐 32 份 + 二次聚合 2.36x 7.22x 多一轮 shuffle, 数据 ×32
加盐 128 份 + 二次聚合 1.50x 11.36x 多一轮 shuffle, 数据 ×128
剥离 top1 热点 key 单独算 9.42x 1.81x 热点占总量 26%
剥离 top10 热点 key 单独算 3.34x 5.10x 热点占总量 59%
剥离 top100 热点 key 单独算 1.84x 9.24x 热点占总量 80%
① Map 端预聚合(Combiner)------效果最好
17.02 → 1.31,改善 13 倍,而且几乎没有额外代价。
原理是在 shuffle 之前 就把同一个 key 的记录合并掉。每个 mapper 只为它见到的每个 key 输出一条部分结果,reducer 收到的数据量从 200 万降到了「mapper 数 × key 数」。
倾斜被消灭在了数据离开 map 端之前。
限制是聚合函数必须可结合、可交换 :sum、count、max、min 都行;avg 要拆成 (sum, count) 再算;中位数、精确去重不行(这也是前几天那篇讲 HyperLogLog 的文章的价值所在------它让「去重计数」变成了可合并的)。
能用 Combiner 就一定要用。 Spark 里 reduceByKey 自带 combiner,groupByKey 没有------这就是那条著名建议「别用 groupByKey」的全部原因。
② 加盐(两阶段聚合)
给 key 拼一个随机后缀,把一个热 key 强行拆成 N 份,聚合完再去掉盐做第二次聚合。
效果取决于盐的份数:8 份只改善 3.5 倍,128 份能到 11.4 倍。
代价是数据量翻 N 倍 (每个 key 变成 N 个),还要多一轮 shuffle。盐撒得越多效果越好,但代价也越大------需要调参,而且参数和数据分布强相关。
③ 剥离热点 key
先统计出 top-N 的热 key,这部分走特殊路径(比如广播 join、单独的大内存任务),其余走正常流程。
剥离 top1 只改善 1.81 倍------因为倾斜不是一个 key 造成的,是一小撮。要剥到 top100 才有 9.24 倍。
这个方案最灵活但也最脏:要先跑一次采样统计、要维护一个热 key 名单、业务逻辑要分叉。
怎么发现倾斜
倾斜最典型的症状是:99% 的 task 几秒钟跑完,剩下 1~2 个跑了一小时。
在 Spark UI 的 Stage 页面看 task 的耗时分布------如果 max 远大于 median,就是倾斜。还有几个信号:
- 某个 task 反复 OOM,重试还是 OOM(数据量根本放不下)
- shuffle read size 的分布极不均匀
- 作业总时长远大于「总数据量 / 集群吞吐」的理论值
Spark 3.0+ 的 AQE(自适应查询执行)能自动处理 join 倾斜 (spark.sql.adaptive.skewJoin.enabled),它会在运行时发现某个分区过大并自动拆分。但它只管 join,聚合还得自己来。
实践清单
- 先看数据分布再写代码。
SELECT key, count(*) ... ORDER BY 2 DESC LIMIT 20花一分钟,能省一天。 - 能用 Combiner /
reduceByKey就别用groupByKey。 这是性价比最高的一条。 - 处理 NULL 和空值。 把它们过滤掉或者单独打散,往往能解决一半问题。
- 别靠加 reducer。 实测 256 倍机器换 8% 改善。
- 开 AQE。 免费的自动倾斜处理。
- 监控 task 耗时的 max/median 比值,而不只是总时长。
最后
分布式计算的性能模型,和单机完全不同。
单机看的是总工作量 ;分布式看的是最大的那一份。
而 Zipf 分布保证了:总会有那么一份,大得离谱。