はじめに:「スキーマレスの甘い罠」に落ちた現場の末路
「アジャイルに素早く新機能をリリースしたい」「フロントエンドの要件変更が激しいから、カラム定義に縛られたくない」「PostgreSQLには強力なJSONB型があるから、テーブル設計に悩む時間は無駄だ」
近年のWebアプリケーション開発において、リレーショナルデータベースを採用していながら、実質的にテーブルを「IDと巨大なJSONB」だけで構成してしまうプロジェクトが後を絶ちません。
-- ❌ 最悪のアンチパターン:主要な属性をすべてJSONBに押し込めた設計
CREATE TABLE user_profiles (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL UNIQUE,
data JSONB NOT NULL DEFAULT '{}'::jsonb, -- プロフィール、設定、ステータス、カウンタ等を何でも放り込む
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
開発初期の数週間、この設計は驚くほど快適に機能します。マイグレーションスクリプトを一切書く必要がなく、フロントエンドから送られてきたJSONオブジェクトをそのまま UPDATE user_profiles SET data = $1 で保存できるからです。GINインデックスを貼っておけば、特定キーの検索もそこそこの速度で動作します。
しかし、サービスが成長し、レコード数が数百〜数千万行に達し、更新トランザクションが毎秒数百回流れる本番環境になった瞬間、この「快適なスキーマレス設計」は突如としてデータベースサーバーのディスク帯域とCPUを食い潰す凶悪なモンスターへと豹変します。
「マイグレーションをサボるために安易に導入されたJSONBは、将来のエンジニアに『ディスクI/O枯渇・レプリケーション遅延・VACUUM地獄』という100倍の利息を請求する。」
なぜ、一見便利に見えるJSONBの多用が、PostgreSQLの本番稼働をこれほどまでに致命的に破壊するのでしょうか。その元凶は、PostgreSQLのコアアーキテクチャである「追記型MVCC」と「TOAST(The Oversized-Attribute Storage Technique)」の物理的噛み合わせにあります。
メカニズム1:TOASTと追記型MVCCが招く「書き込み増幅(Write Amplification)」
PostgreSQLでJSONBを運用する上で、絶対に避けて通れない物理的制約が「行サイズとTOASTの境界線」です。
TOASTテーブル(The Oversized-Attribute Storage Technique)とは
PostgreSQLのデータページ(ブロック)はデフォルトで 8KB の固定長です。PostgreSQLは単一の行(タプル)を複数のデータページに跨いで直接格納することを許容しません。そのため、ヘッダ等を含めて約 2KB(TOAST_TUPLE_THRESHOLD) を超える可変長データ(JSONB、TEXT、BYTEAなど)は、裏側の別テーブルである「TOASTテーブル(pg_toast_xxx)」に自動的に退避・分割(約2KBごとのチャンク化)圧縮されて保存されます。
1バイトの更新で「数百KB」が書き換わる物理的現実
ここで思い出してほしいのが、PostgreSQLのトランザクション分離(MVCC)が「追記型(Append-only)」であるという冷酷な事実です。PostgreSQLはディスク上のデータをインプレース(その場)で上書きしません。UPDATE を実行すると、古い行を「無効(Dead Tuple)」としてマークし、新しい行のコピーを丸ごと別の領域に書き出します。
もし、10KBのJSONBを保持している行に対して、たった1つの真偽値フラグやカウンタを更新したとしましょう。
-- たった1つのキーを更新したつもり
UPDATE user_profiles
SET data = jsonb_set(data, '{is_active}', 'false'::jsonb),
updated_at = NOW()
WHERE user_id = 'e7b0a7c4-...'::uuid;
開発者の頭の中では「1バイトのフラグを変更しただけ」のつもりです。しかし、PostgreSQL内部では以下の恐るべき物理処理が実行されています:
- 既存の10KBのJSONBオブジェクト全体をメモリ上で展開(デシリアライズ)する。
- 指定されたキーの値を書き換える。
- 更新された10KBのJSONB全体を再シリアライズし、再度LZ4/pglzで圧縮する。
- TOASTテーブル内に、新しいチャンク行群(分割ブロック)を完全に新規INSERTする。
- メインテーブル上に、新しいTOASTポインタを持つ新しいバージョンのタプルを追記する。
- 大量のWAL(Write-Ahead Log)レコードを生成しディスクにフラッシュする。
これが「書き込み増幅(Write Amplification)」の正体です。アプリケーション側では数バイトの差分更新のつもりでも、ストレージ層では毎回数KB〜数十KBのブロックが生成・破棄され続けます。更新頻度の高いテーブルでこれをやれば、NVMe SSDであろうと瞬く間にI/O帯域が飽和し、AWS AuroraやCloud SQLのIOPSクォータをあっという間に蒸発させます。
メカニズム2:HOT(Heap-Only Tuple)の完全破壊とインデックス肥大化
書き込み増幅だけではありません。さらに致命的なのが、PostgreSQLが誇る最大の内部最適化機能「HOT(Heap-Only Tuple)」の完全破壊です。
HOT(Heap-Only Tuple)最適化とは何か
通常のリレーショナルテーブルにおいて、インデックスが貼られていないカラムを UPDATE した場合、新しい行が同じデータページ(8KBブロック)内の空き領域に収まるのであれば、PostgreSQLはテーブル上のインデックスを一切更新しません。
インデックスのエントリは古い行(Root Tuple)を指したまま、データページ内のポインタチェーンを辿って新しい行へジャンプさせます。この仕組みのおかげで、B-treeインデックスの更新コストをゼロに抑え、インデックスの肥大化を防ぐことができるのです。
JSONBがHOTを100%不発にする理由
しかし、JSONBカラムを含む行、とりわけTOAST領域とやり取りするようなサイズの大きい行は、元のデータページ内のわずかな空き領域に収まるはずがありません。新しいタプルは必ず「別のデータページ」に書き出されます。
行が別ページに移動した瞬間、HOT最適化は完全に不発(HOT-Safetyの喪失)となります。
-- テーブルに定義された複数のインデックス
CREATE INDEX idx_user_profiles_user_id ON user_profiles (user_id);
CREATE INDEX idx_user_profiles_updated_at ON user_profiles (updated_at);
-- ⚠️ HOTが破壊されると、JSONBを少し更新しただけで上記の「無関係なすべてのインデックス」に新しいエントリが追加される!
HOTが効かないため、PostgreSQLはその行に紐付くすべてのインデックス(主キー、ユニークキー、外部キー、日付インデックス等)に対して、新しい物理ページのアドレスを指すインデックスエントリを1つずつ追記せざるを得ません。
この結果生じるのが、以下の本番破滅コンボです:
- インデックス肥大化(Index Bloat): インデックスの物理サイズがデータ本体の数倍に膨れ上がる。
- バッファキャッシュ(shared_buffers)の汚染: 肥大化したインデックスがメモリを占拠し、本来キャッシュされるべき重要なデータがディスクへ押し出される。
- オートバキューム(Autovacuum)の飽和: 毎秒大量に生まれるDead Tupleと肥大化インデックスを掃除するため、Autovacuumワーカーが四六時中全力でディスクを読み書きし、通常クエリのパフォーマンスを巻き添えにして遅延させる。
メカニズム3:クエリプランナーの盲目化と統計情報の欠落
パフォーマンスの悪化は書き込み時だけにとどまりません。参照クエリ(SELECT)においても、JSONBはPostgreSQLの「脳」であるクエリプランナーを盲目にします。
オプティマイザはJSONBの内部を知らない
PostgreSQLが数百万行の中から最適な実行計画(Seq Scanか、Index Scanか、Bitmap Scanか)をミリ秒単位で選択できるのは、ANALYZE プロセスが収集した高度な統計情報(MCV: Most Common Values、ヒストグラム、相関度など)があるからです。
通常のリレーショナルカラム(例: status VARCHAR(32))であれば、オプティマイザは「active は全体の80%存在するが、banned はわずか0.01%しか存在しない」という事実を正確に知っています。そのため、WHERE status = 'banned' には迷わずIndex Scanを選択します。
しかし、JSONBの内部属性に対して、PostgreSQLはデフォルトで統計ヒストグラムを収集しません。
-- オプティマイザからは「何件ヒットするか」が全く見えない
SELECT * FROM user_profiles
WHERE data->>'plan' = 'enterprise';
オプティマイザにとって、data->>'plan' の中身は未知のブラックボックスです。選択度(Selectivity)を正確に計算できないプランナーは、固定の推測値(デフォルトでは全体の0.5%程度と見積もるなど)に頼らざるを得ません。
もし実際には enterprise ユーザーが100万行中50万行存在していた場合、プランナーは「ごく少数しかヒットしない」と誤認してGIN/B-treeインデックススキャンを選び、結果としてランダムディスクアクセスが大量発生してクエリが数十秒フリーズします。逆に、1件しかないのに全件シーケンシャルスキャンを選択してCPUを100%に張り付かせることすらあります。
現場の解法:アンチパターン vs 堅牢なモデリング
では、現場ではどのようにデータを設計すべきなのでしょうか。答えは明快です。「頻繁に更新・検索・結合される属性」をJSONBから徹底的に追い出すことです。
❌ アンチパターン:何でもJSONBに突っ込む「なんちゃってNoSQL」
-- ❌ すべてをJSONBに同居させた最悪の構造
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
payload JSONB NOT NULL -- { "status": "PAID", "user_id": 123, "total_amount": 5000, "items": [...], "notes": "..." }
);
注文ステータスが変わるたびに、巨大な注文明細配列(items)ごとTOASTテーブルへの書き込み増幅が発生し、ステータス検索は統計情報不足でスローダウンします。
⭕️ ベストプラクティス:更新頻度とスキーマ境界による「ハイブリッド分離」
-- ⭕️ 頻繁に触る列はリレーショナルに、不定形データのみJSONBに隔離する
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL, -- 検索・JOINキー(B-treeインデックス)
status VARCHAR(32) NOT NULL DEFAULT 'NEW', -- 頻繁にUPDATEされる列(HOT最適化が効く!)
total_amount NUMERIC(12, 2) NOT NULL, -- 集計・ソート対象
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- 不定形・外部連携の生ペイロード・更新頻度が極めて低い拡張データのみをJSONBにする
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX idx_orders_user_status ON orders (user_id, status);
この設計の真価は以下の通りです:
statusを更新する際、metadataがTOAST領域にあってもmetadata自体は変更されないため、TOASTの再書き込みは発生しません(PostgreSQLは未変更のTOASTポインタをそのまま再利用します)。- 行サイズが小さく保たれていれば、
statusの更新は同一データページ内で完結し、HOT最適化が100%発動します。無関係なインデックスの書き換えコストはゼロになります。 user_idやstatusには精緻な統計情報が収集されるため、クエリプランナーが常に最速の実行計画を選択できます。
どうしてもJSONB内のキーで検索・インデックスを貼りたい場合の救済策
どうしてもJSONBの特定フィールドを頻繁に検索条件にする必要があるなら、GINインデックスを全体に貼るのではなく、Generated Column(生成列) または 関数インデックス(Functional Index) を定義してください。
-- 特定のJSONBキーを仮想リレーショナル列として抽出し、B-treeインデックスを貼る
ALTER TABLE orders
ADD COLUMN tracking_code TEXT GENERATED ALWAYS AS (metadata->>'tracking_code') STORED;
CREATE INDEX idx_orders_tracking_code ON orders (tracking_code);
生成列を作れば、PostgreSQLの ANALYZE はその列に対して通常の統計情報(ヒストグラム)を収集できるため、オプティマイザの盲目化を完全に防ぐことができます。
まとめ:JSONBは「逃げ場」ではなく「特化ツール」である
PostgreSQLのJSONBは、正しく使えば極めて強力な機能です。だがそれは、「リレーショナルモデリングを放棄するための免罪符」ではありません。
JSONBを使って良いもの・悪いものの境界線
- JSONBが最適な用途:
- 外部サービスのWebhook受信ペイロード(Stripeのイベント生データ等、スキーマが自社都合で制御できないもの)
- 監査ログ・変更履歴の差分スナップショット(追記のみで、決してUPDATEされないデータ)
- ユーザーごとの細かなUI個人設定(ダークモードのON/OFFなど、システム全体の検索・集計に関わらないもの)
- JSONBに入れてはいけないもの:
- ビジネスロジックで頻繁に
UPDATEされるステータスやフラグ WHERE条件で頻繁にフィルタリング・範囲検索されるキー- 他テーブルとの結合(JOIN)キーや外部キー参照
SUM()やAVG()などの集計対象やORDER BYのソート対象
- ビジネスロジックで頻繁に
「スキーママイグレーションが面倒だから」という理由で、安易にJSONBというブラックボックスに逃げ込んではいけません。
初期の数十分のマイグレーション工数をケチった代償として、本番環境で数千倍のディスクI/O、インデックス肥大化、原因不明のクエリ遅延と戦う羽目になるのは、他ならぬあなた自身です。
データ構造の境界を見極め、更新されるデータはリレーショナルに、真に不規則なデータだけをJSONBに閉じ込める。この冷徹な規律こそが、何千万行ものトラフィックを悠々と捌き続ける堅牢なPostgreSQL運用の第一歩なのです。