本番環境でのSQL Join:クエリに10秒かかっていた問題をどう解決したか

Database tutorial - IT technology blog
Database tutorial - IT technology blog

本番稼働から半年:Joinの悪夢

6ヶ月前、私はユーザー数が10万人に達しようとしていたECプラットフォームのレポート用ダッシュボードを引き継ぎました。ステージングサーバーではすべて順調に見えていました。しかし、トラフィックがピークに達した途端、データベースが悲鳴を上げ始めたのです。以前は50msだったクエリが、突然10秒もかかるようになりました。さらに悪いことに、一部のレポートでは最近の注文の顧客データが欠落していたり、逆に売上が二重にカウントされたりしていました。

スロークエリログを調査すると、明らかなボトルネックが見つかりました。開発者が本来 INNER JOIN を使うべき場所で LEFT JOIN を使用していたのです。さらに悪いことに、クリティカルなパスに CROSS JOIN が誤って紛れ込んでいました。これにより巨大なデカルト積が発生し、フラッシュセールの最中に db.t3.medium のRDSインスタンスがクラッシュ寸前まで追い込まれました。

リレーショナルデータベースの性能は、Joinの扱いに左右されます。MySQL、PostgreSQL、MongoDBを扱ってきた経験から、Join submergedのロジック一つでアプリケーションが軽快に動くこともあれば、再起不能になることも見てきました。ここでは、私たちがどのようにロジックを修正し、高コンカレンシー(高並列)環境向けにJoinを最適化したかを紹介します。

根本原因:なぜレポートの数値が間違っていたのか

主な問題はコードが遅いことだけではなく、ロジックの不整合にありました。私たちは主に users(5万行)と orders(50万行)という2つのテーブルを管理していました。ユーザーがアカウントを論理削除したり、ゲストとして注文したりすると、これらのテーブル間の関係性が変わります。チームは、NULL 値や欠落したキーが結果をどのように歪めるかを考慮せずにJoinを書いていたのです。

本番環境では、不適切なJoinタイプの選択は単に誤ったデータを返すだけではありません。行をロックし、一時的なディスク領域を消費し、CPU使用率を100%に跳ね上げます。私たちは、こうしたサイレントな障害を防ぐため、PostgreSQLクラスター全体でデータをリンクする方法を標準化する必要がありました。

目的に適したJoinの選択

Joinをチームに説明する際、データを「必須(Required)」か「任意(Optional)」かで分類するのが最も効果的だと気づきました。この考え方の転換により、新しい機能を開発する際のアプローチが変わりました。

1. INNER JOIN:厳格なフィルタリング

両方のテーブルに存在するレコードだけが必要な場合は、INNER JOIN を使用します。注文に有効な user_id が紐付いていない場合、その注文は「アクティブ顧客の売上」レポートに含まれるべきではありません。

-- 既存の登録ユーザーに紐付く注文のみを返す
SELECT orders.id, users.email
FROM orders
INNER JOIN users ON orders.user_id = users.id;

注意点: 1,000件の注文があっても、そのうち200件が user_id が NULL のゲスト購入だった場合、その200件は消えてしまいます。まさにこのシナリオが、当初の売上レポートの誤差の原因でした。

2. LEFT JOIN:包括的なレポート

これは多くのアプリケーションで最も頻繁に使われます。右側のテーブルに一致するデータがあるかどうかにかかわらず、左側のテーブルのすべての行を返します。一致するものがない場合、データベースは右側を NULL で埋めます。

-- まだ何も購入していないユーザーも含め、すべてのユーザーを返す
SELECT users.name, orders.order_date
FROM users
LEFT JOIN orders ON users.id = orders.user_id;

現在、私たちはこれを解約分析に使用しています。WHERE orders.id IS NULL でフィルタリングすることで、登録はしたもののチェックアウトを一度も完了していないユーザーを即座に特定できます。

3. FULL OUTER JOIN:完全なマップ

LEFT JOIN のテーブル順を入れ替えれば同じ結果が得られるため、私は RIGHT JOIN を使うことは滅多にありません。しかし、両方のテーブルから一致しない行も含めてすべてのデータセットをマッピングする必要がある場合、FULL OUTER JOIN は不可欠です。これは、異なるサービス間でデータがどのように同期されているかを監査するのに最適な方法です。

MySQLは FULL OUTER JOIN をネイティブでサポートしていないことに注意してください。LEFT JOINRIGHT JOINUNION してエミュレートする必要があります。PostgreSQLでは組み込み機能として提供されており、大幅に効率的です。

-- PostgreSQL:可能な限り紐付けを行い、すべてのユーザーとすべての注文を取得する
SELECT users.name, orders.id
FROM users
FULL OUTER JOIN orders ON users.id = orders.user_id;

実際に効果のあった最適化手法

ロジックの修正は戦いの半分に過ぎませんでした。クエリを高速化する必要もありました。数百万行のテーブルをJoinするのはコストがかかりますが、次の3つの戦略により、ダッシュボードの読み込み時間を10秒から400ms未満に短縮できました。

外部キーにインデックスを貼る

基本中の基本に聞こえますが、主キーにはインデックスを貼っても外部キーを忘れてしまう開発者は多いです。orders.user_idusers.id にJoinする場合、両方のカラムにインデックスが必須です。インデックスがないと、エンジンは「シーケンシャルスキャン(Sequential Scan)」を実行します。これは、users テーブルの全ユーザーに対して、orders テーブルの全行を読み取ることを意味します。

Joinする前にフィルタリングする

2つの巨大なテーブルをJoinしてから WHERE 句を使うのは避けましょう。フィルタリングを共通テーブル式(CTE)に移動することで、Join操作が始まる前にデータセットのサイズを小さくできます。これにより、後で捨てることになるデータに対してデータベースが計算リソースを浪費するのを防げます。

-- より効率的な方法:先に注文データをフィルタリングする
WITH completed_orders AS (
    SELECT user_id, total 
    FROM orders 
    WHERE status = 'completed' -- ステータスが完了
    AND created_at > NOW() - INTERVAL '30 days' -- 過去30日以内
)
SELECT u.name, co.total
FROM users u
JOIN completed_orders co ON u.id = co.user_id;

実行計画(Execution Plan)を読む

クエリが遅いと感じたら、必ず EXPLAIN ANALYZE を実行してください。大きなテーブルで「Nested Loops」が発生していないか確認しましょう。これは通常、インデックスの欠如を示しています。理想的には、ソートされていない大きなデータセットには「Hash Joins」、すでにインデックスが貼られソートされているデータには「Merge Joins」が表示されるのが望ましいです。

最後に

ベン図のことは一旦忘れましょう。Joinをマスターするとは、データがどのように関連し、エンジンがその関係をどのように処理するかを理解することです。私たちのケースでは、インデックス付きの LEFT JOIN への切り替えと、CTEによるデータの事前フィルタリングだけで本番環境を救うことができました。もしデータベースのパフォーマンスに苦労しているなら、まずはインデックスから確認してください。次にJoinのタイプを見直しましょう。おそらく、実際に必要な量よりも多くのデータをデータベースに要求してしまっているはずです。

Share: