返回

文章详情

你统计中的 DISTINCT

Hacker News2026年8月6日 19:40

这里有一个在每个分析工作负载中都会出现的查询:SELECT count ( DISTINCT user_id) FROM events; 看起来这是最便宜的事情:统计独特用户。 在有多余核心的机器上,你会期望 Postgres 对它投入一些并行工作者,就像它对任何大型扫描所做的那样。它并没有。那个关键词 DISTINCT 会关闭整个语句的并行查询,而你的表越大,这个成本就越高。没有设置或索引可以改变这一点;原因在于聚合的执行方式。模式 一千万条事件,约五万名独特用户,几乎没有国家。没有什么特别的。 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; max_parallel_workers_per_gather 在新集群中的默认值为 2。对于这些示例,我将其提高到 4,work_mem 提高到 64MB,因此下面的计划没有资源短缺可责备。两个统计,两个不同的计划 从一个普通的 count(*) 开始,它没有重复的数据: EXPLAIN (ANALYZE, COSTS OFF ) SELECT count ( * ) FROM events; 最终聚合 (实际行数=1.00 循环=1) -> 收集 (实际行数=5.00 循环=1) 计划的工作者:4 启动的工作者:4 -> 部分聚合 (实际行数=1.00 循环=5) -> 并行序列扫描 events (实际行数=2000000.00 循环=5) 四个工作者加上领导者(循环=5)各自扫描其片段并保持运行计数,最后领导者将五个部分计数相加。现在加上一个词: EXPLAIN (ANALYZE, COSTS OFF , BUFFERS) SELECT count ( DISTINCT user_id) FROM events; 聚合 (实际行数=1.00 循环=1) 缓冲区:共享命中=15915 读取=47783,临时读取=14681 写入=14684 -> 排序 (实际行数=10000000.00 循环=1) 排序关键:user_id 排序方法:外部合并 磁盘:117448kB 缓冲区:共享命中=15915 读取=47783,临时读取=14681 写入=14684 -> 序列扫描 events (实际行数=10000000.00 循环=1) 缓冲区:共享命中=15912 读取=47783 没有收集。没有部分聚合。没有并行扫描。一个进程读取所有一千万行,按 user_id 对每一行进行排序,以便重复项相邻,然后遍历排序后的输出进行计数。排序不能适应 64MB 的 work_mem,因此它将 115MB 溢出到磁盘上的临时文件。一个核心,整个表,加上并行 count(*) 从未触及的磁盘 IO。为什么计划者不能将其拆分 排序是 Postgres 在聚合中计算 DISTINCT 的方法:对值进行排序并临近相等的值合并。哈希表是另一种选择,但经典的 DISTINCT 聚合路径是排序的。无论哪种方式,它都必须在一个地方看到每个值,这正是问题所在。Postgres 中的并行聚合分为两个部分。每个工作者运行一个部分聚合,建立过渡状态,这是它所见行的小型运行摘要。对于计数,状态只是一个数字。然后领导者运行一个最终聚合,将那些部分状态与聚合的合并函数合并,这个函数知道如何将两个部分状态折叠为一个。count 的 combine 函数将部分计数相加。sum,avg,min,max 都有一个。这个分裂,平行扫描,最后合并,是并行查询在聚合中的整个基础。count(DISTINCT user_id) 没有可用的 combine 步骤,不是因为没有人写一个。想想工作者可以交回什么。为了将两个工作者的结果合并成正确的全局独特计数,领导者需要知道每个工作者看到哪些用户,因为在工作者 1 的切片中出现的用户在工作者 2 的切片中再次出现,因此必须计数一次,而不是两次。独特值的部分计数不能合并;你必须从每个工作者那里传输整组独特值并进行联合。此时,你已经将所有数据移动到一个地方,这正是并行聚合存在的理由。因此,携带 DISTINCT(或内部 ORDER BY)的聚合不能在部分模式下运行,计划者无法将部分聚合放在收集下面,无法提供部分聚合的并行扫描没有任何收益。整个计划崩溃为串行。我检查了 PostgreSQL 17.10、18.4 和 19beta1:部分聚合仍然不支持任何一个的独特和有序聚合。debug_parallel_query 是一种检查这不是偶然偏向串行执行的成本估计的方法。设置为开启,规划者会在任何法律允许的地方寻求并行计划,即使优化器认为串行更便宜:SET debug_parallel_query = on ; EXPLAIN (COSTS OFF ) SELECT count ( DISTINCT user_id) FROM events; 收集 计划的工作者:1 单一副本:true -> 聚合 -> 排序 排序关键:

赞助内容

NordVPN Next-gen Antivirus

本站免费、广告极少。如果觉得有帮助,可以请我们喝杯咖啡 —— 任何金额都对持续运营有实际帮助。

请我喝杯咖啡