PostgreSQL JSONB:NoSQLの煩わしさなしでスキーマの柔軟性を実現

Database tutorial - IT technology blog
Database tutorial - IT technology blog

硬直したスキーマの罠

数年前、私は商品のニーズが大きく異なるマルチテナント型eコマースプラットフォームを構築しました。ある販売者は4つの属性を持つスニーカーを売り、別の販売者は65もの異なる技術パラメータを持つ工業用センサーを売っていました。私の最初の直感は、古典的なEntity-Attribute-Value(EAV)パターンでした。しかし、それは大失敗でした。結合が積み重なるにつれ、本来20ミリ秒で終わるはずのクエリに5秒のタイムアウトが発生し始めたのです。

MySQL、MongoDB、Postgresを渡り歩いてきた私は、それぞれの得意分野を見てきました。以前なら、スキーマが予測不能になった瞬間にMongoDBを検討したでしょう。しかし、リレーショナルとドキュメント指向という2つの別々のデータベースクラスターを管理することは、ほとんどのチームにとって許容できない運用コスト(オペレーショナル・タックス)となります。PostgreSQLのJSONBはこの状況を一変させます。PostgresのACIDコンプライアンスとリレーショナルパワーを維持したまま、ドキュメントストアの柔軟性を提供してくれるのです。

60秒以内にデータ保存を開始する

セットアップはわずか3行で完了します。入力テキストをそのまま保存する標準的なJSON型とは異なり、JSONBは分解されたバイナリ形式でデータを保存します。書き込み時に10〜15%程度のオーバーヘッドが発生しますが、その代わりクエリ速度が大幅に向上します。

イベントごとにメタデータが変わるロギングシステムを作ってみましょう:

CREATE TABLE event_logs (
    id SERIAL PRIMARY KEY,
    event_type TEXT NOT NULL,
    payload JSONB,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

多様なデータの挿入はシームレスです。フィールドを事前に定義することなく、ログインと購入のデータを同じカラムで追跡できます:

INSERT INTO event_logs (event_type, payload) VALUES 
('login', '{"user": "john_doe", "ip": "192.168.1.1", "success": true}'),
('purchase', '{"user": "jane_smith", "amount": 150.00, "items": ["SSD", "RAM"]}');

データの抽出には、->演算子と->>演算子を使用します。さらに処理するためにJSONオブジェクトが必要な場合は->を、生のテキスト値を取得したい場合は->>を使用します:

SELECT 
    payload->>'user' AS username,
    payload->'items' AS items_list
FROM event_logs
WHERE event_type = 'purchase';

パスクエリと包含の力

トップレベルのキーを取得するのは簡単ですが、現実世界のデータがフラットであることは稀です。ここで活躍するのがパスナビゲーションです。深くネストされた設定がある場合、複数の演算子を繋げる代わりに#>演算子を使用して、目的の階層までドリルダウンできます。

-- payload -> metadata -> settings -> theme へのアクセス
SELECT payload #> '{metadata, settings, theme}' FROM event_logs;

包含:高速フィルタリングの秘訣

包含演算子(@>)は、JSONBの最も強力な機能の一つです。左側のJSONに右側のデータが含まれているかどうかをチェックします。これは標準的なテキストフィルタリングよりも大幅に効率的です。なぜなら、Postgresが専用のインデックスを使用して最適化できるからです。

-- 特定のユーザーとステータスのログを検索する
SELECT * FROM event_logs 
WHERE payload @> '{"user": "john_doe", "success": true}';

JSON vs. JSONB:最終的な結論

開発者から「どちらを使うべきか」という質問をよく受けます。JSON型は本質的にテキストの塊(Blob)であり、空白は保持されますが、クエリのたびにPostgresがテキストを再解析する必要があります。一方、JSONBは空白を削除しキーの順序を無視しますが、事前に解析された状態で保存されます。元の文字列의正確なフォーマットを保持する必要がない限り、常にJSONBを選択してください。

インデックス作成:秒単位をミリ秒単位に変える

テーブルが数百万行に増えると、インデックスなしでJSONBをクエリするとフルテーブルスキャンが発生します。ここからパフォーマンスに関する誤解が始まります。「PostgresはJSONの処理が遅い」と言う人がいますが、たいていの場合、単にGINインデックスを忘れているだけです。

GIN(汎用転置インデックス)

GINインデックスは、ドキュメント内のすべてのキーと値をマッピングします。これにより、800ミリ秒のシーケンシャルスキャンが2ミリ秒のルックアップに変わります。検索操作においては非常に重要な役割を果たします。

CREATE INDEX idx_event_logs_payload ON event_logs USING GIN (payload);

注意点として、GINインデックスはサイズが大きくなります。10GBのテーブルに対するGINインデックスが3GBに膨れ上がることがあり、それがINSERT操作を遅くする可能性があります。user_idのように特定のフィールドのみをクエリする場合は、スペースを節約するために代わりに関数Bツリーインデックスを使用してください:

CREATE INDEX idx_payload_user ON event_logs ((payload->>'user'));

精密な更新の実行

たった1つのフィールドを変更するために、5MB ofドキュメント全体を置き換える必要はありません。バージョン9.5以降、jsonb_setを使用することで、データフローの途中で精密な更新が可能です。

-- ペイロードの他の部分には触れずに、特定のIPアドレスのみを更新する
UPDATE event_logs 
SET payload = jsonb_set(payload, '{ip}', '"10.0.0.5"')
WHERE id = 1;

本番運用のガードレール

数多くの高トラフィックシステムにJSONBを導入してきた経験から、パフォーマンスの予測可能性を維持するための4つのルールを守っています。

  • JSON化しすぎない: すべての行に存在し、結合(Join)に使用するフィールド(user_idなど)は、標準的なカラムとして保持してください。JSONBは、変化する「追加」データのためにあります。
  • スキーマの整合性を強制する: JSONBはデフォルトではスキーマレスですが、CHECK制約を追加できます。?(存在)演算子を使用して、必須フィールドが存在することを確認しましょう。
  • インデックスの肥大化を監視する: 巨大なGINインデックスは、最終的にテーブルのサイズを超えることがあります。pg_stat_user_indexesを使用して使用状況を追跡し、冗長なインデックスを特定してください。
  • 読みやすいデバッグ: 縮小(Minified)されたデータを見て目を細めるのはやめましょう。CLIではjsonb_pretty(payload)を使用して、人間が読める形式で表示してください。
-- 例:すべてのペイロードに 'version' キーを必須にする
ALTER TABLE event_logs ADD CONSTRAINT check_payload_version 
CHECK (payload ? 'version');

Postgresは多才なツールです。JSONBを活用することで、リレーショナルシステムの堅牢性を犠牲にすることなく、NoSQLの柔軟性を手に入れることができます。インデックスを尊重し、コアとなるリレーショナルフィールドを分けておくことで、データモデルをコードと同じ速さで進化させることができるでしょう。

Share: