PostgreSQL JSONBと正規化テーブル:選択と移行
PostgreSQLのJSONBと正規化テーブル:プロトタイプでの選び方と、アプリがスケールしたときに安全に移行するための実践的フレームワーク。

本当の問題:速く作る一方で自分の首を絞めないこと
要件が毎週変わるのは、新しいものを作っているときには普通です。顧客がフィールドを追加してほしいと言い、セールスが別のワークフローを求め、サポートが監査ログを必要とする。結果としてデータベースがそうした変更すべての重荷を負うことになります。
高速な反復は単に画面を早く出すことではありません。フィールドを追加・名前変更・削除してもレポートや連携、古い記録が壊れないことを意味します。また新しい問い(「先月配送メモが欠けていた注文は何件か?」)に対して、毎回ワンオフのスクリプトを書かずに答えられることでもあります。
だからこそ、早い段階でJSONBか正規化テーブルかを選ぶことが重要になります。どちらも正しく使えば機能しますし、間違った用途で使うと痛みを生みます。JSONBは今日ほとんど何でも保存できる自由に見えます。正規化テーブルは構造を強制する分、安全に見えます。大事なのは、今データの形がどれだけ不確かで、どれだけ早く信頼できる形にする必要があるかにストレージモデルを合わせることです。
チームが間違ったモデルを選ぶと、症状はだいたい明白です:
- 単純な質問が遅くて汚いクエリやカスタムコードになる。
- 同じものを指すはずのレコードが別々のフィールド名を使っている。
- オプションだったフィールドが後で必須になり、古いデータと合わなくなる。
- 一意性や必須の関係などのルールを強制できず、回避策が必要になる。
- 小さな変更でレポートやエクスポートが壊れ続ける。
実用的な判断はこうです:どこに柔軟性が必要で(しばらくの間不整合を許容できるか)、どこに構造が必要か(データが収益、運用、コンプライアンスを支えるため)?
JSONBと正規化テーブルを簡単に説明すると
PostgreSQLは従来の列(text、number、date)でデータを保存できますし、JSONBを使って1列にJSONドキュメントまるごとを格納することもできます。違いは「新しいか古いか」ではなく、データベースに何を保証させたいかです。
JSONBはキー、値、配列、ネストしたオブジェクトを格納できますが、各行が同じキーを持つとか、値が常に同じ型であるとか、参照先が必ず別テーブルに存在するといったことは自動的には保証しません。チェックは追加できますが、それらを決めて実装する必要があります。
正規化テーブルは、各実体を別テーブルに分け、IDでつなぐ設計です。顧客は顧客テーブル、注文は注文テーブルにあり、注文が顧客を参照します。これにより矛盾に対する保護が強くなります。
日常的なトレードオフは明快です:
- JSONB:デフォルトで柔軟、変更が簡単、ドリフトしやすい。
- 正規化テーブル:変更は意図的になりやすいが、検証しやすく、整ったクエリが書きやすい。
簡単な例としてサポートチケットのカスタムフィールドがあります。JSONBでは明日新しいフィールドを追加してもマイグレーションは不要です。正規化テーブルでは追加はより意図的になりますが、レポーティングやルールは明確になります。
いつJSONBが早い反復の適切なツールか
JSONBは、最大のリスクがデータ形状を間違って作ることにある場合に強力です。製品がまだワークフローを模索しているなら、すべてを固定テーブルに押し込むと頻繁なマイグレーションで遅くなります。
よい兆候はフィールドが週ごとに変わるときです。たとえばオンボーディングフォームでマーケティングが質問を追加したりラベルを変えたりステップを削ったりするケース。JSONBなら各送信をそのまま保存でき、翌日のバージョンが違っていても対処できます。
JSONBは「未知」にも向きます:まだ完全に理解していないデータ、あるいは制御できないデータ。パートナーからのWebhookペイロードを受け取るなら、生のペイロードをJSONBで保存しておけば、新しいフィールドにすぐ対応でき、後で何を本格的な列にするか決められます。
よくある初期の使いみちは、高頻度で変わるフォーム、イベントキャプチャや監査ログ、顧客ごとの設定、フィーチャーフラグ、実験データなどです。書き込みが多く、全体をそのまま読み返す用途で、形がまだ動いているときに特に有効です。
実際に役立つガードレールが1つあります:使っているキーの短い共有メモを保って、同じフィールドのスペル違いが行ごとに5種類にならないようにすることです。
いつ正規化テーブルが安全な長期選択になるか
データが「この機能だけのもの」ではなく、共有され、さまざまに照会され、信頼されるようになると正規化が勝ちます。ユーザーがレコードを多様な軸(ステータス、所有者、地域、期間)でスライスするなら、列とリレーションは挙動を予測しやすく最適化もしやすくなります。
正規化は、データベース側でルールを強制しなければならない場合にも重要です。JSONBは何でも入るので、強い保証が必要なときにはまさに問題になります。
今すぐ正規化すべきサイン
次のうちいくつかが当てはまるなら、JSON優先モデルから移す時期かもしれません:
- 一貫したレポートやダッシュボードが必要になった。
- 必須フィールド、一意性、他レコードとの関係のような制約が必要になった。
- 複数のサービスやチームが同じデータを読み書きしている。
- 単純なインデックスが使えずクエリが多くの行をスキャンし始めた。
- 監査や規制の対象で、ルールを証明する必要がある。
パフォーマンスはよくある転換点です。JSONBではフィルタリングのたびに値を取り出す必要が出ることがあります。JSONパスをインデックスできるものの、要求は成長して管理が難しいインデックスの寄せ集めになりがちです。
具体例
プロトタイプで「顧客リクエスト」をJSONBで保存していたとします。各リクエストタイプでフィールドが違うためです。後に運用チームは優先度とSLAでフィルタできるキューが必要になり、経理は部門別の合計が欲しく、サポートは各リクエストに必ずcustomer_idとstatusがあることを保証したい。こういう場面で正規化テーブルが役に立ちます:共通フィールドの明確な列、顧客やチームへの外部キー、悪いデータを入れさせない制約などです。
30分で使えるシンプルな判断フレームワーク
大きなデータベース理論の議論は不要です。必要なのは速く書ける答えです:どこで柔軟性が構造より価値があるか。
ビルダー、運用、サポート、時には経理など、そのシステムを作り使う人たちと一緒にこれをやってください。目的は単に勝者を決めることではなく、プロダクトの各領域に適した選択をすることです。
5ステップチェックリスト
-
重要な画面トップ10と、それぞれの背後にある正確な質問を列挙します。例:「顧客レコードを開く」「期限超過の注文を見つける」「先月の支払いをエクスポートする」。質問を名前にできなければ、設計できません。
-
いつも正しい必要があるフィールドをハイライトします。これらは厳格なルール:ステータス、金額、日付、所有権、権限。誤った値が金銭的損失やサポート対応を招くなら、通常は制約のある列に入れます。
-
変化の頻度をマークします。週単位で変わるもの(新しいフォーム質問、パートナー固有の詳細)はJSONBの強い候補です。めったに変わらない「コア」フィールドは正規化向きです。
-
UIで検索、フィルタ、ソートされる必要があるものを決めます。ユーザーが頻繁にフィルタするなら、通常はファーストクラスの列(または慎重にインデックスしたJSONBパス)にする方が良いです。
-
領域ごとにモデルを選びます。一般的な分け方は、コアのエンティティとワークフローは正規化テーブル、追加情報や高速で変わるメタデータはJSONBにするハイブリッドです。
詳細に迷わないパフォーマンスの基本
速さは通常1つのことから来ます:最も一般的な質問を安く答えられるようにすること。これはイデオロギーより重要です。
JSONBを使うなら、それを小さく予測可能に保ってください。いくつかの追加フィールドは問題ありませんが、巨大で常に変化するブロブはインデックスが難しく誤用されやすいです。キーが存在することが分かっているなら("priority"や"source"など)、キー名と値の型を一貫させてください。
インデックスは魔法ではありません。読み取りを速くする代わりに書き込みを遅くし、ディスクを多く使います。フィルタや結合で頻繁に使うものだけ、実際のクエリ形状でインデックスを張ってください。
インデックスの経験則
- status、owner_id、created_at、updated_at のような一般的なフィルタには通常のBTREEを使う。
- JSONB列の中を頻繁に検索するならGINインデックスを使う。
- 1〜2個のホットなJSONフィールドには、ドキュメント全体をインデックスするより式インデックス(例:(meta->>'priority'))を優先する。
- 一部だけが重要なら部分インデックスを使う(例、status = 'open' の行だけ)。
JSONBの中に数値や日付を文字列で保存するのは避けてください。"10"は"2"より先にソートされますし、日付演算は面倒になります。可能なら列に実際の数値やタイムスタンプ型を使うか、少なくともJSON上で数値として保存してください。
コアフィールドを列に、柔軟な追加情報をJSONBに置くハイブリッドがしばしば勝ちます。例:運用テーブルに id、status、owner_id、created_at を列にし、meta JSONB にオプション回答を入れる設計。
後で痛みを生むよくある間違い
JSONBは初期に自由を感じさせますが、痛みは数か月後に出ることが多いです。触る人が増え、「とりあえず動く」が「何も変えられない」に変わります。
多くのクリーンアップ作業を生むパターンは次の通りです:
- JSONBを捨て場として扱う。チームごとに形が微妙に違うとカスタム解析ロジックだらけになります。基本的な慣習を決めてください:キー名の一貫性、明確な日付フォーマット、JSON内の小さなバージョンフィールド。
- コアのエンティティをJSONBに隠す。顧客や注文、権限をブロブだけで保存すると最初は楽ですが、ジョインが面倒になり制約が効かず重複が生まれます。誰が何をいつしたかは列に、オプションはJSONBに。
- マイグレーションを緊急事態になるまで考えない。どのキーが存在しどう変わったか、どれが「公式」かを追わないと初めての本格マイグレーションが危険になります。
- JSONB=柔軟かつ高速と決めつける。ルールなしの柔軟性は不整合でしかありません。速さはアクセスパターンとインデックス次第です。
- 時間とともにキーを変えて分析を壊す。statusをstateにリネームしたり、数値を文字列にしたり、タイムゾーンを混ぜるとレポートが静かに壊れます。
具体例:チームがチケットテーブルと詳細を入れるJSONBフィールドを作ったとします。後に経理がカテゴリ別の週次内訳を欲し、運用がSLAトラッキングを欲し、サポートが「チーム別のオープン」を必要とするとき、カテゴリやタイムスタンプがキーや形式でばらつくと、すべてのレポートがワンオフのクエリになってしまいます。
プロトタイプがミッションクリティカルになったときの移行計画
プロトタイプが給料、在庫、顧客対応を動かすようになると「あとでデータを直す」は許されなくなります。最も安全なのは小さなステップで移行し、旧JSONBデータが新構造でも動く状態を保ちながら進めることです。
段階的アプローチはビッグバンのリスクを避けます:
- 設計を先に決める。ターゲットのテーブル、主キー、命名規則を書きます。何が実体(Customer、Ticket、Order)で何が柔軟に残るかを決める。
- 既存データの隣に新しいテーブルを作る。JSONB列は残したまま、正規化テーブルとインデックスを並行して追加する。
- バックフィルをバッチで行い、検証する。行数、NOT NULLの必須フィールド、スポットチェックで比較する。
- まず読み取りを切り替える。クエリとレポートを新テーブルから読むようにし、出力が一致したら新テーブルへの書き込みを始める。
- ロックダウンする。JSONBへの書き込みを停止し、古いフィールドを削除または凍結する。外部キーや一意制約を追加して悪いデータが戻らないようにする。
最終的な切替前に:
- 両パス(旧→新)を1週間ほど並行運用して出力を比較する。
- 遅いクエリを監視し、必要ならインデックスを追加する。
- ロールバック計画(フィーチャーフラグや設定スイッチ)を用意する。
- チームに書き込み切替の正確な時刻を伝える。
確定する前の簡単なチェック
アプローチを確定する前に現実確認をしてください。これらの質問は変更がまだ安価なうちに将来の問題の多くを捕まえます。
結果を決める5つの質問
- 今すぐ(あるいは次のリリースで)一意性、必須フィールド、型の厳格さが必要か?
- UIでユーザーがフィルタやソートで頻繁に使うフィールドは何か(検索、ステータス、所有者、日付)?
- 近いうちにダッシュボード、エクスポート、経理/運用向けレポートが必要になるか?
- 新しいメンバーにデータモデルを10分で説明して曖昧さが残らないか?
- マイグレーションがワークフローを壊したときのロールバック計画はあるか?
最初の3つに「はい」がつくなら、正規化テーブル(あるいはコアは正規化、長尾はJSONBのハイブリッド)に傾きます。最後の質問だけ「はい」なら、大きな問題はプロセスであってスキーマではない可能性があります。
覚えやすいルール
データの形がまだ不明確なときはJSONBを使い、ただし常に必要な少数の安定したフィールド(id、owner、status、created_atなど)は名前を決めて列にしておく。人々が一貫したフィルタ、信頼できるエクスポート、厳格な検証に依存し始めた瞬間に、柔軟性のコストは急速に上がります。
例:柔軟なフォームから信頼できる運用システムへ
毎週変わるカスタマーサポートの受付フォームを想像してください。ある週は"device model"を追加し、次の週は"refund reason"を追加、さらに翌週に"priority"を"urgency"にリネームする。初期はフォームのペイロードを1つのJSONB列に入れるのが完璧に思えます。変更にマイグレーションが不要で誰も不満を言いません。
3か月後、マネージャーは「urgency = high かつ device model が iPhone で始まる」といったフィルタ、カスタマーティアに基づくSLA、先週の数字と一致する週次レポートを欲しがります。
失敗モードは予測可能です:誰かが「このフィールドはどこに行った?」と聞く。古いレコードは別のキー名を使っている、値の型が変わった("3" と 3)、あるいはフィールド自体が半分のチケットには存在しなかった。レポートは特別対応の寄せ集めになります。
実用的な中間地はハイブリッド設計です:安定した業務上重要なフィールド(created_at、customer_id、status、urgency、sla_due_atなど)は実列にし、頻繁に変わる新しい/稀なフィールドはJSONBの拡張領域に保存します。
低中断のタイムライン例:
- Week 1: フィルタやレポートに必要な5〜10のフィールドを選び、列を追加する。
- Week 2: まず最近のレコードから既存のJSONBをバックフィルして列を埋める。次に古いものも。
- Week 3: 新しいレコードが一時的に列とJSONBの両方に書き込むようにする(ダブルライト)。
- Week 4: 読み取りを列に切り替え、JSONBは追加情報だけにする。
次のステップ:決めて、文書化して、出し続ける
何もしなければ、決定は勝手に下されます。プロトタイプは成長し、縁が硬くなり、変更はすべてリスキーに感じられるようになります。より良い動きは、今小さな文書化された決定をして、それでも開発を続けることです。
アプリがすぐに答えなければならない5〜10の質問(「この顧客のオープンな注文をすべて見せる」「メールでユーザーを見つける」「月次収益を報告する」など)を列挙し、それぞれ横に壊してはいけない制約(ユニークなメール、必須ステータス、有効な合計など)を書きます。次に明確な境界を引きます:頻繁に変わり、滅多にフィルタやジョインされないフィールドはJSONBに残し、検索・ソート・ジョイン・検証が必要なものは列やテーブルに昇格させます。
ノーコードプラットフォームを使っているなら、実際のアプリケーションを生成するものは時間とともにこの分割を管理しやすくすることがあります。例えば、AppMaster (appmaster.io) はPostgreSQLテーブルを視覚的にモデル化し、要件が変わるたびに基盤となるバックエンドやアプリを再生成できるため、反復的なスキーマ変更や計画的マイグレーションが楽になります。
よくある質問
JSONBは、形が頻繁に変わるデータで、保存してそのまま取り出す用途(頻繁に変わるフォーム、パートナーのWebhook、フィーチャーフラグ、顧客ごとの設定など)に向いています。フィルタやレポートのために必須の安定した少数のフィールドは通常の列にしておくと、信頼性が保てます。
データが複数の場所で共有され、多様な方法で照会されるか、デフォルトで信頼される必要がある場合は正規化が適切です。必須フィールドや一意性、外部キー、安定したダッシュボードやエクスポートが必要なら、列と制約のあるテーブルのほうが後で時間を節約します。
はい。多くの場合ハイブリッドがデフォルトとして最も有効です。業務上重要なフィールドは列とリレーションに置き、オプションや頻繁に変わる属性はJSONBの「meta」列に入れます。これによりレポートやルールの安定性を保ちながら、ロングテールの反復も続けられます。
ユーザーがUIでフィルタ、ソート、エクスポートする必要があるフィールド、そして毎回正しい必要があるフィールド(金額、ステータス、所有者、権限、日付)を基準に判断します。リストやダッシュボード、ジョインで頻繁に使うなら実際の列に昇格させ、滅多に使わない追加情報はJSONBに残します。
主なリスクはキー名の不整合、値の型混在、時間経過による無言の変更で分析が壊れることです。防ぐには、キー名を一貫させる、JSONBを小さく保つ、数値や日付は適切な型で格納する(あるいはJSONの数値として保存する)、JSON内に簡単なバージョンフィールドを入れる、などの対策が有効です。
可能ですが追加の作業が必要です。JSONBはデフォルトで構造を強制しないため、明示的なチェック、クエリするパスの慎重なインデックス作成、強い運用上の慣習が必要です。正規化スキーマのほうがこうした保証はシンプルで目に見えやすいことが多いです。
実際にクエリするものだけをインデックスするのが基本です。ステータスやタイムスタンプなどの共通列には通常のBTREEを使い、JSONB内部の検索にはGINを使います。だが、ホットな一〜二個のJSONキーにはドキュメント全体をインデックスする代わりに式インデックス(例:(meta->>'priority'))を使う方が管理しやすいです。
遅くて複雑なクエリが増える、フルスキャンが頻発する、簡単な質問にもワンオフスクリプトが必要になる、というのが典型的な兆候です。他にも複数チームが同じJSONキーを別々に書き、厳格な制約や安定したエクスポートの必要性が増す場合も移行の合図です。
まず移行先を設計し、そのテーブルを既存のJSONBデータの横に用意して段階的に移すのが安全です。バッチでバックフィルして検証し、まず読みに切り替え、出力が合えば書き込みも移し、最後にJSONBを書き込まないようにして制約を追加します。ロールバック計画とモニタリングも必須です。
コアエンティティ(顧客、注文、チケット)をテーブルとしてモデル化し、フィルタやレポートに使う列を明確にしてから、柔軟な追加情報用にJSONB列を入れておく、という方法です。AppMaster (appmaster.io) のようなツールは、PostgreSQLモデルを視覚的に編集してバックエンドやアプリを再生成できるため、反復的なスキーマ変更と計画的マイグレーションを楽にします。


