Radim Marek:COUNT中的DISTINCT

Radim Marek:COUNT中的DISTINCT

💡 原文英文,约1900词,阅读约需7分钟。
📝

内容提要

PostgreSQL的count(DISTINCT user_id)查询因DISTINCT聚合缺少合并函数而无法并行执行,导致串行排序性能低下。通过将DISTINCT改写为GROUP BY子查询,可启用并行哈希聚合,速度提升约3.4倍。此问题同样影响带ORDER BY的聚合,但FILTER子句不受影响。建议对大表使用此改写,并检查执行计划确认并行效果。

🔎

延伸解读

为什么 count(DISTINCT) 无法并行

PostgreSQL 的并行聚合依赖每个 worker 生成部分聚合状态,再由 leader 合并。对于 count(DISTINCT),每个 worker 只能返回自己看到的去重集合,合并时需要跨 worker 去重,这相当于把所有数据集中到一处,违背了并行聚合的初衷。因此,DISTINCT 聚合没有可用的合并函数,导致整个查询退化为串行排序。

改写技巧:用 GROUP BY 子查询替代

将 count(DISTINCT user_id) 改写为 SELECT count(*) FROM (SELECT user_id FROM events GROUP BY user_id) s,可以利用 GROUP BY 的并行哈希聚合。每个 worker 先对本地数据分组,leader 再合并各组,避免了全量排序和磁盘溢出。在 1000 万行数据上,改写后速度提升约 3.4 倍,且表越大优势越明显。

注意:一个 DISTINCT 拖累整个查询块

DISTINCT 聚合不仅自身无法并行,还会导致同一 SELECT 中的其他聚合(如 sum)也失去并行能力,因为整个聚合节点只能串行执行。如果查询中有多个聚合,且其中一个带 DISTINCT,建议将 DISTINCT 部分拆到子查询中,避免影响其他聚合的并行执行。

何时值得改写

对于小表,串行执行几乎无感知,改写反而增加复杂度。但当表很大(如千万行以上)且查询频繁时,串行排序和磁盘 I/O 会成为瓶颈。通过 EXPLAIN ANALYZE 观察执行计划,如果看到顶层 Aggregate 下没有 Gather,且出现 Sort 和 external merge Disk,就值得考虑改写。

Q&A

为什么PostgreSQL的count(DISTINCT user_id)查询无法并行执行?

因为DISTINCT聚合没有合并函数,无法将各个工作进程的部分结果合并为全局去重计数。并行聚合需要每个工作进程生成部分聚合状态,然后由领导者合并,但去重计数需要知道每个工作进程看到了哪些值,无法仅通过部分计数合并,因此无法并行。

如何优化PostgreSQL中的count(DISTINCT user_id)查询?

将DISTINCT改写为GROUP BY子查询,例如:SELECT count(*) FROM (SELECT user_id FROM events GROUP BY user_id) s; 这样可以利用并行哈希聚合,显著提升性能。

count(DISTINCT user_id)和count(*) FROM (SELECT user_id FROM events GROUP BY user_id) s在性能上有多大差异?

在10M行表上,count(DISTINCT user_id)耗时约1211毫秒,而改写后的查询耗时约360毫秒,速度提升约3.4倍。随着表增大,差距会进一步扩大。

在PostgreSQL中,哪些聚合函数无法并行执行?

带有DISTINCT或内部ORDER BY的聚合函数无法并行执行,例如count(DISTINCT ...)、string_agg(..., ORDER BY ...)、array_agg(..., ORDER BY ...)、percentile_cont等。这些聚合需要全局排序或去重,无法合并部分结果。

FILTER子句是否会影响聚合的并行执行?

不会。FILTER子句不影响并行执行,例如count(*) FILTER (WHERE country='US')可以正常并行,因为FILTER只是决定哪些行进入聚合,不改变聚合本身的可并行性。

如何检查PostgreSQL查询是否使用了并行执行?

使用EXPLAIN (ANALYZE, COSTS OFF)查看执行计划。如果计划中包含Gather节点和Partial Aggregate,则说明使用了并行;如果只有Aggregate和Sort,且没有Gather,则可能是串行执行。

对于按组统计去重用户数的查询,如何优化?

对于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) s GROUP BY country; 但需要检查执行计划,因为有时优化器可能不会选择并行路径。

在什么情况下应该关注count(DISTINCT)的性能问题?

当表很大(如千万行以上)且查询频繁时,串行排序会消耗大量时间和磁盘I/O。如果执行计划显示顶层Aggregate没有Gather,且有外部排序和磁盘写入,就值得优化。

🏷️

标签

➡️

继续阅读