エラーの状況
大量のMongoDBクエリを処理している最中 — 数千件のドキュメントを処理したり、マイグレーションを実行したり、データをエクスポートしたりしていると — ループの途中でこのエラーに遭遇します:
MongoServerError: cursor id not found
最初の数百件のドキュメントは問題なく処理できました。その後、カーソルが突然死にます。スクリプトは途中でクラッシュし、最初からやり直しになります。
ほとんどの場合、これはカーソルタイムアウトの問題です。MongoDBのサーバーサイドカーソルには、デフォルトで10分のアイドルタイムアウトが設定されています。バッチ取得の間に処理が遅くなると — 重い計算処理、外部APIコール、大規模なデータセットなど — サーバーはカーソルを強制終了します。ドライバーが次のバッチをリクエストしても、そのカーソルIDはすでに存在しません。
発生する原因
MongoDBのカーソルはバッチ単位で動作します。find()呼び出しはサーバー上にカーソルを作成し、最初のバッチ — デフォルトは101件のドキュメントまたは約1MB — を返します。ドライバーはカーソルIDを保存し、イテレートする際に追加のバッチをリクエストします。
ここに落とし穴があります:カーソルはアプリではなくサーバー上に存在します。新しいバッチリクエストなしで10分以上アイドル状態が続くと、MongoDBのカーソルリーパーがそれを削除します。次にドライバーがそのカーソルIDを送信しても、サーバーはそのIDが何を指しているのかわかりません。
よくあるトリガー:
- ループ内でのドキュメントごとの処理が遅い — APIコール、ディスク書き込み、1件あたり2〜5秒かかる重い計算処理
- 200件のドキュメントを1バッチとして処理するのに10分以上かかるデータセット
- イテレーションを一時停止させるネットワークの中断
- 開いたまますぐに消費されないカーソル
- イテレーション途中でリクエスト間のWebアプリがアイドル状態になる
クイックフィックス:カーソルタイムアウトを無効にする
最も手軽な方法:MongoDBにカーソルをタイムアウトさせないよう指示します。それがnoCursorTimeoutフラグです。
Node.js(mongodbドライバー):
const cursor = db.collection('orders')
.find({ status: 'pending' })
.addCursorFlag('noCursorTimeout', true);
for await (const doc of cursor) {
// 処理が遅くても問題なし
await processDocument(doc);
}
// 処理が終わったら必ずカーソルを閉じる
await cursor.close();
Node.js(mongoose):
const cursor = Order.find({ status: 'pending' })
.cursor()
.addCursorFlag('noCursorTimeout', true);
for await (const doc of cursor) {
await processDocument(doc);
}
await cursor.close();
Python(pymongo):
cursor = db.orders.find(
{'status': 'pending'},
no_cursor_timeout=True
)
try:
for doc in cursor:
process_document(doc) # 処理が遅くても問題なし
finally:
cursor.close() # 重要 — 必ず手動で閉じること
重要な注意点:タイムアウトを無効にすると、MongoDBはカーソルを決して自動的に削除しません。finallyブロックでcursor.close()を必ず呼び出す必要があります。これを省略して処理途中でクラッシュした場合、カーソルがリークし、サーバーメモリを占有し続け、MongoDBが再起動するか手動で強制終了するまでそのままになります。
恒久的な修正:長期カーソルへの依存をやめる
タイムアウトの無効化は一時的な対処法です。根本的な修正は、カーソルが期限切れになるほど長時間開いたままにならないようにコードを再構築することです。
オプション1:skip/limitバッチ処理
処理を小さなチャンクに分割します。各チャンクは数秒で完了する新鮮なカーソルを開きます:
const BATCH_SIZE = 500;
let skip = 0;
while (true) {
const docs = await db.collection('orders')
.find({ status: 'pending' })
.skip(skip)
.limit(BATCH_SIZE)
.toArray();
if (docs.length === 0) break;
for (const doc of docs) {
await processDocument(doc);
}
skip += BATCH_SIZE;
}
toArray()を呼び出すたびに、カーソルを即座に取得して閉じます。開いたカーソルがなければ、タイムアウトのリスクもありません。
オプション2:maxTimeMSを使ったカーソルバッチ処理
ストリーミングが必要だがセーフティネットも欲しい場合は、無限にハングする代わりにカーソルを素早く失敗させるようmaxTimeMSを設定します:
const cursor = db.collection('orders')
.find({ status: 'pending' })
.maxTimeMS(30000) // カーソルのアイドルが30秒を超えたら失敗
.batchSize(200); // 1回のラウンドトリップで200件取得
for await (const doc of cursor) {
await processDocument(doc);
}
オプション3:一括変換にaggregation + $outを使う
マイグレーションや変換ジョブの場合は、アプリにドキュメントを引き出す代わりに、処理をMongoDBにプッシュします:
await db.collection('orders').aggregate([
{ $match: { status: 'pending' } },
{ $addFields: { processedAt: new Date() } },
{ $out: 'orders_processed' } // 結果を新しいコレクションに書き込む
]).toArray();
アグリゲーションは完全にサーバーサイドで実行されます。ネットワークをまたぐカーソルなし、タイムアウトリスクなし、ドキュメントごとのラウンドトリップなし。
オプション4:skip/limitの代わりに_idでページネーション
大規模なコレクション — 100万件以上のドキュメントを考えてみてください — ではskipはコストがかかります。MongoDBはページごとに前のすべてのドキュメントをスキャンします。_idでページネーションすることでこれを完全に回避できます:
const BATCH_SIZE = 500;
let lastId = null;
while (true) {
const query = lastId
? { status: 'pending', _id: { $gt: lastId } }
: { status: 'pending' };
const docs = await db.collection('orders')
.find(query)
.sort({ _id: 1 })
.limit(BATCH_SIZE)
.toArray();
if (docs.length === 0) break;
for (const doc of docs) {
await processDocument(doc);
}
lastId = docs[docs.length - 1]._id;
}
各バッチは新鮮で高速なインデックススキャンを使用します。これは大規模なコレクションスキャンのための定番パターンです。
修正を確認する
うまくいったと思い込まないでください。以下のチェックを実行しましょう。
1. サーバー上の開いているカーソルを確認する:
// mongosh内で実行
db.serverStatus().metrics.cursor
open.noTimeoutを確認します。noCursorTimeoutを使用している場合、スクリプトが終了した後にそのカウントが0に戻るはずです — カーソルがきれいに閉じられたことを確認できます。
2. 遅い処理をシミュレートする:
// 修正のストレステスト用に意図的な遅延を追加
for await (const doc of cursor) {
await new Promise(r => setTimeout(r, 100)); // 1件あたり100ms
}
// 1,000件 × 100ms = 100秒 — 大規模では10分を大幅に超える
// 修正が適用されていれば、エラーなしで完了するはず
3. カーソルアクティビティのMongoDBログをスキャンする:
grep -i "cursor" /var/log/mongodb/mongod.log | tail -20
まとめ
MongoServerError: cursor id not found= 10分のアイドル時間後にサーバーサイドカーソルが期限切れになった- クイックフィックス:
noCursorTimeout: true— ただしfinallyブロックでcursor.close()を呼び出すことが必須、例外なし - より良い修正:
skip/limitバッチ処理または_idベースのページネーション — 短命カーソル、タイムアウトリスクなし - 一括変換:aggregation +
$outを完全にサーバーサイドで実行し、カーソルの問題を完全に回避する

