JSONB: データベースを壊す最良の方法
JSONBは強力で便利ですが、ブロブを実質的なスキーマにすると誤用しやすい。
PostgreSQL は、事前に厳密なスキーマを定義せずに半構造化データを保存できるように JSONB を導入しました。考え方自体は妥当です。データの形が分からない、あるいは頻繁に変わるため従来のカラムで表現できないケースがあるからです。
この点が重要なのは、JSONB が「間違い」ではないということです。多くのシステムで、問題領域を最もクリーンに表現できる手段となっています。サードパーティの webhook ペイロード、バージョン管理されたイベント本体、機能フラグ、あるいはプロバイダーやモデルごとに微妙に異なり常に変化するオプションセットを持つ LLM 設定オブジェクトを保存する場合、すべてを第一級カラムに詰め込もうとすると、むしろ扱いにくくなることがあります。
しかし問題は、JSONB がスキーマ決定を先送りする最も手軽な手段であると同時に、その先送りを認めずに進めてしまう点にあります。意図と実装の間で、データベース上の「あとで部屋を片付ける」的な状態になってしまいました。6 か月前に一時的な解決策として導入したものが、今でも残り、プロダクションがそれに依存しているわけです。
同じパターンを繰り返し目にします。要件が不確かだったためにチームが JSONB カラムを追加し、状況が落ち着いたら正規化すると自分たちに約束します。3 年後、そのカラムには本来はユーザープロファイルであるべきデータの 40 種類のバージョンが入り、15 のサービスがそれぞれ異なる前提で中身をクエリしています。
技術的負債の根源は JSONB そのものではありません。自分たちが「何を作る」と言い聞かせていたかと、実際に作ってしまった「ドキュメント化されていないスキーマオンリード」システムとの間にあるギャップです。
通常起こること
機能を追加しようとしていて、ユーザーが twitter_handle を必要とするのか、bluesky_handle を必要とするのか、あるいは全く別のものが必要か確信が持てない場合、スキーマを熟考する代わりに次のように書きます。
CREATE TABLE users ( id SERIAL PRIMARY KEY, profile JSONB);これで動きます。機能をリリースし、次の機能へ、さらに次の機能へと進んでいくうちに、JSONB カラムは静かに肥大化していきます。
ここが分岐点です。profile が user.id で取得する不透明なブロブのままであれば、問題は少ないでしょう。ところが、ビジネスデータの主要な格納場所になり始めると、トレードオフは急速に変わります。
プロダクト側から質問が来ます: 「ニューヨークにいるユーザーは何人いる?」
次のように書きます:
SELECT count(*) FROM users WHERE profile->>'location' = 'New York';Postgres は全テーブルスキャンを実行します。すべての行を走査します。
それでもGIN インデックスを追加します。まだ許容範囲かもしれません。ケースによってはそうです。しかし、第一級のリレーショナルデータとして振る舞うべきフィールドを第一級のカラムにしなかったため、実際の複雑性とストレージコストが発生します。
Year 1: Schema Drift
同じカラムに 3 つのバージョンのデータが混在しています。
- Row 1:
{"city": "NYC"} - Row 1000:
{"location": "NYC"} - Row 5000:
{"address": {"city": "New York"}}
アプリケーションコードは次のようになります:
const city = user.location || user.city || user.address?.city || "Unknown";スキーマを削除したわけではありません。データベース側の検証や整合性チェックを、散在したアプリケーションコードへ移しただけです。
実際に JSONB を使うべきタイミング
JSONB には正当な利用シーンが存在します。多くの場合、十分に問題なく、時には最適な選択肢となります。
重要なのは「構造化データは良い、JSON は悪い」という二分法ではなく、次のような観点です。
- データは安定したプライマリキーで全体を取得することが主ですか?
- キーはプロバイダー、バージョン、テナント、あるいは時間経過に伴い大きく変化しますか?
- 既知の数フィールドだけをクエリしますか、それともスプリントごとに新しいパスクエリを考案していますか?
- アプリケーション側でバージョン管理とバリデーションを意図的に行いますか、それとも適当にやっているだけですか?
正当な JSONB の利用ケース
-
Webhook ペイロード: Stripe、Slack、GitHub などからデータを受け取ります。スキーマは全くコントロールできません。クエリすることはほとんどなく、デバッグやリプレイのために保存するだけです。JSONB に最適。
-
ロギング & イベントストリーム: アプリケーションログ、監査トレイル、エラーコンテキスト。書き込みが多く、特定フィールドでクエリすることは稀で、バルクで分析したり分析プラットフォームへエクスポートしたりします。JSONB で問題ありません。
-
ユーザー設定 & プリファレンス: 100 以上のブールフラグを持つ設定オブジェクトで、ほとんどが false であり、常にユーザー ID で全体ブロブを取得します。
WHERE preferences->>'theme' = 'dark'のようなクエリは実行しません。JSONB が機能します。 -
LLM プロバイダー / モデル設定: 現代の最も分かりやすい例の一つです。OpenAI、Anthropic、Gemini、オープンウェイトのローカルモデル、ベンダー固有のゲートウェイはすべて、重複しつつも異なるパラメータを公開します。同一プロバイダー内でも、モデル機能やオプション名は変遷します。
temperature、top_p、reasoning_effort、json_schema、tool_choice、その他二十以上のノブをすべて汎用カラムに詰め込むより、JSONB 設定ブロブの方が正直です。JSONB がここで正しい抽象化になることが多い。 -
API 応答キャッシュ: 完全な API 応答をキャッシュしています。データベースは高速な Redis の代替です。キャッシュキーで取得し、ネストされたプロパティで検索することはありません。JSONB が適切。
-
イベントソーシング: 不変なイベントペイロードを保存しています。クエリは常に「集約 X のすべてのイベントを時間順に取得する」だけで、イベント属性に対する
WHERE句は使用しません。JSONB が合致します。 -
拡張性の表面: 統合、プラグイン設定、テナントごとのオーバーライド、マーケットプレイスメタデータ、プロバイダー機能、あるいはサブタイプごとに形が変わることを前提とした「extras」フィールド。JSONB は妥協ではなく、正しい契約になることがある。
経験則: アプリケーションが既知のキーでドキュメントを取得し、検証やバージョン管理の方法を把握しているなら JSONB は非常に有効だ。ビジネスロジックがネストされたキーに対してリレーショナルな質問をし続ける場合、そのフィールドは列化しようとしている証拠だ。
ベストプラクティスはハイブリッドが多い
成熟したシステムの多くはここに落ち着く:
CREATE TABLE llm_requests ( id UUID PRIMARY KEY, provider TEXT NOT NULL, model TEXT NOT NULL, status TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), config JSONB NOT NULL);この形は極端などちらかよりも一般的に優れている。
provider、model、status、created_atはファーストクラスのカラムとして扱う。これらはフィルタ、結合、集計、インデックス付けの対象になる。configは JSONB のままにしておく。オプションの具体的な構造はモデル固有、プロバイダー固有で、今後変化する可能性が高い。
それは「正規化に失敗した」ことではなく、適切な位置で境界を引いたということです。
大規模環境での選択: オブジェクトバージョニング > 正規化
ここからが本題です。規模が十分に大きくなると、「正しい」解決策は正規化ではなく、オブジェクトバージョニングになります。
もし数十億行のデータがあり、スキーマの変更が頻繁に起きる場合、カラムのマイグレーションはコストがかかります。Stripe、GitHub、Netflix といった企業はすべてを正規化してはいません。代わりに次のようにします。
CREATE TABLE entities ( id UUID PRIMARY KEY, version INT NOT NULL, data JSONB NOT NULL);アプリケーション側で version: 1、version: 2、version: 3 の読み取り方法を実装します。新しいフィールドが追加されてもデータベースのマイグレーションは不要です。コードが下位互換性を担保します。
これは怠慢による選択ではなく、アーキテクチャ上の判断です。データベースの複雑さをアプリケーションの複雑さと置き換えるトレードオフです。特にドキュメント自体が自然にバージョン管理され、アプリが唯一の解釈者となる場合、このトレードオフが最適になることがあります。
失敗モードは「JSONB を使うこと」ではありません。失敗モードは、バージョニングやバリデーション、昇格ルール、あるいは文書データとリレーショナルデータの明確な境界がないまま JSONB を使用することです。
本当に重要な質問
JSONB カラムを追加する前に、次の点を確認してください。
WHERE、JOIN、GROUP BY、またはORDER BYでネストされたフィールドを定期的にクエリしますか?- スキーマは自分たちで管理していますか、あるいは外部で定義されていて変動しやすいですか?
- レコード間で形状が意図的に異質ですか?
- アプリケーションレベルでバリデーションとバージョニングを行っていますか?
- 後々運用上の指標になる可能性があるフィールドはどれですか?
#1 の答えが「はい、常に」なら、カラムを使用すべきという強いシグナルです。
#2 と #3 の答えが「はい」なら、JSONB は実際に価値を提供している可能性が高いです。
トラップからの脱出
すでにこの穴にハマっているなら、掘り続けるのはやめましょう。
- 監査:
jsonb_object_keysを実行し、想定している形ではなく実際の形状のドリフトを確認します。 - 昇格: フィルタ、結合、ソート、またはレポートで最も頻繁に使用するフィールドを特定し、それらを実際のカラムにします。
- 検証: 残った JSONB の内容について、アプリケーション側またはデータベース側でバリデーションを追加します。
- バージョン管理: ブロブが本当のドメインデータである場合は、明示的にバージョンを付けます。
- 削減: 昇格したカラムが確立したら、ブロブ内の重複キーを削除します。
「すべてのブロブは正規化しなければならない」と自分に言い聞かせないでください。また、永続的なビジネス意味を持つブロブを「一時的」だと決めつけてもいけません。
JSONB はドキュメントそのものが本当にドキュメント形状を持つ場合に有効です。偽の口ひげを付けたリレーショナルスキーマになっていると危険です。