ARTICLE DETAIL

资讯详情

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

Hive去重优化:distinct与group by性能对比及调优实战

Hive去重优化:distinct与group by性能对比及调优实战 做数据的人大概都写过这类SQL统计今日UV、统计独立用户数、统计某维度组合的去重数量。distinct和group by在SQL语义上经常可以互相替代所以网上总有人争论哪个更快。我早年也以为这俩差不多直到有一次线上一个统计任务用distinct跑了快两个小时还卡在最后的Reduce阶段换成group by写法半小时就出结果了。从那以后我对这个问题的态度就变成了看场景、看引擎、看数据分布没有绝对的好但有明确的取舍逻辑。这篇文章我会从Hive的执行原理讲起再用实际场景对比两者的性能差异接着给出一套可落地的调优方案最后聊聊Hive 3.x、Tez/Spark这些新引擎下局面发生了什么变化。不管你是刚接触Hive的新人还是已经在生产环境里踩过坑的老手希望能帮你把group by和distinct的选择逻辑彻底理顺。1. 先搞懂执行原理distinct和group by在Hive里到底怎么跑1.1 从MapReduce模型看两者的本质要判断谁快谁慢得先知道Hive底层怎么执行这两类SQL。在Hive 2.x及更早版本默认执行引擎是MapReduce整个计算过程分为Map、Shuffle、Reduce三个阶段。distinct本质上是全局去重。在Map阶段Hive会把去重字段作为key输出但Shuffle之后为了确保“同一字段值只能出现一次”数据必须汇到同一个Reduce任务上做全局去重。换句话说经典执行计划下select distinct user_id from t这条SQL无论数据量多大Reducer数量基本就是1个。单Reduce意味着所有Map输出的数据全部压向一台机器网络IO、磁盘读写、单节点计算压力全部集中在这一个点上这为数据倾斜埋下了很大的隐患。group by则不一样。它天然支持分治Shuffle阶段按分组key进行分区每个分组key会路由到同一个Reducer但多个分组可以并行分布在不同Reducer上。更关键的是Hive默认开启hive.map.aggrtrueMap阶段会对相同key先做一次局部聚合也就是Map端聚合。这个过程会把大量重复数据在Map端就提前削减掉Shuffle的数据量往往是原始数据的一个零头。我用一个生活化的类比来帮你理解。distinct就像全班同学把作业统一交到班主任一个人手里班主任在讲台上一份一份检查有没有重复的名字全校几千人的作业只由一个人处理group by则是先按班级分组各班班长在自己教室先整理一遍名单只把“班级唯一姓名”汇总到年级组年级组再合并压力分散、流量减少自然更快。1.2 必须正视的“单Reduce魔咒”与数据倾斜上面说的“单Reduce”是Hive早期版本中distinct最致命的问题。实际生产环境里数据分布几乎不可能是均匀的。比如统计按来源渠道分组后的去重用户数某些大渠道的用户量可能是小渠道的几百倍。使用distinct时所有渠道的数据全都进入同一个Reducer热点key直接被放到一台机器上处理OOM、任务失败、长时间卡顿都很常见。即使改用group by source_channel如果某个渠道的key特别大也会出现单个Reducer倾斜。但区别在于group by可以对倾斜的key进行加盐拆分、Salted Shuffle等处理后把大key打散成多份并行处理而distinct因为是全量全局去重加盐处理的逻辑必须额外小心不能直接套用否则去重结果会出错。还有一个很容易被忽视的点distinct在Shuffle阶段没有Map端聚合可以依赖。Map端聚合依赖“相同key在本地就有足够多的重复”这个前提distinct虽然也是按字段分组但map端做局部去重后如果原数据中某个字段值出现次数只有1次局部去重几乎没有削减效果全部数据照样原封不动发给单个Reducer。1.3 group by的Map端聚合优势到底有多大group by的Map端局部聚合在很多场景下效果惊人。假设你有一个10亿条的日志表要统计每天的UV即select day, count(distinct user_id) from log group by day。先别管这段SQL里的distinct单看group by dayMap阶段每个MapTask会先在本地算出一份(day, 部分聚合结果)然后只需要把少量Map端结果Shuffle给Reduce。我在实际测试里遇到过这样的情况一张日增量5亿条的曝光日志按用户维度用group by做预聚合Shuffle的数据量从原始的上百GB直接降到几GB原因是单个用户一天的曝光记录可能有几十上百条Map端聚合能把重复记录拍掉一大半。后续Reduce阶段的压力也成倍下降整个任务的耗时自然大幅缩短。但注意group by的Map端聚合不是万能的。如果分组字段基数特别大比如直接用毫秒时间戳或者完整URL做分组维度Map端聚合基本起不到削减作用。另外如果聚合函数本身依赖全局状态Map端聚合也可能失效这类问题牵扯到UDAF和Combiner的关联我后面会单独展开。2. 实测对比不同场景下谁更划算2.1 单列去重场景group by真的稳赢吗我做了很多次对比测试场景尽量贴近生产一张模拟电商订单表字段包括order_id, user_id, order_date, amount, channel。测试SQL非常简单一个算总去重用户数一个算按渠道分组的去重用户数。单列去重总量这个场景我拿1亿条订单数据测select count(distinct user_id)和select count(*) from (select user_id from t group by user_id) tmp前者默认落在一个Reducer上跑完大概需要18分钟后者因为Map端聚合把重复的user_id压掉了一大批Shuffle数据量小了很多最终耗时大概12分钟。两个SQL如果加上order_date做为时间过滤条件数据量降到几千万条时差异会缩小到一两分钟以内。这意味着什么如果数据量不大或者去重字段的基数远小于总记录数group by的优势没有被完全激发两者耗时的差距就没那么明显甚至可以互相替换。但一旦数据量上亿且去重字段有大量重复值group by的Map端聚合优势会被放大差距能到30%以上。所以我的结论不是“无脑用group by”而是“在数据量大、重复率高的场景group by更稳在数据量小、字段基数又接近总行数的场景两者基本差不多”。2.2 多列去重与多个count(distinct)的经典组合现实业务里只去重一列的情况太少见了。更常见的写法是select channel, count(distinct user_id) as uv, count(distinct order_id) as order_cnt, count(distinct product_id) as product_cnt from orders where day 2025-01-01 group by channel;这类SQL有三个count(distinct)在Hive 2.x的MapReduce引擎下会触发多次MapReduce Job。原因很简单Hive的优化器在应对多个不同字段的distinct时很难在单个Reduce阶段同时完成三种不同的全局去重。最终表现就是Job数量翻倍每一轮都要全量扫描数据整个任务耗时甚至比三个单独SQL加起来还要夸张。另外还需要区分count(distinct a, b)和count(distinct a), count(distinct b)。前者是对(a, b)组合去重后者是分别对a和b去重两者统计口径完全不同但在执行计划里都叫“Distinct”。组合去重在部分Hive版本中同样只能走单Reduce性能更差。针对这种多列去重场景我更推荐先group by去重再做外层聚合select channel, count(*) as uv, count(order_id) as order_cnt, count(product_id) as product_cnt from ( select channel, user_id, order_id, product_id from orders where day 2025-01-01 group by channel, user_id, order_id, product_id ) t group by channel;内层group by把多列一起分组天然形成了“组合去重”的中间结果外层再按渠道做纯count(*)聚合每一步都不涉及全局单Reduce执行计划更均衡Job数量也稳定。2.3 group by 再聚合的组合拳能解决什么“先group by去重再二次聚合”这招几乎可以覆盖所有去重统计口径而且正确性非常好理解。它尤其适用于三类场景。第一类是多列去重计数上面已经演示过了。第二类是不同时间窗口的UV计算比如要同时看当日UV、7日UV、30日UV。如果只用count(distinct user_id)加case when写出来的SQL不仅冗长性能还差。更好的方式是先按user_id聚合出活跃日期列表然后在外层按时间窗口条件计数。第三类是留存分析里的多日用户去重比如同时看昨天和今天的活跃用户交集用group by结合多个sum(case when ...)可以清晰表达。我用一个留存口径的例子来说明select a.day, count(distinct case when b.day 2025-01-02 then a.user_id end) as active_retain_uv from ( select user_id, day from user_active_daily where day 2025-01-01 group by user_id, day ) a left join ( select user_id, day from user_active_daily where day 2025-01-02 group by user_id, day ) b on a.user_id b.user_id group by a.day;这个SQL先用双层group by把两天的活跃明细各自去重再通过join关联最外层计数。count(distinct case when ...)在这里只在结果集很小的外层使用内层完全不碰全局distinct整个执行计划不容易出倾斜问题。3. 调优实战数据倾斜、小文件与UDAF3.1 数据倾斜的经典解法加盐与两阶段聚合数据倾斜是group by和distinct都会遇到的问题。distinct的倾斜体现在单Reduce压垮一台机器group by的倾斜体现在某个大key霸占一个Reducer。针对group by倾斜最稳妥的方案是两阶段聚合。第一步给key加一个随机扰动比如concat(user_id, _, floor(rand() * 10))让原来的大key被拆成10份第二步在每个小分组内做局部聚合第三步去掉盐分再按原key聚合一次。这个过程其实对应了很多数据计算引擎中的“Partial Aggregation Final Aggregation”。用SQL写出来大致是select channel, sum(partial_cnt) as uv from ( select channel, concat(user_id, _, floor(rand() * 10)) as salted_key, count(*) as partial_cnt from orders group by channel, concat(user_id, _, floor(rand() * 10)) ) t group by channel;注意这段SQL处理的是“用户出现多次只算一次”的UV口径。加盐后同一个用户会被随机拆到10个不同的小桶里每个小桶里的count(*)都只是该用户被分到这个桶的记录数。外层把partial_cnt全部加起来恰好等于该渠道下所有用户的出现次数和。如果每个用户只出现一次算出来的就是真正的UV如果用户有重复出现这个方案就不适用了需要先对(channel, salted_key, user_id)做去重再计数。不过Hive本身的count(distinct user_id)更习惯于直接使用select channel, count(distinct user_id) from orders group by channel;这种写法里distinct和group by共存Map端可以把大部分重复值先处理掉Reduce端再对每个channel做去重。如果channel本身倾斜严重再考虑加盐方案但加盐去重比较麻烦需要确保同一个用户始终进入同一个盐分桶比如改成concat(user_id, _, hash(user_id) % 10)不能完全随机。Hive还提供了一个现成参数hive.groupby.skewindatatrue开启后Hive会尝试把倾斜的group by拆成两个MapReduce Job第一个Job对加盐后的key做部分聚合第二个Job对结果做最终聚合。这个参数在常规业务场景已经能解决大部分group by倾斜但代价是额外多一轮计算任务时间可能变长所以不要无脑开启只在确认倾斜时使用。3.2 小文件问题与group by的关联治理很多团队在做Hive优化时痛点不是SQL性能本身而是小文件问题。有段时间我排查一个离线数仓任务发现输出目录下有上千个几十KB的小文件每次下游加载都慢得离谱后来定位到根因就是group by的Reduce数量太多而且每个Reducer的数据输入量差异巨大输出被切割得很碎。Reduce数量主要由hive.exec.reducers.bytes.per.reducer控制默认是256MB或者1GB具体看版本。在实际调优中我会按这个公式估算reducer数量 min(hive.exec.reducers.max, 输入数据量 / hive.exec.reducers.bytes.per.reducer)如果每个Reducer处理的数据量远小于阈值比如只有几十MBReduce数量就会很多输出的小文件自然也多。解决办法是调大hive.exec.reducers.bytes.per.reducer让每个Reducer分配到更多数据减少Reduce数量。但要注意Reduce数量太少也不行比如一个Reduce扛10GB数据单个任务会慢到怀疑人生。另一个治理方式是强制合并小文件。hive.merge.mapfilestrue和hive.merge.mapredfilestrue可以分别控制Map Only任务和MapReduce任务的输出合并。还有hive.merge.smallfiles.avgsize可以设置一个平均大小阈值小于这个阈值就触发合并。这些参数在动态分区插入场景尤为重要因为动态分区本身就容易产生大量小文件group by只是加剧了问题。我遇到过最典型的组合坑是一张大表动态分区按天写入分区键的基数虽有1000多个但每个分区数据量很小结果写出来的文件全是几十KB的小块。我当时的处理方式是先把原SQL拆成“先聚合中间结果再动态分区写入”两步并且给中间结果的外层查询单独设置更合理的Reduce数量再配合distribute by和文件合并参数最终把小文件数量降了一个数量级。distribute by这里的作用是控制数据如何分布给Reducer比如distribute by day可以保证同一天的数据进同一个Reducer每个Reducer输出一个针对该分区的文件避免每个Reducer都朝所有分区写碎片。3.3 为什么count(distinct)不能用Combiner以及自定义UDAF的用武之地Hive的Map端聚合底层依赖一个能力叫Combiner。Combiner对聚合函数有一个隐藏要求函数必须是“结合律”的中间结果可以任意合并而不影响最终结果。count(distinct x)不满足这一要求因为distinct要求全局去重Map端把部分数据合并成“部分去重后的集合”后这个集合之间再做合并时还需要感知全局的重复情况这个过程没法用简单的计数规约来完成。所以count(distinct)在很多版本里不能走Combiner优化部分数据照样全量穿透到Reduce。这个限制也给自定义UDAF留出了空间。比如Hive提供了approx_count_distinct这个函数底层基于HyperLogLog算法不精确但速度快。它允许在Map端记录一个固定大小的HLL位图Combine阶段直接合并位图Reduce阶段估算基数。实测下来在百亿级数据上做近似去重approx_count_distinct比count(distinct)快非常多误差通常能控制在1%以内很多大厂做UV实时统计用的就是这类方案。如果业务要求精确去重又不想忍受count(distinct)的单Reduce问题一般会自己写一个UDAF。例如实现一个基于Roaring Bitmap的UDAF把字段值映射成整数并压入BitmapMap端局部构造BitmapCombine阶段合并BitmapReduce阶段统计BitMap的基数。我见过不少团队把这种方案用在数亿规模的ID去重上效果比直接count(distinct)稳定很多。写自定义UDAF时有几个细节要特别注意。第一initialize、iterate、merge、terminatePartial、terminate五个方法必须完整实现尤其不能漏掉terminatePartial没有它Map端聚合不会生效。第二如果UDAF需要维护大量中间状态比如Bitmap占内存很大要考虑在Map端控制单个key的状态大小必要时可以结合加盐进一步拆分。第三UDAF的返回类型如果是复杂结构要记得实现resolve方法让Hive能正确解析类型。4. 新引擎与新技术Hive 3.x、Tez/Spark与Doris的对比4.1 Hive 3.x Tez/Spark下局面发生了哪些变化很多朋友的印象还停留在“Hive用MapReducedistinct一定慢”。Hive 3.x以后事情有了变化。执行引擎换成Tez或Spark后distinct不再只能走单Reduce。Spark SQL里的distinct会先做一次局部去重再做一次全局去重类似于两阶段聚合。Tez引擎下Hive优化器也会尝试对去重查询做更多改写尽量分散压力。我分别在CDH 6.x的Hive 2.x MapReduce环境和一个Hive 3.x Tez环境上跑同样的SQL10亿条记录统计唯一order_id数量。Hive 3.x Tez环境下count(distinct order_id)和“内层group by order_id、外层count(*)”的耗时差距已经缩小到百分之十几不像MapReduce时代动辄翻倍。但即使如此group by在重复率高的大数据集上依然更稳因为它的Map端聚合优势依然存在。Hive 3.x还引入了LLAP这个概念全称是Live Long and Process。LLAP让一部分常驻Executor留在节点上缓存热数据执行Short-lived查询时不需要重新启动Container。这个能力对交互式单条查询比如select count(distinct user_id) from table where condition帮助很大因为瓶颈从“容器启动调度开销”转移到了实际计算过程。但LLAP对内存要求高集群压力原本已经很大时开启LLAP反而可能把节点整崩需要谨慎评估。4.2 当你手边同时有Hive和DorisSQL去重该交给谁相关热搜词里有个hive与doris这个组合其实代表了批处理和MPP数据库的分界问题。Doris是一个MPP架构的OLAP数据库数据以列存为主查询走向量化执行MPP天然支持并行shuffle去重、分组、join这些操作都分散到多个BE节点同时执行。在同等数据量下Doris执行select count(distinct user_id)这类去重查询速度往往比Hive快出一个量级因为它不需要启动一堆Container节点本身常驻内存表结构也在。但Doris能替代Hive吗不能。Hive的价值在于海量离线数据的批处理、复杂的ETL流程、与HDFS生态的深度绑定以及数据分布在超大集群时的稳定性。Doris更适合数据已经加工好之后提供给业务方做交互式分析和报表查询。所以我的建议非常明确如果瓶颈出现在Hive的离线加工链路里比如每天凌晨跑数耗时在小时级别那么优先优化Hive本身的SQL、参数和UDAF如果需要给业务方提供一个秒级查询的入口让他们自己对UV、去重用户做灵活分析那把数据同步到Doris这类MPP仓库让Hive做批处理让Doris做查询分工明确。实际项目里我见过太多团队试图用Hive扛交互式查询最后把离线集群资源吃干榨净业务方还是嫌慢。反过来也有人试图用Doris跑几百亿规模的全量历史数据加工结果导入超时、内存爆炸。正确思路是画清楚两者的边界Hive负责“算出来”Doris负责“查得快”。5. 常见问题与排查实录5.1 一句话排掉“列不存在”“group by having错误”这些坑关于去重和分组相关的报错我收集了几个高频问题列成表方便你排查报错或问题现场可能原因解决思路查询提示某个列不存在比如漏掉了表别名列名拼写错误或表别名没生效用DESCRIBE表名确认字段检查SQL里的名称和别名group by之后在select里取了非分组字段语义限制Hive默认不开启hive.groupby.orderby.position.alias等宽松选项要么把该字段加进group by要么用聚合函数包裹having里使用了select别名做过滤Hive的having不能直接引用部分版本的别名把过滤条件改写为对聚合函数结果的比较SQL执行时报groups: cannot find name for group id运行任务的Linux用户不在预期用户组中常见于容器环境检查任务运行用户和用户组调整资源队列权限任务输出文件个数暴涨大量小文件Reduce数量过多或动态分区碎片化调整hive.exec.reducers.bytes.per.reducer开启文件合并参数这里特别说一下group by和having的配合。having是对分组后的结果进行过滤所以having里引用的字段只能是分组字段或聚合函数的结果不能引用原始明细字段。比如select day, count(distinct user_id) as uv from log group by day having uv 100这条在多数Hive版本会报错因为having里不能直接用别名uv要写成having count(distinct user_id) 100。习惯了这个规则写完SQL先自查一遍能省掉很多无效调试。5.2 一个真实案例按日去重用户数的排查全过程最后分享一个我记忆很深的真实问题。某个业务需要统计最近7天每天的去重用户数SQL大概长这样select day, count(distinct user_id) as uv from user_action_log where day date_sub(current_date, 7) group by day;线上集群是Hive 2.x MapReduce。第一次跑任务在Reduce阶段卡了将近40分钟然后报OOM。我当时先看了两个东西一是Reduce数量结果日志显示只有1个Reduce符合count(distinct)的单Reduce特征二是数据分布发现最后一天的数据量占了7天总量的70%左右全部压向一个Reduce。我给的优化分两步。第一步先把瓶颈切掉改用内层group by day, user_id去重外层再group by day计数这样Reduce可以按天分布理论上7个Reducer并行每个Reducer只处理一部分数据。select day, count(*) as uv from ( select day, user_id from user_action_log where day date_sub(current_date, 7) group by day, user_id ) t group by day;第二步是对这个方案的数据倾斜做预防。因为最后一天数据量异常大仅仅按天分组还不够大概率出现某一天的单Reducer还是吃不消。我就在内层group by时给user_id加了一个盐值比如substr(hash(user_id), 1, 3)拆成更多局部桶外层再按天汇总相当于绕开了单天倾斜。为了验证SQL改动后结果是否正确我把旧SQL和新SQL各抽了100万条数据做测试对比两个结果的UV完全一致。正式环境跑下来整个任务从40分钟降到15分钟左右。后来我又把引擎切到Tez时间进一步压缩到七八分钟。这个案例其实就把前面讲的原理都串联起来了单Reduce带来的压力、group by的并行优势、加盐处理大key、新引擎对性能的改变。5.3 关于hive引擎升级后SQL兼容性的一点点提醒切引擎或者升级Hive大版本时distinct和group by的语义基本不变但执行计划和资源使用会变化。比如Hive 2.x里跑得好好的SQL切到Spark后可能因为Spark默认的并行度、Shuffle分区数不同而变慢切到Tez后部分场景下Tez的容器复用机制会让任务启动变快但如果你之前手动调了非常细致的Reduce参数新引擎有可能不按你的预期执行。升级前一定要做一轮全量SQL回归验证重点对比“数据量接近生产级的测试表上去重结果是否一致、耗时是否正常”不要只看一两条SQL。写自定义UDAF或依赖某个UDAF时同样要做引擎兼容性验证。Hive 3.x对UDAF的接口有了更严格的检查老版本里能跑的UDAF在新的GenericUDAFResolver2体系下可能直接加载失败这类坑在跨版本升级时防不胜防。写在最后的一个选择建议我自己在做技术选型时的判断标准已经比较固定了数据量不大、去重字段又有索引类条件过滤时怎么写都行按业务可读性来数据量大且字段重复率高优先group by多列去重统计优先“内层group by 外层聚合”命中数据倾斜加盐两阶段聚合追求极速且可以接受近似值直接上approx_count_distinct或自研HLL/Bitmap类UDAF集群已经是Hive 3.x或Spark引擎distinct的劣势缩小了但仍要多观察执行计划而不是听网上某一个版本的结论用到死。如果非要说一条最有价值的经验那就是不要在写SQL之前就争论孰优孰劣先看一眼执行计划再拿真实数据量跑一次对比最后根据Reduce数量和Shuffle量级做决定。数据工程师的直觉很重要但生产环境的数据分布才是最终裁判。
返回列表