Mongooseの「VersionError」を修正:ドキュメントの同時更新を処理する方法

intermediate🍃 MongoDB2026-06-16| Node.js (v16+), Mongoose (v6.x, v7.x, v8.x), MongoDB (v4.4+), Linux/Docker

Error Message

VersionError: No matching document found for id "65f3a2..." version 0 modifiedPaths "status"
#mongoose#mongodb#nodejs#並行性#バックエンド

午前2時のオンコール・ナイトメアログを監視していると、突然 VersionError のアラートが急増しました。ステージング環境では低トラフィックで完璧に動作していた注文処理パイプラインが、本番環境の実際のユーザーによる負荷に耐えきれず失敗しています。ログには、繰り返し発生している原因が表示されています:

VersionError: No matching document found for id "65f3a2..." version 0 modifiedPaths "status"

これはランダムなバグではありません。ECサイトのタイムセールなど、1秒間に100件以上のリクエストが同じ在庫レコードを更新しようとする高トラフィックなシナリオで発生する兆候です。ローカル環境では安定しているように見えても、本番環境のレースコンディション(競合状態)によって、Mongooseが有効な更新を拒否しているのです。

根本原因:オプティミスティック並行性制御Mongooseは __v(バージョンキー)と呼ばれるプロパティを使用してドキュメントの一貫性を管理します。これは**オプティミスティック並行性制御(Optimistic Concurrency Control)**を実装したものです。findById を使用してドキュメントを取得すると、Mongooseはその時点のバージョン(例:__v: 0)を記録します。最終的に doc.save() を呼び出す際、Mongooseは単にIDでフィルタリングするだけではありません。MongoDBに対して以下のような特定のクエリを送信します:

db.collection.updateOne(
  { _id: "65f3a2...", __v: 0 },
  { $set: { status: "shipped" }, $inc: { __v: 1 } }
)

もし別のプロセスがわずか10ミリ秒前に同じドキュメントを更新していた場合、データベース内の __v はすでに 1 になっています。依然として __v: 0 を探している更新クエリは、一致するドキュメントを見つけることができません。Mongooseは修正されたドキュメントがゼロであることを検出し、まだ確認していないデータで上書きしてしまうのを防ぐために VersionError をスローします。

競合パターンの特定サービスレイヤーで、以下の特定のシーケンスを確認してください:

  • 取得(Fetch): const doc = await Model.findById(id);- ロジック(Logic): CPU負荷の高いタスクの実行や、他のAPIコールの待機。- 変更(Mutate): doc.status = 'processed';- 保存(Save): await doc.save();ステップ1からステップ4までの間隔が長ければ長いほど、リスクは高まります。2つのリクエストが同時にステップ1を開始した場合、両方が __v: 0 を保持することになります。先に完了した方が勝ち、2番目のリクエストはクラッシュします。

VersionErrorを解決するための実証済みの戦略### オプション1:単純な変更にはアトミックな更新を使用するもし pre('save') フックや複雑なスキーマバリデーションに依存していない場合は、「取得して保存」のサイクルをスキップしてください。findOneAndUpdateupdateOne を使用します。これらのコマンドはデータベース上で直接実行され、デフォルトではMongooseの内部バージョン管理を無視します。

// 高速で競合が発生しない
await Model.updateOne(
  { _id: id },
  { $set: { status: 'shipped' } }
);

なぜこれでうまくいくのか: 「読み取り・変更・書き込み」のサイクルが排除されるからです。1ミリ秒前に何が起こったかに関係なく成功する単一の操作となります。

オプション2:リトライラッパーを実装するビジネスロジックが複雑すぎて .save() から切り離せない場合は、競合を適切に処理する必要があります。エラーをキャッチし、データをリフレッシュして、再度試行します。シンプルな3回のリトライループで、並行性スパイクの99%は解決することが多いです。

async function safeUpdate(id, newStatus, retries = 3) {
  for (let attempt = 1; attempt  setTimeout(res, 50 * attempt));
        continue;
      }
      throw error;
    }
  }
}

オプション3:'optimisticConcurrency' オプションを有効にするMongoose 5.10以降では、スキーマレベルでこれを処理する組み込みの方法が導入されました。optimisticConcurrency: true を設定すると、Mongooseはすべての保存操作に自動的にバージョンチェックを追加しますが、アプリケーションコードで発生するエラーをキャッチして処理する必要があることに変わりはありません。

修正のテストPromise.all を使用して同じIDに対して同時に更新をトリガーすることで、ローカルでレースコンディションをシミュレートできます:

const doc = await Order.create({ status: 'pending' });

// 2つの更新を同時に実行する
try {
  await Promise.all([
    safeUpdate(doc._id, 'shipped'),
    safeUpdate(doc._id, 'delivered')
  ]);
  console.log('Handled concurrent updates successfully.');
} catch (err) {
  console.error('Update failed:', err.message);
}

まとめ- バージョンキーはデータを保護する: __v フィールドはバグではなく、安全機能です。あるユーザーの変更が別のユーザーによって静かに上書きされる「更新の紛失(lost updates)」を防ぎます。- メモリ保持時間を最小限にする: ドキュメントの取得から保存までの時間をできるだけ短くしてください。- 適切なツールを選択する: ミドルウェアやバリデーションが必要な場合は .save() を使用します。高頻度で単純なステータスの切り替えやカウンターの場合は updateOne() を使用してください。

Related Error Notes