A FIELD GUIDE TO DATABASE DESIGN

データベース設計の原典と現代関係の引き方

1970 年の Codd から PostgreSQL 18、そして expand-contract の現場まで

読了目安 48 MIN
構成 11 CHAPTERS
想定読者 BACKEND DEVS
scroll
PROLOGUE

スキーマは消えない

フレームワークが変わってもテーブルは生き続ける

2026 年。私たちの仕事の表層は静かに変わり続けている。フレームワークは三年で世代が変わり、デプロイ手順は半年で書き換わり、AI は SQL を生成してくれる。それでも、おそらく十年後も残っているものがある ── スキーマだ。

テーブルとカラムと外部キーは、書いた瞬間から組織の決定として固まる。それを変えるには本番のデータを動かさなければならず、本番のデータは何千万行・何億行と積み上がる。だからスキーマ設計の判断は、コードのリファクタリングとは違う重みを持つ。後戻りが難しい決定として残り続ける。

この本の前提

フレームワーク・ORM・AI コード生成が普及するほど、設計者は 「下のレイヤーがどう動いているか」を理解している必要が増す。生成された SQL が正しいか、ORM の N+1 を見抜けるか、隔離レベルの違いが本番でどう牙を剥くか ── これらの判断は、抽象を上から眺めているだけでは身につかない。

本書は、PostgreSQL を主な舞台として、関係型データベース設計を 原典から現代まで辿り直す十章である。第1〜2章は理論の土台 (Codd と正規化)、第3〜5章は具体的な設計 (主キー、リレーション、インデックス)、第6〜7章は並行性の現実 (トランザクションと MVCC)、第8章はクエリ最適化、第9〜10章は本番運用 (スキーマ進化と拡張性)。

各章は思想実務の二段構えで、月曜の朝に自分のスキーマを開いたとき、すぐに使える形を目指した。コーヒーを淹れて、はじめよう。

CHAPTER ONE

関係モデルの起源
── Codd の問い

なぜテーブルなのか。なぜ集合なのか

関係型データベースの根幹を作ったのは Edgar F. Codd という IBM の研究者で、その問いは 1970 年の論文 "A Relational Model of Data for Large Shared Data Banks" に書かれている。彼の問いは単純だった ── データを保存するアプリケーションが、データの物理的な置き場所を知らずに済むようにできないか

I. 思想関係 = 集合のサブセット

Codd の答えはこうだ。すべてのデータを 関係 (relation) として表現する。関係とは、ある属性集合の直積のサブセット ── 平たく言えば、同じ列構造を持つ行の集合だ。テーブルというのは関係の見やすい表記法にすぎない。

ここで重要なのは「集合」という性質だ。集合には順序がない。重複もない。だから関係型データベースでは、テーブルの行に「上から3行目」という意味は本来ない。ORDER BY を書かなければ並び順は保証されない。これは仕様の貧弱さではなく、集合論を素直に表現した結果だ。

Codd, 1970 "Future users of large data banks must be protected from having to know how the data is organized in the machine." A Relational Model of Data for Large Shared Data Banks

I. 思想第一正規形と原子性

Codd の最初の規律が 第一正規形 (1NF) だ。「各セルには 原子的な値を一つだけ入れる」。配列もネスト構造も入れない。一見窮屈だが、これがなければ集合論的な操作 (選択、射影、結合) が機械的に書けなくなる。

2010 年代以降、JSONB や配列型の登場で「Postgres でも非原子的な値を持てるじゃないか」という議論が出てきた。これらは確かに便利だが、原則として 関係モデルの外側にある機能だと理解しておくべきだ。JSONB を多用するほど、関係代数の恩恵 (型安全な JOIN、宣言的なクエリ、強い整合性) からは離れていく。

II. 実務テーブルを「集合」として読み直す

自分のスキーマを開いて、各テーブルを「これは何の集合か」と一行で言えるか試してみる。たとえば users は「サービスの利用者という集合」、orders は「注文という事象の集合」、user_roles は「『この利用者にこの役割が付与されている』という事実の集合」。

言い表せないテーブルがあったら、それは設計が曖昧なサインだ。「この行は何を表しているのか」が不明瞭な状態は、後から JOIN を書くときに必ず痛い目を見る。

同じ演習で、非原子的なカラムも見つけられる。CSV を一つのカラムに突っ込んでいる、JSON にしてはいけない関係データを JSONB に入れている ── そういうコードスメルが、原子性の規律から逸脱した結果として浮き上がる。

CHAPTER SUMMARY

この章で押さえたこと

  • 思想関係 = 同じ属性を持つ行の集合。順序も重複もない
  • 思想第一正規形 ── 各セルは原子的な値を一つ。集合操作の前提
  • 実務各テーブルを「何の集合か」を一行で言えるか
  • 実務JSONB/配列は関係モデルの外。多用するほど整合性は遠ざかる
CHAPTER TWO

正規化と非正規化
── 関数従属を扱う

3NF まではほぼ無条件に行く。BCNF と非正規化は判断

正規化 (normalization) はスキーマ設計の最初の規律で、その目的は更新時の異常 (update anomaly) を防ぐことだ。同じ事実が複数の場所に書かれていると、片方を直し忘れた瞬間にデータが嘘をつき始める。これを構造的に不可能にするのが正規化の仕事だ。

I. 思想関数従属と正規形の階段

核心は 関数従属 (functional dependency) という概念だ。「A → B」と書けば「A の値が決まれば B の値も決まる」ことを意味する。user_id → email なら、user_id を見れば email が一意に決まる。スキーマ設計の大部分は、こうした関数従属を整理して、どこに置くかを決める作業だ。

正規形規律排除する異常
1NF各セルは原子的な値非関係的構造
2NF非キー属性は主キー全体に従属 (複合キーがある場合の話)部分従属
3NF非キー属性は他の非キー属性に従属しない推移従属
BCNFすべての非自明な関数従属の左辺はスーパーキー3NF が見逃す稀な異常

実務的には 3NF までは規律として無条件に守るのが大正解だ。BCNF は 3NF を満たしていても破れる稀なケース (複数の候補キーが重なる場合) で発動するので、必要になったときに対応すればいい。

Figure 1 ── Normalization Steps

正規化の進行 ── 一つのテーブルから三つへ

STAGE 01 非正規化 配列を含むひとつのテーブル orders id customer_name customer_email items[ ] 1 Alice a@ex.com pen, book, ink → 1NF違反 原子的でない STAGE 02 1NF 配列を子テーブルへ分離 orders id customer_name customer_email order_items order_id (FK) item → 3NF違反 customer情報が 推移従属 STAGE 03 3NF customer情報を別テーブルに customers id name, email orders id customer_id (FK) order_items order_id (FK) item 関数従属 が整理 同じ事実は一箇所だけ ── これが更新時異常を防ぐ規律

