COUNT(DISTINCT)必须全量加载非NULL值构建哈希表或排序缓冲区,无法跳过或索引提前终止;高基数导致内存爆炸、多字段引发Expand膨胀、GROUP BY加剧性能断崖;精确需物化,近似可流式估算。

为什么COUNT(DISTINCT)必须全量加载非NULL值才能去重
它不是“边扫边丢”,而是必须把所有满足WHERE条件的非NULL值一次性拉进内存,构建哈希表或排序缓冲区——这个过程无法跳过,也不能靠索引提前终止。哪怕你给user_id建了唯一索引,优化器仍大概率走全表扫描,因为:B+树索引默认不存NULL,数据库没法仅靠索引确认哪些行该被排除;去重需要比对全部值,而索引只加速查找,不加速集合合并。
高基数字段会让哈希表直接爆内存
当user_id是亿级唯一值(比如UUID、订单号),COUNT(DISTINCT user_id)的哈希表会膨胀到GB级。MySQL的innodb_buffer_pool_size不够大时,哈希表溢出→写磁盘临时表→I/O暴增;PostgreSQL的work_mem设太小,同样触发落盘且排序变慢;Spark SQL中每个task都要维护自己的HashSet,节点内存打满就OOM。ClickHouse的uniqExact()也一样会崩,只有uniq()用HyperLogLog++流式估算才不占内存。
多个COUNT(DISTINCT)同时出现会触发Expand膨胀
写成SELECT COUNT(DISTINCT uid), COUNT(DISTINCT aid), COUNT(DISTINCT pid),Spark或Hive 3.x会插入Expand节点:1行原始数据变成3行(每行只标记一个字段用于去重)。30个字段?就是1行→30行,Shuffle数据量翻30倍,网络和磁盘全卡死。这不是慢,是直接资源耗尽。Hive 4.0才默认启用Multi Distinct优化,但当前多数生产环境还在Hive 3.x,必须显式改写为子查询+JOIN。
GROUP BY配合COUNT(DISTINCT)会让性能断崖下跌
比如SELECT channel, COUNT(DISTINCT user_id) FROM t GROUP BY channel,如果分组数是10万,数据库就得做10万次独立哈希/排序——每次都要重建哈希表。EXPLAIN里一旦出现Using temporary; Using filesort,基本等于在磁盘上建临时表。正确做法是下沉去重逻辑:SELECT b.name, new_a.ct FROM table_b b JOIN (SELECT dashboard_id, COUNT(DISTINCT user_id) AS ct FROM table_a GROUP BY dashboard_id) new_a ON new_a.dashboard_id = b.id,子查询结果集越小,外层JOIN越快。
真正难的从来不是“怎么写SQL”,而是要不要精确。如果能接受±1%误差,APPROX_COUNT_DISTINCT或uniq()几乎不占内存;如果必须精确,就得提前物化——没人会在实时查询里硬扛亿级COUNT(DISTINCT),这不是调优问题,是架构选择问题。










