Article

PostgreSQLのCOUNT(DISTINCT)が並列集約を使えない理由と書き換え方

PostgreSQLでは、集約関数内にDISTINCTがあると並列集約を利用できません。一次ソースの実行計画とベンチマークを軸に、GROUP BYへ分離する書き換え、NULLの扱い、EXPLAINでの検証ポイントを整理します。

Share

こはるの読みどころ

COUNT(DISTINCT)を一律に避ける話ではありません。大きな集計で並列化されない理由を知り、結果の同値性と実行計画を比べてから書き換えるための読みどころです。

こはるの読みどころ

PostgreSQLでユニークユーザー数を数えるとき、count(DISTINCT user_id)は自然な書き方です。ただし、大きなテーブルでCPUコアに余裕があっても、この集約は並列集約になりません。

一次ソースは、この制約を実行計画と1,000万行の検証で掘り下げています。焦点はSQLの正しさではなく、大規模な分析クエリで直列処理や一時ファイルがボトルネックになる場合の見分け方です。

PostgreSQLのCOUNT(DISTINCT)は並列集約を使えない

一次ソースでは、PostgreSQL 17.10、18.4、19beta1でcount(DISTINCT user_id)が部分集約されないことを確認しています。2026年8月6日時点のPostgreSQLのバージョン方針では18.4が最新の安定版で、19系はBeta 2が公開中です。

この挙動は一次ソースだけの観察ではありません。PostgreSQL 18の並列プラン文書は、集約関数の呼び出しにDISTINCTまたはORDER BYが含まれる場合、並列集約をサポートしないと明記しています。開発版の同じ文書にも同じ制約が残っています。

ただし、「クエリのどこにも並列処理が現れ得ない」という一般則ではありません。ここで確定しているのは、そのDISTINCTを含む集約ノードをPartial AggregateFinalize Aggregateへ分割できないことです。実際のプラン全体は、結合やサブクエリなどの構造とコスト見積もりで変わります。

部分集約はワーカーの途中結果を結合して成立する

PostgreSQLの並列集約は二段階です。各プロセスが担当行から途中結果を作り、GatherまたはGather Mergeで集めた後、リーダーが最終結果へまとめます。実行計画では前半がPartial Aggregate、後半がFinalize Aggregateとして現れます。

count(*)なら、各ワーカーが持つ途中状態は件数です。最後に足し合わせれば正しい総数になるため、分割しやすい処理ですね。

一方、ワーカーごとのユニーク件数だけを足すと、複数ワーカーが見た同じuser_idを重複して数えてしまいます。正確な全体件数を得るには、各ワーカーが見た値の集合を突き合わせる必要があり、単純な件数の結合では済みません。公式文書も、並列集約には並列安全性と途中状態をまとめるcombine functionが必要だと説明しています。

一次ソースが示すもう一つの注意点は、同じ集約ノードにsum(amount)count(DISTINCT user_id)を並べる場合です。sum単独なら部分集約できても、同じノード内のDISTINCTによってその集約ノード全体を部分モードにできなくなります。

1,000万行の検証では直列ソートが一時ファイルへ退避

一次ソースの検証表は1,000万行で、user_idNOT NULL、異なる値は約5万件です。max_parallel_workers_per_gatherを4、work_memを64MBに設定して、count(*)count(DISTINCT user_id)の実行計画を比較しています。

count(*)では4ワーカーとリーダーが並列シーケンシャルスキャンを分担し、部分件数を最後に集約しました。対してcount(DISTINCT user_id)は、1プロセスが1,000万行をソートするプランとなり、external mergeで約115MBをディスクへ退避しています。

同じ検証環境における3回の中央値は、元のクエリが1,211ms、後述するGROUP BY書き換えが360msでした。約3.4倍という差は一次ソースのデータ分布、ハードウェア、設定に対する測定値であり、一般的な改善率ではありません。公式のEXPLAIN解説も、統計のサンプリングやプラットフォーム、テーブル規模によってコストとプランが変わると説明しています。

小さなテーブルなら直列処理でも十分速く、書き換えの方が複雑になることがあります。行数が多く、ソートのディスク退避やCPUの偏りが実際に観測されたときに検討する話です。

DISTINCTをGROUP BYへ分離すると部分集約の候補になる