一つのテーブルに混在していた情報を、関数従属に沿って三つに分けていく。各ステージで「同じ事実を二度書かない」原則に近づいていく。

I. 思想非正規化はなぜ・いつ・どれだけ

では非正規化 (denormalization) はなぜするのか。答えは一つ ── 読み取り性能のため。3NF で綺麗に分けると JOIN が増え、JOIN が増えるとクエリが重くなる。だから読み取りが極端に多いテーブルや、集計結果を高速に返したい場面では、意図的に冗長を持ち込む。

ただし非正規化には必ず代償がある。同じ事実が複数の場所に書かれるので、更新時に両方を直す責任が発生する。これを忘れると、サイレントなデータ破損が始まる。Karpathy 流に言えば「自動化できる検証関数を書ける範囲でしか非正規化してはいけない」── 整合性を保つトリガーや、定期バッチで再計算する仕組みがセットでなければ、非正規化は時限爆弾になる。

KEY CONCEPT

3NF をデフォルトの規律として守る。非正規化は「読み取り性能のためだけ」に、整合性を保つ仕組みとセットで導入する。理由を説明できない非正規化は、技術的負債の温床になる。

II. 実務非正規化を導入するときのチェックリスト

  • 計測したか? 想像で「速くなりそう」と決めない。EXPLAIN ANALYZE で実際に重いことを示す
  • キャッシュで解決しないか? Redis 等のキャッシュで先に試す。スキーマを汚す前の最後の砦
  • マテビュー (Materialized View) ではダメか? Postgres のマテビューなら、整合性責任を DB が持つ
  • 更新時の整合性をどう保つか? トリガー / バッチ / アプリ責任 ── 必ず決めて文書化する
  • 戻せる設計か? 非正規化を後から消すパスを残しておく (列を追加する形で、既存列を保持)

もう一つ、よくある誤解を解いておく。「集約テーブル (例: orders_summary)」は非正規化ではない。集計結果のキャッシュであり、元の orders テーブルが真実の源。失われても再計算できる。これは非正規化というより、計算結果の永続化に近い。混同しない。

CHAPTER SUMMARY

この章で押さえたこと

  • 思想正規化の目的は更新時異常を防ぐこと。関数従属を整理する作業
  • 思想3NF までは規律として守る。BCNF は必要になったら考える
  • 実務非正規化は読み取り性能のため だけ。整合性を保つ仕組みとセットで
  • 実務計測・キャッシュ・マテビュー を試した後でなければ手を出さない
CHAPTER THREE

主キーの設計
── 何で識別するか

後戻りが最も難しい一つの決定

テーブルを作ったら、必ず最初に決めるのが 主キー (primary key)だ。主キーは行の身分証明書で、外部キーに参照されるアンカーになる。決め直しは原則として不可能で、組織のすべてのテーブル設計に波及する。一番最初に、慎重に決めるべき値。

I. 思想自然キーかサロゲートキーか

選択肢は大きく二つある。

自然キー (natural key) は、業務的に意味のある値を主キーに使う。たとえば商品コード、ISBN、ISO 国コード、メールアドレス。データを見ればその行が何を指すか分かるという美点があるが、致命的な弱点が一つある ── 変わるかもしれない。メールアドレスは変わる。商品コードは型番変更で書き換わる。主キーが変わると、参照しているすべての外部キーが連鎖して書き換わる必要があり、ほぼ常に事故になる。

サロゲートキー (surrogate key) は、業務に意味のない人工的な識別子を使う。auto-increment 整数、UUID、ULID、Snowflake ID。意味がないので変わる理由がない。これが最大の利点だ。デメリットは、データを見ても行の中身が分からない (= 別途ユニーク制約を業務キーに張る必要がある) こと。

実務上の合意は明確で、サロゲートキーをデフォルトにし、業務キーには別途 UNIQUE 制約を張る。Codd 自身も後期にはこの立場を取っている。自然キーを主キーに使うのは、本当に不変が保証される稀なケース (国コード、通貨コードなど) に限る。

I. 思想2026 のサロゲートキー選択肢

サロゲートキーをどう生成するか、ここに 2026 年の地殻変動がある。

方式サイズ順序分散生成用途
auto-increment (bigint)8 bytes厳密に増加不可単一 DB の最高性能
UUIDv416 bytesランダム過去のデフォルト。インデックス断片化が痛い
UUIDv716 bytes時系列順に近い2026 年のデフォルト。B-tree との相性が良い
ULID26 chars (16 bytes)時系列順人間にも読める文字列
Snowflake8 bytes時系列順可 (要 worker ID 管理)大規模分散システム
補 ── UUIDv4 の罠 UUIDv4 は完全ランダムなので、B-tree インデックスへの挿入位置が毎回バラバラになる。これは ページ分割キャッシュミスを多発させ、書き込み性能を大きく落とす。これを解決したのが時系列ソート可能な v7 / ULID / Snowflake で、いずれも先頭にタイムスタンプを持つので、新規挿入は B-tree の右端に集中する。

2026 年現在、グリーンフィールドのプロジェクトでは UUIDv7 がデフォルトに近い。RFC として標準化され、Postgres 18 のネイティブサポートも入り、性能と分散生成の両方を取れる。ULID は人間が読みやすい文字列を残したい場合の選択肢。Snowflake は超大規模のサービスで、worker ID 管理のオペコストを払える組織向け。

一方で、auto-increment bigint が依然として正解な場面もある。単一 DB で完結する内部システム、ID を URL に露出させない管理画面、データウェアハウスの集計テーブル ── こうした文脈では性能とサイズの両方で勝るので、無理に UUID にする必要はない。

II. 実務主キー設計の判断フロー

新しいテーブルを作るときの判断順序:

  1. ID を URL や外部 API に露出するか? → Yes なら UUIDv7。No なら次へ
  2. 複数の DB / リージョンで分散生成するか? → Yes なら UUIDv7 か Snowflake。No なら次へ
  3. クライアント側で ID を生成して、サーバーに送りたいか? → Yes なら UUIDv7 か ULID。No なら次へ
  4. それ以外 → auto-increment bigint が最適

そして必ず守るべき規律:

  • 業務キーには UNIQUE 制約を張る。サロゲートを主キーにしても、メールアドレスや商品コードの一意性は DB で保証する
  • 主キー型はプロジェクト全体で統一する。一部のテーブルだけ UUID、他は bigint、という混在は外部キーの型を悩ませる
  • 連番 ID を露出しない。URL に /orders/12345 と出すと、注文数や成長率が外から推測できる。露出するなら UUIDv7
  • 主キーを変更しない。本当にやむを得ない場合は新しい列を追加して expand-contract で移行する (第9章参照)
