データベースは「正しかった」。でも296倍遅すぎた。
Postgresデータベースに89個の外部キーがあるのにインデックスがゼロ——そのことに気づいたとき、ミリ秒で終わるはずのクエリが843msという悪夢になっていた理由がわかりました。アルファ版ローンチを危うく葬り去りかけた話です。
2025年10月25日。SaaSアプリケーションはようやく機能完成を迎えていました。9つの主要機能、マルチテナントアーキテクチャ、プログレッシブラーニング——すべてが動いていました。でも、遅かった。本当に遅かった。
ダッシュボードのクエリはのろのろと動き、一瞬で読み込まれるべきリストが数秒かかっていました。アルファテスターたちは「サイト壊れてる?」と聞き始めていました。
パフォーマンスの問題だけではありませんでした。生き残れるかどうかの問題でした。
2日間かけてクエリを最適化し、RLSポリシーを書き直し、キャッシュを追加しました。何も効果がなかった。苛立ちは募る一方——計画していたアルファ版ローンチまで5日というタイミングで、技術的には正しいはずのデータベースがなぜこんなに遅いのか、まったくわからなかった。
そして、ある診断クエリを実行した瞬間、胃が沈み込む感覚がしました。
外部キー89個。インデックスゼロ。
これは、Postgresの「仕様」について誰も教えてくれなかったこと——アルファ版ローンチを2週間かけて危機に追い込み、そして4分間のフィックスで全てを救った話です。
賭けているもの:遅さはそのまま死を意味する
研究によれば、レイテンシが100ms増えるごとにコンバージョン率が1%低下します。2〜3秒の読み込み時間では、悪いユーザー体験を提供しているだけでなく、アルファ版の成否を左右する第一印象を台無しにしていました。
当面のリスク:
- プラットフォームが安定しているか疑問を持ち始めたアルファテスター
- 信頼を寄せてくれた初期ユーザーからの信頼損失
- そのまま正式ローンチを迎えた場合:業界調査によれば3秒の読み込みでバウンス率40%
- 「これは有望だ」と「これは壊れてる」の差
- データベースCPU使用率80〜90%(高コストで悪化中)
- ローンチ5日前、完全に詰まった状態
その2週間で実際にかかったコスト:
- 遅いクエリのデバッグ、コードプロファイリング、RLSポリシーへの責任転嫁に40時間以上
- ローンチ1週間遅延(内部デッドライン未達)
- 機能開発に使えたはずの時間の喪失
- 初期テスターへの信頼損失
技術的な負債がビジネスの負債になっていました。その理由がわからないまま。
---
調査:遅いクエリを追いかけて
10月末、プラットフォームは本格的な複雑さを持っていました:テーブル30以上、RLSポリシー80以上、外部キーは数百。一瞬で終わるはずのシンプルな操作が痛いほど遅い——リストの読み込み、ダッシュボードデータの取得、org_idでのフィルタリング。
まずpg_stat_statementsで犯人を探しました:
```sql
SELECT query, mean_exec_time, calls
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;
```
遅いクエリはすべて同じパターンを持っていました——外部キーでのフィルタリング:
```sql
SELECT * FROM user_data
WHERE org_id = '...' AND resource_id = '...';
```
一瞬で終わるはずです。カラムはある。RLSポリシーもある。なぜ遅い?
実行計画を確認しました:
```sql
EXPLAIN ANALYZE
SELECT * FROM user_data
WHERE org_id = 'abc-123' AND resource_id = 'xyz-789';
```
結果を見て胃が落ちました:
```
Seq Scan on user_data (cost=0.00..2847.23 rows=15 width=1024)
Filter: ((org_id = 'abc-123') AND (resource_id = 'xyz-789'))
Rows Removed by Filter: 12,834
Execution Time: 843.271 ms
```
シーケンシャルスキャン。Postgresはテーブルの全行を読み込んでからフィルタリングしていました。ミリ秒で終わるはずのクエリで。
でもorg_idとresource_idには外部キーがある。それはインデックスと同じじゃないの?
---
啓示:外部キーはインデックスを自動作成しない
その日、痛い目に遭いながら学んだことがこれです:
PostgresはPRIMARY KEYとUNIQUE制約のインデックスを自動的に作成します。
PostgresはFOREIGN KEYのインデックスを自動的に作成しません。
もう一度言います。この一つの誤解が私の2週間を奪いました:
FOREIGN KEYS ≠ INDEXES
こう書いたとき:
```sql
CREATE TABLE user_data (
id UUID PRIMARY KEY, -- ✅ 自動的にインデックス作成
org_id UUID REFERENCES organizations(id), -- ❌ インデックスなし!
resource_id UUID REFERENCES resources(id), -- ❌ インデックスなし!
created_at TIMESTAMPTZ
);
```
Postgresは外部キー制約(参照整合性)を作成しますが、org_idやresource_idのインデックスは作成しません。
なぜか?Postgresはあなたがデータをどのようにクエリするか仮定できないからです。外部キーでフィルタリングしないかもしれない。常に特定の方向でジョインするかもしれない。だから判断はあなたに委ねられています。
**問題は?**私はその判断が必要だと知りませんでした。「外部キー」は「クエリ用のインデックス」を意味すると思っていました。それは違います。
---
診断:どれほどひどい状態だったか
インデックスのない外部キーをすべて見つけるクエリを書きました:
```sql
SELECT
c.conrelid::regclass AS table_name,
a.attname AS column_name
FROM pg_constraint c
JOIN pg_attribute a ON a.attnum = ANY(c.conkey) AND a.attrelid = c.conrelid
WHERE c.contype = 'f'
AND NOT EXISTS (
SELECT 1 FROM pg_index i
WHERE i.indrelid = c.conrelid AND a.attnum = ANY(i.indkey)
);
```
結果は貨物列車に轢かれたような衝撃でした:
89行。
32テーブルにわたる外部キー89個。インデックスゼロ。
すべてのジョイン、すべてのフィルタ、すべてのRLSポリシーチェックがフルテーブルスキャンをしていました。何もかも遅いはずです。
---
フィックス:インデックス大虐殺
2025年10月25日。やるべきことはわかりました。
マイグレーション1つ。インデックス89個。私はそれを「インデックス大虐殺」と呼びました。
```sql
-- インデックス大虐殺マイグレーション
-- 目的:外部キーカラムにインデックスを追加してパフォーマンスを修正
-- 組織スコープのテーブル
CREATE INDEX idx_users_org_id ON users(org_id);
CREATE INDEX idx_resources_org_id ON resources(org_id);
CREATE INDEX idx_documents_org_id ON documents(org_id);
-- リソーススコープのテーブル
CREATE INDEX idx_user_data_resource_id ON user_data(resource_id);
CREATE INDEX idx_tasks_resource_id ON tasks(resource_id);
-- よく使うクエリパターン用の複合インデックス
CREATE INDEX idx_user_data_org_resource ON user_data(org_id, resource_id);
-- ソフト削除クエリ
CREATE INDEX idx_resources_org_archived ON resources(org_id, archived_at);
-- 合計:32テーブルに89インデックス
```
マイグレーションを実行しました。Enterキーに手をかざしながら、少しの間躊躇しました——悪化させるんじゃないかと。T.T
マイグレーション時間:4分。
---
結果:災害からローンチ準備完了へ
修正前(インデックスなし)
```sql
Seq Scan on user_data
Execution Time: 843.271 ms
Rows Removed by Filter: 12,834
```
修正後(インデックスあり)
```sql
Index Scan using idx_user_data_org_resource
Execution Time: 2.847 ms
```
843ms → 2.8ms
単一クエリで296倍高速化。
実世界の効果:
- ダッシュボードの読み込み:2〜3秒 → 120ms
- リソース一覧:1秒以上 → 45ms
- データクエリ:850ms → 12ms
平均改善:20〜40倍高速化。複数の外部キーを持つ複雑なジョインは?100倍高速化。
違いは歴然でした。「これ壊れてる?」から「すごく速い!」に変わりました。:D
---
マルチテナントSaaSにとって重要な理由
Row-Level Securityを使ったマルチテナントSaaSを構築しているなら、これは絶対に押さえておくべきことです。
RLSポリシーはすべてのクエリで実行されます:
```sql
CREATE POLICY resources_org_isolation ON resources
USING (org_id = get_user_org_id());
```
org_idにインデックスがなければ、このポリシーはすべてのクエリでシーケンシャルスキャンを強制します。単純なSELECT文すら痛いほど遅くなります。
コスト: インデックスのないマルチテナント分離 = 高コストインフラ+遅いユーザー体験 = チャーン。
ビジネスの教訓: パフォーマンス最適化なしのセキュリティ機能は、実は安全じゃない——ユーザーはセキュリティを体験する前に離れていくからです。
---
パターン:何をインデックス化すべきか
この高い授業料の後、新しいテーブルごとのチェックリストです:
必ずインデックス化すること:
1. 外部キーカラム - 一つ残らず全部
2. RLSポリシーのカラム - マルチテナントアプリでは特にorg_id
3. WHERE句のカラム - 頻繁にフィルタリングするならインデックスを
4. ORDER BYのカラム - インデックスなしのソートはパフォーマンスを破壊する
複合インデックスを検討すること:
```sql
-- WHERE org_id = X AND resource_id = Y の場合
CREATE INDEX idx_table_org_resource ON table(org_id, resource_id);
```
インデックスを作りすぎないこと: インデックスはストレージ、書き込みパフォーマンス、メンテナンスコストを増やします。目安:WHERE/JOIN/ORDER BYに頻繁に使うならインデックスを。そうでなければ不要です。
---
今すぐデータベースを確認する方法
この診断クエリを実行してください:
```sql
SELECT
c.conrelid::regclass AS table_name,
a.attname AS column_name
FROM pg_constraint c
JOIN pg_attribute a ON a.attnum = ANY(c.conkey) AND a.attrelid = c.conrelid
WHERE c.contype = 'f'
AND NOT EXISTS (
SELECT 1 FROM pg_index i
WHERE i.indrelid = c.conrelid AND a.attnum = ANY(i.indkey)
);
```
結果が出たなら、インデックスが不足しています。
次に、遅いクエリを探します:
```sql
SELECT query, mean_exec_time, calls
FROM pg_stat_statements
WHERE mean_exec_time > 100
ORDER BY mean_exec_time DESC;
```
遅いものにEXPLAIN ANALYZEを使って、シーケンシャルスキャンが発生していないか確認してください。
---
学んだこと
1. Postgresの思い込みは危険
外部キーはインデックスされていると思っていました。されていません。常に確認しましょう。
2. RLSにはインデックスが必要
Row-Level Securityは適切なインデックスなしでは役に立たない——実際には役に立つどころかマイナスです。すべてのクエリにオーバーヘッドを追加します。
3. マルチテナント = どこでもorg_idにインデックスを
マルチテナントアーキテクチャではorg_idはほぼすべてのクエリに登場します。すべてのテーブルにインデックスを。例外なし。
4. パフォーマンスはビジネスの問題
SQLが技術的に正しくても、ページの読み込みに3秒かかるならユーザーは気にしません。速いクエリ = コンバージョン向上 = 収益。
5. インデックスは早めに追加する
空のテーブルへのインデックス追加は一瞬。数百万行のテーブルへの追加には時間がかかり、テーブルをロックします。初期開発の段階でやりましょう。
6. すべてを測定する
EXPLAIN ANALYZEを前後で使う。データで改善を証明しましょう。
---
あなたのチェックリスト
新しいテーブルを作るたびに:
```sql
CREATE TABLE new_table (
id UUID PRIMARY KEY,
org_id UUID REFERENCES organizations(id),
resource_id UUID REFERENCES resources(id),
created_at TIMESTAMPTZ,
archived_at TIMESTAMPTZ
);
-- ✅ 外部キーにインデックスを
CREATE INDEX idx_new_table_org_id ON new_table(org_id);
CREATE INDEX idx_new_table_resource_id ON new_table(resource_id);
-- ✅ RLSカラムと共通フィルタにインデックスを
CREATE INDEX idx_new_table_org_archived ON new_table(org_id, archived_at);
-- ✅ ソートカラムにインデックスを
CREATE INDEX idx_new_table_created ON new_table(created_at DESC);
-- ✅ RLSポリシーを作成
CREATE POLICY new_table_org_isolation ON new_table
FOR ALL TO authenticated
USING (org_id = get_user_org_id());
```
---
最後に
外部キー89個の欠如。2週間のデバッグ。全部を見つける診断クエリ1つ。フィックスに4分。
皮肉なことに、Postgresはこれを診断するためのすべてのツールを提供しています。私はただ、それを見る必要があることを知らなかっただけです。今では新しいテーブルを作るたびに即座にインデックスを追加します——外部キー、RLSカラム、ソートフィールド、すべて。
89のインデックスを追加した4分間のマイグレーションが、将来何週間もかかるはずの最適化作業を省き、ローンチの危機を防ぎました。
ファウンダーへ: パフォーマンスはエンジニアリングの問題だけじゃありません。コンバージョンの問題、リテンションの問題、信頼性の問題です。使うには遅すぎる「技術的に正しい」データベース?それはお客様を失っています。早めにパフォーマンスに投資しましょう。
エンジニアへ: Postgresは外部キーを自動でインデックス化しません。でも、あなたはすべきです。ユーザー(とデータベースCPU)があなたに感謝します。
データベースで「技術的には正しいけど痛いほど遅い」という経験をしたことはありますか?フィックスは何でしたか——見つけるのにどれくらいかかりましたか?
STRATUMアーキテクチャシリーズ: このパフォーマンス危機は、Day 2でマルチテナントを構築したところから始まり、Day 67に完全なスキーマ再構築を経て、ナビゲーションコンテキストの喪失による31の空白画面の修正も含む、マルチテナントパズルの最後のピースでした。
---
PostgresでマルチテナントSaaSを構築中ですか? この教訓を痛い目に遭って学んだのは、あなたに同じ経験をさせないためです。ユーザーが気づく前に今すぐデータベースを確認しましょう。
アルファアクセスのリクエストはhttps://stratum.chandlernguyen.com/request-invitationから。
---
「動く」と「速く動く」は別の目標——そしてそれを出荷できるのはどちらか一方だと、今も学び続けています。
---
よろしくお願いします、Chandler





