【PostgreSQL】集計クエリを速くするLEFT JOIN LATERAL

こんにちは。バックエンド担当の長谷部です。

本記事では、「Webエンジニアなら知っておきたい」と題して、最近知ったDBのパフォーマンスの向上につながるJOINの方法について解説していきます。

開発において、ダッシュボードやレポート機能のAPIが「最初は速かったのに、データが増えるにつれてどんどん重くなる」という現象に遭遇したことはありませんか?

私も、とある案件でSQLを作成していた際に、当初は標準的な結合のSQLの書き方を使用していました。しかし、データ件数が想定を上回る規模に膨らんだことで、パフォーマンスが急激に悪化するということが起きました。

親テーブルに対して条件をつけた子テーブルを結合して取得するような要件では、無意識のうちに巨大な GROUP BY を含むサブクエリを書いてしまいがちです。 今回は、PostgreSQLなどのモダンなRDBMSで利用できる LEFT JOIN LATERAL を活用したチューニング手法について解説します。

以下のような「レポート(reports)」と、それに紐づく「作業詳細(report_workers)」という親子関係のデータを想定します。

reports テーブル (10,000件)

id user_id report_date status
1 1 2026-03-01 active
2 1 2026-03-02 active

report_workers テーブル (30,000件〜)

id report_id start_time end_time
21715 1 09:00:00 11:00:00
21716 1 13:00:00 18:00:00

本記事の検証では、パフォーマンスの違いを正しく計測するため、あらかじめ以下のインデックスが設定されている前提とします。 後述する LATERAL を用いたチューニングでは、この report_workers.report_id のインデックスがパフォーマンス向上の鍵(高速な Index Scan の必須条件)となります。

-- reportsテーブル:対象ユーザーの絞り込み用
CREATE INDEX idx_reports_user_id ON reports(user_id);
-- report_workersテーブル
CREATE INDEX idx_report_workers_report_id ON report_workers(report_id);

やりたいことは、「特定のユーザー(user_id = 1)の最新5件のレポートと、その日の合計作業時間を取得する」というシンプルなものです。

パフォーマンスの比較

通常の JOIN + 全体 GROUP BY

EXPLAIN ANALYZE
SELECT 
    r.id,
    r.report_date,
    COALESCE(SUM(EXTRACT(EPOCH FROM (w.end_time - w.start_time))), 0) AS total_work_seconds
FROM reports r
LEFT JOIN report_workers w ON w.report_id = r.id
WHERE r.user_id = 1
GROUP BY r.id, r.report_date  
ORDER BY r.report_date DESC
LIMIT 5;

Planning Time: 1.845 ms Execution Time: 10.890 ms

私が最初に思いつく書き方です。 このクエリは、「結合してから集計する」というステップを踏みます。

データ量が増えるほど結合コストと集計コストが重くのしかかり、スケールした際にボトルネックになりやすいです。ここでは10msほどかかっています。

GROUP BY

EXPLAIN ANALYZE
SELECT 
    r.id,
    r.report_date,
    COALESCE(w.total_seconds, 0) AS total_work_seconds
FROM reports r
LEFT JOIN (
    SELECT 
        report_id,
        SUM(EXTRACT(EPOCH FROM (end_time - start_time))) AS total_seconds
    FROM report_workers
    GROUP BY report_id
) w ON w.report_id = r.id
WHERE r.user_id = 1
ORDER BY r.report_date DESC
LIMIT 5;

【実行結果】 Planning Time: 0.557 ms Execution Time: 21.498 ms

このクエリの問題点は、最終的に必要なレポートがたった5件であるにも関わらず、データベースが裏側で report_workers の全3万件をスキャンし、メモリ上で巨大な集計を行ってから結合している点です。データ量が数百万件になれば、スロークエリと化してしまいます。ここでは21msほどかかっています。

LEFT JOIN LATERAL

LATERAL で先ほどのSQLを修正して実行してみます。 LATERAL を使うと、左側のテーブルの行ごとに、右側のサブクエリを動的に実行することが可能になります。

EXPLAIN ANALYZE
SELECT 
    r.id,
    r.report_date,
    COALESCE(w.total_seconds, 0) AS total_work_seconds
FROM reports r
LEFT JOIN LATERAL (
    SELECT 
        SUM(EXTRACT(EPOCH FROM (end_time - start_time))) AS total_seconds
    FROM report_workers w
    WHERE w.report_id = r.id
) w ON true
WHERE r.user_id = 1
ORDER BY r.report_date DESC
LIMIT 5;

【実行結果】 Planning Time: 0.872 ms Execution Time: 0.487 ms

実行時間が0.487ms に高速化しました。 実行計画を読み解くと、全件スキャンが消滅し、以下のような動きに変わっています。

  1. reports テーブルからインデックスを使って対象の5件を素早く見つける。
  2. 見つけた5件に対して Nested Loop(ループ処理)を開始。
  3. ループのたびに report_workers へ Index Scan をかけ、対象の作業時間だけをピンポイントで計算する。

全件スキャンが消滅し、DBのメモリ消費も最小限に抑えられました。

ただし、LATERALはいつでも使ってパフォーマンスが向上するものではありません。

ここまでの結果を見ると「これからは全部 LATERAL で書こう!」と思いがちですが、ここには大きな罠があります。 今回のクエリが劇的に速かったのは、WHERE r.user_id = 1 と LIMIT 5 によって親テーブルの取得件数が極めて少なかったからです。

もし、これが「全ユーザーの今月の日報をエクスポートする」といったバッチ処理のクエリだったらどうなるでしょうか? 親テーブルが1万件ヒットした場合、LATERALは Nested Loop を1万回実行することになります。こうなると、インデックススキャンであってもオーバーヘッドが膨らみ、逆に従来の 事前全件集計の方が圧倒的に速くなります。

使い分けのガイドライン

LATERAL が輝くケース: 親テーブル側での絞り込みが強く、取得対象となる親レコードの件数が限定的な場合(APIのページネーションなど)です。 例えば、少数のレコードに対してインデックスを使ったピンポイントな探索を繰り返すほうが、全体を計算するよりも明らかにコストが低いケースに適しています。

GROUP BY が輝くケース: テーブルの全件、あるいは大部分のレコードを走査して広範囲のデータを一括で集計する必要がある場合です。 例えば、数万〜数百万回のインデックススキャンを発生させるよりも、フルスキャンしてメモリ上で一気に集計してしまったほうが、トータルの処理効率が高くなるケースに適しています。

まとめ

データ量や取得のユースケース、そしてインデックスの有無によって、最適な実行計画は変わります。 EXPLAIN ANALYZE を使用して、適材適所で便利なSQLを使い、パフォーマンスの向上を目指しましょう!