早上七点出头,团队群被一条调度告警刷屏了。平时稳定20分钟跑完的dws_flow_channel_uv_di任务,今天跑了一个多小时还挂在YARN上,最终以1小时21分收场。这个任务是数仓里再普通不过的渠道UV日报,源表是每天的行为明细,逻辑就是GROUP BY channel加COUNT(DISTINCT user_id)。看到任务卡住的那一刻,我第一反应是"是不是上游数据量暴涨了?",但打开YARN页面看完Task分布,心里基本就有数了——这是典型的数据倾斜。这篇文章就还原一下当时从报警到定位、再从修复到复盘的全过程,希望能给同样被Hive数据倾斜折磨过的朋友一点参考。
1. 突发变慢:从20分钟到81分钟的报警现场
1.1 单量暴涨?先做一轮"外部因素"排除
任务跑得慢,最忌讳的就是上来就喊"数据倾斜",因为资源抢占、上游延迟、甚至队列配置变更都可能造成同样的表象。我当时的排查顺序是这样的:
- 先和上游同步任务的负责人确认,昨天到今天
dwd_flow_event_detail的分区数据量基本持平,没有出现类似翻倍式增长。 - 再看YARN队列,集群里同时段就只有三四个常规任务,资源剩余充足,不存在被大任务挤占的问题。
- 确认最近一周没人动过Hive的执行参数、队列容量调度配置。
- 最后看了下HiveServer2的锁状态,没有出现长时间
LOCK_WAIT。
这一轮走完,外部因素基本可以排除。任务变慢的嫌疑全部落在了SQL本身和执行过程上。
1.2 把嫌疑锁定在任务内部执行环节
排除了外部因素之后,比较常见的内部原因也无非是几种:执行计划发生改变、Map阶段小文件暴涨导致启动任务过多、某个Task反复重试,或者某个Task承担了远超预期的数据量。
我当时的主要判断依据是阶段耗时。在YARN和调度平台的监控页面上,能看到每个阶段的大致耗时区间。如果Map阶段耗时和平时差不多,说明数据读入、解析、过滤都没问题;如果Reduce阶段出现异常的长尾,那就要往"同一个key的数据全部被路由到了同一个Reducer"这个方向去想。后来证实,这个思路是对的——Map阶段没有异常,真正的问题全部集中在Reduce阶段。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 缩小包围圈:YARN长尾与Shuffle硬指标坐实倾斜
2.1 Reduce长尾:并行任务里那几颗"钉子"
从YARN Application页面进入Tasks标签页,按Task类型过滤后,现象非常直观:96个Reduce Task里,93个都在10到15分钟内跑完了,唯独剩下3个Task,状态一直卡在RUNNING,耗时一路冲到80分钟附近才结束。
这是数据倾斜最具标志性的画面——大量并行任务已经"收工",少数几个Reduce Task成了"钉子户"。分布式计算有个最朴素的规则:整个作业的完成时间取决于最后一个完成的Task,而不是平均耗时。当这3个Reducer还在处理海量数据的时候,哪怕其他93个Reducer全部空闲,整个作业也只能干等。
2.2 三个Task级Counter:Shuffle字节数、Spill次数、GC时间
只看长尾还不够,我得拿到更硬的证据去确认"到底是不是数据分配不均"。于是进到YARN History Server,逐个点开这几个最慢Task的Counter,和正常Task做了个对比:
| 指标 | 最慢Task(3个) | 正常Task(93个) |
|---|---|---|
| Task完成耗时 | 约75-80分钟 | 10-15分钟 |
| Shuffle读数据量 | 约38GB | 2.1GB-3.2GB |
| 磁盘Spill次数 | 40+次 | 0-3次 |
| JVM GC累计时间 | 6分半以上 | 30秒以内 |
这组数据基本宣告实锤了。Shuffle读数据量差了十几倍,说明这3个Reducer被分到的数据量本身就不正常。Spill次数也说明问题:Hive在Reduce端做聚合时,先把中间结果放在内存缓冲里,放不下了才往磁盘溢写。正常Task几乎没有Spill,而生病的Task却反复溢写了40多次,等于大部分时间都在做序列化、写磁盘、再读磁盘的重复劳动。GC时间6分半更是雪上加霜——大对象在内存和磁盘之间来回搬运,JVM老年代不断被撑满,典型的内存压力型倾斜。
这里有个重要的"为什么"需要说清楚:GROUP BY channel + COUNT(DISTINCT user_id)这条SQL,对同一个channel的所有记录必须全部路由到同一个Reducer处理,因为distinct去重要求"必须在看到该channel全部数据之后,才能给出唯一计数"。只要channel维度上某个key变成热点,热点数据就会无条件灌进单个Reducer,内存和磁盘自然扛不住。
3. 顺藤摸瓜:热点Key原来是Group By字段里的脏数据
3.1 用Explain确认热点发生的Stage
拿到Counter证据后,我重新打开这条SQL的执行计划EXPLAIN,重点看下面的shuffle阶段。Hive的GROUP BY在Map端会先做一轮部分聚合,然后根据group字段的Hash值进行分区,同一个key的所有数据都会被分到同一个Reducer。
最终执行计划里出现了两个Stage:第一个Stage在Map端按channel做预聚合,然后Shuffle到Reducer;第二个Stage负责对COUNT(DISTINCT user_id)做最终去重计数。热点发生的位置就在第一个Stage——按channel哈希分区的shuffle上。因为channel字段的取值天然稀疏,几个头部渠道就可能占据全表大部分记录,一旦某一个channel的记录数远高于其他channel,分区就会严重失衡。
3.2 抽样统计:直播渠道+占位符user_id
明确了热点发生在哪个Stage之后,下一步就是找出具体的热点key。我用一个快速统计SQL直接看channel维度的数据分布:
sql复制SELECT
channel,
COUNT(1) AS cnt
FROM dwd_flow_event_detail
WHERE dt = '2024-01-15'
GROUP BY channel
ORDER BY cnt DESC;
结果果然有惊喜也有惊吓:排在第一的live_streaming渠道当天记录数约1.2亿,而其他渠道普遍只有几百万,两者差了将近一个量级。这已经足够解释为什么那3个Reducer会拖到80分钟——直播渠道这一个key的数据,全都压在了这一个partition上。
接下来我再深入一层,单独把直播渠道的数据拎出来看user_id字段的质量:
sql复制SELECT
CASE
WHEN user_id IS NULL OR user_id = '' OR user_id = 'NULL' THEN 'bad'
ELSE 'normal'
END AS user_flag,
COUNT(1) AS cnt
FROM dwd_flow_event_detail
WHERE dt = '2024-01-15'
AND channel = 'live_streaming'
GROUP BY 1;
统计结果让我有点意外:这1.2亿条记录里,有将近9000万条的user_id是脏数据,全是空字符串或者字符串类型的'NULL'。这些脏数据全部指向同一个user_id值,而它们在shuffle阶段又是跟着channel走的,于是直播渠道这个本就偏大的partition又被放大了近亿条记录。长尾Reducer的真相到这里就完全清楚了。
3.3 业务复盘:埋点变更怎么引入的脏数据
查完数据就该查业务变更。我和埋点团队对了下时间线,发现就在前两天,客户端上线了直播渠道的埋点,但新埋点代码在用户未登录时没有做单独处理,userId字段在返回时被统一填充成了字符串'NULL'传给服务端,最终落进数仓。
这个案例最有价值的地方在于:任务变慢的当天,全表总数据量其实没有明显上涨,真正暴涨的是"某一个key维度上的数据量"。数据倾斜的本质从来不是数据总量变大了,而是数据在key维度上的分布严重失衡。一个渠道占据了全表60%以上的记录,这本身就是灾难性的分布,再加上count(distinct)把去重压力又叠加上去,20分钟变81分钟也就不奇怪了。
4. 修复落地:SQL改写方案与81分钟到15分钟的实测结果
4.1 方案A:先剔除业务上无意义的脏ID
在动手改SQL之前,我特意找了业务方确认了口径。这个渠道UV日报统计的是"登录用户的独立访客数",未登录用户的占位符ID本身就没有去重统计价值。既然业务上可以直接排除,那最简单的修复就是把这些脏数据过滤掉:
sql复制INSERT OVERWRITE TABLE dws_flow_channel_uv_di
SELECT
channel,
COUNT(DISTINCT user_id) AS uv
FROM dwd_flow_event_detail
WHERE dt = '2024-01-15'
AND user_id IS NOT NULL
AND user_id != ''
AND user_id != 'NULL'
GROUP BY channel;
这个改动的成本最低,一行WHERE条件就能让直播渠道的1.2亿条记录瞬间缩到真实规模。但我也清楚,这属于"止血"而不是"根治"——因为就算没有脏数据,头部channel天然比其他channel数据量大几个量级的情况依然存在,只是没那么严重而已。要从根上降低count(distinct)在大key场景下的倾斜风险,还得继续往前走一步。
4.2 方案B:count(distinct)两阶段改写
于是我直接把SQL改成了"先按组合键去重、再按业务键计数"的两阶段聚合写法:
sql复制INSERT OVERWRITE TABLE dws_flow_channel_uv_di
SELECT
channel,
COUNT(1) AS uv
FROM (
SELECT
channel,
user_id
FROM dwd_flow_event_detail
WHERE dt = '2024-01-15'
AND user_id IS NOT NULL
AND user_id != ''
AND user_id != 'NULL'
GROUP BY channel, user_id
) t
GROUP BY channel;
这里面的原理值得展开讲一下。原来的GROUP BY channel在做shuffle时,分区键是channel,同一个channel的所有记录只能进同一个Reducer。改写后,内层的GROUP BY channel, user_id分区键变成了组合键,Hive按照(channel, user_id)的整体哈希值来分区,这样一来,同一个channel下的几百万个不同user_id会被打散到不同的Reducer上,单台机器的压力瞬间被摊平。
内层完成去重之后,同一个(channel, user_id)只剩一行,外层再按channel做COUNT(1),数出来的行数恰好就是每个渠道的精确UV。整个过程没有牺牲任何数据正确性,也不依赖额外参数,这是count(distinct)场景下最推荐的一种写法。
4.3 为什么不能直接给热点Key加盐
看到这里可能有朋友会问:网上都说数据倾斜加盐,我这里为什么不用?问得好。加盐(Salting)确实是处理倾斜的常用手段,但用它有个前提——聚合操作必须"拆得开、合得拢"。比如SUM、COUNT这类聚合,先加盐拆成多份分别求和,再合并结果,数据不会错。
COUNT(DISTINCT user_id)就不行了。如果给user_id拼接随机数,比如concat(user_id, '_', rand()),同一个user_id在每行都会变成不同的值。distinct去重时本来应该保留一个用户,加盐后却被拆成了几十个不同的"用户",UV虚高到完全没法看。去重是全局操作,拆成多份再合并,必然重复计算。
所以这里的原则要记住:加盐适合SUM/COUNT这种可加性聚合,不适合COUNT(DISTINCT)。如果非要走加盐路线,也得用确定性盐值(比如hash(user_id))保证同一个user_id始终落到同一个盐桶,但那样做SQL复杂度会高很多,收益还未必比两阶段聚合明显。
4.4 验证结果与数据对账
改完SQL后重新调度,效果立竿见影:
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 任务总耗时 | 1小时21分 | 15分32秒 |
| 长尾Reducer个数 | 3个 | 0个 |
| 最大Shuffle读数据量 | 约38GB | 约3.8GB |
| 结果准确性 | 直播渠道UV虚高 | 登录用户UV与历史口径一致 |
除了耗时降下来,我还做了一轮数据质量对账:把新逻辑跑出来的结果和旧逻辑在"排除脏数据后"的预期值做对比,登录用户UV完全一致,直播渠道的UV也终于从9000万脏行撑起来的虚高数字恢复到了真实水平。这次修复不仅解决了性能问题,还顺手修正了一个潜伏很久的数据口径Bug。
5. 延伸防护:数据倾斜的系统性兜底与预警机制
5.1 参数兜底:SkewJoin与SkewGroupBy的边界
修复完问题之后,我又把团队常用的几个倾斜优化参数重新梳理了一遍。很多情况下,光靠人工改SQL是不够的,参数层面的兜底也得配置好,但必须清楚它们的边界。
hive.groupby.skewindata=true是Hive针对GROUP BY聚合倾斜提供的加速开关。开启后Hive会把普通聚合拆成两个MR作业:第一个作业给分组键加随机数打散到多个Reducer,做一次部分聚合;第二个作业去掉随机数,按真实分组键做最终聚合。这样热点key确实能被分散处理。但这个参数有个硬伤——COUNT(DISTINCT)场景下它无法生效,因为distinct的去重必须看到全量数据,做不到"先拆散再合并"。而且多一个MR作业,本身也要多一份资源开销。
hive.optimize.skewjoin=true则是针对Join倾斜的,运行时它会检测倾斜key,把大key单独拆分处理。但这类动态判断有额外开销,而且对于本文这种group by聚合场景根本不适用。真正稳妥的做法依然是:参数兜底做止血,SQL改写做根治。
5.2 从SQL审查到数据质量监控
这次踩坑之后,我和数仓团队约法三章,把预防做在前面:
- 新任务上线前,必须明确
GROUP BY、JOIN、DISTINCT字段是否存在天然热点。比如渠道、省份、默认分类这类取值少但数据量极大的离散字段,都需要标记风险。 - 关键ID字段(user_id、device_id、order_id)禁止用空字符串、
'NULL'、-1、全0这类占位符填充。没有值就写SQL的NULL,并且要在dwd层做约束校验。 - 对核心事实表增加"空值/占位符占比""枚举字段TopN占比"的数据质量监控,超过阈值就阻断调度并告警,而不是让脏数据一路穿透到DWS层。
这套规范看上去简单,但很多团队其实都做不到。特别是"占位符占比监控",如果早一天上线,这次直播渠道里9000万条'NULL'记录在dwd层就会被发现,根本轮不到调度任务跑到1小时21分。
5.3 告警阈值与"倾斜自查清单"
监控层面也不能只依赖人工去YARN页面里翻Task,我后面做了两件事。
第一是任务耗时基线告警。每个离线任务根据历史表现计算基线耗时,只要当前耗时超过基线1.5倍就触发告警,这次81分钟的情况会在前20分钟内就被提前暴露。
第二是长尾Reducer自动扫描。每次跑批结束后,写一个脚本从YARN History Server拉取所有Reduce Task的耗时数据,把"最长Reduce耗时 ÷ 最短Reduce耗时 > 5"的任务自动识别出来,推送到团队群。这个比例能直观反映partition是否均匀,比人工盯着任务列表高效得多。
另外,我把这次完整的定位路径整理成了一份"倾斜自查清单"分享给了组里同事,防患于未然:
- 看YARN页面的Reduce长尾比例,确认是否出现少数Task耗时远高于其他Task。
- 从History Server查看Task级Shuffle读字节数、Spill次数、GC时间,判断是否数据分配不均。
- 用
EXPLAIN定位热点发生的Stage,确认shuffle分区键是什么字段。 - 对这个分区键做数据分布抽样,锁定热点key和脏数据来源。
- 结合最近一周的业务或埋点变更,找到根因。
- 根据场景选择修复方式:脏数据过滤、两阶段聚合改写、加盐(仅限SUM/COUNT类)或者参数兜底。
这已经是我今年第三次被数据倾斜拖下水了,前两次因为没有留下排查路径,每次都从头开始翻日志,又急又低效。把完整链路走通之后,再遇到类似问题基本半小时内就能定位到热点key。数据倾斜本身并不可怕,可怕的是没有一套可复现的排查流程,以及一群没人管的数据质量——这两样东西早点配齐,比临时抱佛脚有用得多。
