多个count(distinct)同时使用会触发expand膨胀,导致数据量激增、资源耗尽;正确做法是拆分为独立子查询并join,且需保证where条件一致、添加复合索引和分区裁剪。

多个COUNT(DISTINCT)同时出现会触发Expand膨胀
Spark SQL 和部分 MySQL/PostgreSQL 版本在遇到 COUNT(DISTINCT col1), COUNT(DISTINCT col2) 同时存在时,会插入 Expand 节点:原始 1 行数据被复制成 N 行(N = 去重字段数),每行只标记一个字段用于去重。30 个字段?就是 1 行 → 30 行,Shuffle 数据量直接翻 30 倍。
这会导致网络带宽打满、磁盘临时文件暴增、task OOM 频发——不是慢,是直接卡死。
- ❌ 错误写法:
SELECT dt, COUNT(DISTINCT uid), COUNT(DISTINCT aid), COUNT(DISTINCT pid) FROM events GROUP BY dt - ✅ 正确做法:拆成独立子查询,各自去重后 JOIN
- 子查询必须加相同
WHERE条件(如dt BETWEEN '2026-07-01' AND '2026-08-31'),否则结果不一致 - 每个子查询结果集要小(理想是几百~几千行),否则 JOIN 成本反超
多字段 COUNT(DISTINCT) 必须拆开预聚合
数据库对多个 COUNT(DISTINCT) 不是“并行维护哈希表”,而是为每个字段单独建一套哈希结构——CPU 和内存开销线性翻倍。尤其当字段基数高(如 order_no 是 UUID),内存哈希表瞬间膨胀到 GB 级。
MySQL 的 innodb_buffer_pool_size、PostgreSQL 的 work_mem 很容易被撑爆,触发磁盘临时表;ClickHouse 的 uniqExact() 同样会 OOM,只有 uniq() 是流式 HLL,但不精确。
- ✅ 拆开写法示例:
SELECT t1.channel, t1.order_cnt, t2.user_cnt FROM (SELECT channel_code AS channel, COUNT(DISTINCT order_no) AS order_cnt FROM user_order WHERE del_flag = '0' AND create_date BETWEEN '2026-01-01' AND '2026-06-30' GROUP BY channel_code) t1 JOIN (SELECT channel_code AS channel, COUNT(DISTINCT user_id) AS user_cnt FROM user_order WHERE del_flag = '0' AND create_date BETWEEN '2026-01-01' AND '2026-06-30' GROUP BY channel_code) t2 ON t1.channel = t2.channel - ⚠️ 注意:两个子查询的
WHERE条件必须完全一致,否则分组键对不上 - 如果
channel_code是字符串且长度大,JOIN 时建议加USING(channel)或确保类型一致,避免隐式转换拖慢
子查询里必须加分区裁剪和覆盖索引
拆开后性能没提升?大概率是子查询没走索引,或者扫了全分区。比如 user_order 按 dt 分区,但子查询没写 WHERE dt = '2026-06',就会扫全部历史分区;又或者只建了 user_id 单列索引,但查询带了 WHERE del_flag = '0',优化器直接放弃索引扫描。
- ✅ 必须建复合索引:
CREATE INDEX idx_dt_del_channel_uid ON user_order (dt, del_flag, channel_code, user_id)(顺序按 WHERE + GROUP BY + COUNT(DISTINCT) 字段排列) - ✅ 子查询中显式写
WHERE dt BETWEEN '2026-01-01' AND '2026-06-30' AND del_flag = '0',让分区裁剪和索引都生效 - ❌ 避免子查询里 SELECT 多余字段,比如
SELECT DISTINCT channel_code, user_id, name——name不在索引里,强制回表,性能反而更差
要不要精确,比怎么写更重要
真正卡住的从来不是语法或索引,而是业务是否允许误差。如果接受 ±1% 误差,APPROX_COUNT_DISTINCT(PostgreSQL)、uniq()(ClickHouse)、HLL_COUNT.INIT(Spark)几乎不占内存;如果必须精确,就别指望单次查询扛住亿级去重——得物化。
- 物化不是“加个物化视图”就完事,而是每天定时跑:
INSERT INTO daily_uv_summary SELECT dt, channel_code, COUNT(DISTINCT user_id) AS uv FROM raw_log WHERE dt = '2026-09-01' GROUP BY dt, channel_code - 查的时候直接
SELECT uv FROM daily_uv_summary WHERE dt = '2026-09-01' AND channel_code = 'app',毫秒级响应 - 物化表的更新策略要和业务 SLA 对齐:T+1 可接受?那就不做实时;需要小时级?就得加调度任务每小时刷一次











