ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Hive去重:distinct与group by的执行原理及优化

Hive去重:distinct与group by的执行原理及优化 刚入行那年我在一次面试里被问过这么一个问题“Hive 里去重到底用 distinct 还是 group by”我当时的回答是“优先 group bydistinct 慢”但对方追问了一句“为什么慢慢在哪一步”我一下就卡住了。后来在生产环境里连续踩了几个大坑才把这句话彻底吃透。今天想把这个问题展开聊透从一条 SQL 进入 Hive 后变成什么执行计划到哪种场景必须避开 distinct再到数据倾斜、OOM、小文件这些衍生问题全部放在一起说。如果你是写数仓 SQL 的数据工程师或者正在准备大数据面试这篇文章应该能帮你省下不少排查时间。1. 先搞清楚两者的执行机制差异在做选型之前先看一条 SQL 在 Hive 里到底是怎么跑的。Hive 会把 SQL 翻译成一个执行计划落到 MapReduce、Tez 或者 Spark 引擎上。distinct 和 group by 从结果上看都能去重但在物理执行计划上完全是两条路。1.1 distinct 的“单点聚合”特性distinct 在逻辑上是对输出列做唯一化而在物理执行时Hive 通常需要把相同 key 的数据拉到同一个地方做去重。如果只是select distinct dt from table最终可能只有一两个 reducer因为日期字段的枚举值就那么几个每个 reducer 各自去重完结果数量已经很有限。真正的问题是count(distinct col)这种写法。它要对全量数据做“全局去重计数”Map 端虽然可以做一些局部去重但最核心的环节还是得把所有不同的 key 汇总到同一个 reducer 上完成计数。在这个 reducer 上内存里要维护一个巨大的 set 或者哈希表数据量大一点就直接成为整个任务的单点瓶颈。这也是为什么老工程师一看到count(distinct)就皱眉它把一个明明可以并行处理的问题硬生生拉成了串行问题。1.2 group by 的“分治聚合”路径group by 走的是另一条路先按 key 分组在每个 Map Task 内部维护一个哈希表做部分聚合也就是传统 MapReduce 里的 combiner 阶段。Map 输出的数据量已经被压缩过一轮shuffle 到 Reduce 之后再做最终聚合最后每个 key 只输出一行。这种“分而治之”的设计带来两个直接好处第一大量重复数据在 Map 端就被消化掉了网络传输和磁盘 IO 明显减少第二Reducer 数量可以随着数据量扩展分散压力。即使聚合函数是count(1)、sum这种无状态累加也可以用多个 reducer 并行算最后再汇总一次。Hive 的 group by 天然适合大数据量下的分组聚合这也是它比 distinct 在生产环境中更受青睐的根本原因。1.3 一个 count(distinct) 慢的经典例子看一个实际例子。假设有一张 20 亿行的访问日志表表里有user_id字段现在想统计有多少个用户访问过。-- 方式 Adistinct select count(distinct user_id) from access_log;方式 A 在旧版本 Hive 中非常难受它需要把所有不重复的 user_id 汇集到一个 reducer 里内存占用和耗时都会随着去重基数的增大而恶化。-- 方式 Bgroup by 嵌套 select count(*) from ( select user_id from access_log group by user_id ) t;方式 B 在执行时第一步把 user_id 分散到多个 reducer 上每个 reducer 只负责处理一部分 user_id 去重第二步count(*)的时候因为子查询已经拿到完整的 user_id 列表直接全表 count 就能出结果不需要再做任何全局去重。同样的语义方式 B 把最重的负担从“一个 reducer 做全局去重”变成了“多个 reducer 并行去重”效率差距在数据量大时可以非常夸张。2. 从性能维度拆解快慢不是绝对的上面讲的听起来好像是“group by 完胜”但实际生产环境里不能只凭一个结论选型。数据量、去重基数、数据分布、执行引擎版本每个变量都会改变结论。这一节把影响性能的几个关键因素单独拎出来说。2.1 数据量、基数与重复率三个变量要判断一个去重 SQL 的性能先看三个数字表的总行数、去重列的基数唯一值个数、数据的重复率。表格里的场景可以做一个快速参考场景distinct 的表现group by 的表现结论小表、低基数很快代码简洁也很快但多一层嵌套distinct 更合适大表、高基数全局单点去重容易变慢多 reducer 并行压力分散group by 更合适大表、高重复率最终 key 不多reduce 压力有限Map 聚合先压缩数据reduce 很轻松group by 更稳妥大表、数据严重倾斜热点 key 全堆在一个 reducer可通过参数或加盐二次聚合group by 可调优空间更大2.2 数据倾斜下的表现差异数据倾斜是指某个 key 的数据量远远大于其他 key。比如统计用户性别分布select distinct sex最终 reducer 数量可能只有两个其中一个 sex 对应的记录数占 99.9%那么这个 reducer 就要处理几乎全部的数据。这种场景下 distinct 并不占优甚至可能因为单 key 过大而内存溢出。group by 遇到倾斜虽然没有本质区别但至少还有缓解手段。常用的hive.groupby.skewindata参数开启后Hive 会启用两轮 MapReduce第一轮打散数据做部分聚合第二轮再做最终聚合。distinct 没法简单打散因为打散之后还要保证全局唯一不能像普通聚合函数那样先拆后合。这也是在筛选场景时我会优先考虑 group by 的一个重要原因。2.3 Hive 版本差异与新优化不同 Hive 版本对 distinct 的优化力度完全不同。Hive 1.x、2.x 时代count(distinct)单 reducer 问题几乎是“常识”用 Tez 引擎后执行计划更好但最核心的单点限制还在。到了 Hive 3.xCBO成本优化器已经可以在部分场景下把count(distinct)改写成 group by 的骨架加上 LLAP、向量化执行很多简单去重查询已经不用手动改写了。我在实际集群上观察到的经验是版本越新SQL 写起来越“随意”但不要因此忽略原理。线上环境仍然有大量 CDH 5.x、Hive 2.x 集群这些系统的优化器不会自动帮你改写手动改 SQL 依然是最稳的方案。所以不管版本多新我都会先跑一句explain select ...看看执行计划里有没有单点 reducer再决定要不要改。3. 实操中的选型建议与改写套路3.1 优先 group by 的四种场景第一需要聚合函数的场景比如 sum、avg、count 配合分组统计这本来就是 group by 的地盘。第二需要对多个字段做去重计数的场景特别是count(distinct col1, col2)强烈建议改写成内层group by col1, col2外层 count。第三去重之后还需要继续过滤的场景比如只保留出现次数大于一次的 key这时group by ... having count(1) 1比 distinct 灵活得多。第四数据倾斜明显、需要加盐二次聚合的场景group by 可以通过哈希分桶把热点打散distinct 基本没有操作空间。一个“多个去重计数”的改写示例-- 原写法 select count(distinct user_id) as uv, count(distinct order_id) as order_cnt from t; -- 改写方案拆成两个子查询再 union all select sum(if(type uv, 1, 0)) as uv, sum(if(type order, 1, 0)) as order_cnt from ( select uv as type, user_id as col from t group by uv, user_id union all select order as type, order_id as col from t group by order, order_id ) tmp;这种改写把原本可能生成多个全局去重 reducer 的计划变成了两个独立的分组聚合计划每个都能并行扩展。代价是 SQL 变长但换来的稳定性非常值。3.2 distinct 依然值得用的三种场景不能因为 group by 强大就完全抛弃 distinct。第一小维度去重比如枚举只有两三个的select distinct status用 distinct 最直观。第二数据探查阶段拿到一张新表想快速看某个字段的取值分布随手写个 distinct 效率很高不必为了“最佳实践”增加复杂度。第三子查询或 join 之后消除重复行比如一个关联结果集需要去重后再参与计算直接写 distinct 语义清晰。这些场景的共同特点是数据量可控、没有后续聚合需求。如果在这些地方强行改写成 group by除了代码看起来更“专业”之外性能提升很有限反而增加了阅读成本。工具没有绝对的优劣只有匹配不匹配的问题。3.3 去重后的明细group by 窗口函数如果只是去重取字段distinct 和 group by 基本等价。但很多时候业务要的是“每个用户最新的一条订单”这样一个明细需求distinct 完全无能为力group by 也要配合窗口函数才能完成。select user_id, order_id from ( select user_id, order_id, row_number() over (partition by user_id order by create_time desc) as rn from order_t ) t where rn 1;如果你的需求是“用户维度 最新一条明细”核心思路是先按用户分组排序再去取第一条。这个场景下distinct无法表达“按某个字段有序去重”的语义而 group by row_number 是数仓里的标准答案。遇到这类问题别再纠结 distinct直接上窗口函数。3.4 别忽视小文件distinct/group by 落地的连锁反应很多同学改完 SQL 后发现跑得快了但下游同事抱怨表里小文件爆炸。这个问题在 distinct 和 group by 的落地阶段非常常见。去重结果的文件数基本由 reducer 数量决定如果数据量很大默认每个 reducer 处理 256MB可能直接产生几百甚至上千个输出文件。我常用的处理手段是控制 reducer 数量并开启合并set hive.exec.reducers.max50; set hive.exec.reducers.bytes.per.reducer512000000; set hive.merge.mapredfilestrue; set hive.merge.size.per.task256000000;如果是 group by 去重后写入分区表还可以在 SQL 尾部加distribute by来控制文件分布比如distribute by cast(rand()*50 as int)把输出均匀落到 50 个文件中。distinct 因为不能直接控制 reducer 的 key 分布写回表时更要留意小文件问题。这个点很多人忽略但生产环境里它比查询本身慢更让人头疼。4. 常见问题与排查技巧实录4.1 COUNT(DISTINCT) 多字段导致的内存崩溃我在一台 16G 内存的节点上跑过一条 SQLselect count(distinct user_id, hour) from t任务运行一半直接 OOM。原因很简单多字段 count distinct 要在 reducer 上同时维护多个去重集合当基数比较高时内存里的 set 占用直线上升JVM 直接扛不住。排查时可以先用explain看执行计划确认是否存在 reducer 接收全量数据的情况再到 YARN 日志里看对应 task 报的是不是 GC overhead 或者 container killed。解决方式基本就是改成内层 group by 多字段select count(*) from ( select user_id, hour from t group by user_id, hour ) tmp;如果 Hive 版本本身不支持多字段 count distinct会直接语法报错这时候上面的改写更是唯一出路。这条经验我后面给团队写进开发规范凡是 count(distinct a, b) 或者 count(distinct a) count(distinct b) 同现的 SQL一律人工 review。4.2 NULL 值引发的口径不一致distinct 和 group by 在处理 NULL 时有一个容易忽略的差异。标准 SQL 中count(distinct col)不统计 NULL而group by col会把 NULL 当成分组值输出一行。于是同样一套语义两种写法可能得到不同数字。-- 结果可能不一样 select count(distinct col) from t; select count(*) from (select col from t group by col) tmp;如果业务上要排除 NULL老老实实在去重前加where col is not null。如果业务上要统计 NULL 这条记录group by 的count(*)反而更接近需求。这个问题在报表口径核对时经常引发“数据对不上”的排查而且很难查因为两个数都对只是口径不同。提前在建模文档里写清楚 NULL 的处理约定比事后争论要省事得多。4.3 reducer 数据倾斜的定位与加盐改写参考 2.2 里的分析我在实战中定位倾斜最常用的方法是打开 YARN 的任务列表看 reducer 的完成时间柱状图。如果出现一个任务跑到天荒地老、其他任务早就结束基本可以判定热点 key 集中。另一种方法是看每个 reducer 处理的记录数 counter差异超过一个数量级就要警觉。group by 场景可以用“哈希加盐”的方式绕过select count(*) from ( select abs(hash(uid)) % 100 as salt, uid from t group by abs(hash(uid)) % 100, uid ) tmp;关键是这里用abs(hash(uid)) % 100而不是rand()因为同一个 uid 必须被映射到同一个桶里否则会重复计数。这样原本可能全部堆到某个 reducer 的数据被均匀拆到 100 个桶中。distinct 无法做这种中间步骤因为它不能利用额外字段先分组再合并去重。这是我在选型时最看重的一个点group by 留下了“干预执行计划”的入口distinct 没有。4.4 一个生产压测案例20 分钟变成 2 分半最后分享一个印象深刻的案例。某张 20 亿行埋点表业务要统计去重设备数。最初线上 SQL 是select count(distinct device_id) from event_log每天跑大约 20 分钟而且经常在凌晨高峰期失败。当时我做了两步改动第一改成 group by 嵌套 count第二因为 task 本身可以并行顺手开着set hive.exec.paralleltrue让第二步 count 尽早启动。改完后整个任务稳定在 2 分半左右内存从接近上限降到健康水位。后来查执行日志原来的写法把所有 device_id 都送到一个 reducer单个 reducer 处理上亿条数据改完之后device_id 被哈希到多个 reducer 分别去重单个 reducer 的输入量只有原来的十分之一不到。这个案例让我意识到很多“慢 SQL”不是集群不行而是写法把并行能力亲手堵死了。最后一点体会写到这里我个人的态度已经很明确了能用 group by 的场景优先用 group bydistinct 留给小维度和快速验证。这不是说 distinct 一无是处而是因为 group by 不但能去重还能留出后续聚合、过滤、加盐的空间执行计划的可干预性更强。日常写离线 SQL我基本都会先想清楚去重之后还要不要做别的然后才决定关键字怎么写。每写一条去重 SQL顺手跑一句 explain 看一眼执行计划这个习惯能让你避开绝大多数暗坑。
返回列表