SELECT count(DISTINCT user_id) FROM events 看起来只是一个普通的统计查询,但在 PostgreSQL 中,DISTINCT 会改变聚合的执行方式。对于大型事件表,这个变化可能意味着查询从多核并行扫描退化为单进程排序,并把临时数据写入磁盘。
问题不在索引配置,也不在 max_parallel_workers_per_gather 没有调大,而在于精确去重本身需要一个能够合并的全局状态。理解这一点后,很多 COUNT(DISTINCT ...) 查询都可以通过改写重新获得并行执行能力。
两个 COUNT,完全不同的计划
可以用下面的 SQL 创建一个可复现实验。示例假设使用 PostgreSQL,并生成一千万条事件记录。
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY,
user_id int NOT NULL,
country text NOT NULL,
amount numeric(10, 2) NOT NULL
);
INSERT INTO events (user_id, country, amount)
SELECT
(random() * 50000)::int + 1,
(ARRAY['US', 'DE', 'GB', 'FR', 'JP', 'BR', 'IN', 'CA'])[(random() * 7)::int + 1],
(random() * 500)::numeric(10, 2)
FROM generate_series(1, 10000000);
ANALYZE events;
SET max_parallel_workers_per_gather = 4;
SET work_mem = '64MB';
普通的 count(*) 不需要去重。每个 worker 扫描自己负责的数据块,维护一个局部计数,最后由 leader 合并这些数字:
EXPLAIN (ANALYZE, COSTS OFF)
SELECT count(*) FROM events;
典型计划大致如下:
Finalize Aggregate
-> Gather
Workers Planned: 4
Workers Launched: 4
-> Partial Aggregate
-> Parallel Seq Scan on events
这里有四个 worker,再加上 leader,一共五个执行循环。每个进程只需要保存一个计数值,最终的合并操作就是把几个数字相加。
只增加一个 DISTINCT,计划就会明显变化:
EXPLAIN (ANALYZE, COSTS OFF, BUFFERS)
SELECT count(DISTINCT user_id) FROM events;
典型结果会接近:
Aggregate
-> Sort
Sort Key: user_id
Sort Method: external merge Disk: 117448kB
-> Seq Scan on events
这里没有 Gather、没有 Partial Aggregate,也没有并行顺序扫描。单个进程读取整张表,把一千万个 user_id 排序,让重复值相邻,然后顺序扫描排序结果来统计不同值。由于排序数据超过 work_mem,排序还可能溢出到临时文件。
为什么 DISTINCT 很难做部分聚合
PostgreSQL 的并行聚合通常分成两步:
- 每个 worker 执行
Partial Aggregate,为自己看到的行构建一个局部状态。 - leader 执行
Finalize Aggregate,通过聚合函数的 combine 逻辑合并这些局部状态。
对于 count(*),局部状态只是一个整数,合并两个状态就是相加。sum、min、max 等聚合也有类似的合并方式。
但 count(DISTINCT user_id) 的局部结果不能只是一个数字。假设 worker A 和 worker B 都看到了用户 42:
worker A: {1, 2, 42}
worker B: {3, 42, 99}
如果两个 worker 只返回各自的 distinct 数量,leader 无法知道 42 被重复计算了。要得到精确结果,leader 必须拿到每个 worker 看到的完整用户集合,然后做集合并集。这样一来,大量数据仍然需要被搬到一个地方,失去了并行聚合减少数据交换的意义。
因此,带有 DISTINCT 的聚合通常不能进入 partial aggregation 模式。没有 partial aggregate,底层扫描也就没有可以有效分割的聚合工作,整个查询往往退化为串行执行。
聚合内部的 ORDER BY 也有类似限制。例如:
EXPLAIN (COSTS OFF)
SELECT string_agg(country, ',' ORDER BY country)
FROM events;
string_agg 必须保证全局顺序。每个 worker 各自排序后,仍然需要重新合并这些局部结果,不能简单地把它们拼接起来。array_agg、带内部排序的 json_agg,以及 percentile_cont、percentile_disc 等聚合也应当重点检查执行计划。
FILTER 不会触发同样的问题
不要把 DISTINCT 和所有复杂聚合混为一谈。FILTER 只是决定哪些行参与聚合,仍然可以在各个 worker 上独立计数:
EXPLAIN (COSTS OFF)
SELECT count(*) FILTER (WHERE country = 'US')
FROM events;
这类查询仍然可以使用:
Finalize Aggregate
-> Gather
Workers Planned: 4
-> Partial Aggregate
-> Parallel Seq Scan on events
每个 worker 只对满足条件的行更新局部计数,leader 最后把局部计数相加。FILTER 没有引入跨 worker 的去重状态。
一个 DISTINCT 会拖慢同一聚合节点中的其他聚合
影响范围也不是只限于 distinct 结果本身。下面的查询同时计算金额总和和去重用户数:
EXPLAIN (COSTS OFF)
SELECT sum(amount), count(DISTINCT user_id)
FROM events;
sum(amount) 单独执行时本来可以并行,但它和 count(DISTINCT user_id) 位于同一个聚合节点。这个聚合节点无法采用 partial aggregation,于是两个计算都会被拖进串行排序计划。
如果两个统计可以拆到不同的查询块中,影响范围就可能被隔离。例如,可以把某个普通聚合放到独立子查询或 CTE 中,再与去重统计组合。不过是否真的改善,仍应以 EXPLAIN (ANALYZE, BUFFERS) 为准,因为优化器可能会重新合并或调整查询结构。
推荐改写:用 GROUP BY 先完成去重
对于整张表上的单个 distinct 计数,可以把去重操作改写为 GROUP BY,再对分组结果计数:
SELECT count(*)
FROM (
SELECT user_id
FROM events
GROUP BY user_id
) AS distinct_users;
语义上,GROUP BY user_id 产生的正是所有不同的用户。关键区别在于,GROUP BY 可以使用 partial hash aggregation:每个 worker 先对自己的数据分片建立局部哈希表,leader 再合并局部分组。
检查执行计划:
EXPLAIN (ANALYZE, COSTS OFF)
SELECT count(*)
FROM (
SELECT user_id
FROM events
GROUP BY user_id
) AS distinct_users;
理想情况下可以看到类似结构:
Aggregate
-> Finalize HashAggregate
Group Key: events.user_id
-> Gather
Workers Planned: 4
-> Partial HashAggregate
Group Key: events.user_id
-> Parallel Seq Scan on events
在摘要中的一千万行测试中,原始 count(DISTINCT user_id) 使用串行排序并产生约 115MB 的临时磁盘数据,耗时中位数约为 1211ms。改写后的查询使用并行哈希聚合,耗时中位数约为 360ms,结果都为 50001。
具体收益取决于硬件、数据分布、work_mem 和 PostgreSQL 版本,但这说明改写的价值不只是让计划中出现一个 Gather,而是把昂贵的去重工作放回了可以被 worker 分担的聚合阶段。
分组维度上的 DISTINCT
按国家统计不同用户时,原始写法通常是:
SELECT country, count(DISTINCT user_id)
FROM events
GROUP BY country;
可以尝试把去重拆成两列的分组,再进行外层统计:
SELECT country, count(*)
FROM (
SELECT country, user_id
FROM events
GROUP BY country, user_id
) AS distinct_country_users
GROUP BY country;
这个改写把精确去重表达成了可分组的数据流,理论上更容易进入并行计划。不过它包含两层分组,最终是否采用并行执行取决于基数估算和成本模型。中间分组数量较少时,优化器可能认为串行执行已经足够便宜。
因此,不要仅因为 SQL 被改写就假设它一定并行。应检查以下信号:
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT country, count(*)
FROM (
SELECT country, user_id
FROM events
GROUP BY country, user_id
) AS distinct_country_users
GROUP BY country;
什么时候值得处理
小表上的 count(DISTINCT ...) 通常不值得专门改写。几千行数据即使串行执行也很快,额外的查询层次反而增加了维护成本。
真正应该关注的是大型事实表和重复运行的分析查询,尤其是:
- 按月或按季度扫描大量事件的仪表盘查询;
- 每晚运行的用户或设备去重统计;
- 表已经达到千万级甚至更大规模;
EXPLAIN (ANALYZE, BUFFERS)显示单个Aggregate下方没有Gather;- 执行计划出现
Sort Method: external merge和Disk:; - 整张表由单个进程以
loops=1扫描。
如果业务允许近似结果,可以考虑 HyperLogLog 一类的基数估算。它用固定大小的 sketch 表示集合,局部 sketch 可以合并,因此更适合并行和仪表盘场景。代价是结果存在有界误差,不能直接替代财务、计费或审计类精确统计。
落地检查清单
- 对大型
COUNT(DISTINCT ...)执行EXPLAIN (ANALYZE, BUFFERS)。 - 检查是否存在串行扫描和外部排序。
- 尝试用
GROUP BY子查询先生成 distinct 集合。 - 对按维度去重的查询,使用
GROUP BY dimension, key的两阶段写法进行测试。 - 不要只看
Gather是否出现,还要确认是否真的存在Partial Aggregate和Parallel Seq Scan。 - 只有在业务允许误差时,才考虑 HyperLogLog 等近似方案。
DISTINCT 并不是语法层面的微小修饰符。它改变了聚合状态是否能够跨 worker 合并,也就改变了 PostgreSQL 能否把整张表的工作分摊到多个核心上。遇到大型精确去重查询时,先读执行计划,再决定是改写 SQL、调整数据汇总方式,还是接受近似结果。