CHAPTER SUMMARY

この章で押さえたこと

  • 思想サロゲートキーがデフォルト。自然キーは UNIQUE 制約で守る
  • 思想2026 のデフォルトは UUIDv7。bigint も用途次第で正解
  • 実務URL 露出 / 分散生成 / クライアント生成 で UUID を選ぶ
  • 実務プロジェクト内で主キー型を統一。連番を露出しない
CHAPTER FOUR

リレーションの引き方
── 1対1, 1対多, 多対多, ポリモーフィック

関係の四形態と、外部キーという規律

テーブル単体は孤独な集合にすぎない。複数のテーブルが 関係 (relationship) で結ばれて、はじめてデータベースは意味を持つ。関係の引き方には基本の四形態があり、それぞれに定石がある。

I. 思想関係の四形態

1対多 (one-to-many)。最も基本かつ最頻出。usersorders の関係 ── 一人のユーザーが複数の注文を持つ。実装は単純で、多側 (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 拡張やクロージャテーブルも検討する。

Figure 2 ── Four Relationship Patterns

リレーションの四形態

PATTERN 01 1対1 users id, email 1 ── 1 profiles user_id, bio 統合 or UNIQUE 外部キー PATTERN 02 1対多 users id orders #1 orders #2 orders #3 多側に外部キー (user_id) PATTERN 03 多対多 users user_roles user_id, role_id roles 中間テーブル経由 PATTERN 04 自己参照 categories id, parent_id parent_id を同じテーブルに

関係の四形態。外部キーをどこに置くか、中間テーブルが要るか、自己参照になるか ── ほぼ全てのスキーマは、これら四つの組み合わせで表現できる。

I. 思想ポリモーフィック関連の罠

Rails や Django でよく見るパターンに ポリモーフィック関連がある。comments テーブルが article にも video にもコメントできるように、commentable_idcommentable_type という二つのカラムで多態を実装する。

これは 関係モデルに対する敗北だ、と Codd 派は言う。なぜなら commentable_id は単独では外部キー制約を張れない。型ごとにテーブルが違うから、DB レベルで参照整合性を強制できない。データ整合性は完全にアプリケーション任せになる。

解決策はいくつかある。(a) ポリモーフィック側を諦めて、article_commentsvideo_comments に分ける。(b) 共通の親テーブル commentables を作り、article と video がその一種であるようにする (Class Table Inheritance)。(c) どうしても必要なら、アプリ層に整合性チェックを書く。最初の選択肢が最もシンプルで安全だ。

補 ── 「外部キーは制約か、参照か」 外部キー制約は、参照先の行が存在することを DB が保証する仕組みだ。これは性能と引き換えに整合性を買う取引で、本番では 必ず張るのが標準。「外部キーは性能のために外す」という古い助言は、現代の Postgres ではほぼ当てはまらない。整合性が壊れた本番データを修復するコストは、外部キーチェックのコストより何桁も大きい。

II. 実務ON DELETE / ON UPDATE の判断

外部キーを張るとき、削除時の挙動を必ず指定する。デフォルト (NO ACTION) では削除がエラーになるので、意図を明示するのが習慣。

挙動意味典型的な用途
CASCADE親が消えたら子も消える所有関係 (post → comments)
SET NULL親が消えたら子の FK は NULL緩やかな関係 (author → posts、著者削除でも記事は残す)
RESTRICT / NO ACTION子があるなら親を消せない会計データ、注文履歴 (消してはいけない)
SET DEFAULT親が消えたら子の FK は DEFAULT 値稀。「未割当」を表すデフォルト行がある場合

判断軸は「子の存在意義は親に依存しているか」だ。コメントは記事なしでは意味がないので CASCADE。注文は注文者のアカウント削除後も残る (会計義務がある) ので RESTRICT か SET NULL。これを設計時に決めておかないと、後から GDPR 対応で「ユーザー削除して」と言われた瞬間に詰む。

CHAPTER SUMMARY

この章で押さえたこと

  • 思想関係の四形態 ── 1対多 / 1対1 / 多対多 / 自己参照
  • 思想ポリモーフィック関連は関係モデルへの敗北。可能なら避ける
  • 実務外部キーは本番でも必ず張る。整合性 > 性能
  • 実務ON DELETE は「子の存在意義は親に依存するか」で決める
CHAPTER FIVE

インデックスの内部
── B-tree, GIN, GIST, BRIN

なぜ B-tree がデフォルトで、いつそれ以外を選ぶか

インデックスは「クエリを速くする魔法」ではなく、データ構造の選択だ。何を速くしたいかを宣言し、その代わりに書き込みコストと容量を払う。トレードオフを理解せずインデックスを撒くと、書き込みが遅くなり、ストレージが膨らみ、プランナが迷う。

I. 思想なぜ 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 の中のキーを持つか」── つまり、カラムが複合構造を持っていて、その内部を検索したい場合だ。ここから他のインデックス型の出番になる。

I. 思想四つの主要インデックス型

得意典型的な用途
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 以下になることも) かわりに、データの物理順が検索キーと相関している必要がある。ログテーブルやイベントテーブルのように、ほぼ時系列順に挿入されるデータで威力を発揮する。

I. 思想部分・関数・複合インデックス

もうひとつ、インデックスを賢く絞るテクニックがある。

部分インデックス ── 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 単独には効かない。

II. 実務インデックス設計の流儀

本番でインデックスを足すときの実践原則:

  1. 主キーと外部キーには必ずインデックス。主キーは自動、外部キーは Postgres では自動で作られないので明示的に張る (JOIN 性能の生命線)
  2. WHERE / ORDER BY / JOIN で頻繁に使う列に張る。SELECT で出す列のためには張らない (= covered index は別の話)
  3. EXPLAIN ANALYZE で「効いている」ことを確認する。張っただけでは安心しない。プランナが使わない場合もある
  4. 書き込み多いテーブルではインデックス数を絞る。各 INSERT/UPDATE が全インデックスを更新するため、書き込み性能は線形に劣化する
  5. 不要インデックスを定期的に削除する。pg_stat_user_indexes で利用回数を確認できる
  6. 本番では CREATE INDEX CONCURRENTLY を使う。テーブルロックを取らずに作成できる

