1970 年の Codd から PostgreSQL 18、そして expand-contract の現場まで
フレームワークが変わってもテーブルは生き続ける
2026 年。私たちの仕事の表層は静かに変わり続けている。フレームワークは三年で世代が変わり、デプロイ手順は半年で書き換わり、AI は SQL を生成してくれる。それでも、おそらく十年後も残っているものがある ── スキーマだ。
テーブルとカラムと外部キーは、書いた瞬間から組織の決定として固まる。それを変えるには本番のデータを動かさなければならず、本番のデータは何千万行・何億行と積み上がる。だからスキーマ設計の判断は、コードのリファクタリングとは違う重みを持つ。後戻りが難しい決定として残り続ける。
フレームワーク・ORM・AI コード生成が普及するほど、設計者は 「下のレイヤーがどう動いているか」を理解している必要が増す。生成された SQL が正しいか、ORM の N+1 を見抜けるか、隔離レベルの違いが本番でどう牙を剥くか ── これらの判断は、抽象を上から眺めているだけでは身につかない。
本書は、PostgreSQL を主な舞台として、関係型データベース設計を 原典から現代まで辿り直す十章である。第1〜2章は理論の土台 (Codd と正規化)、第3〜5章は具体的な設計 (主キー、リレーション、インデックス)、第6〜7章は並行性の現実 (トランザクションと MVCC)、第8章はクエリ最適化、第9〜10章は本番運用 (スキーマ進化と拡張性)。
各章は思想と実務の二段構えで、月曜の朝に自分のスキーマを開いたとき、すぐに使える形を目指した。コーヒーを淹れて、はじめよう。
なぜテーブルなのか。なぜ集合なのか
関係型データベースの根幹を作ったのは Edgar F. Codd という IBM の研究者で、その問いは 1970 年の論文 "A Relational Model of Data for Large Shared Data Banks" に書かれている。彼の問いは単純だった ── データを保存するアプリケーションが、データの物理的な置き場所を知らずに済むようにできないか。
Codd の答えはこうだ。すべてのデータを 関係 (relation) として表現する。関係とは、ある属性集合の直積のサブセット ── 平たく言えば、同じ列構造を持つ行の集合だ。テーブルというのは関係の見やすい表記法にすぎない。
ここで重要なのは「集合」という性質だ。集合には順序がない。重複もない。だから関係型データベースでは、テーブルの行に「上から3行目」という意味は本来ない。ORDER BY を書かなければ並び順は保証されない。これは仕様の貧弱さではなく、集合論を素直に表現した結果だ。
Codd の最初の規律が 第一正規形 (1NF) だ。「各セルには 原子的な値を一つだけ入れる」。配列もネスト構造も入れない。一見窮屈だが、これがなければ集合論的な操作 (選択、射影、結合) が機械的に書けなくなる。
2010 年代以降、JSONB や配列型の登場で「Postgres でも非原子的な値を持てるじゃないか」という議論が出てきた。これらは確かに便利だが、原則として 関係モデルの外側にある機能だと理解しておくべきだ。JSONB を多用するほど、関係代数の恩恵 (型安全な JOIN、宣言的なクエリ、強い整合性) からは離れていく。
自分のスキーマを開いて、各テーブルを「これは何の集合か」と一行で言えるか試してみる。たとえば users は「サービスの利用者という集合」、orders は「注文という事象の集合」、user_roles は「『この利用者にこの役割が付与されている』という事実の集合」。
言い表せないテーブルがあったら、それは設計が曖昧なサインだ。「この行は何を表しているのか」が不明瞭な状態は、後から JOIN を書くときに必ず痛い目を見る。
同じ演習で、非原子的なカラムも見つけられる。CSV を一つのカラムに突っ込んでいる、JSON にしてはいけない関係データを JSONB に入れている ── そういうコードスメルが、原子性の規律から逸脱した結果として浮き上がる。
3NF まではほぼ無条件に行く。BCNF と非正規化は判断
正規化 (normalization) はスキーマ設計の最初の規律で、その目的は更新時の異常 (update anomaly) を防ぐことだ。同じ事実が複数の場所に書かれていると、片方を直し忘れた瞬間にデータが嘘をつき始める。これを構造的に不可能にするのが正規化の仕事だ。
核心は 関数従属 (functional dependency) という概念だ。「A → B」と書けば「A の値が決まれば B の値も決まる」ことを意味する。user_id → email なら、user_id を見れば email が一意に決まる。スキーマ設計の大部分は、こうした関数従属を整理して、どこに置くかを決める作業だ。
| 正規形 | 規律 | 排除する異常 |
|---|---|---|
| 1NF | 各セルは原子的な値 | 非関係的構造 |
| 2NF | 非キー属性は主キー全体に従属 (複合キーがある場合の話) | 部分従属 |
| 3NF | 非キー属性は他の非キー属性に従属しない | 推移従属 |
| BCNF | すべての非自明な関数従属の左辺はスーパーキー | 3NF が見逃す稀な異常 |
実務的には 3NF までは規律として無条件に守るのが大正解だ。BCNF は 3NF を満たしていても破れる稀なケース (複数の候補キーが重なる場合) で発動するので、必要になったときに対応すればいい。
一つのテーブルに混在していた情報を、関数従属に沿って三つに分けていく。各ステージで「同じ事実を二度書かない」原則に近づいていく。
では非正規化 (denormalization) はなぜするのか。答えは一つ ── 読み取り性能のため。3NF で綺麗に分けると JOIN が増え、JOIN が増えるとクエリが重くなる。だから読み取りが極端に多いテーブルや、集計結果を高速に返したい場面では、意図的に冗長を持ち込む。
ただし非正規化には必ず代償がある。同じ事実が複数の場所に書かれるので、更新時に両方を直す責任が発生する。これを忘れると、サイレントなデータ破損が始まる。Karpathy 流に言えば「自動化できる検証関数を書ける範囲でしか非正規化してはいけない」── 整合性を保つトリガーや、定期バッチで再計算する仕組みがセットでなければ、非正規化は時限爆弾になる。
3NF をデフォルトの規律として守る。非正規化は「読み取り性能のためだけ」に、整合性を保つ仕組みとセットで導入する。理由を説明できない非正規化は、技術的負債の温床になる。
もう一つ、よくある誤解を解いておく。「集約テーブル (例: orders_summary)」は非正規化ではない。集計結果のキャッシュであり、元の orders テーブルが真実の源。失われても再計算できる。これは非正規化というより、計算結果の永続化に近い。混同しない。
後戻りが最も難しい一つの決定
テーブルを作ったら、必ず最初に決めるのが 主キー (primary key)だ。主キーは行の身分証明書で、外部キーに参照されるアンカーになる。決め直しは原則として不可能で、組織のすべてのテーブル設計に波及する。一番最初に、慎重に決めるべき値。
選択肢は大きく二つある。
自然キー (natural key) は、業務的に意味のある値を主キーに使う。たとえば商品コード、ISBN、ISO 国コード、メールアドレス。データを見ればその行が何を指すか分かるという美点があるが、致命的な弱点が一つある ── 変わるかもしれない。メールアドレスは変わる。商品コードは型番変更で書き換わる。主キーが変わると、参照しているすべての外部キーが連鎖して書き換わる必要があり、ほぼ常に事故になる。
サロゲートキー (surrogate key) は、業務に意味のない人工的な識別子を使う。auto-increment 整数、UUID、ULID、Snowflake ID。意味がないので変わる理由がない。これが最大の利点だ。デメリットは、データを見ても行の中身が分からない (= 別途ユニーク制約を業務キーに張る必要がある) こと。
実務上の合意は明確で、サロゲートキーをデフォルトにし、業務キーには別途 UNIQUE 制約を張る。Codd 自身も後期にはこの立場を取っている。自然キーを主キーに使うのは、本当に不変が保証される稀なケース (国コード、通貨コードなど) に限る。
サロゲートキーをどう生成するか、ここに 2026 年の地殻変動がある。
| 方式 | サイズ | 順序 | 分散生成 | 用途 |
|---|---|---|---|---|
| auto-increment (bigint) | 8 bytes | 厳密に増加 | 不可 | 単一 DB の最高性能 |
| UUIDv4 | 16 bytes | ランダム | 可 | 過去のデフォルト。インデックス断片化が痛い |
| UUIDv7 | 16 bytes | 時系列順に近い | 可 | 2026 年のデフォルト。B-tree との相性が良い |
| ULID | 26 chars (16 bytes) | 時系列順 | 可 | 人間にも読める文字列 |
| Snowflake | 8 bytes | 時系列順 | 可 (要 worker ID 管理) | 大規模分散システム |
2026 年現在、グリーンフィールドのプロジェクトでは UUIDv7 がデフォルトに近い。RFC として標準化され、Postgres 18 のネイティブサポートも入り、性能と分散生成の両方を取れる。ULID は人間が読みやすい文字列を残したい場合の選択肢。Snowflake は超大規模のサービスで、worker ID 管理のオペコストを払える組織向け。
一方で、auto-increment bigint が依然として正解な場面もある。単一 DB で完結する内部システム、ID を URL に露出させない管理画面、データウェアハウスの集計テーブル ── こうした文脈では性能とサイズの両方で勝るので、無理に UUID にする必要はない。
新しいテーブルを作るときの判断順序:
そして必ず守るべき規律:
/orders/12345 と出すと、注文数や成長率が外から推測できる。露出するなら UUIDv7関係の四形態と、外部キーという規律
テーブル単体は孤独な集合にすぎない。複数のテーブルが 関係 (relationship) で結ばれて、はじめてデータベースは意味を持つ。関係の引き方には基本の四形態があり、それぞれに定石がある。
1対多 (one-to-many)。最も基本かつ最頻出。users と orders の関係 ── 一人のユーザーが複数の注文を持つ。実装は単純で、多側 (orders) に外部キー user_id を置く。「外部キーは多側に置く」── これだけ覚えておけば 8 割の関係は片付く。
1対1 (one-to-one)。ユーザーとプロフィール、注文と請求書のような関係。実装は二通り ── 同じテーブルに統合するか、別テーブルにして UNIQUE 制約付き外部キーを張るか。必須かどうかと列の数で判断する。ほぼ常に存在し、列数も少ないなら統合。任意 (片方だけ持つ場合がある) で列数が多いなら分離。
多対多 (many-to-many)。ユーザーとロール、記事とタグ、注文と商品。実装は必ず 中間テーブル (join table) を経由する。user_roles (user_id, role_id) のように、両方の外部キーを持ち、両者の組み合わせに UNIQUE 制約を張る。中間テーブルが独自の属性 (付与日時、付与者など) を持つ場合は、それも追加する。
自己参照 (self-referencing)。階層構造 (カテゴリの親子、コメントのスレッド、組織のレポートライン) を表現する。parent_id を同じテーブルに持つだけ。深い階層を扱う場合は Postgres の 再帰 CTE (WITH RECURSIVE) で辿れる。本当に深い・頻繁にアクセスするなら、ltree 拡張やクロージャテーブルも検討する。
関係の四形態。外部キーをどこに置くか、中間テーブルが要るか、自己参照になるか ── ほぼ全てのスキーマは、これら四つの組み合わせで表現できる。
Rails や Django でよく見るパターンに ポリモーフィック関連がある。comments テーブルが article にも video にもコメントできるように、commentable_id と commentable_type という二つのカラムで多態を実装する。
これは 関係モデルに対する敗北だ、と Codd 派は言う。なぜなら commentable_id は単独では外部キー制約を張れない。型ごとにテーブルが違うから、DB レベルで参照整合性を強制できない。データ整合性は完全にアプリケーション任せになる。
解決策はいくつかある。(a) ポリモーフィック側を諦めて、article_comments と video_comments に分ける。(b) 共通の親テーブル commentables を作り、article と video がその一種であるようにする (Class Table Inheritance)。(c) どうしても必要なら、アプリ層に整合性チェックを書く。最初の選択肢が最もシンプルで安全だ。
外部キーを張るとき、削除時の挙動を必ず指定する。デフォルト (NO ACTION) では削除がエラーになるので、意図を明示するのが習慣。
| 挙動 | 意味 | 典型的な用途 |
|---|---|---|
CASCADE | 親が消えたら子も消える | 所有関係 (post → comments) |
SET NULL | 親が消えたら子の FK は NULL | 緩やかな関係 (author → posts、著者削除でも記事は残す) |
RESTRICT / NO ACTION | 子があるなら親を消せない | 会計データ、注文履歴 (消してはいけない) |
SET DEFAULT | 親が消えたら子の FK は DEFAULT 値 | 稀。「未割当」を表すデフォルト行がある場合 |
判断軸は「子の存在意義は親に依存しているか」だ。コメントは記事なしでは意味がないので CASCADE。注文は注文者のアカウント削除後も残る (会計義務がある) ので RESTRICT か SET NULL。これを設計時に決めておかないと、後から GDPR 対応で「ユーザー削除して」と言われた瞬間に詰む。
なぜ B-tree がデフォルトで、いつそれ以外を選ぶか
インデックスは「クエリを速くする魔法」ではなく、データ構造の選択だ。何を速くしたいかを宣言し、その代わりに書き込みコストと容量を払う。トレードオフを理解せずインデックスを撒くと、書き込みが遅くなり、ストレージが膨らみ、プランナが迷う。
Postgres で CREATE INDEX と書くと、デフォルトで作られるのは B-treeだ。B-tree (正確には B+tree) は、等価検索 (WHERE x = 5)、範囲検索 (WHERE x BETWEEN 1 AND 10)、並び替え (ORDER BY x)、最小値・最大値 ── これらすべてに対数時間で答えられる。汎用性が極端に高いので、迷ったら B-tree でいい。
B-tree が苦手なのは、「列の値に対する全文検索」「配列の中の特定の要素を含むか」「JSONB の中のキーを持つか」── つまり、カラムが複合構造を持っていて、その内部を検索したい場合だ。ここから他のインデックス型の出番になる。
| 型 | 得意 | 典型的な用途 |
|---|---|---|
| B-tree | 等価・範囲・並び替え | 汎用 (デフォルト) |
| GIN | 「複数値を含むか」 | JSONB, 配列, 全文検索, trigram |
| GIST | 幾何・範囲・近似 | 地理データ (PostGIS), 範囲型, 全文検索 |
| BRIN | 物理順と相関する範囲 | 巨大時系列テーブル, ログ |
GIN (Generalized Inverted Index) は転置インデックス。本のページ番号を逆引きするように、「この値を含む行はどれか」を高速に引ける。JSONB の特定キー検索、配列の包含、Postgres 全文検索の標準。
GIST (Generalized Search Tree) は汎用ツリーで、距離や重なりといった幾何学的な関係を扱える。PostGIS の地理空間検索、tstzrange のような範囲型、k-NN 近傍検索などで活躍する。
BRIN (Block Range Index) は変わり種で、テーブルの物理ブロックごとの要約値を持つだけ。インデックス自体が極端に小さい (B-tree の 1/1000 以下になることも) かわりに、データの物理順が検索キーと相関している必要がある。ログテーブルやイベントテーブルのように、ほぼ時系列順に挿入されるデータで威力を発揮する。
もうひとつ、インデックスを賢く絞るテクニックがある。
部分インデックス ── WHERE 条件付きで作る。「アクティブなユーザーだけ」「未削除の行だけ」のような偏ったデータでは劇的に小さくなる。CREATE INDEX ON users (email) WHERE deleted_at IS NULL;
関数インデックス (expression index) ── 関数の戻り値を索引化する。LOWER(email) で検索するなら CREATE INDEX ON users (LOWER(email)); を張る。普通の B-tree では効かない。
複合インデックス ── 複数カラムを並べて作る。順序が重要で、左から順に使われる。(user_id, created_at) なら user_id 単独の検索にも、user_id AND created_at の検索にも効くが、created_at 単独には効かない。
本番でインデックスを足すときの実践原則:
pg_stat_user_indexes で利用回数を確認できるCREATE INDEX CONCURRENTLY を使う。テーブルロックを取らずに作成できるそして避けるべきアンチパターン:
CONCURRENTLY。EXPLAIN ANALYZE で効いていることを確認隔離レベルを知らないと、本番で必ず牙を剥く
トランザクションは銀行口座の振込のように、複数の処理を「全部成功するか全部やめるか」のひとつの単位にまとめる仕組みだ。これを支えるのが ACID 四つの性質 ── 原子性 (Atomicity)、整合性 (Consistency)、隔離性 (Isolation)、永続性 (Durability)。
このうち I (Isolation) が最も誤解されやすく、最も実害が大きい。完全な隔離は性能を犠牲にするので、SQL 標準は四段階の隔離レベルを定義した。レベルを下げると性能は上がるが、特定の現象 (anomaly) が観測されうる。
| レベル | Dirty Read | Non-Repeatable Read | Phantom Read | Serialization Anomaly |
|---|---|---|---|---|
| Read Uncommitted | 起きる | 起きる | 起きる | 起きる |
| Read Committed | 防止 | 起きる | 起きる | 起きる |
| Repeatable Read | 防止 | 防止 | (Postgresでは防止) | 起きる |
| Serializable | 防止 | 防止 | 防止 | 防止 |
用語の意味は次の通り:
Postgres を含む多くの RDBMS のデフォルトは Read Committedだ。これは「他トランザクションがコミット済みの最新値が見える」という、直感的にわかりやすい挙動。性能も最高に近い。
ただし、Read Committed には落とし穴がある。同じトランザクション内で同じ行を二度 SELECT すると、間に他トランザクションがコミットしていれば違う値が返る ── これが Non-Repeatable Read。アプリ側で「最初の SELECT の結果に基づいて UPDATE する」というロジックを書くと、レースコンディションが入り込む余地が生まれる。
典型的な事故が在庫管理だ:
-- T1: 残高を読み取り、計算して書く
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 100 を取得
-- アプリ側で 50 を引いて、新しい残高 50 を計算
UPDATE accounts SET balance = 50 WHERE id = 1;
COMMIT;
このあいだに T2 が同じことをやれば、両方とも「100 から 50 引いて 50」と書き、最終的に 50 になる ── 本来は 0 になるはずなのに。これが lost update 問題で、Read Committed では防げない。
実務での選択肢は三つ。
(A) Read Committed + 明示的ロック。最も一般的な戦略。デフォルトは Read Committed で走らせ、危険な箇所だけ SELECT ... FOR UPDATE で行ロックを取る:
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- ロック
UPDATE accounts SET balance = balance - 50 WHERE id = 1;
COMMIT;
これで T2 は T1 が COMMIT するまで待たされる。シンプルで効果的だが、忘れた場所が事故源になる。
(B) Repeatable Read で重要トランザクションを囲む。決済や在庫操作のトランザクションを SET TRANSACTION ISOLATION LEVEL REPEATABLE READ; で開始する。Postgres の Repeatable Read は実は SQL 標準より厳しく、Phantom Read も防止する。書き込み衝突時にはエラーになるので、アプリ側でリトライ実装が必要。
(C) Serializable で全部囲む。Postgres の Serializable は SSI (Serializable Snapshot Isolation) という賢いアルゴリズムで、ロックを増やさずに整合性を保つ。ただし衝突時のエラー率が上がるので、リトライロジックがアプリ側で書かれている前提が必要。
Repeatable Read か Serializable を使うなら、SQLSTATE 40001 (Serialization Failure) が返ったとき、アプリ側でトランザクションを最初からやり直す実装が必須。これは性能のための妥協ではなく、正しさのための前提。
もうひとつ覚えておくべきは、隔離レベルは そのトランザクション内でのみ有効ということ。クライアント間で値を共有したいなら、advisory lock や Redis などの DB 外の調停が要る。
SELECT FOR UPDATE で十分PostgreSQL がロックを取らずに整合性を保つ仕組み
第6章で見た隔離レベルを、Postgres はどう実装しているのか。答えが MVCC (Multi-Version Concurrency Control)。多版同時実行制御 ── 行を更新するとき、古いバージョンを残したまま新しいバージョンを書く、という発想だ。
従来のロックベースの並行制御では、トランザクションが行を読むあいだ他の書き込みをブロックし、書くあいだ他の読み込みをブロックする。これは確実だが、競合が増えるほど性能が劣化する。
MVCC は別の発想を取る。すべての行に「いつ作られたか (xmin)」「いつ消されたか (xmax)」のトランザクション ID を持たせる。読み手は スナップショットを取り、その時点で「自分にとって有効だった行」だけを見る。書き手は古い行を残したまま新しい行を作る。読み手と書き手はブロックし合わない。
これが Postgres の並行性能を支える土台で、隔離レベルもこの上に実装されている。Read Committed は「ステートメント開始時のスナップショット」を、Repeatable Read は「トランザクション開始時のスナップショット」を取る ── 同じ MVCC の仕組みを、スナップショットの取り方で使い分けているだけだ。
同じ行の二つのバージョン (v1, v2) が同居する。T1 は自分のスナップショット時点で有効だった v1 を見続け、T2 が作った v2 は見ない。読み手と書き手はブロックし合わない。
MVCC には代償がある。古い行を残し続けるとテーブルが肥大化するので、誰かが 不要になった古い行を回収する仕事をしなければならない。これが VACUUMだ。
Postgres は autovacuum がバックグラウンドで自動的に走り、参照されなくなった行を回収する。普段は意識しなくていいが、大量更新・大量削除があるテーブルでは、autovacuum の設定 (autovacuum_vacuum_scale_factor など) を調整することがある。
関連する罠が long-running transaction。一つの長いトランザクションが居座ると、その時点のスナップショットを保持するために、その後の更新で作られた古い行を VACUUM できなくなる。テーブルがどんどん膨らみ、I/O が劣化する。長時間 BEGIN したまま放置するトランザクションは害悪と覚えておく。
並行性の戦略は大きく二つ。
悲観的ロック (pessimistic) ── 「衝突する前提でロックを先に取る」。SELECT ... FOR UPDATE で行ロックを獲得し、トランザクション完了まで保持する。確実だが、長く保持すると他を待たせる。
楽観的ロック (optimistic) ── 「衝突しない前提で進め、最後に確認する」。version 列やタイムスタンプを使い、UPDATE 時に「自分が読んだバージョンと同じか」をチェックする。違えばエラーにしてリトライ。並行性が高い場面で性能が出る。Repeatable Read / Serializable の挙動は、内部的にこれに近い。
どちらを選ぶかは 衝突頻度で決める。衝突が多いと予測される (たくさんの人が同じ在庫を奪い合う) なら悲観的。衝突が稀 (同じ行を二人が同時に編集するのが稀) なら楽観的のほうが速い。
悲観的ロックの最大の敵は デッドロックだ。T1 が A をロックして B を待ち、T2 が B をロックして A を待つ ── どちらも永遠に進めない。Postgres はデッドロックを検出して片方を強制終了させるが、頻発するとエラー率が上がる。
防止策はシンプル ── 常に同じ順序でロックを取る。複数の行をロックする必要があるなら、ID 昇順で FOR UPDATE を取るルールを決める。これだけでデッドロックの大半は消える。
-- 良い例: id 昇順でロック
SELECT * FROM accounts
WHERE id IN (1, 2)
ORDER BY id
FOR UPDATE;
もうひとつ、SELECT ... FOR UPDATE SKIP LOCKED という強力な機能を覚えておく。ジョブキューやタスクキューで「次の未処理タスクを取って、他のワーカーには見せない」というパターンに完璧に合う:
-- ワーカーが次のタスクを掴む
SELECT * FROM tasks
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED;
そして最後に advisory lock。Postgres の pg_advisory_lock() / pg_advisory_xact_lock() は、テーブルの行に縛られない名前付きロック。Cron 重複防止や、外部システム連携の排他制御に便利。Redis のロックを使う前に、まず DB の advisory lock で済まないかを考える。
FOR UPDATE) / 衝突稀 → 楽観 (version 列)SKIP LOCKED はジョブキューに最適プランナはコスト推定で動く。読み方が分かれば味方になる
SQL は 宣言的な言語だ。「何が欲しいか」を書き、「どう取るか」はプランナが決める。プランナは複数の取り方 (実行計画) を生成し、それぞれにコストを見積もり、最も安いものを選んで実行する。クエリのチューニングとは、プランナにより良い計画を選ばせる作業であり、SQL を物理的に書き換える作業ではない。
Postgres のプランナはコスト計算機だ。テーブルサイズの推定、インデックスの選択肢、結合戦略、ソートの必要性、利用可能なメモリ (work_mem) などを入力に、各実行計画の抽象的なコスト値を弾き出す。コストの単位は時間ではなく、シーケンシャル I/O 1 ページぶんを 1.0 とした相対値。
この見積もりは 統計情報に依存する。ANALYZE コマンドや autovacuum がテーブルから定期的にサンプリングし、pg_statistic に保存する。統計が古いと、プランナの判断は外れる。「最近大量に INSERT したテーブルが急に遅くなった」── 多くの場合、犯人は古い統計情報だ。
JOIN の実装は三種類しかない。プランナはこの三つから一つを選ぶ。
| 戦略 | 仕組み | 得意な状況 |
|---|---|---|
| Nested Loop | 外側の各行に対して内側を引く | 外側が小さく、内側にインデックスがある |
| Hash Join | 小さい側でハッシュテーブルを作り、大きい側で引く | 両方が大きく、等価結合 |
| Merge Join | 両方をソートして並行スキャン | 両方が大きく、すでにソート済み |
JOIN の実装は三戦略しかない。プランナはデータサイズとインデックスから最適なものを選ぶ。エンジニアの仕事は「プランナが正しく選べる状況を整える」こと。
「どれが速い」という普遍的な答えはなく、データサイズとインデックスの組み合わせで最適解が変わる。プランナがこの選択をするので、エンジニアは プランナが正しく選べる状況を整えるのが仕事。具体的には、適切なインデックスを張り、統計情報を新鮮に保ち、サブクエリの構造を素直に書く。
遅いクエリに出会ったら、まず EXPLAIN ANALYZE を打つ。EXPLAIN だけだと推定値、EXPLAIN ANALYZE は実際に実行して実測値も出す:
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT u.name, COUNT(o.id)
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.created_at > '2026-01-01'
GROUP BY u.id;
読み方の急所:
ORM が普及した世界で最頻出のアンチパターンが N+1 クエリだ。「ユーザー一覧を取得 (1 クエリ)」「各ユーザーの注文数を取得 (N クエリ)」── 合計 N+1 クエリ走る。一見正しく動くが、ユーザーが千人いれば千一回 DB を叩く。
検出と対策:
bullet、Django の django-debug-toolbar、Prisma の logger。N+1 検出ツールを CI に入れる手もあるincludes、Django なら select_related / prefetch_relatedハッシュ結合やソートが work_mem に収まらないと、ディスク上の一時ファイルにあふれる。これが起きると劇的に遅くなる。EXPLAIN で external merge Disk: 256000kB のような表示があれば、それは赤旗だ。
対策は二つ。(a) work_mem を上げる (全体だと危険なのでセッション単位で SET work_mem)。(b) ソート対象を絞る (WHERE で先に削る、LIMIT を効かせる)。
遅い SQL の犯人は、ほぼ常にプランナの選択ではなく、人間の側にある。足りないインデックス、腐った統計、必要以上に広い JOIN、ORM の N+1 ── プランナを責める前に、これらを順に潰す。EXPLAIN ANALYZE が読めれば、99% の遅さは自分で解決できる。
ゼロダウンタイムは祈りではなく、手順
スキーマ設計の最後の壁は、本番で動いているテーブルをどう変えるかだ。テーブルが空っぽなら何でもできる。1,000 万行入って、24 時間トラフィックが来ているテーブルでは、軽率な ALTER 一発でサービスが止まる。
ゼロダウンタイムのスキーマ変更の中核は expand-contract (parallel change とも呼ばれる) というパターンだ。考え方はこう ── 古い形と新しい形を一時的に共存させ、段階的に移行する。
この四段階はそれぞれ独立したデプロイで進める。各段階で「アプリの旧バージョンも新バージョンも動く」という後方互換性を保つ。これが祈りではなく手順としてのゼロダウンタイムだ。
同じスキーマ変更を「拡張 → 埋め込み → 切替 → 収縮」の四段階に分け、各段階で旧/新両方のアプリが動くようにする。これがゼロダウンタイムの実装パターン。
Postgres の DDL は、内部でテーブルロックを取るものとそうでないものがある。これを知らないと事故る。
| 操作 | 本番安全性 | 備考 |
|---|---|---|
| 列を追加 (DEFAULT なし) | 安全 | Postgres 11+ で全行書き換え不要 |
| 列を追加 (DEFAULT あり) | 条件付き安全 | 静的 DEFAULT は安全、動的 (now() 等) は全行書き換え |
| NOT NULL 追加 (新列) | 注意 | 既存値が NULL の場合エラー |
| NOT NULL 追加 (既存列) | 注意 | 制約検証中、テーブル全体をスキャン |
| 列名変更 | 条件付き | 瞬時だがアプリが旧名を参照していると壊れる |
| 列の型変更 | 危険 | 多くの場合、全行書き換え + 長時間ロック |
| 列を削除 | 瞬時 | 論理的削除のみ。物理回収は VACUUM |
| インデックス作成 | 要 CONCURRENTLY | 普通の CREATE INDEX はテーブル書き込みをブロック |
具体例として「users.username を users.handle にリネームしたい」を見る。素朴に ALTER TABLE users RENAME COLUMN username TO handle; は一見動くが、瞬時にアプリの旧バージョン (まだ username を参照している) が全滅する。
expand-contract での手順:
handle 列を追加 (NULL 許容)、必要ならトリガーで username と双方向同期UPDATE users SET handle = username をバッチで実行 (1000 行ずつなど)handle を読み書きするように切替。書き込みは両方に書く期間を設けるusername 列への参照がコードベースとログから消えたことを確認username 列を削除面倒だが、これがゼロダウンタイム本番運用の標準フォーマット。慣れると数日〜数週間の作業として淡々と回せる。
expand-contract を毎回手で組むのは大変なので、ツールが助けてくれる。
SET search_path で旧/新を切り替えるだけそして組織のルールとして決めるべきこと:
SaaS を作るなら避けて通れない、最初に決めるべき構造
SaaS を作るなら、避けて通れないのが 複数の顧客 (テナント) のデータをどう同居させ、どう分離するかという設計だ。「最初から考えるべきか、後で導入できるか」── 答えはほぼ常に、最初から。後から足すのは expand-contract の長い旅になり、組織にとって最も高くつくマイグレーションの一つになる。
テナント分離の実装は、大きく分けて三つしかない。論理的に分けるか、Postgres スキーマで分けるか、データベースごと分けるか。それぞれにコスト、隔離度、運用負荷のトレードオフがある。
| パターン | 分離単位 | 隔離度 | 運用コスト | 典型的な向き先 |
|---|---|---|---|---|
| Shared Schema + RLS | 行 (tenant_id) | 論理的 | 低 | 多数の中小テナント (B2C SaaS, 早期 B2B) |
| Schema per Tenant | Postgres スキーマ | 名前空間レベル | 中 | 中規模テナント、カスタマイズあり |
| Database per Tenant | データベース | 物理的 | 高 | 規制業界 (医療、金融)、エンタープライズ |
三つのパターンは隔離度と運用コストのトレードオフ。Shared Schema + RLS から始めて、ホットなテナントだけ右へ移していくのが現代の作法。
Shared Schema + RLS は、すべてのテナントが同じテーブルを共有する。各行に tenant_id を持ち、Postgres の Row-Level Security (RLS) で「自分のテナントの行しか見えない」状態を作る。テーブル数が増えない・運用がシンプル・コストが低い、と利点は明快だが、テーブルが巨大化したときにテナント間で性能干渉が起きる。隔離はあくまで論理的なので、RLS ポリシーやアプリのバグでテナント間漏洩のリスクが残る。
Schema per Tenant は、Postgres の「スキーマ (namespace)」機能を使い、テナントごとに同じ構造のスキーマを持つ。tenant_a.users, tenant_b.users のように、テーブルの完全コピーが各テナントに存在する。SET search_path = tenant_a, public; でアクセスするスキーマを切り替える。隔離度は一段上がる ── テナント単位のバックアップ、削除、移行が一発で済む。引き換えに、スキーマ数が増えるとマイグレーションが厄介になる (全スキーマに DDL を流す)。Postgres は数千スキーマまでなら問題なく扱えるが、数万に到達するとカタログテーブルが膨らむ。
Database per Tenant は、データベース自体を分ける。最も強い隔離。HIPAA など規制要件で「データが他組織と物理的に混在してはならない」と書かれている場合は、これが唯一の選択肢になる。引き換えに運用コストが最大で、テナント間横断の集計はほぼ不可能になる (アプリ層で集計するしかない)。
Shared Schema + RLS から始めて、必要に応じて分離していくのが現代の作法。最初から Database per Tenant にすると、運用コストでスタートアップが潰れる。逆に、ホットなテナントだけ後から Schema や Database に切り出す段階的な進化は実装可能で、それが現代の B2B SaaS でよく見られる構成。
Shared Schema + RLS で始めるとき、規律は次の通り。
tenant_id UUID NOT NULL を最初から仕込む。後から追加するのは、全テーブルへの ALTER + バックフィル + 外部キー再設計 + RLS 設定の総工事になるtenant_id を含める。UNIQUE(tenant_id, email) のように。「メールアドレスはテナント内で一意」が正しい制約tenant_id を含める。FOREIGN KEY (parent_id, tenant_id) REFERENCES parents(id, tenant_id)。これで 誤って他テナントの行を参照する事故を DB レベルで防げるtenant_id。あらゆるクエリが WHERE tenant_id = ? を持つので、複合インデックスの最左に置くアプリ層の WHERE tenant_id = ? だけに頼ると、一箇所書き忘れただけで本番でテナント間漏洩が起きる。DB が保証する形に持っていくのが RLS だ。
-- 1. テーブルに RLS を有効化
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
-- 2. 「自分のテナントの行しか見えない」ポリシーを定義
CREATE POLICY tenant_isolation ON users
USING (tenant_id = current_setting('app.current_tenant')::uuid);
-- 3. アプリは接続ごとに current_tenant をセット
SET app.current_tenant = 'a1b2c3d4-...'; -- リクエスト開始時
SELECT * FROM users; -- 自動でテナント絞り込み
これで、たとえアプリのバグで WHERE を書き忘れても、別テナントの行は返らない。Supabase をはじめ、近年の SaaS スターターキットの多くがこの設計を採用している。
tenant_id = ?) なら性能影響はほぼゼロだが、ポリシー内で副問い合わせを書くと劇的に重くなる。RLS は「シンプルな等価フィルタ」で使い、複雑な権限ロジックはアプリ層で書く ── これが安全な使い方。
B2B SaaS では、トップ 10% のテナントが負荷の 80% を占めるパターンが多い (パレートの法則)。Shared Schema で全テナントを抱え続けると、巨大テナントの重いクエリが小さなテナントのレスポンスを押し下げる ── 「ノイジーネイバー (noisy neighbor) 問題」だ。
このとき採る手は二つ。
tenant_xyz スキーマにテーブルを複製し、アプリが search_path で切替えるようにする。残り全テナントは Shared Schema のままどちらも、最初から tenant_id を仕込んでおいたからこそ可能な引き出しだ。テナント単位でデータを切り出す SQL が一発で書ける。これが「最初から tenant_id」の真価。
| 条件 | 推奨 |
|---|---|
| テナント数 1,000 以上が見込まれる | Shared Schema + RLS |
| テナント数 数十〜数百、テナントごとにカスタマイズがある | Schema per Tenant |
| 医療・金融など物理隔離が規制で要求される | Database per Tenant |
| テナント横断の集計・分析が頻繁にある | Shared Schema + RLS |
| 大口テナントが負荷の大半を占めている | Shared が基本、ホットだけ Schema/DB に分離 |
| 「とにかく最速で動かしたい」 | Shared Schema + RLS |
WHERE tenant_id = ? だけに頼る。一箇所の書き忘れがテナント間漏洩を引き起こす。RLS で DB が保証する形にSERIAL を独立採番したくなる誘惑があるが、Shared Schema ではグローバルなサロゲートキー (UUIDv7) が正解tenant_id UUID NOT NULL を全業務テーブルに最初から単一インスタンスを越えて、PostgreSQL がどこまで伸びるか
テーブルが 10 億行になっても、トラフィックが秒間 10 万リクエストになっても、Postgres は伸ばし方を用意している。順序は決まっていて、(1) インデックスとクエリの最適化 → (2) 読み取りレプリカ → (3) パーティショニング → (4) シャーディング。この順で必要なだけ取り組む。一足飛びにシャーディングに行くのは典型的な over-engineering。
パーティショニングは、論理的には一つのテーブルを、物理的には複数の子テーブルに分割する仕組み。Postgres 11 以降の宣言的パーティショニング (declarative partitioning) を使うのが標準。クエリは親テーブルに対して書き、Postgres が適切な子テーブルにルーティングする (partition pruning)。
| 戦略 | 分割キー | 典型的な用途 |
|---|---|---|
| Range | 範囲 (日付など) | 時系列データ。月次・日次の分割 |
| List | カテゴリ値の集合 | 地域、テナント、ステータス |
| Hash | キーのハッシュ値 | 均等分散したい、特定の鍵を持たない |
最大の恩恵は二つ。(a) クエリの絞り込み ── WHERE created_at > '2026-01-01' が、その期間の子テーブルだけスキャンすればよくなる。(b) 古いデータの安価な削除 ── 子テーブルを DETACH して落とすだけ。DELETE でなく構造として消せる。
注意点もある。子テーブルの数が増えすぎると、プランナのオーバーヘッドが効いてくる。数十〜数百の子テーブルが目安。pg_partman 拡張で自動管理するのが現代の作法。
レプリケーションは同じデータを別インスタンスに複製する。Postgres の物理レプリケーション (streaming replication) が標準で、読み取り専用の standby を複数立てて読み取りを分散する。書き込みは依然として primary 一台に集中する点に注意。
シャーディングは別物で、データを水平に分割して別インスタンスに置く。書き込みも分散できる代わりに、複雑性が桁違いに上がる。トランザクションの跨ぎ、JOIN の分散実行、再シャーディングの困難さ ── 簡単に「最後の手段」と言いたくなる重さがある。
シャーディング層を提供する Postgres エコシステム:
Postgres の真の強さは、拡張モジュールで 多様なデータモデルを関係型の上に乗せられること。「Postgres があれば他の DB はあまり要らない」と言われる根拠だ。
| 拡張 | 提供 | 代替を考えるべき DB |
|---|---|---|
| JSONB (組込) | スキーマレスな文書ストア | MongoDB |
| pg_trgm (組込) | 類似度・あいまい検索 | Elasticsearch (軽い用途) |
| tsvector / tsquery (組込) | 多言語全文検索 | Elasticsearch (中規模) |
| pgvector | ベクトル検索 (RAG, 類似度) | Pinecone, Weaviate |
| PostGIS | 地理空間データと演算 | 専用地理 DB |
| TimescaleDB | 時系列向け最適化 | InfluxDB |
| pg_partman | パーティション自動管理 | (なし) |
「将来スケールするから」と言って最初からシャーディングするのは、よくある自滅パターン。実務的な段階:
多くのプロダクトはステージ1〜2 で十分に走り続ける。「シャーディングが必要だ」と気付くのは、それ自体が組織のサイズを示すマイルストーンで、その時には専任の DBA がいる規模になっているはずだ。設計時の判断は、未来のパーティション化を妨げない形にしておくこと。たとえばパーティションキーになりうる created_at や tenant_id を主要テーブルに必ず持たせる ── これだけで、将来パーティション化が必要になったときの工数が大幅に減る。
スキーマは、後から書く長い手紙
十章を辿ってきた。関係モデルの原典、正規化と非正規化、主キー設計、リレーションと外部キー、インデックスの内部、ACID と隔離レベル、MVCC と並行制御、クエリ最適化、expand-contract のスキーマ進化、パーティションと Postgres の広がり。
これらの章はバラバラに見えて、ひとつの姿勢で繋がっている ── スキーマは長期意思決定の集積であるという認識だ。一つひとつの主キー選択、一つひとつの外部キー、一つひとつのインデックスは、それぞれは小さな判断に見える。でも、合わせると組織が次の十年に渡って使う「契約書」になる。
だから本書の章は、どれも「後から書く長い手紙」のような重みを持つ判断だった。今日 UUIDv7 を選んだあなたが、五年後の自分にどう読まれるか。今日 ON DELETE CASCADE を書いたあなたが、十年後の GDPR 対応でどう感謝されるか、あるいは恨まれるか。
同時に、本書はもう一つの主張を裏に持っていた ── 関係モデルは、いまも (そしてこれからも) 最も汎用的なデータの組織法だ。NoSQL の波、AI 生成の波、ORM 万能の風潮が来ても、テーブル・行・関係という三つの基本概念は揺らがない。むしろ、それらを十分に理解していないと、上に乗っかった抽象を使いこなせない。
テーブルは消えない。
あなたが書く SQL も、十年後の誰かが読んでいる。
もし本書を読み終えて、自分のリポジトリのスキーマファイルを開き直したくなったなら、それがこの本の目的だ。関係を引き直すのはいつでもできる。expand-contract という手順がある。
あなたのスキーマが、長く生き残りますように。
本書がよりかかった、関係モデルと Postgres の一次資料
本書は PostgreSQL 18 (2025年9月リリース) の挙動に準拠しています。MySQL / MariaDB / SQL Server の独自挙動については本書では触れていません。