Skip to content
··3分で読めます

データベースは「正しかった」。でも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_idresource_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_idresource_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

続きを読む

私の歩み
つながる
言語
設定