そして避けるべきアンチパターン:

  • とりあえず全カラムにインデックス。書き込みが死ぬ。インデックスは投資、無料ではない
  • SELECT 結果に含めたい列のためにインデックスを張る。それはインデックスの仕事ではない (例外: covering index / INCLUDE 句)
  • クエリを書く前にインデックスを設計する。クエリのパターンが見えていないと、適切なインデックスは決まらない
CHAPTER SUMMARY

この章で押さえたこと

  • 思想B-tree は等価・範囲・並び替えに最適。迷ったらこれ
  • 思想JSONB/配列/全文検索は GIN、地理は GIST、巨大時系列は BRIN
  • 実務主キー・外部キーには必ず、それ以外は WHERE/JOIN/ORDER BY で必要なものだけ
  • 実務本番では CONCURRENTLY。EXPLAIN ANALYZE で効いていることを確認
CHAPTER SIX

トランザクションと隔離
── ACID の現実

隔離レベルを知らないと、本番で必ず牙を剥く

トランザクションは銀行口座の振込のように、複数の処理を「全部成功するか全部やめるか」のひとつの単位にまとめる仕組みだ。これを支えるのが ACID 四つの性質 ── 原子性 (Atomicity)、整合性 (Consistency)、隔離性 (Isolation)、永続性 (Durability)。

I. 思想ACID の四つ

  • A (Atomicity 原子性) ── トランザクション内の全操作は「全部成功」か「全部失敗」のどちらか。中途半端はない
  • C (Consistency 整合性) ── 制約 (NOT NULL, UNIQUE, FK, CHECK) を守った状態から守った状態に遷移する
  • I (Isolation 隔離性) ── 同時に走る他のトランザクションから影響を受けない (=直列に走ったかのように見える)
  • D (Durability 永続性) ── COMMIT 後はクラッシュしてもデータは残る (WAL で実現)

このうち I (Isolation) が最も誤解されやすく、最も実害が大きい。完全な隔離は性能を犠牲にするので、SQL 標準は四段階の隔離レベルを定義した。レベルを下げると性能は上がるが、特定の現象 (anomaly) が観測されうる。

I. 思想四つの隔離レベルと現象

レベルDirty ReadNon-Repeatable ReadPhantom ReadSerialization Anomaly
Read Uncommitted起きる起きる起きる起きる
Read Committed防止起きる起きる起きる
Repeatable Read防止防止(Postgresでは防止)起きる
Serializable防止防止防止防止

用語の意味は次の通り:

  • Dirty Read: 他トランザクションの未コミットの値が見える (常に防止すべき)
  • Non-Repeatable Read: 同じ行を二度読むと違う値が返る (間に他がコミットした)
  • Phantom Read: 同じ条件で行を取得すると、二回目に新しい行が現れる
  • Serialization Anomaly: 複数トランザクションの並行実行結果が、どんな直列実行とも違う
補 ── Postgres は Read Uncommitted を持たない SQL 標準は四段階を定義しているが、Postgres は実装上 Read Uncommitted を持たない (要求しても Read Committed として扱う)。MVCC で実装しているため、未コミットの値を見せるほうが逆に面倒だからだ。実用的に意識すべき選択肢は三つ ── Read Committed (デフォルト)、Repeatable Read、Serializable。

I. 思想Postgres のデフォルトは Read Committed

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 では防げない。

II. 実務隔離レベルの選び方と対策

実務での選択肢は三つ。

(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) という賢いアルゴリズムで、ロックを増やさずに整合性を保つ。ただし衝突時のエラー率が上がるので、リトライロジックがアプリ側で書かれている前提が必要。

KEY CONCEPT ── リトライは設計の一部

Repeatable Read か Serializable を使うなら、SQLSTATE 40001 (Serialization Failure) が返ったとき、アプリ側でトランザクションを最初からやり直す実装が必須。これは性能のための妥協ではなく、正しさのための前提

もうひとつ覚えておくべきは、隔離レベルは そのトランザクション内でのみ有効ということ。クライアント間で値を共有したいなら、advisory lock や Redis などの DB 外の調停が要る。

CHAPTER SUMMARY

この章で押さえたこと

  • 思想ACID のうち I (隔離) が最も実害を生む
  • 思想Postgres のデフォルトは Read Committed。三段階を意識的に選ぶ
  • 実務多くは Read Committed + SELECT FOR UPDATE で十分
  • 実務Repeatable Read / Serializable を使うならリトライ実装が必須
CHAPTER SEVEN

MVCC と並行制御
── ロックを持たない並行性

PostgreSQL がロックを取らずに整合性を保つ仕組み

第6章で見た隔離レベルを、Postgres はどう実装しているのか。答えが MVCC (Multi-Version Concurrency Control)。多版同時実行制御 ── 行を更新するとき、古いバージョンを残したまま新しいバージョンを書く、という発想だ。

I. 思想MVCC の動作原理

従来のロックベースの並行制御では、トランザクションが行を読むあいだ他の書き込みをブロックし、書くあいだ他の読み込みをブロックする。これは確実だが、競合が増えるほど性能が劣化する。

MVCC は別の発想を取る。すべての行に「いつ作られたか (xmin)」「いつ消されたか (xmax)」のトランザクション ID を持たせる。読み手は スナップショットを取り、その時点で「自分にとって有効だった行」だけを見る。書き手は古い行を残したまま新しい行を作る。読み手と書き手はブロックし合わない

PostgreSQL 公式ドキュメント "The main advantage of using the MVCC model of concurrency control rather than locking is that locks acquired for querying (reading) data do not conflict with locks acquired for writing data, and so reading never blocks writing and writing never blocks reading." PostgreSQL Docs ── Concurrency Control

これが Postgres の並行性能を支える土台で、隔離レベルもこの上に実装されている。Read Committed は「ステートメント開始時のスナップショット」を、Repeatable Read は「トランザクション開始時のスナップショット」を取る ── 同じ MVCC の仕組みを、スナップショットの取り方で使い分けているだけだ。

Figure 3 ── MVCC in Action

MVCC の動作 ── 古い行と新しい行が共存する

time T1 (reader, xid=11) BEGIN SELECT → v1 SELECT → v1 (再) COMMIT T2 (writer, xid=12) BEGIN UPDATE → v2 COMMIT v1 (xmin=5, alive) v1 (xmax=12, T2以降からは見えない) v2 (xmin=12, alive) T1 のスナップショットは xid=11 時点 ── T2 (xid=12) のコミット後も v1 を見続ける

同じ行の二つのバージョン (v1, v2) が同居する。T1 は自分のスナップショット時点で有効だった v1 を見続け、T2 が作った v2 は見ない。読み手と書き手はブロックし合わない。

I. 思想VACUUM ── 削除された行は誰が消すか

