JSON/半構造化データの物理格納とシュレッディング
JSON 列が遅い・重い理由が物理レイアウトから腑に落ちます。BSON/JSONB のバイナリ表現、パス索引、shredding による列展開まで、スキーマレス文書の格納とクエリ最適化を内部から解説します。
- 1.BSON/JSONB はテキスト JSON を再パースせず読めるよう、型タグ・長さ・オフセットを埋め込んだ自己記述バイナリ。キー探索をパース無しの相対ジャンプに変える。
- 2.shredding は文書を列へ事前展開する手法。存在するパスを物理列(または定義レベル付きの繰り返し列)へ写し、スキャン時のパース費用を消す代わりにスキーマ進化のコストを背負う。
- 3.設計判断は『どこでパース費用を払うか』。書き込み時に索引・列へ展開するか、読み取り時に丸ごとパースするか、選択的パスだけを部分実体化するかのトレードオフ。
半構造化データの格納が難しい理由
JSON のようなスキーマレス文書は、行ごとに キーの集合も値の型も入れ子の深さも異なる。リレーショナルな固定スキーマなら「N列目は INT、先頭から固定オフセット」と決め打ちできますが、文書では各行が独自の形を持つため、その前提が全面的に崩れます。素朴にテキストのまま列へ詰めると、クエリのたびに文字列を頭からパースし直す羽目になり、1要素を取り出すだけでも文書全体の走査が必要になります。
この問題への答えは大きく2系統あります。1つは 値を自己記述バイナリに変換して、パースを相対ジャンプに変える(BSON/JSONB)。もう1つは 文書を事前に列へ展開して、読み取り時のパースを消す(shredding)。どちらも「パース費用をいつ払うか」をずらす設計であり、その選択がドキュメント DB やリレーショナル DB の JSON 機能の性格を決めます。
BSON/JSONB のバイナリ表現
テキスト JSON の弱点は、構造情報が 区切り文字としてしか存在しない ことです。{"a":1,"b":[2,3]} から b を読むには、頭から { " ... と1文字ずつ走査して入れ子を数えるしかありません。BSON(MongoDB)や JSONB(PostgreSQL)は、この構造を 明示的なメタデータ としてバイナリに焼き込みます。
| 要素 | テキスト JSON | バイナリ表現(BSON/JSONB) |
|---|---|---|
| 型情報 | 値の字面から推測 | 各値に型タグを前置(int/string/array…) |
| 長さ | 区切り文字を走査して判定 | 長さ/要素数を整数で格納 |
| 子要素の位置 | 走査するまで不明 | 長さ前置で読み飛ばし可(JSONB はさらにオフセット表で直接ジャンプ) |
| 数値 | 10進文字列(再変換が必要) | ネイティブ整数/浮動小数でそのまま読める |
JSONB を例にとると、各 JSON 値は ヘッダ(JEntry)+データ本体 の対で表されます。JEntry は「型(文字列/数値/真偽/オブジェクト/配列)」と「その値の長さまたは終端オフセット」を持つ固定幅のメタデータです。オブジェクトは内部的に キーをソートして保持 し、キー群とそれぞれの JEntry をまとめて並べ、続いて値本体を置きます。これにより data->'b' は キー配列を二分探索 → 対応する JEntry でオフセットを得て値へ直接ジャンプ でき、文書全体のパースが要りません。BSON も思想は同じで、各フィールドが「型バイト+キー名(NUL終端)+値」の連なりとなり、ドキュメント先頭の総長フィールドで丸ごとスキップできます。
速さの源は「アルゴリズムの工夫」ではなく データ表現そのものに長さ・位置情報を持たせた ことです。テキストでは1文字ずつ区切りを数える走査が必要でしたが、各値が長さを自己申告するため不要な部分を一気に読み飛ばせます。JSONB はさらにキーをソートし JEntry のオフセット表を持つので、キー探索が O(log キー数) の二分探索+定数時間ジャンプになります(BSON は offset 表を持たずフィールド列を線形に走査しますが、各値を長さで読み飛ばせる点でテキストより速い)。ストレージ上のページレイアウトでスロット配列がレコードへのオフセットを持つのと同じ発想で(スロットページのレイアウト)、「順に読まないと位置が分からない」を「位置が先に分かる」へ変えるのが鍵です。
ただしバイナリ化には代償があります。キーのソート保持やオフセット表のぶん、テキストより肥大しやすく(短い数値ほど型タグの相対コストが効く)、文書を更新するとオフセットが連鎖的にずれるため 部分更新がしにくい(多くの実装は文書を丸ごと書き直す)。JSONB が挿入時にキー順を正規化し重複キーを畳む一方、読み取り順を保てないのもこの構造ゆえです。
パス索引:文書を「パスの集合」と見る
文書から特定パスを引くだけなら自己記述バイナリで足りますが、「status が active の文書を全部出せ」のような 値での絞り込み には索引が要ります。ここで効くのが、文書を 「ルートからのパスと値」の集合 として索引化する発想です。
PostgreSQL の JSONB に対する GIN 索引がその典型で、文書を (パス, 値) の多数のキーへ展開して逆引きします(GIN 逆引きインデックス)。演算子クラスで展開粒度が変わります。
-- jsonb_path_ops: 「ルートからのパス+値」を1つのハッシュキーへ。@> 専用・索引が小さい
CREATE INDEX ON docs USING gin (data jsonb_path_ops);
SELECT * FROM docs WHERE data @> '{"status":"active"}';
-- 特定パスだけを式索引で B+Tree 化。等値・範囲・ソートに使える
CREATE INDEX ON docs ((data->>'status'));
SELECT * FROM docs WHERE data->>'status' = 'active';
設計判断はこうです。どのパスを引くか事前に分かっているなら、そのパスだけを式索引で B+Tree 化すれば、等値だけでなく 範囲・ORDER BY まで効きます。任意のパスを包含検索したいなら GIN を張る。前者は索引が小さく的確だがパスごとに作る必要があり、後者は1つで広く効くが索引が肥大します。MongoDB のドット記法索引(db.c.createIndex({"a.b": 1}))も思想は同じで、特定のドットパスを B-Tree 化します。配列を含むパスは1文書が複数の索引エントリを生む(マルチキー索引)点が、スカラ列の索引と決定的に異なります。
shredding:文書を列へ展開する
索引が「絞り込み」を速くするのに対し、shredding(シュレッディング、断片化展開) は スキャンとパースそのもの を速くします。アイデアは単純で、文書中に現れるパスを 物理的な列へ事前に展開 してしまう、というものです。
元の文書(1行):
{"id":1, "user":{"name":"a","age":30}, "tags":["x","y"]}
shredding 後(列指向ストレージ上):
列 id : 1
列 user.name : "a"
列 user.age : 30
列 tags : ["x","y"] ← 繰り返しは定義/反復レベルで表現
こうすると、user.age だけを集計するクエリは その列だけを読む(projection pushdown が効く)。文書を丸ごと読んでパースし age を抜く必要がなくなり、列指向圧縮も効くため、分析クエリで桁違いに速くなります。これは Parquet などの列指向フォーマットが入れ子構造を扱う仕組みそのもので(列指向ファイルフォーマット)、入れ子と繰り返しを 定義レベル(definition level)と反復レベル(repetition level) という2つの整数で符号化します。定義レベルは「パス上のどこまでが NULL でなく存在したか」、反復レベルは「繰り返しのどの階層で新要素が始まったか」を表し、この2値があれば平坦な列値の並びから元の入れ子を完全復元できます。
shredding の代償は スキーマ進化 です。文書ごとにパスが違うため、すべてのパスを列にすると 列爆発(列数が際限なく増える)を招き、新しいパスが来るたびに列定義を追加する必要があります。実装はたいてい折衷をとり、頻出する一部のパスだけを shred して列化し、残りは生の JSON(またはバイナリ)を「漏れ落ち先(rest/overflow)」列に丸ごと残す という二層構成にします。SQL Server の JSON 機能や各種 lakehouse の「variant 型+sub-columnarization」がこの形で、よく引かれるパスは列の速度、稀なパスは文書の柔軟性、と両取りを狙います。
shredding はパース費用を 読み取り時から書き込み時へ移す 最適化です。取り込み時にパスを抽出し列へ振り分ける負荷が増え、スキーマが安定していないと列定義の管理コストとスキューが膨らみます。書き込みは軽く読み取り時に毎回パースする素朴な JSON 格納とは正反対のトレードオフで、読み取りが圧倒的に多く、引くパスが安定している分析系 で初めて元が取れます。混在ワークロードでは「全列 shred」ではなく「ホットパスだけ部分実体化(partial materialization)」が現実解になります。
クエリ最適化の設計判断
以上を踏まえると、半構造化データの格納設計は「パース費用をいつ・どこまで払うか」という一本の軸で整理できます。
| 戦略 | パース費用を払う場所 | 向くワークロード |
|---|---|---|
| 生テキスト JSON | 読み取りのたび全文を再パース | 書き込み主体・スキーマ未確定・低頻度アクセス |
| BSON/JSONB(自己記述) | 書き込み時に1回バイナリ化、読みは相対ジャンプ | 文書単位の取得・部分パスの読み出し |
| パス索引(GIN/式索引) | 書き込み時に索引更新、読みは索引から絞り込み | 値での選択的検索・特定パスのフィルタ |
| shredding(列展開) | 取り込み時に列へ展開、読みはパース無しスキャン | 分析・集計・引くパスが安定した大量スキャン |
実システムはこれらを 重ねて 使います。たとえば PostgreSQL なら「JSONB で自己記述格納 → 頻出パスに式索引 → 任意パス検索に GIN」を併用し、lakehouse なら「variant バイナリで取り込み → ホットパスを sub-column へ shred」とします。オプティマイザ側の最適化も対応していて、data->>'status' に式索引があればプランナがそれを認識して索引スキャンへ切り替え、shred 済み列があれば述語と射影をその列へ押し下げます(predicate/projection pushdown)。逆に索引も shred もないパスへの述語は、全行を読んでバイナリをデコードしてから評価するしかなく、ここがスキーマレス設計で最も詰まりやすい点です。
リレーショナルと NoSQL の格納思想の違いを踏まえると(RDB と NoSQL の使い分け)、JSON 機能とは「固定スキーマの速さ」と「文書の柔軟さ」の連続的なスペクトル上で、どのパスをどこまでスキーマ化(shred/索引化)し、どこから先を生の文書に委ねるか を行ごと・パスごとに選べる仕組みだと言えます。
まとめ
半構造化データの物理格納は、すべて「パース費用の置き場所」を巡る設計です。BSON/JSONB は型タグ・長さ・オフセットを文書に焼き込み、キー探索を線形走査から相対ジャンプへ変えます(スロットページと同型の発想)。パス索引 は文書を (パス, 値) の集合と見て選択的検索を速くし(GIN・式索引)、shredding はパスを物理列へ前倒し展開してスキャンとパースを消し去ります(列指向フォーマットの定義/反復レベルがその基盤)。万能解はなく、引くパスの安定度・読み書き比・スキーマ進化の頻度を見て、これらを重ねて使うのが半構造化データ設計の要諦です(RDB と NoSQL の中間に位置づけて捉えると見通しが良くなります)。
データベース Article
JSON/半構造化データの物理格納とシュレッディングを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
JSONB
比較で見る軸
難易度: advanced / カテゴリ: データベース / タグ数: 5
導入後に効く点
shredding は文書を列へ事前展開する手法。存在するパスを物理列(または定義レベル付きの繰り返し列)へ写し、スキャン時のパース費用を消す代わりにスキーマ進化のコストを背負う。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- データベース
- タグ数
- 5
判断チェックリスト
- 自社の用途が「JSONB / BSON」に近いか確認する。
- 強みである「BSON/JSONB はテキスト JSON を再パースせず読めるよう、型タグ・長さ・オフセットを埋め込んだ自己記述バイナリ。キー探索をパース無しの相対ジャンプに変える。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。