多个COUNT(DISTINCT)同现于GROUP BY查询会引发Expand膨胀和哈希表级联爆炸,导致OOM或ERROR 2013;应拆为独立子查询再JOIN,确保各子查询索引覆盖、分区裁剪生效且tmp_table_size与max_heap_table_size配对调优。

多个 COUNT(DISTINCT) 同时出现在一个 GROUP BY 查询里,不是“可能”爆内存,而是几乎必然触发 Expand 膨胀和哈希表级联爆炸——尤其当字段基数高(如 UUID)、分组数多、并发稍高时,OOM 或 ERROR 2013 是常态。
为什么单条语句写多个 COUNT(DISTINCT) 会炸内存?
数据库(MySQL/PostgreSQL/Spark SQL)在解析 COUNT(DISTINCT a), COUNT(DISTINCT b) 时,不会并行维护两个哈希表。它会插入 Expand 节点:把原始 1 行复制成 N 行(N = 去重字段数),每行只标记一个字段参与去重。30 个字段?就是 1→30 行;1000 万行输入 → 3 亿行中间数据。
- Shuffle 数据量翻倍甚至几十倍,网络打满、磁盘临时文件暴增
- 每个字段单独建哈希结构,
user_id和order_no(UUID)同时去重,内存占用线性叠加 - MySQL 的
tmp_table_size和max_heap_table_size很快触顶,强制落盘 MyISAM 临时表 - PostgreSQL 的
work_mem不够用时,直接Spill to disk,IO 拖垮整条链路
必须拆成独立子查询再 JOIN
这是目前最稳定、可预测、能压测的解法。核心是让每个去重操作独占一个轻量聚合路径,避免交叉干扰。
- 每个子查询必须带完全一致的
WHERE条件(包括时间范围、状态过滤等),否则JOIN后分组键对不上 - 子查询结果集要小:理想是几百~几千行;如果单个子查询返回超 10 万行,说明没走索引或分区裁剪失效
-
JOIN字段类型必须严格一致,避免隐式转换(比如INTvsVARCHAR)导致索引失效 - 推荐用
USING(channel_code)替代ON t1.channel = t2.channel,减少字段重复和类型歧义
示例:
SELECT t1.dt, t1.uid_cnt, t2.aid_cnt, t3.pid_cnt
FROM (
SELECT dt, COUNT(DISTINCT uid) AS uid_cnt
FROM events
WHERE dt BETWEEN '2026-07-01' AND '2026-08-31'
AND event_type = 'click'
GROUP BY dt
) t1
JOIN (
SELECT dt, COUNT(DISTINCT aid) AS aid_cnt
FROM events
WHERE dt BETWEEN '2026-07-01' AND '2026-08-31'
AND event_type = 'click'
GROUP BY dt
) t2 USING (dt)
JOIN (
SELECT dt, COUNT(DISTINCT pid) AS pid_cnt
FROM events
WHERE dt BETWEEN '2026-07-01' AND '2026-08-31'
AND event_type = 'click'
GROUP BY dt
) t3 USING (dt);
拆开后性能没提升?先查这三件事
拆了不等于就安全,很多团队改完发现更慢甚至照样 OOM,问题往往出在底层执行路径没被真正约束。
- 确认每个子查询是否走了索引:用
EXPLAIN看Extra列有没有Using index;没出现就说明(dt, event_type, uid)这类联合索引缺失 - 检查分区裁剪是否生效:如果
events按dt分区,但子查询里漏了WHERE dt = ...,就会扫全量历史分区 - 验证
tmp_table_size和max_heap_table_size是否设为相同值且足够(如536870912),否则 MySQL 仍按默认 16MB 跑
真正难的不是写对语法,而是让每个子查询都变成“确定性小结果集”——这要求你清楚数据分布、索引覆盖度、分区边界,以及 WHERE 条件能否下推到扫描层。一旦某个子查询因条件松动返回几十万行,JOIN 就成了新瓶颈。

