MVCC には代償がある。古い行を残し続けるとテーブルが肥大化するので、誰かが 不要になった古い行を回収する仕事をしなければならない。これが VACUUMだ。

Postgres は autovacuum がバックグラウンドで自動的に走り、参照されなくなった行を回収する。普段は意識しなくていいが、大量更新・大量削除があるテーブルでは、autovacuum の設定 (autovacuum_vacuum_scale_factor など) を調整することがある。

関連する罠が long-running transaction。一つの長いトランザクションが居座ると、その時点のスナップショットを保持するために、その後の更新で作られた古い行を VACUUM できなくなる。テーブルがどんどん膨らみ、I/O が劣化する。長時間 BEGIN したまま放置するトランザクションは害悪と覚えておく。

I. 思想楽観 vs 悲観 ── どちらでロックするか

並行性の戦略は大きく二つ。

悲観的ロック (pessimistic) ── 「衝突する前提でロックを先に取る」。SELECT ... FOR UPDATE で行ロックを獲得し、トランザクション完了まで保持する。確実だが、長く保持すると他を待たせる。

楽観的ロック (optimistic) ── 「衝突しない前提で進め、最後に確認する」。version 列やタイムスタンプを使い、UPDATE 時に「自分が読んだバージョンと同じか」をチェックする。違えばエラーにしてリトライ。並行性が高い場面で性能が出る。Repeatable Read / Serializable の挙動は、内部的にこれに近い。

どちらを選ぶかは 衝突頻度で決める。衝突が多いと予測される (たくさんの人が同じ在庫を奪い合う) なら悲観的。衝突が稀 (同じ行を二人が同時に編集するのが稀) なら楽観的のほうが速い。

II. 実務デッドロックを防ぐパターン

悲観的ロックの最大の敵は デッドロックだ。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 で済まないかを考える。

CHAPTER SUMMARY

この章で押さえたこと

  • 思想MVCC ── 古い行を残して新しい行を書く。読み書きはブロックし合わない
  • 思想long-running transaction は VACUUM を妨げる害悪
  • 実務衝突多い → 悲観 (FOR UPDATE) / 衝突稀 → 楽観 (version 列)
  • 実務デッドロック防止は「常に同じ順序でロック」。SKIP LOCKED はジョブキューに最適
CHAPTER EIGHT

クエリプランナと最適化
── EXPLAIN を読む

プランナはコスト推定で動く。読み方が分かれば味方になる

SQL は 宣言的な言語だ。「何が欲しいか」を書き、「どう取るか」はプランナが決める。プランナは複数の取り方 (実行計画) を生成し、それぞれにコストを見積もり、最も安いものを選んで実行する。クエリのチューニングとは、プランナにより良い計画を選ばせる作業であり、SQL を物理的に書き換える作業ではない。

I. 思想プランナのコストモデル

Postgres のプランナはコスト計算機だ。テーブルサイズの推定、インデックスの選択肢、結合戦略、ソートの必要性、利用可能なメモリ (work_mem) などを入力に、各実行計画の抽象的なコスト値を弾き出す。コストの単位は時間ではなく、シーケンシャル I/O 1 ページぶんを 1.0 とした相対値。

この見積もりは 統計情報に依存する。ANALYZE コマンドや autovacuum がテーブルから定期的にサンプリングし、pg_statistic に保存する。統計が古いと、プランナの判断は外れる。「最近大量に INSERT したテーブルが急に遅くなった」── 多くの場合、犯人は古い統計情報だ。

I. 思想三つの結合戦略

JOIN の実装は三種類しかない。プランナはこの三つから一つを選ぶ。

戦略仕組み得意な状況
Nested Loop外側の各行に対して内側を引く外側が小さく、内側にインデックスがある
Hash Join小さい側でハッシュテーブルを作り、大きい側で引く両方が大きく、等価結合
Merge Join両方をソートして並行スキャン両方が大きく、すでにソート済み
Figure 4 ── Three Join Strategies

三つの結合戦略

STRATEGY 01 Nested Loop A (小) B (大) + index 外側を走査 → 内側を引く 外側が小・内側にindex で速い STRATEGY 02 Hash Join A (小) build Hash Table in mem probe B (大) 小さい側でハッシュを作る work_mem に収まる範囲で最速 STRATEGY 03 Merge Join A sorted 1 2 3 B sorted 1 3 5 両側ソート済み・並行スキャン 既にソート済みのときに勝つ

JOIN の実装は三戦略しかない。プランナはデータサイズとインデックスから最適なものを選ぶ。エンジニアの仕事は「プランナが正しく選べる状況を整える」こと。

「どれが速い」という普遍的な答えはなく、データサイズとインデックスの組み合わせで最適解が変わる。プランナがこの選択をするので、エンジニアは プランナが正しく選べる状況を整えるのが仕事。具体的には、適切なインデックスを張り、統計情報を新鮮に保ち、サブクエリの構造を素直に書く。

II. 実務EXPLAIN ANALYZE を読む

遅いクエリに出会ったら、まず 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;

読み方の急所:

  • 木構造で読む。下から上へ、内側から外側へ。インデント深いほど内側
  • cost と actual time の乖離を見る。推定 100 行で実測 1,000,000 行なら、統計情報が腐っている
  • Seq Scan を疑う。大きいテーブルの全スキャンは、たいていインデックスが足りていない
  • Filter で大量を捨てているなら、その条件をインデックスに含める
  • Rows Removed by Filter の数字が大きいと、無駄な行を取って捨てているサイン

II. 実務N+1 を見つけて潰す

ORM が普及した世界で最頻出のアンチパターンが N+1 クエリだ。「ユーザー一覧を取得 (1 クエリ)」「各ユーザーの注文数を取得 (N クエリ)」── 合計 N+1 クエリ走る。一見正しく動くが、ユーザーが千人いれば千一回 DB を叩く。

検出と対策:

  • ログを見る。同じ形のクエリが連続して大量に出ていたら N+1
  • ORM のクエリログを開発時に標準で出す。Rails の bullet、Django の django-debug-toolbar、Prisma の logger。N+1 検出ツールを CI に入れる手もある
  • JOIN か IN(...) で一括取得。Rails なら includes、Django なら select_related / prefetch_related
  • そもそも一発の SQL で書けないか考える。集計なら GROUP BY、ランキングなら window 関数

II. 実務work_mem と一時ファイル

ハッシュ結合やソートが work_mem に収まらないと、ディスク上の一時ファイルにあふれる。これが起きると劇的に遅くなる。EXPLAIN で external merge Disk: 256000kB のような表示があれば、それは赤旗だ。

