DanLevy.net

JSONB: データベースを台無しにする最良の方法

JSONBは強力かつ有用だが、blobを実際のスキーマとして放置すると簡単に誤用されてしまう。

PostgreSQLがJSONBを追加したのは、事前に厳密なスキーマを定義せずに半構造化データを保存できるようにするためだった。このアイデア自体は健全だ。データがどのような形になるか本当に分からない場合や、従来のカラムでは対応しきれないほど頻繁に変化する場合は確かにある。

これが重要なのは、JSONBが間違いではないからだ。多くのシステムにおいて、JSONBは問題領域を表現する最もクリーンな方法である。サードパーティのWebhookペイロード、バージョン管理されたイベントボディ、機能フラグ、すべてのプロバイダーとモデルが少しずつ異なる常に変化するオプションセットを公開するLLM設定オブジェクトなどを保存する場合、すべてを第一級のカラムに強制するよりも、JSONBの方が便利なことが多い。

問題は、JSONBがスキーマ決定を先送りする最も簡単な方法になってしまっていることだ。意図と実装の間で、JSONBはデータベース版の「後で部屋を片付ける」という状態になってしまった。6ヶ月前に作った一時的な解決策? それはまだそこにあり、今では本番環境がそれに依存している。

同じパターンを何度も目にする。チームは要件が定まらないという理由でJSONBカラムを追加する。「状況が落ち着いたら正規化する」と自分に約束する。3年後、そのカラムには supposed to be ユーザープロファイルだったものの40の異なるバージョンが含まれ、それぞれが内部について異なる仮定を持つ15のサービスからクエリされている。

技術的負債はJSONBそのものではない。自分が構築していると言っていたものと、実際に構築したものとのギャップだ。それは文書化されていない読み取り時スキーマシステムだ。

何が起こるのが一般的か

機能を追加していて、ユーザーに twitter_handle が必要か bluesky_handle が必要か、それとも他の何かが必要か分からないとする。スキーマについて考える代わりに、こうするだろう:

CREATE TABLE users (
id SERIAL PRIMARY KEY,
profile JSONB
);

動く。機能をリリースし、次の機能へ、そしてその次へと進む。JSONBカラムは静かに成長していく。

ここが分岐点だ。profileuser.id によってフェッチされる不透明なblobのままなら、おそらく問題ない。しかし、それがビジネスデータが格納される主要な場所になり始めると、トレードオフは急速に変わる。

プロダクトから質問:「ニューヨークにいるユーザーは何人?」

こう書く:

SELECT count(*) FROM users WHERE profile->>'location' = 'New York';

PostgreSQLはフルテーブルスキャンを実行する。すべての行を。

GINインデックスを追加するかもしれない。それでもまだ許容できる場合もある。しかし、第一級の関係型データのように振る舞うフィールドが、実際には第一級のカラムにならなかったために、本当の複雑さとストレージコストを支払うことになる。

1年目: スキーマドリフト

同じカラム内に3つのバージョンのデータが存在する。

アプリケーションコードはこうなる:

const city = user.location || user.city || user.address?.city || "Unknown";

スキーマを削除したわけではない。バリデーションと一貫性チェックをデータベースから散在するアプリケーションコードへ移動しただけだ。


実際にJSONBを使うべき時

JSONBには正当なユースケースがある。多くの場合、JSONBは完全に問題なく、時には最良の選択だ。

重要な違いは「構造化は良い、JSONは悪い」ではない。むしろこうだ:

