TL;DR
MongoDBはパイプラインステージごとにRAMを100MBに制限しています。allowDiskUse: trueを追加すれば、クラッシュの代わりに中間データをディスクにスピルしてクエリをすぐに解除できます。本番環境で使用する場合は、パイプラインの早い段階に$matchを組み合わせて、重い処理ステージに到達するデータ量を減らしてください。
// クイックフィックス — Node.jsドライバー
db.collection('orders').aggregate(
[
{ $group: { _id: '$customerId', total: { $sum: '$amount' } } },
{ $sort: { total: -1 } }
],
{ allowDiskUse: true } // ← これを追加
);
発生する原因
$group、$sort、$bucketなどのステージはブロッキング処理です。出力を生成する前に、すべての入力を蓄積しなければなりません。MongoDBはステージごとにその蓄積量をRAM 100MBに制限しています。この上限に達すると、MongoDBは利用可能なメモリをすべて消費しないよう、クエリを停止します。
この問題が確実に発生する状況をいくつか挙げます:
- フィルタリングされていない大規模なコレクション(数千万件のドキュメント)に対するグループ化
$limitの前に$sortを実行する — MongoDBは絞り込む前にすべてをソートしなければならない- 多数のドキュメントに対して
$pushや$addToSetで配列を構築する wiredTigerCacheSizeGBが低い共有クラスター(例:1GBインスタンスで0.5GB)
修正1:allowDiskUse(回避策)
ディスクスピルを使うと、MongoDBはすべてをRAMに保持する代わりに、ステージの中間状態を一時ファイルに書き込めます。クエリは完了しますが、処理は遅くなります。高速なNVMe SSDでは、インメモリ実行の5〜20倍のレイテンシが見込まれます。HDDやネットワークストレージではさらに悪化します。
mongosh / mongoシェル
db.orders.aggregate(
[
{ $group: { _id: '$customerId', total: { $sum: '$amount' } } }
],
{ allowDiskUse: true }
);
Node.js(mongodbドライバー)
const cursor = collection.aggregate(pipeline, { allowDiskUse: true });
const results = await cursor.toArray();
Python(pymongo)
results = list(collection.aggregate(pipeline, allowDiskUse=True))
Mongoose
const results = await Order.aggregate(pipeline).allowDiskUse(true);
適している用途:単発レポート、管理者エクスポート、データ移行。適していない用途:ユーザーリクエストに応えるAPIや数秒ごとに更新されるダッシュボード。このパイプラインを頻繁に実行する場合は、次の修正方法がより重要になります。
修正2:早期フィルタリングで処理対象データを削減する
$matchを最初に配置してください。この一つの変更が、他のどの方法よりもメモリに効果をもたらします。MongoDBがその後に続くすべての重い処理ステージで処理するドキュメント数が減少するためです。5000万件の注文コレクションを先月の完了済み注文に絞り込むと、$groupに到達する前に20万件程度まで減少することがあります。
db.orders.aggregate([
// ✅ グループ化の前にフィルタリング — インデックスを使用し、処理対象を縮小
{ $match: {
createdAt: { $gte: new Date('2024-01-01') },
status: 'completed'
}},
{ $group: {
_id: '$customerId',
total: { $sum: '$amount' },
count: { $sum: 1 }
}}
]);
$matchフィールドにインデックスがない場合、MongoDBはコレクション全体をスキャンします。インデックスを作成してください:
db.orders.createIndex({ createdAt: 1, status: 1 });
修正3:必要なフィールドのみを射影する
$matchの直後にフィールドを削除してください。埋め込み配列や大きなテキストフィールドを持つドキュメントは1件あたり数キロバイトになることがあります。$groupで使用しないフィールドをすべて除外すると、処理対象のデータサイズを50〜80%削減できます。
db.orders.aggregate([
{ $match: { status: 'completed' } },
// 重いフィールド(例:埋め込まれた明細行配列)を早期に削除
{ $project: { customerId: 1, amount: 1, _id: 0 } },
{ $group: {
_id: '$customerId',
total: { $sum: '$amount' }
}}
]);
修正4:無制限の$push / $addToSetを避ける
$group内の$pushは、そのグループの各ドキュメントに対して1エントリを追加します。1万人の顧客に分散した100万件の注文では、平均で1配列あたり100アイテムになり、各アイテムにデータが含まれる場合は急速に増大します。代わりにカウントするか、グループ化後に配列サイズを制限してください。
// ❌ 巨大な配列が生成される可能性がある
{ $group: { _id: '$userId', items: { $push: '$productId' } } }
// ✅ 必要なのがそれだけなら、代わりにカウントする
{ $group: { _id: '$userId', itemCount: { $sum: 1 } } }
// ✅ またはグループ化後に配列サイズを制限する
{ $project: { items: { $slice: ['$items', 100] } } }
修正5:サーバーレベルの制限を引き上げる(MongoDB 6.0以降)
MongoDB 6.0以降、internalQueryMaxBlockingSortMemoryUsageBytesがステージごとの上限を制御します。デフォルトは104,857,600バイト(ちょうど100MB)です。引き上げることで余裕が生まれますが、暴走クエリが失敗する前により多くのRAMを消費する可能性があるため、このトレードオフを理解した上で調整してください。
// 制限を500MBに引き上げる(デフォルトは100MB = 104857600バイト)
db.adminCommand({
setParameter: 1,
internalQueryMaxBlockingSortMemoryUsageBytes: 524288000
});
MongoDB Atlasではこのパラメーターは公開されていません。Atlasでの選択肢は、パイプラインを最適化するかクラスタートップグレードのみです。
確認:修正が適用されたかを確認する
explainを使ってパイプラインを実行し、各ステージで何が起きたかを確認します:
const explain = await db.orders.aggregate(
pipeline,
{ allowDiskUse: true, explain: true }
).toArray();
console.log(JSON.stringify(explain, null, 2));
ステージの出力でusedDisk: trueを探してください。これはそのステージがディスクにスピルしたことを確認します。usedDiskキーがない(またはfalse)場合はインメモリで実行されています。本番環境では、変更前後のoperationTimeを比較して実際の改善効果を測定してください。
クイックリファレンス
- 即時修正:aggregateオプションに
allowDiskUse: trueを追加 - ベストプラクティス:最初に
$match、次に$project、最後にグループ化 - インデックス確認:
$matchフィールドにインデックスが設定されているか確認 - 避けるべきこと:大規模コレクションでの無制限な
$push/$addToSet - Atlasユーザー:クラスタートップグレードまたはパイプラインの最適化 — サーバーパラメーターへのアクセス不可

