本番稼働から半年: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 JOIN と RIGHT JOIN を UNION してエミュレートする必要があります。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_id を users.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のタイプを見直しましょう。おそらく、実際に必要な量よりも多くのデータをデータベースに要求してしまっているはずです。

