COUNT(DISTINCT) 慢的根源是 JOIN 导致中间结果行数爆炸,使去重在膨胀数据上进行;正确做法是拆分为带强过滤的子查询预聚合,再 JOIN,避免共享膨胀结果。

因为多表关联会先让行数爆炸,而 COUNT(DISTINCT) 又被迫在膨胀后的巨大中间结果上逐分组去重——这不是函数写慢了,是数据量被 JOIN 人为撑大了几十倍甚至几百倍。
JOIN 阶段就已注定 COUNT(DISTINCT) 慢
比如用户表 1 行关联订单表 5 行、地址表 3 行,LEFT JOIN 后变成 1×5×3 = 15 行;COUNT(DISTINCT order_id) 不是在原始 5 个订单上算,而是在这 15 行里找不重复的 order_id。数据库得为每个分组维护一个哈希表,内存和 CPU 压力直接翻倍。
- EXPLAIN 中看到
Using temporary; Using filesort,基本就是这个原因 - 即使
order_id有索引,JOIN 后的中间结果无法走索引,优化器大概率放弃索引扫描 - 如果关联的是分区表(如按
dt),但没在 JOIN 子句里加WHERE dt = '2026-08',分区裁剪失效,全分区扫描不可避免
多个 COUNT(DISTINCT) 同时出现会让性能雪上加霜
写成 COUNT(DISTINCT user_id), COUNT(DISTINCT order_no), COUNT(DISTINCT product_id),主流引擎(MySQL/Spark/Trino)会为每个字段单独建哈希结构,还可能触发 Expand 节点——1 行原始数据被复制成 N 行(N=去重字段数),Shuffle 或临时表体积暴增。
- Spark SQL 中,3 个
COUNT(DISTINCT)可能让网络传输量翻 3 倍 - MySQL 8.0 对多个
COUNT(DISTINCT)不合并去重逻辑,CPU 使用率常飙到 100% - 正确做法是拆成独立子查询,各自预聚合后再 JOIN,避免共享中间膨胀结果
子查询预聚合不是“可选技巧”,而是必须动作
把去重下沉到子查询,能大幅缩小中间集规模。例如:原查询对 dashboard_id 分组统计去重用户数,应先在 orders 表里按 dashboard_id 聚合,再 JOIN dashboards 表。
- 子查询中必须带强过滤条件(如
WHERE dt BETWEEN '2026-08-01' AND '2026-08-31'),否则预聚合失去意义 - 复合索引要覆盖子查询的
GROUP BY和WHERE字段,例如INDEX (dt, dashboard_id, user_id) - 子查询里别写
SELECT DISTINCT user_id, name——哪怕只为了去重user_id,只要name不在索引中,就会强制回表,性能反而更差
真正容易被忽略的点是:你调优的从来不是 COUNT(DISTINCT) 这个函数本身,而是它运行在哪一层数据上。JOIN 后的数据,哪怕只有百万行,也可能比预聚合后几千行的子查询慢两个数量级。别在执行计划里反复看 hash_agg 耗时,先检查 JOIN 的 rows 列有没有异常跳变。


















