巨大JSONに潜む「メモリの罠」と、Node.jsストリーム処理の真実
こんにちは。日々の開発で「なぜか本番環境でだけOOM(Out of Memory)エラーが起きる」と頭を抱えた経験はありませんか?
多くの場合、犯人は「巨大なJSONファイル」の読み込み処理です。Node.jsで `fs.readFileSync` や `JSON.parse` を使って数GBのファイルを一度にメモリへ展開すれば、V8エンジンのヒープメモリ制限にあっという間に到達します。
今日は、Node.jsのストリーム(Stream)という「バケツリレー」の概念を使って、メモリを一切枯渇させずに数ギガバイトのJSONを捌く技術を伝授します。これをマスターすれば、あなたのシステムは「データ量が増えても壊れない」堅牢なアーキテクチャへと進化します。
—
1. なぜ「JSONStream」なのか?:ストリーム処理の哲学
通常、`JSON.parse()` は文字列全体をメモリにロードする必要があります。しかし、`JSONStream` ライブラリは違います。ファイルの中身を小さな「チャンク(断片)」として読み込み、JSONの構造を解析しながら、データが一個完成するたびにイベントを発火させます。
イメージとしては、「巨大な倉庫から荷物を一気に運び出す」のではなく、「ベルトコンベアに乗って流れてくる荷物を、一つずつその場で検品して箱詰めする」という感覚です。
セットアップ
まずはプロジェクトを作成し、必要なツールを導入しましょう。
プロジェクトの初期化
mkdir json-stream-lab && cd json-stream-lab
npm init -y
JSONStreamのインストール(必須)
fsモジュールはNode.js標準なので追加インストール不要です
npm install JSONStream
—
2. HelloWorld:ストリームによる「メモリに優しい」解析
まずは、巨大なJSON配列(`items.json`)を想定して、中身を一つずつ取り出す最も効率的な書き方を見てみましょう。
items.json (サンプルデータ)
[
{“id”: 1, “name”: “Apple”},
{“id”: 2, “name”: “Banana”},
{“id”: 3, “name”: “Cherry”}
// …実際にはこれが数百万行続く
]
index.js (ストリーム処理の実装)
const fs = require(‘fs’);
const JSONStream = require(‘JSONStream’);
// 読み込みストリームを作成
const stream = fs.createReadStream(‘items.json’, { encoding: ‘utf8’ });
// JSONStreamを使って、配列の各要素(”)をパース対象として指定
const parser = JSONStream.parse(”);
// ストリームをパイプラインで繋ぐ
stream.pipe(parser)
.on(‘data’, (data) => {
// ここで各データが1つずつ処理される。
// メモリには常に現在の1要素分しか存在しないため、OOMは発生しない。
console.log(‘処理したアイテム:’, data.name);
})
.on(‘end’, () => {
console.log(‘— すべての処理が完了しました —‘);
})
.on(‘error’, (err) => {
console.error(‘パースエラー:’, err);
});
このコードの肝は `.pipe()` です。これは「読み込み元(ReadStream)」から「処理者(TransformStream)」へ、データが溢れないように自動的に流量を制御(バックプレッシャー)しながら橋渡しをする魔法のメソッドです。
—
3. 実務で直面する「バッチ処理」の最適化
実務では、「パースしたデータをそのままDBへ投入したい」というケースがほとんどです。ここで注意すべきは「DBへの書き込み速度は、ファイル読み込み速度より圧倒的に遅い」という事実です。
ファイルからデータを高速に読み込みすぎてDBがパンクしないよう、バッチ処理(まとめて挿入)を組み合わせるのがアーキテクトの腕の見せ所です。
const fs = require(‘fs’);
const JSONStream = require(‘JSONStream’);
let batch = [];
const BATCH_SIZE = 100; // 一度に処理する件数
fs.createReadStream(‘large_data.json’)
.pipe(JSONStream.parse(”))
.on(‘data’, async (item) => {
batch.push(item);
// バッチサイズに達したら書き込み
if (batch.length >= BATCH_SIZE) {
// 処理を一時停止(バックプレッシャーの制御)
parser.pause();
await db.insertMany(batch); // ここでDBに一括登録
batch = []; // バッチをクリア
// 再開
parser.resume();
}
});
なぜこのアプローチが最強なのか?
1. 定数メモリ消費: どんなに巨大なファイルでも、メモリ消費量は「バッチサイズ分」で一定です。
2. バックプレッシャー: `parser.pause()` を活用することで、DBの書き込みが完了するまでファイルの読み込みを「待機」させます。これにより、サーバーがデータで溺れることを防ぎます。
—
最後に:エンジニアとしての視点
ストリーム処理を使いこなせるようになると、コードから「ファイルサイズ」という変数が消えます。どんなに大きなデータが来ても、あなたのプログラムは淡々と、しかし確実に仕事をこなす「プロフェッショナルな挙動」を示すようになります。
まずは、手元の巨大なJSONファイルを読み込むところから始めてみてください。「あれ、こんなに速く、かつメモリを食わずに終わるのか!」という感動が、あなたの開発者人生のスタンダードになるはずです。
何か詰まったら、いつでも聞いてくださいね。現場の壁を突破するための知見は、まだまだたくさんありますよ。