対策は二つ。(a) work_mem を上げる (全体だと危険なのでセッション単位で SET work_mem)。(b) ソート対象を絞る (WHERE で先に削る、LIMIT を効かせる)。

KEY CONCEPT

遅い SQL の犯人は、ほぼ常にプランナの選択ではなく、人間の側にある。足りないインデックス、腐った統計、必要以上に広い JOIN、ORM の N+1 ── プランナを責める前に、これらを順に潰す。EXPLAIN ANALYZE が読めれば、99% の遅さは自分で解決できる。

CHAPTER SUMMARY

この章で押さえたこと

  • 思想プランナはコスト推定で動く。統計情報が燃料
  • 思想JOIN は三戦略 (nested loop / hash / merge) しかない
  • 実務遅いクエリは EXPLAIN ANALYZE。cost vs actual の乖離・Seq Scan・Filter 捨てを疑う
  • 実務N+1 を CI で検出する。work_mem 不足は一時ファイル落ちで分かる
CHAPTER NINE

スキーマ進化
── expand-contract で本番を生かす

ゼロダウンタイムは祈りではなく、手順

スキーマ設計の最後の壁は、本番で動いているテーブルをどう変えるかだ。テーブルが空っぽなら何でもできる。1,000 万行入って、24 時間トラフィックが来ているテーブルでは、軽率な ALTER 一発でサービスが止まる。

I. 思想parallel change の原則

ゼロダウンタイムのスキーマ変更の中核は expand-contract (parallel change とも呼ばれる) というパターンだ。考え方はこう ── 古い形と新しい形を一時的に共存させ、段階的に移行する

  1. Expand (拡張) ── 新しい構造を追加する。古い構造はそのまま残す
  2. Backfill (埋め込み) ── 古いデータを新しい構造に複写する。バッチで分割実行
  3. Switch (切替) ── アプリの書き込み・読み込みを新しい構造に向ける
  4. Contract (収縮) ── 古い構造への参照がなくなったことを確認してから削除

この四段階はそれぞれ独立したデプロイで進める。各段階で「アプリの旧バージョンも新バージョンも動く」という後方互換性を保つ。これが祈りではなく手順としてのゼロダウンタイムだ。

Figure 5 ── Expand-Contract Timeline

expand-contract の四フェーズ

PHASE 01 EXPAND PHASE 02 BACKFILL PHASE 03 SWITCH PHASE 04 CONTRACT old col full data full data unused (まだ存在) DROP new col empty (ADD COLUMN) filling ↓ full + reads final state old app: reads old col new app: reads new col ↑ 旧/新両アプリが並走する重なり (= ゼロダウンタイム) 各フェーズは独立したデプロイ。後方互換を保ったまま、段階的に切り替える

同じスキーマ変更を「拡張 → 埋め込み → 切替 → 収縮」の四段階に分け、各段階で旧/新両方のアプリが動くようにする。これがゼロダウンタイムの実装パターン。

Xata 公式ブログ "The expand-contract pattern tackles these challenges by splitting schema changes into two phases: Expand and Contract. This pattern avoids downtime and allows old and new versions of your application to coexist while the migration is underway." Schema changes and the power of expand-contract

I. 思想危険な操作と安全な操作

Postgres の DDL は、内部でテーブルロックを取るものとそうでないものがある。これを知らないと事故る。

操作本番安全性備考
列を追加 (DEFAULT なし)安全Postgres 11+ で全行書き換え不要
列を追加 (DEFAULT あり)条件付き安全静的 DEFAULT は安全、動的 (now() 等) は全行書き換え
NOT NULL 追加 (新列)注意既存値が NULL の場合エラー
NOT NULL 追加 (既存列)注意制約検証中、テーブル全体をスキャン
列名変更条件付き瞬時だがアプリが旧名を参照していると壊れる
列の型変更危険多くの場合、全行書き換え + 長時間ロック
列を削除瞬時論理的削除のみ。物理回収は VACUUM
インデックス作成要 CONCURRENTLY普通の CREATE INDEX はテーブル書き込みをブロック

II. 実務列名変更を expand-contract で

具体例として「users.usernameusers.handle にリネームしたい」を見る。素朴に ALTER TABLE users RENAME COLUMN username TO handle; は一見動くが、瞬時にアプリの旧バージョン (まだ username を参照している) が全滅する。

expand-contract での手順:

  1. Expand ── handle 列を追加 (NULL 許容)、必要ならトリガーで username と双方向同期
  2. Backfill ── UPDATE users SET handle = username をバッチで実行 (1000 行ずつなど)
  3. Deploy (アプリ) ── アプリが handle を読み書きするように切替。書き込みは両方に書く期間を設ける
  4. Validate ── 旧 username 列への参照がコードベースとログから消えたことを確認
  5. Contract ── username 列を削除

面倒だが、これがゼロダウンタイム本番運用の標準フォーマット。慣れると数日〜数週間の作業として淡々と回せる。

II. 実務ツールに頼る

expand-contract を毎回手で組むのは大変なので、ツールが助けてくれる。

  • pgroll (Xata 社) ── スキーマを「ビュー」として複数バージョン共存させる。アプリは SET search_path で旧/新を切り替えるだけ
  • Atlas ── 宣言的スキーマ管理、デストラクティブ変更の警告
  • gh-ost / pt-online-schema-change ── MySQL 系のオンライン DDL ツール
  • Django / Rails / Prisma のマイグレーション ── 基本機能は揃っているが、本番ゼロダウンタイムは追加配慮が必要 (CI で警告を出すなど)

そして組織のルールとして決めるべきこと:

  • マイグレーションファイルは PR で必ずレビュー。誰かが見て「これ ALTER TYPE じゃない?」と気付ける形を保つ
  • 本番に流すマイグレーションは、staging で同等データに対して走らせる。所要時間とロック範囲を測る
  • 長時間かかる UPDATE はバッチで。一度に全行を更新しない。ロックタイムアウトを設定する
  • ロールバック手順を書いてから流す。「これは戻せない」と分かったら、戻せる形に分割し直す
CHAPTER SUMMARY

この章で押さえたこと

  • 思想ゼロダウンタイムの中核は expand-contract (parallel change)
  • 思想各段階で旧/新両方のアプリが動くよう後方互換を保つ
  • 実務Postgres の DDL には危険な操作と安全な操作がある。事前に表で確認
  • 実務マイグレーションは PR レビュー必須、staging で計測、ロールバック手順をセットで
CHAPTER TEN

マルチテナント設計
── テナント分離の三つのパターン

SaaS を作るなら避けて通れない、最初に決めるべき構造

