一、引言:那个让无数工程师抓狂的 COUNT DISTINCT
大家好,我是x哥。在之前的爆款文章《从2小时到3分钟:Spark SQL多维分析性能优化实战》中,我们曾惊鸿一瞥地提到了 COUNT DISTINCT 背后的 Expand 操作。没想到一石激起千层浪,最近技术交流群里好几位朋友都表示对这个话题非常好奇,催更的私信都快把我的对话框挤爆了。
宠粉如我,必须安排!
所以,今天这篇文章,我们就把 COUNT DISTINCT 这个磨人的“小妖精”拉到台前,从源码和执行计划的层面,把它从里到外扒个底朝天。我们将聚焦于精准去重,因此像 HyperLogLog 这类近似计算方法,我们暂时按下不表。
准备好了吗?发车!
二、COUNT DISTINCT 的“幻象”:它为何如此之重?
在SQL世界里,COUNT DISTINCT 就像一个熟悉的陌生人。我们每天都在用它,但又似乎从未真正了解它。
一个简单的 COUNT(DISTINCT user_id),背后真的只是“去个重,计个数”这么简单吗?
当然不是。当你在一个查询中同时对多个字段进行去重计数时,一场性能风暴正在悄然酝酿。它到底有多消耗资源?Spark 在背后做了哪些我们看不见的工作?它最大的性能瓶颈又在哪里?
别急,让我们从一个典型的业务场景开始说起。
三、深入引擎:Spark 的四步“魔法”
假设我们有一个订单表 orders,需要同时计算总订单数、总销售额,并对商品(product)和品类(category)进行去重计数。
SQL 语句如下:
SELECT
COUNT(*),
SUM(items),
COUNT(DISTINCT product),
COUNT(DISTINCT category)
FROM orders;
原始数据分布(假设有8条数据,分布在2个节点上):
接下来,就是见证奇迹的时刻。Spark 会通过四个核心步骤,将这个看似复杂的 SQL “翻译”成高效的分布式任务。
第一步:Expand 变形 - 一生万物
这是整个魔法的核心。Spark 并不会真的直接去重,而是巧妙地将 COUNT DISTINCT 转换为 GROUP BY + COUNT。
为了实现这个转换,Spark Catalyst 优化器首先会使用 Expand 操作,将原始数据“膨胀”开来。具体来说,它会为每一个聚合函数(包括普通聚合和去重聚合)生成一个副本,并引入一个 gid(grouping ID)列来区分它们。
gid = 0: 用于普通聚合COUNT(*)和SUM(items)。gid = 1: 用于COUNT(DISTINCT product)。gid = 2: 用于COUNT(DISTINCT category)。
Expand 之后,一行数据会裂变成三行:
这个操作的精髓在于:通过用空间换时间,将不同目标的聚合计算,解耦到了不同的数据行上,为后续的并行计算铺平了道路。
第二步:首次 HashAggregate - 本地“预聚合”
数据膨胀后,如果不加控制,Shuffle 的数据量将是灾难性的。因此,Spark 会立即在每个 Executor 本地进行一次“预聚合”(Partial Aggregate)。
这次聚合会以 gid 和所有去重列(product, category)作为 Key,对 gid=0 的普通聚合进行求和,并对 gid=1 和 gid=2 的数据进行初步去重。
本地预聚合的效果立竿见影:
可以看到,每个节点的数据量都得到了有效的削减,这极大地减轻了后续 Shuffle 的压力。
第三步:Shuffle 之舞与二次聚合
经过本地预聚合“瘦身”后的数据,现在可以进入 Shuffle 阶段了。数据会根据聚合键(gid, product, category)被重新分区,确保相同的 Key 被发送到同一个 Executor。
Shuffle 过程示意:
数据到达目标 Executor 后,会进行第二次 HashAggregate,完成全局的最终去重。
全局聚合示意:
第四步:最终聚合 - 真相大白
经过前三步,所有的数据都已经被完美地去重和预计算好了。最后一步,只需将所有分区的结果汇集到 Driver 或单个 Executor 中,进行一次简单的收尾聚合。
最终结果聚合示意:
通过 FILTER 子句,根据 gid 轻松地计算出最终结果:
-- 伪代码
SELECT
SUM(count_star) FILTER (WHERE gid = 0),
SUM(sum_items) FILTER (WHERE gid = 0),
COUNT(product) FILTER (WHERE gid = 1),
COUNT(category) FILTER (WHERE gid = 2)
最终结果: 至此,Spark 用一套行云流水的组合拳,优雅地完成了 COUNT DISTINCT 的计算。
四、性能的“两难”:基数是关键
理解了原理,我们就能轻松洞察其性能表现。COUNT DISTINCT 的性能好坏,几乎完全取决于一个核心指标——数据基数(Cardinality) 。
- 低基数场景(性能优越) :当去重列的唯一值很少时(例如,性别、省份),
Expand后的数据在本地预聚合阶段就能被大幅压缩,Shuffle 量很小,计算速度飞快。 - 高基数场景(性能噩梦) :当去重列的唯一值非常多时(例如,用户ID、订单ID),本地预聚合几乎起不到压缩作用。
Expand会导致数据量爆炸式增长,巨量的 Shuffle 数据和沉重的聚合压力,足以拖垮你的整个任务。
五、终极优化工具箱
当 COUNT DISTINCT 成为瓶颈时,我们该如何应对?下面这份优化清单,请务必收藏:
- 拆分查询:这是最简单有效的办法。与其在一个 SQL 中执行多个高基数的
COUNT DISTINCT,不如将它们拆分成多个独立的任务。 - 增加并行度:通过
spark.sql.shuffle.partitions参数,增加 Shuffle 的分区数,让更多 Task 来分担聚合压力。 - 善用预聚合:如果业务场景允许,可以先对数据进行一层
GROUP BY,在更小的粒度上完成去重,再进行上卷聚合。 - 内存调优:适当增加 Executor 的内存(
spark.executor.memory),特别是当数据倾斜严重时,可以有效避免 OOM。 - 拥抱近似算法:如果业务能容忍微小的误差(通常在 1%-2%),
approx_count_distinct(底层为 HyperLogLog) 是你的不二之选,它能以极低的资源消耗实现超高基数的去重计数。
六、总结:从“会用”到“精通”
今天,我们一起踏上了一段深入 Spark 引擎的旅程,揭开了 COUNT DISTINCT 神秘的面纱。
我们了解到,它并非一个简单的原子操作,而是由 Expand -> 本地聚合 -> Shuffle -> 全局聚合 这一系列精密设计的步骤构成的艺术品。它的性能与数据基数息息相关,这既是它的优势,也是它的阿喀琉斯之踵。
希望通过这篇文章,你对 COUNT DISTINCT 的理解,不再仅仅停留在“会用”的层面。当你下一次面对一个缓慢的去重查询时,能够胸有成竹地分析其执行计划,定位到瓶颈所在,并从我们的“优化工具箱”中,拿出最适合的那一把“扳手”。
更多 Flink / Spark / Doris / Clickhouse / Paimon 实战文章,请关注公众号 「数据慢想」