外部キー:速度を問うのをやめよう
本当に最適化したいものは何か。
私がこれまで目にした中で最もコストの高いデータベース最適化は、誰かが外部キーをすべて削除したところから始まった。
ボトルネックを計測したからではない。書き込みが実際に遅かったからでもない。「外部キーはスケールしない」という記事をどこかで読んだからだ。6ヶ月後、彼らは20億件の孤立したレコード、削除済みユーザーに請求し続ける課金システム、そして40%もずれた分析データを抱えていた。
制約を戻そうとしたとき、すでに破綻していた既存データを検証しようとしてデータベースは完全に停止した。
Web開発には「外部キーは本質的に遅い」「“本物の”システムに成長したら外すための補助輪だ」という蔓延した考えがある。だがそれは、制約が何のためにあるのかという根本的なポイントを完全に外している。これは速度と正しさの二択ではない。異なる障害モードの選択なのだ。
こう考えてみよう。合わせガラス、シートベルト、エアバッグはすべて車の重量を増す。確実に車両は遅くなり、燃費も悪化する。だが0-60タイムを最適化するためにそれらを ripped out したりはしない。まったく別のものを最適化しているのだから。
問題は外部キーが遅くするかどうかではない。もちろん遅くする。問題は、その代わりに何を得られるか、そして実際にそれが必要かどうかだ。
本当のトレードオフ
具体例を挙げよう。気象監視システムを構築していて、気象観測所、センサーデバイス、センサー読み取り値、そして米国の州のテーブルがあるとしよう。
すべてに外部キーを張るのか?実際に何が変わり、どんな結果になるか考えてみよう。
米国の州はおそらく変更されない。ワイオミング州の名前が突然変わることはない。参照データが静的だとわかっているのに、挿入のたびに州コードを検証する外部キーは不要だ。意味のないオーバーヘッドでしかない。
気象観測所は追加、移転、廃止される。だがここで質問だ:誰かが観測所レコードを誤って削除した場合、過去の読み取り値が観測所を「見失う」ことを望むか?おそらく、観測所が消えてもデータはそのまま残しておきたいはずだ。これは読み取り値をライブ参照ではなく歴史的スナップショットとして扱うことを意味し、外部キーが意味を持つかどうかすら変わってくる。
センサー読み取り値は1分間に数千回挿入される。外部キーチェックはすべてルックアップを伴う。ルックアップはすべてテーブルの競合を生む。検証が遅すぎて挿入キューが溢れ、リアルタイムデータを失うなら、それは孤立したレコードとは別の種類のデータ損失だ。
おわかりだろう。これは抽象的な概念としてのパフォーマンス対正しさの話ではない。実際の制約と実際の結果を踏まえて、どの特定の障害をより許容できるかの話だ。
参照の誤りが課金データの破綻や規制違反につながるなら、パフォーマンスコストに関わらず外部キーで守ってほしいはずだ。検証の遅さがキュー溢れを引き起こし、リアルタイムセンサーデータを永久に失うなら、検証は間違ったトレードオフかもしれない。
高速な書き込みが実際に問題になるとき
最大限の書き込み速度が必要だと判断したとしよう。キューが積み上がり、トランザクションがタイムアウトし、外部キーチェックが実際に問題を引き起こしている(推測ではなく計測済みの問題だ)。
選択肢はいくつかある。トランザクション分離レベルを SERIALIZABLE から READ COMMITTED に変更する方法がある。これは高速だが、一部の整合性保証を犠牲にする。コミットをバッチ化する方法もある。1回ずつではなく1トランザクションあたり1000行を挿入し、FKオーバーヘッドを分散させる。あるいは、参照を検証しようとしない追記専用ログ構造に非正規化する方法だ。
3つ目の選択肢はズルではない。別の設計だ:
CREATE TABLE sensor_log ( id BIGSERIAL PRIMARY KEY, recorded_at TIMESTAMPTZ NOT NULL, data JSONB NOT NULL -- { station_id, sensor_id, temp, humidity, ... });
CREATE INDEX ON sensor_log USING GIN (data);CREATE INDEX ON sensor_log (recorded_at);JOIN なし。外部キーチェックなし。データを追記して、時間範囲かJSONB blobのGINインデックスでクエリするだけ。これが「ベストプラクティス」か?データベースの教科書が教える意味ではおそらく違う。Raspberry Piで1分間に50,000行を挿入するときに機能するか?間違いなく機能する。
disconnect が起きるのは、人々が「ベストプラクティス」を道徳的な義務として扱い、一般的なシナリオではうまくいくが自分には合わないかもしれないパターンとして扱わないときだ。
正規化の罠
データベースの授業は正規化を教えるのが大好きだ。重複を避けること。第三正規形か、さもなくば死か。
その結果こうなる:Orders → OrderItems → Products → Variants → Colors → Sizes
「去年のクリスマスに赤いシャツを注文したか、青いシャツを注文したか?」に答えるだけで6つのテーブルをJOINする。商品名を含める必要があれば、カタログ階層さらに3つ先のJOINが必要になる。
だが待て。正当化はたいてい「ブランドが”Blue”のラベルを変更したらどうする?」というものだ。そんなことが起きたとき、過去の注文までさかのぼって色を変更したいか?もちろん違う。注文した瞬間、その人はカタログエントリの抽象的な参照ではなく、その時点での「青いTシャツ、Mサイズ」を購入したのだ。
これは注意深く考える価値がある。なぜなら微妙だからだ。一部のデータは本質的にスナップショットであり、参照ではない。スナップショットデータをライブ参照として扱うと、書き込み時に非正規化しておくべきだったものを再構築するために、 absurd なJOINの増殖に終わる。
{"color": "blue", "size": "M"} を注文に直接保存すれば、それで終わりだ。
スナップショットデータの見分け方
何かがスナップショットであるべきかどうかはどうやってわかるか?時点の記録かどうかを自問してみよう。
注文は購入時点の商品詳細を捉える。監査ログはユーザーがアクションを実行したときのユーザー状態を記録する。履歴テーブルは更新前のレコード状態を保持する。イベントストリームはいつ、どんなデータで何が起こったかを捉える。
答えが「はい、これは瞬間を記録している」なら、正規化をやめよう。スナップショットを始めよう。
不透明なBlob
スナップショットとは別のカテゴリがある。決して中身をクエリしないデータだ。保存して、一括して取得するだけ。
{"model": "gpt-4", "temperature": 0.7, "max_tokens": 2000} のようなLLMモデル設定を temperature でクエリすることはない。必要ならリクエストIDで設定全体を取得する。デコード後のJWTペイロード、デバッグ用のAPIリクエスト/レスポンスログ、テーマ設定や通知フラグを含むユーザー設定オブジェクト。これらはすべて不透明なblobだ。正規化は不要。外部キーも不要。JSONBに放り込んで人生を進めよう。
注文されたシャツの色を知るための6テーブルJOIN?それは適切な正規化ではない。参照を保存しているのか値を保存しているのかを混同した思考だ。
(ただし注意:このデータを後からクエリする必要が出たときに破綻する可能性がある。このアプローチが独自の悪夢を生むケースについては The JSONB Seduction を参照。)
スケールは文脈である
「外部キーはスケールしない」と言う人がいるだろう。だがスケールはハードウェアとアーキテクチャに対して完全に相対的だ。
microSDカードに1分間10,000件のセンサー読み取り値を記録するRaspberry Pi?そのハードウェアにとっては確かに高負荷だ。Provisioned IOPSを備えたAWS Auroraで数十億行を処理?外部キーを張り巡らせても問題ない。
本当のハードリミットは行数や書き込み量ではない。シャーディングだ。
Users テーブルがサーバーAに、Orders テーブルがサーバーBにあるとき、外部キーは物理的に機能しない。ネットワーク境界を越えて制約を強制するメカニズムをデータベースは持っていない。その時点で、孤立レコードを見つけるバックグラウンドジョブを実行し、結果整合性パターンを実装しているはずだ。
これは、コンプライアンスのためにテナントごとに隔離されたデータベースを持つマルチテナントSaaSや、それぞれローカルでSQLiteを実行する50,000台のエッジデバイスを持つIoTデプロイメントで発生する。そこまで到達したら、パフォーマンスの考慮に関わらず外部キーは選択肢から外れる(文字通り)。
だがそのアーキテクチャの境界に到達するまでは、10ユーザーの内部ツールを作っているのにNetflixの問題に対して prematurely に最適化するのはやめよう。
実践ではどうするか
「外部キーを使うべきか」と問う代わりに、この3つを問うてみよう。
この参照が間違っていたら何が壊れるか?訴訟、課金の破綻、規制違反か?それとも分析ダッシュボードでnullが返ってくるだけのmissing joinか?
検証が遅かったら何が壊れるか?取り返しのつかないリアルタイムデータを失うか?それともクエリが50ミリ秒長くなるだけか?
このデータはスナップショットか、参照か?特定の瞬間の状態を記録しているのか、それとも権威ある現在の値を指しているのか?
そこからパターンは自然に見えてくる。
金融取引、認証セッション、データ破綻が法的責任につながるもの——これらはおそらくパフォーマンスオーバーヘッドに関わらず外部キーがほしい。
高ボリュームログ、追記専用時系列データ、1分間に100万件のイベントを書き込むもの——これらはおそらく書き込みごとの検証オーバーヘッドを必要としない。
注文や監査ログのような歴史的スナップショット、ユーザー設定のように常に完全なblobとして取得するデータ、外部APIからのWebhookペイロードのように自分が制御しないスキーマ……これらは非正規化した方がうまくいくことが多い。
だが「おそらく」「ことが多い」と言ったことに注目してほしい。文脈が重要であり、あなたの文脈は私のものとは違うからだ。
最後に
外部キーはパフォーマンス問題ではない。書き込み速度とデータ整合性のトレードオフであり、そのトレードオフが意味を持つかどうかは、特定のボトルネックと特定の結果に完全に依存する。
本当の問題は、書き込みパフォーマンス問題を実際に計測することも、何を犠牲にするかを考えることもなく、「Webスケール」について読んだ何かを理由に外部キーを削除する人々だ。1日100トランザクションを処理するグリーンフィールドプロジェクトにNetflixのアーキテクチャを cargo cult することになる。
もしかしたらパフォーマンスコストはあなたのユースケースに見合うかもしれない。見合わないかもしれない。少なくとも、何を最適化すべきだと思っているかではなく、実際に何を最適化しているかに基づいてその決定を下そう。
あなたは何を最適化しているのか?