PostgresのCOUNT(DISTINCT)が並列クエリを静かに殺す——修正方法はこちら
DISTINCTキーワード一つで、PostgreSQLの文全体の並列クエリがオフになり、テーブル全体の直列ソートを強制されます。GROUP BYへの書き換えで並列性が回復します。

どの分析ワークロードにもこのクエリがあります: SELECT count(DISTINCT user_id) FROM events;。これは可能な限り安価に見えます——ユニークユーザーを数えるだけです。コアが余っているマシンでは、Postgresがほぼすべての大規模スキャンで行うように、いくつかの並列ワーカーを投入することを期待するでしょう。しかし、そうはなりません。その一つのキーワード、DISTINCTが、文全体の並列クエリをオフにし、テーブルが大きいほどコストがかかります。設定やインデックスでは変えられません。理由は集約の実行方法にあります。
スキーマ
1,000万イベント、約5万のユニークユーザー、数か国。何も特別なことはありません。max_parallel_workers_per_gatherを4に、work_memを64MBに上げても、以下のプランを非難するリソース不足はありません。
2つのカウント、2つの異なるプラン
まず、重複排除の必要がない単純なcount(*)から始めます。プランは、4つのワーカーが起動されたGatherの上のFinalize Aggregateを示し、各ワーカーがParallel Seq Scanの上でPartial Aggregateを実行します。4つのワーカーとリーダーがそれぞれ自分のスライスをスキャンして実行中のカウントを維持し、リーダーが最後に5つの部分カウントを合計します。
次に、一語追加します: count(DISTINCT user_id)。プランは、1,000万行すべてをuser_idでソートするSortの上の直列Aggregateに崩壊し、115MBをディスクにスピルします。GatherもPartial Aggregateも並列スキャンもありません。1コア、テーブル全体、そして並列count(*)が触れなかったディスクIO。
プランナーが分割できない理由
ソートは、Postgresが集約内でDISTINCTを計算する方法です: 値を順序付け、隣接する等しい値を潰します。ハッシュテーブルがもう一つの選択肢ですが、古典的なDISTINCT集約パスはソートします。どちらにせよ、すべての値を一箇所で見る必要があり、それが問題の核心です。
Postgresの並列集約は2つの半分で機能します。各ワーカーはPartial Aggregateを実行し、遷移状態を構築します。これは、見た行の小さな実行中サマリーです。countの場合、その状態は単なる数値です。リーダーはその後、Finalize Aggregateを実行し、集約の結合関数でそれらの部分状態をマージします。countの結合関数は部分カウントを加算します。sum、avg、min、maxはすべて持っています。
count(DISTINCT user_id)には利用可能な結合ステップがありません。2つのワーカーの結果を正しいグローバルな個別カウントにマージするには、リーダーは各ワーカーがどのユーザーを見たかを知る必要があります。なぜなら、ワーカー1のスライスとワーカー2のスライスの両方に現れるユーザーは、2回ではなく1回カウントされなければならないからです。個別値の部分カウントは結合できません。すべてのワーカーから個別値のセット全体を送ってユニオンする必要があります。その時点で、データをすべて一箇所に移動したことになり、それはまさに並列集約が避けようとしていることです。
したがって、DISTINCT(または内部ORDER BY)を運ぶ集約は部分モードで実行できず、プランナーはGatherの下にPartial Aggregateを配置できず、部分集約がなければ並列スキャンは何も買いません。プラン全体が直列に崩壊します。これはPostgreSQL 17.10、18.4、19beta1にわたって当てはまります——部分集約はまだいずれでも個別および順序付き集約をカバーしていません。
debug_parallel_queryは、これがたまたま直列実行を好んだコスト見積もりではないことを確認します。onに設定すると、法的に可能な場所で並列プランを強制します。結果は、Workers Planned: 1とSingle Copy: trueのGatherを示します: 1つのプロセスがソートを含むプラン全体を実行し、Gatherノードは出力をexecutorの並列機構を通してルーティングするためだけに存在します。集約、ソート、スキャンのいずれも実際にはワーカー間で分割されません。
注目すべきは、FILTERにはこの問題がないことです。count(*) FILTER (WHERE country='US')は、単純なcount(*)と同じ並列形状を得ます。なぜなら、FILTERは各ワーカーが部分カウントに折り込む行を決定するだけだからです。
1つのDISTINCTが文全体を毒する
コストは個別集約に限定されません。それは、クエリブロックを共有する集約ノードに限定されます。完全に並列化可能な集約を、同じSELECT内の個別集約の隣に置くと、両方が並列性を失います。なぜなら、1つのAggregateノードが両方を計算し、1つの方法でしか実行できないからです。別のサブクエリまたはCTE内の集約は異なるノードであり、影響を受けません。
例えば、SELECT sum(amount), count(DISTINCT user_id) FROM events; — sum(amount)単独なら4つのワーカーで実行されたでしょう。1つのcount(DISTINCT)とSELECTを共有することで、同じ直列ソートに引きずり下ろされます。
書き換え: DISTINCTをGROUP BYに押し込む
Postgresができる並列化する1つの操作、GROUP BYで重複排除を行い、その後グループを数えます:
SELECT count(*) FROM (SELECT user_id FROM events GROUP BY user_id) s;これにより、各ワーカーが自分のスライスを並列で重複排除し、リーダーが部分グループセットをマージできます。トレードオフは、プランナーが常に書き換えが同等であることを証明できないことです——特に個別列がNULL許容の場合や、グループごとの個別カウントが必要な場合——そのため、このプランを自動的に選択しないかもしれません。
より難しいケースは、グループごとの個別カウントです。例えば、SELECT country, count(DISTINCT user_id) FROM events GROUP BY country;。ここではDISTINCTがグループ内にあり、各グループ内で同じ直列ソートが適用されます。書き換えはよりトリッキーです: 最初に(user_id, country)ペアを重複排除し、次にcountryでグループ化する必要があります。それはプランナーが導出してくれない2ステップのクエリです。
実際に気にするべき時
テーブルがメモリに収まり、クエリがミリ秒で実行されるなら、これはすべて無関係です。しかし、大規模な分析テーブルでは、ディスクにスピルするcount(DISTINCT)は並列代替案より一桁遅くなる可能性があります。COUNT(DISTINCT)に手を伸ばす前に、プランを確認してください。大きなテーブルで直列Sortが見えたら、GROUP BY書き換えを検討するか——または個別集約を独自のサブクエリに移動して、クエリの残りの並列性を毒しないようにしてください。
DISTINCTキーワード一つで文全体の並列クエリがオフになり、テーブルが大きいほどコストがかかります。
| クエリパターン | 並列ワーカー | プラン形状 |
|---|---|---|
| count(*) | 4 + リーダー | Partial Aggregate → Gather → Finalize Aggregate |
| count(DISTINCT col) | 0 | Serial Sort → Aggregate |
| count(*) FILTER (WHERE ...) | 4 + リーダー | Partial Aggregate → Gather → Finalize Aggregate |
| sum(col) + count(DISTINCT col) | 0 | Serial Sort → Aggregate(両方が並列性を失う) |
| count(*) FROM (SELECT col FROM t GROUP BY col) s | 4 + リーダー | Partial Aggregate → Gather → Finalize Aggregate |