全体のユニーク件数だけが必要なら、一次ソースは重複排除を内側のGROUP BYへ移し、そのグループ数を外側で数える形を示しています。

SQL
SELECT count(*)
FROM (
  SELECT user_id
  FROM events
  GROUP BY user_id
) AS distinct_users;

一次ソースのuser_idNOT NULLなので、このクエリとcount(DISTINCT user_id)は同じ結果を返します。内側のグループ化をPartial HashAggregateFinalize HashAggregateへ分けられれば、ワーカーごとに候補を縮約してからリーダーへ渡せます。

列がNULLを許容する場合は、そのまま置き換えてはいけません。PostgreSQLの集約式仕様では、count(DISTINCT expression)は異なる非NULL値だけを数えますが、GROUP BYはNULLのグループも1行作ります。同じ意味にそろえるなら、内側でNULLを除外します。

SQL
SELECT count(*)
FROM (
  SELECT user_id
  FROM events
  WHERE user_id IS NOT NULL
  GROUP BY user_id
) AS distinct_users;

国別のユニークユーザー数なら、内側でcountry, user_idをグループ化し、外側で国ごとに数える形へ展開できます。ただし、一次ソースではこの二段階集約が常に並列プランになるわけではなく、グループ数とコストによって直列のままになる場合もあったとしています。

元のSQLと書き換え後をEXPLAIN ANALYZEで比較する

まず元のSQLと書き換え後のSQLが同じ結果を返すことを、NULL、絞り込み条件、結合による重複、グループ単位を含むデータで確かめます。速くても集計の意味が変われば採用できません。

次に、代表的なデータ量と統計情報を使い、両方へ次の形式で実行計画を取ります。

SQL
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS)
SELECT count(DISTINCT user_id)
FROM events;

元のプランではAggregateまたはGroupAggregateの下にある大きなSortexternal mergeDiskの表示、GatherPartial Aggregateの有無を見ます。書き換え後はPartial HashAggregateGatherFinalize HashAggregateが選ばれたか、ハッシュのBatchesやメモリ・一時ファイルがどう変わったかを比べます。ノード名はプランによって変わるため、特定の形が必ず出るとは限りません。

公式文書にある通り、EXPLAIN ANALYZEは対象SQLを実際に実行し、計測オーバーヘッドも加わります。読み取りクエリでも本番負荷を増やし得るため、まず検証環境か負荷を管理できる時間帯で試すのが安全です。

並列集約はPostgreSQL 9.6から続く仕組みだが常に速いとは限らない

PostgreSQLは2016年9月29日公開の9.6で、大きなクエリに対する並列実行の初期対応を導入しました。対象にはシーケンシャルスキャン、結合、対応済みの集約が含まれますが、当時から「すべての集約が自動で並列になる」という仕組みではありません。

現在の公式文書も、最終集約をリーダーが担うため、入力行に対してグループ数が多いと並列集約の利点が小さくなり、プランナーが選ばない場合があると説明しています。GROUP BYへ書き換えた事実だけで、並列化や高速化を断定できないと分かります。

並列化には資源面の代償もあります。PostgreSQLの資源設定では、work_memなどの制限はワーカーごとに適用され、4ワーカーの並列クエリはリーダーを含めてCPU、メモリ、I/Oを最大で非並列時の5倍ほど使い得ると説明されています。要求したワーカーが実行時に確保できず、計画より少ないプロセスで動く場合もあります。

そのため、max_parallel_workers_per_gatherwork_memを大きくすれば解決するとは限りません。クエリ単体の時間だけでなく、同時実行数、総メモリ、一時ファイル、I/O、ワーカーの確保状況まで見て運用へ落とし込みます。

大規模な正確集計では同値性と実行計画を見て採用を決める

count(DISTINCT ...)は正しいSQLであり、小さなテーブルや頻度の低い集計なら、そのままの方が読みやすいこともあります。問題になるのは、大きなファクトテーブルで直列ソートやディスク退避が繰り返され、待ち時間や資源利用に表れているケースです。

その場合は、重複排除をGROUP BYへ分離した候補を作り、NULLを含む結果の同値性、代表データでの実行計画、並列化後の総資源量を比べます。一次ソースの3.4倍という結果を期待値にせず、自分のワークロードで差を測るのが結論です。

出典

Share

Related Articles

カテゴリやタグが近い記事を続けて読めるように並べています。