PostgreSQL 中的 DISTINCT 为什么会让 COUNT 失去并行能力

2026-08-06 47 预计阅读时间: 1 分钟
来源: postgr.es AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:12 分钟

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 的并行聚合通常分成两步:

  1. 每个 worker 执行 Partial Aggregate,为自己看到的行构建一个局部状态。
  2. leader 执行 Finalize Aggregate,通过聚合函数的 combine 逻辑合并这些局部状态。

对于 count(*),局部状态只是一个整数,合并两个状态就是相加。summinmax 等聚合也有类似的合并方式。

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_contpercentile_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 mergeDisk:
  • 整张表由单个进程以 loops=1 扫描。

如果业务允许近似结果,可以考虑 HyperLogLog 一类的基数估算。它用固定大小的 sketch 表示集合,局部 sketch 可以合并,因此更适合并行和仪表盘场景。代价是结果存在有界误差,不能直接替代财务、计费或审计类精确统计。

落地检查清单

  1. 对大型 COUNT(DISTINCT ...) 执行 EXPLAIN (ANALYZE, BUFFERS)
  2. 检查是否存在串行扫描和外部排序。
  3. 尝试用 GROUP BY 子查询先生成 distinct 集合。
  4. 对按维度去重的查询,使用 GROUP BY dimension, key 的两阶段写法进行测试。
  5. 不要只看 Gather 是否出现,还要确认是否真的存在 Partial AggregateParallel Seq Scan
  6. 只有在业务允许误差时,才考虑 HyperLogLog 等近似方案。

DISTINCT 并不是语法层面的微小修饰符。它改变了聚合状态是否能够跨 worker 合并,也就改变了 PostgreSQL 能否把整张表的工作分摊到多个核心上。遇到大型精确去重查询时,先读执行计划,再决定是改写 SQL、调整数据汇总方式,还是接受近似结果。


相关推荐