SaaS を作るなら、避けて通れないのが 複数の顧客 (テナント) のデータをどう同居させ、どう分離するかという設計だ。「最初から考えるべきか、後で導入できるか」── 答えはほぼ常に、最初から。後から足すのは expand-contract の長い旅になり、組織にとって最も高くつくマイグレーションの一つになる。

I. 思想三つのパターン

テナント分離の実装は、大きく分けて三つしかない。論理的に分けるか、Postgres スキーマで分けるか、データベースごと分けるか。それぞれにコスト、隔離度、運用負荷のトレードオフがある。

パターン分離単位隔離度運用コスト典型的な向き先
Shared Schema + RLS行 (tenant_id)論理的多数の中小テナント (B2C SaaS, 早期 B2B)
Schema per TenantPostgres スキーマ名前空間レベル中規模テナント、カスタマイズあり
Database per Tenantデータベース物理的規制業界 (医療、金融)、エンタープライズ
Figure 6 ── Three Multitenancy Patterns

マルチテナント分離の三パターン

PATTERN 01 Shared Schema + Row-Level Security database schema: public users tenant_id, ... T1 T2 T3 T1 行で分離 (tenant_id + RLS) PATTERN 02 Schema per Tenant database tenant_a users orders tenant_b users orders tenant_c users orders スキーマで分離 (namespace) PATTERN 03 Database per Tenant db_tenant_a users orders db_tenant_b users orders db_tenant_c users orders DBレベルで物理分離 隔離度 / 運用コスト ── 必要なだけ右へ進む

三つのパターンは隔離度と運用コストのトレードオフ。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 など規制要件で「データが他組織と物理的に混在してはならない」と書かれている場合は、これが唯一の選択肢になる。引き換えに運用コストが最大で、テナント間横断の集計はほぼ不可能になる (アプリ層で集計するしかない)。

KEY CONCEPT

Shared Schema + RLS から始めて、必要に応じて分離していくのが現代の作法。最初から Database per Tenant にすると、運用コストでスタートアップが潰れる。逆に、ホットなテナントだけ後から Schema や Database に切り出す段階的な進化は実装可能で、それが現代の B2B SaaS でよく見られる構成。

II. 実務tenant_id を全テーブルに最初から

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 = ? を持つので、複合インデックスの最左に置く

II. 実務Row-Level Security の設定

アプリ層の 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 スターターキットの多くがこの設計を採用している。

補 ── RLS と性能 RLS ポリシーは、内部的にクエリの WHERE 条件に自動追加される形で動く。簡単な等価条件 (tenant_id = ?) なら性能影響はほぼゼロだが、ポリシー内で副問い合わせを書くと劇的に重くなる。RLS は「シンプルな等価フィルタ」で使い、複雑な権限ロジックはアプリ層で書く ── これが安全な使い方。

II. 実務段階的な進化 ── ホットなテナントを分離する

B2B SaaS では、トップ 10% のテナントが負荷の 80% を占めるパターンが多い (パレートの法則)。Shared Schema で全テナントを抱え続けると、巨大テナントの重いクエリが小さなテナントのレスポンスを押し下げる ── 「ノイジーネイバー (noisy neighbor) 問題」だ。

このとき採る手は二つ。

  1. ホットなテナントを Schema per Tenant に切り出す。該当テナントだけ tenant_xyz スキーマにテーブルを複製し、アプリが search_path で切替えるようにする。残り全テナントは Shared Schema のまま
  2. ホットなテナントを Database per Tenant に切り出す。別 RDS / 別 Aurora インスタンスに移して、リソースを物理的に分ける。コネクション文字列をテナント ID で切替

どちらも、最初から tenant_id を仕込んでおいたからこそ可能な引き出しだ。テナント単位でデータを切り出す SQL が一発で書ける。これが「最初から tenant_id」の真価。

II. 実務パターン選択の判断軸

条件推奨
テナント数 1,000 以上が見込まれるShared Schema + RLS
テナント数 数十〜数百、テナントごとにカスタマイズがあるSchema per Tenant
医療・金融など物理隔離が規制で要求されるDatabase per Tenant
テナント横断の集計・分析が頻繁にあるShared Schema + RLS
大口テナントが負荷の大半を占めているShared が基本、ホットだけ Schema/DB に分離
「とにかく最速で動かしたい」Shared Schema + RLS

II. 実務避けるべき罠

  • tenant_id を「後から追加」する。全テーブルへの ALTER、全行のバックフィル、全外部キーの再設計、RLS 設定 ── 数ヶ月の総工事になる。最初から仕込んでおけば回避できる
  • アプリ層の WHERE tenant_id = ? だけに頼る。一箇所の書き忘れがテナント間漏洩を引き起こす。RLS で DB が保証する形に
  • 「テナント情報」を共有テーブルに残すのを忘れる。Schema per Tenant に移行しても、テナント自体のメタ情報 (会社名、契約状態など) は共有スキーマに残す。これは多くの設計者がはまる
  • RLS ポリシーをテストしない。「ポリシーがあるから安全」と思い込まず、別テナントを装ったクエリが本当に弾かれることを CI でテスト
  • テナント増加で破綻するキーを使う。テナントごとに SERIAL を独立採番したくなる誘惑があるが、Shared Schema ではグローバルなサロゲートキー (UUIDv7) が正解
CHAPTER SUMMARY

この章で押さえたこと

  • 思想三パターン: Shared+RLS / Schema per Tenant / Database per Tenant
  • 思想Shared+RLS から始めて、必要に応じて段階的に分離していく
  • 実務tenant_id UUID NOT NULL を全業務テーブルに最初から
  • 実務RLS でテナント分離を DB が保証する形に。アプリ層の WHERE は補助
  • 実務ホットテナントの分離は、tenant_id を持っているからこそ可能
CHAPTER ELEVEN

拡張性
── パーティション、レプリケーション、Postgres の広がり

単一インスタンスを越えて、PostgreSQL がどこまで伸びるか

テーブルが 10 億行になっても、トラフィックが秒間 10 万リクエストになっても、Postgres は伸ばし方を用意している。順序は決まっていて、(1) インデックスとクエリの最適化 → (2) 読み取りレプリカ → (3) パーティショニング → (4) シャーディング。この順で必要なだけ取り組む。一足飛びにシャーディングに行くのは典型的な over-engineering。

I. 思想パーティショニング ── 一つのテーブルを物理的に分ける

パーティショニングは、論理的には一つのテーブルを、物理的には複数の子テーブルに分割する仕組み。Postgres 11 以降の宣言的パーティショニング (declarative partitioning) を使うのが標準。クエリは親テーブルに対して書き、Postgres が適切な子テーブルにルーティングする (partition pruning)。

