【ITニュース解説】PostgreSQL JSONB Write Amplification: TOAST Chunk Fragmentation and Unseen I/O Thrashing
2026年10月14日に「Dev.to」が公開したITニュース「PostgreSQL JSONB Write Amplification: TOAST Chunk Fragmentation and Unseen I/O Thrashing」について初心者にもわかりやすく解説しています。
ITニュース概要
PostgreSQLの大きなJSONBデータを頻繁に更新すると、一部変更でもデータ全体が書き換えられ、大量の書き込み(Write Amplification)が発生し性能が大きく低下する。対策は、更新頻度の高い項目を別のカラムに分離するか、データをキーバリュー形式で管理することだ。これにより、データベースの負荷を抑制できる。
ITニュース解説
PostgreSQLのJSONB型は、開発者にとって非常に便利な機能で、スキーマを事前に厳密に決めなくても、柔軟な形式でデータを保存できるため、開発効率を大きく向上させる。特に、B2B SaaSのような成長段階のプラットフォームでは、テナントごとの設定情報や機能フラグ、動的なカスタム属性などをJSONBカラムに格納することが一般的である。しかし、この手軽さの裏には、データベースのパフォーマンスを著しく低下させる可能性のある「落とし穴」が潜んでいる。それは「ライトアンプリフィケーション(書き込み増幅)」と呼ばれる現象で、特に大きなJSONBデータを頻繁に更新する際に発生する。
PostgreSQLは、データを「ページ」と呼ばれる固定サイズのブロック(通常8KB)で管理している。1つの行データがこのページ内に収まらないほど大きくなる場合、PostgreSQLは自動的にその大きなデータを別の場所に移動させて保存する。この仕組みを「TOAST(The Oversized-Attribute Storage Technique)」と呼ぶ。JSONBのような大きなデータも、このTOASTの対象となる。データがTOASTされる際、PostgreSQLは効率のためにデータを圧縮し、必要に応じて複数の小さな「チャンク」に分割してTOASTテーブルと呼ばれる補助的なストレージに保存する。
ここで重要な誤解が生まれる。多くの開発者は、JSONBデータ内の特定の小さなフィールドを更新する際、データベースがその部分だけを効率的に書き換えてくれると考えがちである。例えば、jsonb_set()のような関数を使ってJSONBドキュメント内の1つのキーの値を変更する操作である。しかし、実際にはPostgreSQLにはTOASTされた値に対する部分的な更新パスがない。これは、TOASTデータが圧縮されてチャンクに分割されているため、その一部だけを変更するのが非常に複雑だからである。結果として、たとえ1バイトのブーリアン値を更新するだけでも、PostgreSQLは以下の手順を踏むことになる。
まず、ディスクに保存されている圧縮されたTOASTチャンクをすべて読み込み、元のJSONBドキュメント全体をメモリ上で解凍する。次に、その解凍されたドキュメントに対して、指定された変更(例えば、1つのブーリアン値をtrueからfalseへ変更する)を適用する。そして、変更後のドキュメント全体を再び圧縮し、それを新しいTOASTチャンクとしてディスクに書き込む。
この一連の作業は、本来の変更がごくわずかであるにもかかわらず、ドキュメント全体を読み込み、解凍し、再圧縮し、そしてまったく新しいデータとしてディスクに書き込むことを意味する。これが「ライトアンプリフィケーション」である。論理的な変更はたった1バイトであっても、物理的な書き込み量は元のドキュメントサイズに比例して増大する。
このライトアンプリフィケーションは、いくつかの深刻な性能問題を引き起こす。一つは「WAL(Write-Ahead Log)の肥大化」である。PostgreSQLはデータベースの整合性を保つために、すべての変更操作をWALというログに記録する。JSONBの全体書き換えが発生すると、新しいTOASTチャンク全体がWALに記録されるため、ごくわずかな変更でも数十キロバイトものWALデータが生成されてしまう。これにより、ディスクへの書き込み(I/O)が飽和し、データベースのレプリケーションストリームが大量のデータで溢れかえり、読み込み用レプリカの同期が大幅に遅れる原因となる。
もう一つは「MVCC(Multi-Version Concurrency Control)のオーバーヘッド」の増加である。PostgreSQLはMVCCという仕組みで、複数のトランザクションが同時にデータを扱えるようにしている。データが更新されると、古いバージョンはすぐに削除されず「デッドタプル」として残り、後でAutovacuumというプロセスによってクリーンアップされる。JSONBの全体書き換えが発生すると、親テーブルのレコードだけでなく、TOASTテーブル内でも大量の新しいデッドチャンクタプルが生成される。これにより、Autovacuumの作業量が倍増し、データベースの負荷が増加する。
この現象の具体的な影響を測るために、70KB程度の大きなJSONBドキュメント(約1,500のキーを持つ)を持つテーブルを想定する。このドキュメント内のたった1つのブーリアン値をtrueからfalseへ変更する操作を実行したところ、約58KBものWALデータが生成された。もし同じデータが独立したブーリアン型のカラムとして保存されていれば、発生するWALデータは200バイト未満であり、実に約300倍もの書き込み量の違いが出ることが確認された。このような更新が数千テナントにわたって頻繁に発生すると、データベースのI/Oコストは計り知れないほど高くなる。
では、このJSONBのライトアンプリフィケーションを避けるにはどうすればよいだろうか。
最初の解決策は「ハイブリッドカラム分解(Hot Fieldの抽出)」である。これは、JSONBペイロードの中で特に更新頻度が高い属性(例えば、last_active_at、login_counter、active_statusなど)を特定し、それらをメインテーブルの専用のリレーショナルカラムとして独立させる方法である。このようにすることで、これらの頻繁に更新される属性に対する更新は、JSONBカラムに触れることなくメインテーブルのインラインデータとして処理される。JSONBカラムが変更されない限り、PostgreSQLは既存のTOASTポインタを再利用するため、大規模なJSONBドキュメントの書き換えを回避できる。例えば、tenant_settingsテーブルにis_activeというブーリアン型カラムを追加し、ユーザーのアクティブ状態の変更はこの新しいカラムに対してのみ行うようにする。これにより、JSONBオブジェクトがそのまま維持され、TOASTの書き換えが不要になる。
二つ目の解決策は「キーバリューEAVパーティショニング」である。もし1つのテナントあたり数千もの動的なキーバリューペアを保存する必要があり、それが頻繁に更新されるようなケースであれば、モノリシックなJSONBドキュメントを分解し、明示的なキーバリュー形式のテーブルに正規化する方法が有効である。例えば、tenant_attributesというテーブルを作成し、tenant_id、attribute_key、attribute_valueの3つのカラムで構成する。このアプローチでは、1つの属性に対する更新は、大きなJSONBドキュメント全体ではなく、小さな1行の更新として処理される。これにより、書き込みコストはドキュメント全体サイズに比例せず、常に小さな一定サイズに保たれ、I/O負荷を最小限に抑えることができる。
また、もしJSONBカラムが非常に大きいものの、めったに更新されない(読み込みが主である)場合は、TOASTストレージの圧縮戦略を調整することも検討できる。PostgreSQL 14以降では、デフォルトのpglz圧縮からLZ4圧縮に切り替えることで、読み込み時のCPUオーバーヘッドを低減できる。あるいは、STORAGE EXTERNALオプションを設定して、完全に圧縮をスキップすることも可能である。これにより、ディスク使用量は増えるが、データの読み込み時に解凍処理が不要になるため、CPUリソースの節約になる。これらの設定は、変更後に書き込まれる値にのみ適用され、既存の行は更新されるまで元の形式を維持する。
まとめると、PostgreSQLでJSONB型を使う際は、その便利さと引き換えに潜在的なパフォーマンス問題があることを理解しておくことが重要である。特に、2KBを超えるような大きなJSONBデータを頻繁に更新するシナリオでは、極端なTOASTライトアンプリフィケーションとWALの肥大化が必ず発生する。このような問題を避けるためには、JSONB内部で頻繁に更新されるキーをメインテーブルのリレーショナルカラムに昇格させる、あるいは、動的なキーバリューペアを専用の正規化されたテーブルに分解するといったアーキテクチャ上の工夫が必要である。そして、データベースのWAL生成量を定期的に監視し、JSONBの更新による予期せぬI/O負荷の増大がないかを確認することが、安定したシステム運用には不可欠である。