MongoDBの「MongoServerError: cursor id not found」エラーの修正方法

intermediate🍃 MongoDB2026-03-17| MongoDB 4.x〜7.x、Node.js(mongoose / mongodbドライバー)、Python(pymongo)、任意のOS

Error Message

MongoServerError: cursor id not found
#mongodb#cursor#timeout#query

エラーの状況

大量の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を完全にサーバーサイドで実行し、カーソルの問題を完全に回避する

Related Error Notes