ARTICLE DETAIL

资讯详情

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

Hive中Group By与Distinct的深度对比:性能差异与数据倾斜调优

Hive中Group By与Distinct的深度对比:性能差异与数据倾斜调优 开头凌晨两点被值班电话叫醒报警短信写着订单去重统计任务 running 超过 2.5 小时这是跑批任务最怕看到的场景。赶紧登进集群看 Yarn 页面发现 Reduce 阶段卡了一个长尾任务内存指标一直在报警。下意识打开任务日志翻 SQL看到一段刚上线的代码SELECT COUNT(DISTINCT user_id) FROM dwd_order_detail WHERE dt 2024-01-01;而之前跑的版本是SELECT COUNT(1) FROM ( SELECT user_id FROM dwd_order_detail WHERE dt 2024-01-01 GROUP BY user_id ) t;同一个需求两种写法一个 20 分钟跑完一个跑到 2 个半小时还没结束。这就是 Hive 里group by和distinct之间那点看似等价、实则天壤之别的真实写照。这篇文章不打算跟你复述官方文档我直接讲三件事两者在 MapReduce 执行模型里到底差在哪、什么数据分布下选哪个稳、以及生产环境里我踩过哪些坑。文章面向正在写 Hive 但还没被这两兄弟坑过的数据开发、数仓工程师也适合准备面试但只想背答案的人——不过看完你会发现真正有用的不是结论是判断结论怎么来的。1. 执行机制的分水岭distinct 缺的从来不是代码而是 Combiner1.1 原地去重与全局去重的差别先说最容易被忽略的一个事实distinct和group by在语义上都能去重但它们在 MapReduce 里走的数据搬运路线完全不一样。distinct这个词翻译过来是把所有数据先汇总到一起再逐个比对去掉重复的。执行时Hive 会把 distinct 的字段作为 key 交给 Reduce 端同一个 key 的所有行都会被 Shuffle 到同一个 Reducer 上。Reducer 拿到这批数据后在内存里维护一个去重集合逐个 key 判断遇到已经出现过的就丢弃没出现过就保留并输出。这个过程里最要命的一点Map 端无法对数据进行有效的预合并。原因是 distinct 的本质操作不是可累加的计数而是集合成员的判断。假设一个字段有一万条重复记录Map 端确实可以做局部去重把一万条压成一条——但如果这个字段本身的基数不同取值数有八千万那么 Map 端无论如何合并最终 Shuffle 到 Reduce 阶段的数据量还是接近八千万条。因为每个不同的值都得送给 Reducer 判一次。所以distinct的 Shuffle 数据量取决于字段的实际基数基数大数据传输量和 Reducer 内存压力就直线上升。1.2 group by 的可结合聚合让 Map 端预聚合成为可能再看group by。它的执行链路里多了个大杀器CombinerMap 端合并器。group by通常不是孤立使用的它后面一定跟着聚合函数比如COUNT()、SUM()、AVG()。这类聚合操作在数学上是可结合的把一百万条记录分别在一百个节点上各统计一次再把一百个局部统计结果汇总一遍最终结果和一次性统计完全一致。这个特性直接催生了 Map 端的预聚合阶段。Hive 在 Map 阶段就会对相同 key 的记录进行局部聚合比如统计某一天的订单数每个 Map Task 先算出自己处理的那部分数据里每个城市的订单数输出已经缩小到每个城市一行。等数据进入 Shuffle 阶段需要搬运的记录数从几千万行被压成了几百行。这就是为什么同样是去重场景group by跑大表时往往比distinct稳它把去重和聚合的动作提前分散到了每个 Map 节点上而不是把所有原始数据集中到一个 Reducer 上再处理。1.3 用一张搬箱子的类比理解两者的 IO 差异我用一个生活场景帮理解。假设你有一千个仓库每个仓库里堆着几万件快递现在要知道全国一共有多少个不同的收件人地址。distinct的做法所有仓库把所有快递全部运到北京的一个大院子里工作人员拿到一件拆一件拆完看地址用笔记本记着已经出现过的地址重复的扔掉不重复的登记在册。结果就是——全国所有快递都要挪动一遍最后那个院子可能被快递淹没。group by的做法每个仓库的负责人先把自己仓库里的快递按地址分类各自数一遍我这里有多少个不同地址、每个地址有几件然后只把统计结果带到北京汇总。北京只需要把一百份、一千份统计表合并一下就得到了答案。所以你看group by的性能优势本质上不是代码写得好而是它借助可结合的聚合函数在数据源头做了一次压缩。数据量越大、重复率越高这个优势越明显。2. 数据倾斜才是真正的生死线重复越多distinct 越容易翻车2.1 为什么重复数据全都砸向同一个 Reduce聊完 IO 量第二个绕不开的问题是数据倾斜Data Skew。这是 Hive 任务在生产环境里最常见的死法之一而distinct几乎是把死因写在了脸上。MapReduce 的 Shuffle 阶段数据从 Map 端流向 Reduce 端时靠的是对 key 做哈希计算然后模上 Reducer 数量决定这条记录该进哪个 Reducer。这个分区算法本身很公平——不同 key 均匀散开。但问题是同一个 key 的所有记录必然会被分到同一个 Reducer。一旦某个字段存在明显的热点值比如一个城市的订单量占全表的 40%SELECT DISTINCT city FROM t的执行逻辑里这个城市的全部几千万行原始记录会全部砸向同一个 Reducer。其他城市可能每个 Reducer 只分到几万行而热点城市的 Reducer 要吞吐千万行数据整体任务耗时直接被这个长尾 Reducer 拖死。我见过最夸张的一次任务总共 212 个 Reducer其中 190 个在 5 分钟内跑完了剩下的 22 个卡了整整一个半小时。看 Counter 才发现卡住的那几个都接收了占比极高的同城市数据。2.2 group by 怎么在源头把重复抹掉同一份数据换成group by写法情况就完全不同了。因为 Map 端的 Combiner 会在 Shuffle 之前先把每个 Map 节点里相同的 key 合并成一行。热点城市的记录虽然多但它们被分摊到了几百个 Map Task 上每个 Map 只处理其中一部分输出给 Shuffle 阶段的数据已经是该城市一行索引级别的聚合值。最终到达热点 Reducer 的数据量从几千万行变成了几百个 Map 输出的几十行。Reducer 的压力降了几个数量级长尾问题也就自然消解了。说白了group by是在源头把热点数据摊平了而distinct必须等到数据全汇聚到 Reducer 才能处理中间没有任何缓冲机制。2.3 明确什么数据分布适合 distinct、什么必须 group by这里给你一个可落地的判断框架不用背原理直接对着看字段特征示例推荐写法原因基数低且分布均匀订单状态成功/失败/取消distinctShuffle 量小写法可读性最好基数低但有热点值城市、渠道、省份group by热点值反复被合并避免单 Reducer 长尾基数高且分布均匀订单号、设备ID两者皆可Shuffle 量由基数决定差异不大基数极高user_id、client_id、cookie_idgroup by计数场景distinct 的 Reducer 内存会被 key 集合撑爆去重同时要计数每个城市有多少用户group by天然契合聚合语义distinct 写不了只取枚举值有哪些状态类型distinct最简单、最直观注意最后一行很多人一听到大数据量就不要用 distinct就把 distinct 一棒子打死其实不对。如果你只是想拿到一张有哪些可用的状态枚举值的小表数据量就算几个亿select distinct status也很快——因为 status 的基数就几个Map 端局部合并后剩下的几条记录怎么搬运都不慢。真正怕的是高基数 distinct。这个判断维度后面第 5 章还会结合实际业务再展开。3. 1 亿行订单表的实测结果我拿真实数据跑给你看3.1 造一张有代表性的测试表理论讲得再多不如直接拿数据跑一遍。我最早在项目里验证这个问题造了一张模拟订单表结构如下CREATE TABLE test_order_detail ( order_id STRING, user_id STRING, city_id INT, pay_amount DECIMAL(10,2), dt STRING ) PARTITIONED BY (dt STRING);测试环境是 Hive 2.3.7采用 MapReduce 引擎跑在 6 个节点的集群上。数据量控制在 1 亿行左右分区为dt2024-01-01。为了照顾真实场景我特意把city_id的数据做成了 Zipf 分布——前 5 个城市的订单量占了全表的 35% 左右模拟那种头部城市撑着大半业务的长尾情况user_id则控制在高基数且相对均匀总去重数量约 900 万。造数用的是一条常见的 trick SQLINSERT OVERWRITE TABLE test_order_detail PARTITION(dt2024-01-01) SELECT concat(order_, id), concat(user_, floor(rand() * 9000000)), floor(pow(rand(), 2) * 100), -- 二次幂制造头部热点城市 round(rand() * 500, 2), 2024-01-01 FROM ( SELECT explode(split(space(9999), )) 1 AS id ) t CROSS JOIN ( SELECT explode(split(space(999), )) 1 AS x ) t2;这种造数方式可以快速生成亿级行数不需要依赖外部数据源适合自己搭测试环境复现验证。3.2 四组对比实验与耗时我跑了四组典型的 SQL每组在任务结束后去 Yarn 或 Hive CLI 里看执行时间和关键 Counter结果如下实验SQL 写法执行时间Reduce 阶段备注实验 1SELECT DISTINCT user_id FROM t约 19 分钟有长尾但不致命Reducer 数量 300实验 2SELECT user_id FROM t GROUP BY user_id约 16 分钟长尾不明显明显跑得舒服实验 3SELECT COUNT(DISTINCT user_id) FROM t约 43 分钟单 Reduce 卡死该 Reduce 处理 900 万 key实验 4子查询COUNT(1) FROM (GROUP BY user_id)约 21 分钟多 Reduce 并行比实验 3 快一半以上先说结论纯去重场景实验 1 和 2group by 略优但差距没有想象中大。因为user_id基数高达 900 万即使 group by 有 Combiner最终输出到 Shuffle 的数据量也不会低于这个基数两者的瓶颈都是 900 万级别的记录传输。真正拉开差距的是实验 3 和实验 4——COUNT(DISTINCT ...)。从 MR 引擎的角度讲count distinct 会把 distinct 去重和计数耦合在一起Reducer 只能把去重集合全部放进内存维护通常是一个巨大的 hash 表等收完所有数据后再统计集合大小。这 900 万个 key 全部集中在唯一的一个 Reducer 上内存压力直接爆炸。而实验 4 的多层查询把问题拆解成了两轮 MapReduce第一轮用 group by 把去重均匀分摊到 300 多个 Reducer 上第二轮再对已经很小的去重结果做一次简单 countRedux 压力的量级完全不同。这个对比我在不同数据量下重复验证过好几次方向基本一致COUNT(DISTINCT) 是大数据量场景下最差的写法没有之一。3.3 多字段去重distinct a,b 与 group by a,b 的差别还有一个需要单独验证的场景——多字段去重也就是SELECT DISTINCT a, b和SELECT a, b GROUP BY a, b。这两者在 MapReduce 层面几乎走的是同一套逻辑Hive 会把(a, b)拼成一个复合 key。由于复合 key 的基数通常远大于单字段基数Shuffle 量天然就大但两个写法的差异倒不如单字段 count distinct 那么夸张。我实测下来GROUP BY a, b略快 5%~10%主要赢在 Map 端预聚合的稳定性上。但多字段场景里group by有一个不可替代的优势它可以在去重的同时带上其他聚合字段。比如SELECT city_id, user_id, COUNT(*) FROM t GROUP BY city_id, user_id这种维度组合 计数的需求用 distinct 一行都写不出来必须靠 group by 或窗口函数。这算是一个选型上的默认加分项。3.4 Hive 3.x 自动 distinct 改写带来的版本差异上面这些实测结果有个重要前提Hive 2.3.7 MapReduce 引擎。如果你的环境是 Hive 3.x行为会有变化。从 Hive 3.0 开始社区引入了hive.optimize.distinct.rewrite参数默认是开启的。开启后COUNT(DISTINCT expr)在某些简单场景下会被自动改写成COUNT(1) FROM (SELECT expr FROM t GROUP BY expr) t的形式相当于把实验 4 的优化思路内置到了优化器里。但千万不要以为开了这个参数就万事大吉。这个功能在部分版本上演进时出过正确性问题比如与LIMIT子句、ORDER BY组合时可能出现结果不符。所以我的经验是生产环境用了 Hive 3.x 的自动改写即使任务能跑也要对结果做一轮回归校验如果业务对正确性极其敏感不如自己在 SQL 层显式改写成 group by 子查询至少出了问题你知道代码逻辑是什么样的。4. 生产环境兜底方案倾斜时我把这些参数掏出来用4.1 hive.groupby.skewindata两轮 MapReduce 换稳定性先声明一下这个参数只作用于group bydistinct没有对等的优化开关。这也是很多任务被迫放弃 distinct 的直接原因之一。hive.groupby.skewindata默认是 false。把它设为 true 后Hive 会启动两轮 MapReduce来处理 group by第一轮Map 端输出的 key 会经过一个随机因子打散。也就是说同一个热点 key 不会再全部砸向同一个 Reducer而是被随机分摊到多个 Reducer 上做部分聚合。第二轮拿第一轮的部分聚合结果再按真实 key 做一次最终聚合。这样做的效果是把长尾 Reducer 的压力平均化了。代价也明显多了一轮 MR就是多一次全量 shuffle 和落盘整体任务耗时会增加对于本来就不倾斜的任务纯属浪费。SET hive.groupby.skewindata true;生产环境我的建议是先用一个临时 SQL 和 EXPLAIN 去判断热点键占比确认长尾严重再开这个参数。不要全局开启因为很多任务是分布均匀的开了反而把快任务拖慢。这个坑我在第 7 章会单独讲一个真实案例。4.2 调整 Reduce 数为什么救不了 distinct经常有人在处理 distinct 倾斜时第一反应是调大 Reducer 数量比如SET mapreduce.job.reduces 500;这个操作在 group by 场景下可能有效但在 distinct 场景下基本没用。为什么因为Reducer 数量改变的是分区数而同一个 key 永远哈希到同一个 Reducer 上。如果热点 key 本身有那么大的量无论你分成 500 个还是 5000 个 Reducer热点数据还是挤在同一个分区里长尾该出现还是出现。想治 distinct 的倾斜唯一的思路是绕开同 key 同 Reduce的约束要么放弃 distinct 改用 group by 加盐两阶段聚合要么用 Hive 3.x 的自动 distinct 改写。加盐方案我放 4.3 讲。4.3 count(distinct) 的三种改写思路针对COUNT(DISTINCT)这个最让人头疼的写法我总结了三种实际可落地的改写方案。方案一子查询 GROUP BY最通用强烈推荐-- 改造前 SELECT COUNT(DISTINCT user_id) FROM test_order_detail WHERE dt 2024-01-01; -- 改造后 SELECT COUNT(1) FROM ( SELECT user_id FROM test_order_detail WHERE dt 2024-01-01 GROUP BY user_id ) t;这套写法的要点在于第一层 GROUP BY 的去重工作被分散到了多个 Reducer 上第二层 COUNT(1) 只面对去重后的行数据量已经很小。它在 MR 引擎下的性能收益我在第 3 章已经用数据验证过。方案二加盐两阶段聚合热点极强时用如果字段本身存在大量热点值比如一个 user_id 占了千万行单纯 GROUP BY 也可能让这一个 key 触发长尾这时候要加盐-- 第一层加盐把热点 key 随机拆散 SELECT user_id, concat(user_id, _, floor(rand() * 50)) AS salted_key FROM test_order_detail WHERE dt 2024-01-01 GROUP BY user_id, concat(user_id, _, floor(rand() * 50)); -- 然后用一层 GROUP BY user_id 做最终去重第二层 SQL 省略不写全了思路是把同一个高热点 key 随机附上一个 0~49 的盐值这样原来的一个 key 被拆成 50 个分区去聚合热点压力就分摊了。注意这里盐值范围不宜过大否则第一轮聚合后中间数据膨胀也不宜过小否则热点没拆开。通常 50~100 之间比较合适。这个方案本质上是hive.groupby.skewindata的手动版好处是你可以精确控制拆分数坏处是要自己维护两段 SQL可读性差一些。方案三用 Hive 3.x 的自动改写版本合适再用SET hive.optimize.distinct.rewrite true;这个参数前面说过能自动把 COUNT(DISTINCT) 改写为 group by 子查询形式。但要注意版本差异和正确性问题生产环境必须在测试环境先回归验证。我自己碰到过 Hive 3.1.2 上它与某些子查询场景组合后结果多算的问题所以最终宁可在 SQL 层手动改写也不赌优化器的实现。4.4 去重任务落库别忽略小文件问题去重任务本身跑得快不算完很多人栽在最后一步把去重结果写入分区表时因为并行度太高或动态分区过多生成了大量小文件后面每次读表都要被小文件的元数据开销拖累。如果你在跑完 group by 后要 INSERT 覆盖一张分区表建议顺手加上这几个参数SET hive.merge.mapfiles true; -- 合并 Map-only 任务的小文件 SET hive.merge.mapredfiles true; -- 合并 MR 任务的小文件 SET hive.merge.size.per.task 256000000; -- 合并目标256MB 一个文件 SET hive.merge.smallfiles.avgsize 16000000; -- 小于 16MB 的文件触发合并如果使用的是动态分区还可以在写入时用DISTRIBUTE BY指定分桶键让数据在落盘前就按分区聚拢减少小文件产生。这个属于去重任务落地时的配套优化和数据倾斜一样重要很多线上事故都是任务跑完一天后下游读表才发现文件数量爆炸。5. 用需求反推写法一张决策清单解决九成去重场景5.1 直接给结论什么场景选什么我把平时接到的大部分去重需求整理成了一张表配合前面的原理一起看基本能覆盖日常工作里的选择典型需求数据特征推荐方案一句话理由查状态枚举值基数个位数distinct数据量再大也不用怕输出极小查有哪些城市下了单基数几十/几百distinct基数小怎么写都快统计去重后的总用户数/设备数基数千万级group by 子查询 count避免单 Reducer 内存爆炸统计每个维度下的去重用户数多维度组合双层 group by先组内去重再组间计数已有一张明细表需要按多字段取唯一组合多字段复合去重group by 或 distinct 均可性能接近group by 更稳明细表按用户取最新一条记录顺序敏感row_number() 窗口函数distinct/group by 做不了取首条同一结果集还要聚合计数任何特征group bydistinct 无法附带聚合这张表不是让你死记硬背而是提醒你选型之前第一步是看字段基数与分布而不是看 SQL 好不好写。5.2 网约车活跃司机统计从业务需求到 SQL 落地的完整推演拿网约车项目里最常见的统计活跃司机数来走一遍完整推演这个场景我写过太多次了。假设有一张亿级的订单明细表dwd_order_detail字段含order_id、driver_id、city_id、hour。业务方给的需求是统计 2024-01-01 当天每个城市的活跃司机数。新手的直觉写法SELECT city_id, COUNT(DISTINCT driver_id) AS active_drivers FROM dwd_order_detail WHERE dt 2024-01-01 GROUP BY city_id;这条 SQL 在数据量小的时候没问题但到了亿级订单规模COUNT(DISTINCT driver_id)的局限性就暴露了每个城市的去重集合都要在 Reducer 内存里维护热点城市的 driver 数量可能达到百万量级内存很容易打爆。标准的生产级改法是双层 group bySELECT city_id, COUNT(1) AS active_drivers FROM ( SELECT city_id, driver_id FROM dwd_order_detail WHERE dt 2024-01-01 GROUP BY city_id, driver_id ) t GROUP BY city_id;第一层GROUP BY city_id, driver_id把每个城市的唯一司机先做出来这一步的去重可以均匀分摊到多个 Reducer第二层再按城市做最终计数。实际跑下来同样的 1 亿数据双层 group by 比直接 count distinct 能快 50% 以上而且内存稳定。如果只是想统计整个平台当天的活跃司机总数不需要分城市就直接用 4.3 方案一的子查询写法也是一样的道理。5.3 窗口函数去重distinct 和 group by 都做不到的第三种选择说到给每一行标号和取最新一条很多新人会困惑去重不就是 distinct 或 group by 吗怎么还有个窗口函数窗口函数解决的是另一类问题不再是把重复行合并成一个而是从重复行里挑出符合条件的那一条。比如订单表里一个订单可能有多次状态变更记录要取每个订单最新的状态记录SQL 长这样SELECT order_id, status, updated_at FROM ( SELECT order_id, status, updated_at, ROW_NUMBER() OVER(PARTITION BY order_id ORDER BY updated_at DESC) AS rn FROM dwd_order_status_log WHERE dt 2024-01-01 ) t WHERE rn 1;这个场景下distinct和group by都写不出来——因为它们只能把重复行合并无法决定保留哪一行。取每个分组内的最新记录必须窗口函数出场。所以在去重需求里我的默认思考顺序是先问业务是只要唯一值还是要每个唯一值下的某条代表记录前者走 group by/distinct后者直接上 row_number()。5.4 Spark/Tez 引擎下结论要打折前面所有的结论都是基于 Hive on MapReduce 引擎。在实际生产环境里很多公司已经把执行引擎换成了 Tez 或者 Spark这两类引擎对 distinct 的处理逻辑跟 MR 差异很大结论要打折扣。以 Spark SQL 为例它的聚合执行器是HashAggregate在 Map 端或者说 shuffle 前的 Executor 端就能做部分聚合甚至 distinct 也可以利用类似机制。因此Spark 引擎下COUNT(DISTINCT)与GROUP BY子查询的性能差距往往比 MR 引擎下小得多某些情况下二者甚至持平。Tez 引擎则更像是 MR 的增强版它的 DAG 优化能减少中间落盘但 shuffle 层面 distinct 的同 key 同分区约束依然存在所以 group by 的优势仍在只是幅度变小。我的经验是判断引擎类型后再决定要不要改写。如果公司还在用 Hive on MRcount distinct 一定要绕开如果已经上了 Spark 引擎可以先跑个小数据量的 EXPLAIN 对比一下不必迷信脚本改写成必快。第 6 章就教你具体怎么看。6. 从 EXPLAIN 和 Counter 里找真相别再拍脑袋选写法6.1 看执行计划重点盯 Map 端输出如果你不想每次都靠猜来做选型最扎实的做法是直接看执行计划。Hive 里一条命令就能办到EXPLAIN EXTENDED SELECT COUNT(DISTINCT user_id) FROM test_order_detail WHERE dt 2024-01-01;再跑一条EXPLAIN EXTENDED SELECT COUNT(1) FROM ( SELECT user_id FROM test_order_detail WHERE dt 2024-01-01 GROUP BY user_id ) t;拿到两份执行计划后不用逐行读重点看几处Map Operator Tree 下有没有聚合器aggregation信息。group by 的 plan 里你能看到类似aggregations: count()、keys: user_id这样的节点代表 Map 端会做局部聚合distinct 的 plan 里则几乎没有这类预聚合描述Map 输出基本等于原始列的投影。Reduce Operator 的输入结构。distinct 的 Reducer 需要维护一个完整去重集合而 group by 的 Reducer 接收的是已经部分聚合后的数据两者的处理模式一眼就能分辨。预估输出行数。看Statistics段里的outputSize、numRows等指标。如果 Map 端的预估输出行数接近原始表行数说明这个 SQL 的 map 端压缩能力很弱大概率是 distinct 在裸奔。这个方法在生产环境特别实用写完 SQL 不确定性能先 EXPLAIN 一下几十秒就能做出判断不用等任务跑起来。6.2 Shuffle Bytes 和 SPILLED_RECORDS 才是体检报告执行计划只能做预判真正的体检报告要看任务跑完后的 Counter 数据。在 Yarn 的 Application 页面或者 Job History 里找到 Map-Reduce 框架的 Counter 列表重点关注几个指标Counter 名称含义怎么判断问题Shuffle BytesShuffle 阶段传输的数据量如果远大于聚合后应有数据量说明 map 端预聚合没生效SPILLED_RECORDS溢写到磁盘的记录数数值越大说明内存装不下已经在疯狂落盘Reduce shuffle bytes单个 Reducer 接收的数据某个 Reducer 数值异常高就是长尾倾斜的铁证GC time任务垃圾回收耗时数值高说明 Reducer 内存压力大我一般在遇到去重任务慢时第一步先看Shuffle Bytes和SPILLED_RECORDS。比如一个 1 亿行的表按城市去重如果 Shuffle Bytes 显示传了几十个 GB而聚合后理论上只要几十 KB那几乎可以肯定 SQL 把去重逻辑全部压到了 Reduce 端——不用猜直接换写法就行。这套EXPLAIN 预判 Counter 验证的排查链路比任何文档里的调优口诀都可靠。因为不同集群、不同版本、不同数据分布同一个 SQL 的表现可能天差地别只有数据不会说谎。7. 踩过的坑比文档更值钱三个生产案例复盘7.1 2 亿行里 count(distinct 8000 万基数字段) 是怎么把 Reducer 内存撑爆的这个案例是我职业生涯里印象最深的一个线上事故。某业务方在 2 亿行的行为日志表里直接跑了一段SELECT COUNT(DISTINCT client_id) FROM dwd_user_behavior_log;client_id的基数大约 8000 万。任务跑到 Reduce 阶段那个唯一的 Reducer 开始疯狂 GC然后抛出Java heap spaceFailed 重试再失败最后整个任务挂掉。复现后分析原因非常清楚den distinct 的 Reducer 必须把 8000 万个 client_id 全部维护在内存的哈希集合里。8000 万个长字符串 key 的内存占用轻松超过 10GB而单个容器内存我们只给了 8GB。哪怕提高容器内存也只能缓解无法根治——数据量再翻倍内存又要翻倍。最后改成了 group by 子查询方案把去重分散到 400 个 Reducer 上每个 Reducer 只维护 20 万级别的 key内存压力瞬间归零耗时从跑不完降到了 40 分钟内。这个案例之后我在团队里立了一条硬规矩凡是统计去重场景默认写 group by 子查询除非能证明字段基数很小。7.2 分布均匀的任务开 skewindata性能不升反降有段时间团队统一开了set hive.groupby.skewindatatrue理由是优化倾斜、保证稳定。结果一个原本 25 分钟跑完的日活统计任务变成了 40 多分钟。原因不复杂那张表按天分完区后只做两级维度聚合每个 key 对应的记录量都差不多根本没有倾斜。开了 skewindata 后反而强制任务走两轮 MapReduce多了一轮全量 Shuffle 和落盘纯属自找麻烦。教训是skewindata 必须按任务精准开关不能图省事全局开。判断方式很简单先用EXPLAIN看执行计划再抽样看下 key 分布如果 Top 10 key 的占比不超过 10%这任务不需要防倾斜。7.3 为了带出附带字段distinct 列表越写越长最后被迫改 group by max最后一个坑很隐蔽属于写法上的慢性死亡。有个同事写了个 SQL业务需求是按用户维度取一张订单表的去重数据并保留用户首次下单时间。他可能习惯了 distinct 的写法硬写成SELECT DISTINCT user_id, first_order_time FROM dwd_order_detail;这 SQL 在逻辑上是错误的——user_id和first_order_time的组合去重并不能保证一个用户只有一条记录。结果他加了更多字段进去user_id, first_order_time, last_order_time, city_id...distinct 列表越写越长Shuffle 的数据量也随之膨胀了几个量级任务越来越慢。实际上这个需求的标准解法是 group by 聚合函数SELECT user_id, MIN(first_order_time) AS first_order_time, MAX(last_order_time) AS last_order_time FROM dwd_order_detail GROUP BY user_id;用 group by 配合MIN/MAX等聚合函数才能做到按用户一行、附带字段取特定值。distinct 在这种场景下越写越变形本质上是把一个聚合需求硬生生套在去重语义下最终只能越改越糟。这也印证了第 1 章讲的那个道理distinct 解决是否存在的问题group by 解决每一组的值是什么的问题二者定位不同硬凑在一起必然出事。顺带说一句第 4 章提到的小文件合并参数在这一类group by 重分区后写新表的任务里也是必配。我见过有人把去重结果写进动态分区表一小时的任务跑完了产生了两千多个几 KB 的小文件下游查询直接被元数据拖慢。做完 group by/distinct 之后想想数据写到哪、怎么避免碎文件这句提醒能帮你少吃很多亏。跑了几年数仓任务我个人的习惯已经固定成了计数去重优先 group by 子查询取小基数枚举值才用 distinct按组取最新一条直接上窗口函数。但更想提醒你的是别把这些结论固化。Hive 的版本在变、引擎在变、数据特征在变同一个 SQL 今天跑得飞快明天换了 Tez 引擎可能又是另一回事。我每次碰到拿不准的写法都会先EXPLAIN看一眼执行计划再用 Counter 数据验证一遍前后不超过几分钟。把这个习惯带进你的日常开发里比背下任何一条什么场景选什么的规则都管用。
返回列表