戦略分割キー典型的な用途
Range範囲 (日付など)時系列データ。月次・日次の分割
Listカテゴリ値の集合地域、テナント、ステータス
Hashキーのハッシュ値均等分散したい、特定の鍵を持たない

最大の恩恵は二つ。(a) クエリの絞り込み ── WHERE created_at > '2026-01-01' が、その期間の子テーブルだけスキャンすればよくなる。(b) 古いデータの安価な削除 ── 子テーブルを DETACH して落とすだけ。DELETE でなく構造として消せる。

注意点もある。子テーブルの数が増えすぎると、プランナのオーバーヘッドが効いてくる。数十〜数百の子テーブルが目安。pg_partman 拡張で自動管理するのが現代の作法。

I. 思想レプリケーションとシャーディング

レプリケーションは同じデータを別インスタンスに複製する。Postgres の物理レプリケーション (streaming replication) が標準で、読み取り専用の standby を複数立てて読み取りを分散する。書き込みは依然として primary 一台に集中する点に注意。

シャーディングは別物で、データを水平に分割して別インスタンスに置く。書き込みも分散できる代わりに、複雑性が桁違いに上がる。トランザクションの跨ぎ、JOIN の分散実行、再シャーディングの困難さ ── 簡単に「最後の手段」と言いたくなる重さがある。

シャーディング層を提供する Postgres エコシステム:

  • Citus ── Postgres 拡張として動く分散シャーディング。同じ Postgres SQL がそのまま動く
  • YugabyteDB / CockroachDB ── Postgres 互換だが内部は分散ストレージ。シャードを意識せず使える
  • アプリ層シャーディング ── アプリでテナントごとに DB を分けるなど。シンプルだが運用負荷

I. 思想Postgres を「ほぼ唯一の正解」にする拡張モジュール

Postgres の真の強さは、拡張モジュールで 多様なデータモデルを関係型の上に乗せられること。「Postgres があれば他の DB はあまり要らない」と言われる根拠だ。

拡張提供代替を考えるべき DB
JSONB (組込)スキーマレスな文書ストアMongoDB
pg_trgm (組込)類似度・あいまい検索Elasticsearch (軽い用途)
tsvector / tsquery (組込)多言語全文検索Elasticsearch (中規模)
pgvectorベクトル検索 (RAG, 類似度)Pinecone, Weaviate
PostGIS地理空間データと演算専用地理 DB
TimescaleDB時系列向け最適化InfluxDB
pg_partmanパーティション自動管理(なし)
補 ── Vol.I との接続 Vol.I 第5章は「PostgreSQL はほぼ唯一の正解」という強い主張をした。その理由の半分は拡張モジュールの広さにある。LLM の RAG パイプラインで pgvector を使い、地理アプリで PostGIS を使い、ログ分析で TimescaleDB を使い、ファクトテーブルで Citus を使う ── すべて同じ Postgres SQL で書ける。スタックがシンプルでなくなるが、運用する DB の数は減る。

II. 実務スケールへの段階的アプローチ

「将来スケールするから」と言って最初からシャーディングするのは、よくある自滅パターン。実務的な段階:

  1. ステージ0 (〜数十万行): 単一インスタンス + 適切なインデックス。何も足さない
  2. ステージ1 (〜数億行): インデックス見直し、N+1 撲滅、不要列の削除。VACUUM 設定の調整
  3. ステージ2 (〜数十億行): 読み取り専用 replica で読み取りを分散。pgbouncer で接続プール
  4. ステージ3 (〜数百億行): パーティショニング (時系列なら Range が最も効く)
  5. ステージ4 (それ以上): Citus 等のシャーディング、もしくは分散 SQL DB (YugabyteDB / CockroachDB) への移行

多くのプロダクトはステージ1〜2 で十分に走り続ける。「シャーディングが必要だ」と気付くのは、それ自体が組織のサイズを示すマイルストーンで、その時には専任の DBA がいる規模になっているはずだ。設計時の判断は、未来のパーティション化を妨げない形にしておくこと。たとえばパーティションキーになりうる created_attenant_id を主要テーブルに必ず持たせる ── これだけで、将来パーティション化が必要になったときの工数が大幅に減る。

CHAPTER SUMMARY

この章で押さえたこと

  • 思想スケール順序 ── インデックス → replica → パーティション → シャーディング
  • 思想Postgres 拡張 (pgvector / PostGIS / TimescaleDB) で多くの専門 DB を代替できる
  • 実務多くのプロダクトはステージ 1〜2 で完走する
  • 実務設計時に「将来パーティション可能か」を意識する (パーティションキー候補を持たせる)
EPILOGUE

設計判断の集積として

スキーマは、後から書く長い手紙

十章を辿ってきた。関係モデルの原典正規化と非正規化主キー設計リレーションと外部キーインデックスの内部ACID と隔離レベルMVCC と並行制御クエリ最適化expand-contract のスキーマ進化パーティションと Postgres の広がり

これらの章はバラバラに見えて、ひとつの姿勢で繋がっている ── スキーマは長期意思決定の集積であるという認識だ。一つひとつの主キー選択、一つひとつの外部キー、一つひとつのインデックスは、それぞれは小さな判断に見える。でも、合わせると組織が次の十年に渡って使う「契約書」になる。

だから本書の章は、どれも「後から書く長い手紙」のような重みを持つ判断だった。今日 UUIDv7 を選んだあなたが、五年後の自分にどう読まれるか。今日 ON DELETE CASCADE を書いたあなたが、十年後の GDPR 対応でどう感謝されるか、あるいは恨まれるか。

同時に、本書はもう一つの主張を裏に持っていた ── 関係モデルは、いまも (そしてこれからも) 最も汎用的なデータの組織法だ。NoSQL の波、AI 生成の波、ORM 万能の風潮が来ても、テーブル・行・関係という三つの基本概念は揺らがない。むしろ、それらを十分に理解していないと、上に乗っかった抽象を使いこなせない。

テーブルは消えない。
あなたが書く SQL も、十年後の誰かが読んでいる。

もし本書を読み終えて、自分のリポジトリのスキーマファイルを開き直したくなったなら、それがこの本の目的だ。関係を引き直すのはいつでもできる。expand-contract という手順がある。

あなたのスキーマが、長く生き残りますように。

REFERENCES

出典一覧

本書がよりかかった、関係モデルと Postgres の一次資料

本書は PostgreSQL 18 (2025年9月リリース) の挙動に準拠しています。MySQL / MariaDB / SQL Server の独自挙動については本書では触れていません。