正当なJSONBユースケース

  1. Webhookペイロード: Stripe、Slack、GitHubからのデータを受信する。スキーマを完全に制御できない。クエリを実行しないかもしれない。デバッグやリプレイのために保存するだけ。JSONBに最適。

  2. ロギングとイベントストリーム: アプリケーションログ、監査証跡、エラーコンテキスト。書き込みが多く、特定のフィールドでクエリされることは稀で、バルクで分析されたり分析プラットフォームにエクスポートされたりすることが多い。JSONBはここで問題ない。

  3. ユーザー設定: 100以上のbooleanフラグがあり、そのほとんどがfalseで、ユーザーIDによってblob全体をフェッチする設定オブジェクト。WHERE preferences->>'theme' = 'dark' は実行しない。JSONBは機能する。

  4. LLMプロバイダー / モデル設定: これは現代の最も明確な例の一つだ。OpenAI、Anthropic、Gemini、オープンウェイトのローカルモデル、ベンダー固有のゲートウェイはすべて、重複しつつも異なるパラメータを公開する。1つのプロバイダー内でも、モデルの機能とオプション名は進化する。temperaturetop_preasoning_effortjson_schematool_choice、その他20以上のノブがすべて普遍のカラムであるべきだと pretending するよりも、JSONB設定blobの方が正直なことが多い。JSONBはここで適切な抽象化であることが多い。

  5. APIレスポンスキャッシング: APIレスポンス全体をキャッシュしている。データベースは単なる高速なRedisだ。キャッシュキーでフェッチし、ネストされたプロパティでクエリすることはない。JSONBが適切。

  6. イベントソーシング: 不変のイベントペイロードを保存している。クエリは常に「アグリゲートXのすべてのイベントを時間順に取得」だ。イベントプロパティで WHERE 句を実行することはない。JSONBは適合する。

  7. 拡張性サーフェス: 統合、プラグイン設定、テナントごとのオーバーライド、マーケットプレイスメタデータ、プロバイダーの機能、または形状がサブタイプによって異なることを明示的に期待する「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
);

これは両極端よりも優れていることが多い。

これは「正規化に失敗した」わけではない。線を正しい場所に引いているのだ。

大規模スケールで: 正規化よりオブジェクトバージョニング

ここで面白くなる。十分に大規模なスケールでは、「正しい」解決策は正規化ではなくオブジェクトバージョニングだ。

数十億行があり、頻繁なスキーマ進化がある場合、カラムのマイグレーションは高コストになる。Stripe、GitHub、Netflixのような企業はすべてを正規化しない。代わりに:

CREATE TABLE entities (
id UUID PRIMARY KEY,
version INT NOT NULL,
data JSONB NOT NULL
);

アプリケーションは version: 1version: 2version: 3 の読み方を知っている。新しいフィールドのデータベースマイグレーションは不要。コードが後方互換性を処理する。

これは architectural decision であり、怠惰ではない。データベースの複雑さをアプリケーションの複雑さとトレードオフする。ドキュメントが自然にバージョン管理されており、アプリが標準的な解釈器である場合、これはまさに正しいトレードオフだ。

失敗モードは「JSONBを使う」ことではない。失敗モードは、バージョニング、バリデーション、昇格ルール、ドキュメントデータと関係型データの間の明確な境界なしにJSONBを使うことだ。

実際に重要な質問

JSONBカラムを追加する前に、こう自問せよ:

  1. ネストされたフィールドを WHEREJOINGROUP BYORDER BY で定期的にクエリするか?
  2. このスキーマを制御しているのか、それとも外部で定義されており変動するのか?
  3. 形状がレコード間で意図的に異質か?
  4. アプリケーションレベルのバリデーションとバージョン管理があるか?
  5. どのフィールドが後に運用ディメンションになる可能性が高いか?

#1の答えが「はい、常に」なら、それはカラムへの強いシグナルだ。

#2と#3の答えが「はい」なら、JSONBはおそらく実際に役立っている。


トラップからの脱出

すでにこの穴にいるなら、掘るのをやめよ。

  1. 監査: jsonb_object_keys を実行し、実際に存在すると仮定している形状ではなく、実際の形状ドリフトを検査する。
  2. 昇格: 最も頻繁にフィルタリング、結合、ソート、レポートするフィールドを特定する。それらを実際のカラムにする。
  3. バリデーション: JSONBに残っているものに対して、アプリケーションまたはデータベースレベルのバリデーションを追加する。
  4. バージョン管理: blobが実際のドメインデータである場合、明示的にバージョン管理する。
  5. 削除: 昇格したカラムが確立されたら、blobから重複したキーを削除する。

すべてのblobを正規化する必要があると自分に言い聞かせるな。また、永続的なビジネスセマンティクスを持つblobが「一時的」だとも自分に言い聞かせるな。

JSONBはドキュメントが genuinely ドキュメントの形をしている場合に優れている。関係型スキーマが偽のひげをつけている場合は危険だ。

リソース