JSONファイルが大きくなると実際に壊れるもの
JSONツールにおけるサイズの閾値はどれも特定の限界に由来し、人が警告してくる限界はたいてい間違ったほうの限界です。
このページの記述はすべて実測か出典付きです。そのどちらでもない場合は、そのことを明記しています。
エクスポートが40MBの.jsonファイル1つとして届きます。エディタのシンタックスハイライトは諦め、ウェブの整形ツールに貼り付けるとタブが数秒間まっ白になります。誰かがストリーミングを勧め、別の誰かが再帰でスタックが飛ぶと言い、そのどちらの助言も、あなたがまだ抱えていない問題に向けられています。
役に立つ問いは「このファイルは大きいか」ではなく「いま自分はどの限界にぶつかろうとしているか」です。限界は4つほどしかなく、決まった順序でやってきます。
サイズの階段
| サイズ | 何が起きるか |
|---|---|
| 1MB | 何も。ネイティブのパースは数ミリ秒、オブジェクトの木は数十MB。出来の悪いものも含め、どのツールも動きます。 |
| 10MB | ネイティブのJSON.parseで約158ms。当サイトのようにJavaScriptで書かれたトークナイザーは、同じ入力の整形に約780msかかります。それに対して辿れるノードの木を組むと約294MBのヒープを消費します。まだ全部動きますが、同期パースはもうクラッシュに見えるだけの長さになっています。 |
| 100MB | 同じ数字を外挿してください。JSトークナイザーでは数秒のパース、木はギガバイト単位。ここからブラウザのタブは時間ではなくメモリで死に始めます。サーバー側ではまだ日常です。 |
| 500MB以上 | V8は単一の文字列を536,870,888文字、ASCIIで約512MBに制限します。ChromeとNodeではファイルを文字列として読み込むことすらできないので、その手順の上に建てられたツールは、どう書かれていようと触れられません。 |
最初の3行は当サイト自身のツールでの実測で、あなたのパーサーでは違う数字になります。4行目は性能の数字ではなく、エンジンの硬い天井です。
512MBの壁
ブラウザのJSONツールはどれも同じ道を通ります。ファイルを文字列に読み込み、その文字列をパーサーに渡す。非常に大きなファイルが死ぬのはこの最初の手順です。エンジンの最大文字列長は固定の定数であり、それを超えるものに対するFileReader.readAsTextやResponse.text()は、あなたのコードが走る前に例外を投げます。
この定数はエンジンごとに違います。V8は536,870,888文字(64ビットのNodeではrequire('buffer').constants.MAX_STRING_LENGTH)、SpiderMonkeyは1,073,741,822、JavaScriptCoreは2,147,483,647で止まります。したがってFirefoxとSafariはChromeが拒むファイルを生き延びますが、3つとも天井はあるので、どこでも動く必要のあるツールはV8の値に合わせて設計されます。
ReadableStream上のチャンク単位のパーサーはもう少し先まで行けます。文書を1つの文字列として具体化しないからです。それは文字列の上限を解決しますが、次の問題は解決しません。パース結果もまたメモリに収まらなければならない、という問題です。数百MBを超えるものはブラウザの問題ではありません。シェルか、言語のランタイムか、データベースへ移してください。
パース後の木がなぜファイルよりずっと大きいのか
10MBのテキストが約294MBのヒープになると人は驚きますが、算術に不思議はありません。ディスク上で18バイトの{"id":1,"ok":true}を考えます。メモリ上ではこうなります。
- ヘッダーと、自身の形状(プロパティマップ)へのポインタを持つオブジェクト。
- 値の手前に、プロパティごとにポインタ幅のスロットが1つ。ポインタ圧縮ありの64ビットビルドで4バイト、なしで8バイト。
- 文字列の値はそれぞれ自分のヘッダー、長さフィールド、文字データを抱え、文字列のキーもエンジンがインターン化していない限り同じ。
- 小さな整数でない値は、自分のヘッダーを持つ別のヒープセルとして格納され、さらにもう1つのポインタを辿って到達される。
オーバーヘッドはバイトごとではなくノードごとなので、データが構造化されるほど比率は悪化します。長い文字列1本の10MBは安く、キーが8つある小さなオブジェクト8万個の10MBは安くありません。素のJSON.parseの結果は、ノードごとのメタデータを抱えるビューアーの木より軽いとはいえ、それでも元の何倍かです。1桁分を見込んでおき、それから自分のデータ形状で測ってください。
再帰にまつわる神話の訂正
よくある警告は、深くネストしたJSONはパース時にスタックを飛ばす、というものです。ブラウザで、いまのエンジンでは、これはもう正しくありません。V8はv7.6で再帰的なJSONパーサーを反復的なものに置き換え、100万段のネストも文句なく読みます。Node v24.15.0(V8 13.6.233.17)で計測したところ、かつては致命的だった深さでもパースは平気でした。
失敗は反対側へ移りました。JSON.stringifyはいまも再帰し、数千段下でRangeErrorを投げます。同じビルドでは4,800前後です。
const deepText = '{"a":'.repeat(1000000) + '1' + '}'.repeat(1000000);
const deep = JSON.parse(deepText); // 問題なし、100万段
JSON.stringify(deep); // RangeError: Maximum call stack size exceeded
この数字は定数ではありません。ランタイムが開始したときのスタックサイズや、その呼び出し時にスタックに載っている他のものによって動くので、設計の基準にできる値ではありません。
つまりサービスは、敵対的なペイロードを受け取り、文句も言わずパースし、保存し、そのあと同じ値をログに書こうとしたり再出力しようとしたところで倒れうるのです。深さの制限は依然として入力の境界に置くべきものです。生き延びるのが入力側だとしても、です。
ほかのランタイムは両方向に対してより厳しい。CPythonのjsonモジュールはデコードでもエンコードでも再帰するので、深くネストした入力は入る時点でRecursionErrorになります。そこまでどれだけ潜れるかはビルド次第で、ここではCPython 3.14のCスキャナーが14,000段あたりで力尽きました。V8が受け入れる深さにはるかに届きません。言語の境界をまたぐなら、あなたのサービスが耐える深さは、その経路でいちばん厳しいホップの深さです。
ストリーミング、そして人が間違える箇所
ストリーミングとは、文書全体を決して保持しないということです。主要な言語にはどれもこのためのプル型パーサーがあります。
Pythonのijsonは、接頭辞パスに一致する値を1つずつ返します。接頭辞records.itemは「トップレベルのキーrecordsにある配列の各要素」を意味し、itemはフィールド名のプレースホルダではなく配列要素を表すリテラルなトークンです。初めて使う人が間違えるのがこの点です。
import ijson
total = 0
with open("events.json", "rb") as f: # テキストではなくバイナリモード
for record in ijson.items(f, "records.item"):
if record["status"] == "failed":
total += 1
print(total)
ijsonはインポート時に利用できる最速のバックエンドを選びます。Cのバックエンドは純Pythonのフォールバックよりはるかに高速です。ストリーミングは遅い、と結論づける前に、どれが選ばれたか確認してください。
Goのencoding/jsonはDecoderで同じことをしますが、罠の性質が違います。トップレベルの配列に対してDecodeを1回呼ぶと、配列全体が1つのスライスにデコードされます。まさに避けようとしていたことです。まず開き括弧をトークンとして読み進め、それから要素ごとにデコードする必要があります。
f, err := os.Open("events.json")
if err != nil { log.Fatal(err) }
defer f.Close()
dec := json.NewDecoder(f)
if _, err := dec.Token(); err != nil { log.Fatal(err) } // '[' を読む
for dec.More() {
var r Record
if err := dec.Decode(&r); err != nil { log.Fatal(err) }
process(r)
}
Nodeではstream-json(部分木を選ぶPickと要素を流すStreamArray)や、より古いJSONStreamが同等のことをし、JVMではJacksonのJsonParserが同じトークンループを与えます。いずれも、文書全体を一度に必要とする操作を諦めることで、一定のメモリ使用量を買っています。
問題は形式のほうだった
巨大なJSON配列をストリーミングするという作業は、そのファイルが1つの配列であるべきではなかったからこそ発生しています。NDJSON、すなわち1行に完結したJSON値を1つ置く形式は、この問題の分類ごと消し去ります。1行読み、1行パースし、捨てる。メモリは最大の1レコードで頭打ちになります。splitで分割でき、テキストとしてgrepでき、書き換えずに追記でき、書き込みが途中で切れても失うのはファイルではなく1レコードです。
いま配列に縛られていて明日は行にしたいなら、NDJSON→JSON変換が双方向に変換し、JSONビューアーはどちらも開きます。
780msがなぜ「壊れたタブ」なのか
同期パースはメインスレッドを握ります。何も描画されず、クリックも受け付けられません。おおよそ100msを超えると操作は即時に感じられなくなり、1秒を超えるとページは固まったものとして読まれ、利用者は再読み込みに手を伸ばします。再読み込みはパースをやり直します。
対策は速いパーサーではなく、描画するスレッドから作業を外すことです。当サイトの整形ツールはWeb Workerでパースと整形を行うので、タブは描画を続け、進捗表示はブロッキング呼び出しの直前に掲げた嘘ではなく実際の状態になります。この構造上の選択は、トークナイザーのどんなマイクロ最適化よりも効きます。
答えがツールではない場合
上のどれかに手を伸ばす前に、やっておく価値のあることがあります。
まずjqで絞り込み、読み込むものを小さくする。
jq -c '.records[] | select(.status == "failed")' events.json > failed.ndjson
素のjqは文書全体をメモリに読み込むことに注意してください。RAMより大きなファイルにはjq --streamがあり、こちらはメモリに載せませんが、代わりにかなり風変わりなイベントベースの構文になります。
ちょっと眺めたいだけの一部分なら、フィルタツールがブラウザ内で同じ選択を行い、ミニファイアは整形用の空白を取り除きます。インデント付きのエクスポートでは、それが実際にバイト数のかなりの割合を占めます。
そして時には、これはファイルの問題ではない、というのが正直な答えです。2GBのエクスポートに何度もgrepをかけているなら、一度SQLiteかDuckDBに書き込んでそこで問い合わせれば、以降の問いはすべて安上がりになります。何度もパースし直しているファイルは、テーブルになりたいとすでに告げています。