問題:なぜ MongoDB は単一パスへの複数更新をブロックするのか
ユーザープロフィールの更新処理を実装している最中、突然 MongoDB が ConflictingUpdateOperators エラーでプロセスを停止させることがあります。これは、1つの更新コマンドで、同じフィールド(または同じオブジェクトの重複する部分)を複数の演算子を使って同時に変更しようとしたときに発生します。
コンソールには通常、以下のようなエラーが表示されます。
Plan executor error during findAndModify :: caused by :: ConflictingUpdateOperators: Updating the path 'user_metadata' would create a conflict at 'user_metadata'
なぜこのようなことが起こるのでしょうか? MongoDB の更新エンジンは明確さを求めます。1つのコマンド内において、$set、$inc、$push といった演算子の実行順序は保証されません。予測不可能なデータ状態を防ぐため、同じフィールドを2回ターゲットにする操作は、単に拒否される仕組みになっています。
このエラーが発生する主な原因
ほとんどの開発者は、次の2つのパターンのいずれかでこの問題に直面します。
1. 単一フィールドに対する演算子の重複
注文処理を例に考えてみましょう。ステータスを "shipped"(出荷済み)に更新すると同時に、バージョンカウンターをインクリメントしたいとします。同じフィールドに対して2つの異なる指示を与えようとすると、失敗します。
// これは失敗します
db.orders.updateOne(
{ _id: 101 },
{
$set: { "status": "shipped" },
$inc: { "status": 1 } // 競合:'status' は既に $set によって変更されています
}
)
2. 親要素と子要素の重複
これは、より「気づきにくい」パターンのエラーです。オブジェクト全体を上書きしようとしながら、同時にそのオブジェクト内の特定のプロパティも更新しようとしたときに発生します。
// これは失敗します
db.users.updateOne(
{ _id: "user_v42" },
{
$set: { "profile": { "name": "Alice", "role": "admin" } },
$set: { "profile.lastLogin": new Date() } // 競合:'profile' と 'profile.lastLogin' が重複しています
}
)
解決方法
方法1:単一のオブジェクトにまとめる
重複するパスに対して同じ演算子($set など)を使用する場合、最もクリーンな修正方法はそれらを統合することです。2つの別々のパスを指定するのではなく、親フィールドに対して1つの完全なオブジェクトを提供します。
誤った方法:
$set: { "settings": { "theme": "dark" } },
$set: { "settings.fontSize": 14 }
正しい方法:
$set: {
"settings": {
"theme": "dark",
"fontSize": 14
}
}
方法2:ドット記法(Dot Notation)でピンポイントに指定する
親オブジェクト全体を「ロック」するのを避けるために、ドット記法を使用して必要な特定のサブフィールドのみを更新します。この方法は、オブジェクト内の無関係なデータを上書きしないため、最も効率的なアプローチです。
// 安全かつ正確
db.users.updateOne(
{ _id: "user_v42" },
{
$set: {
"profile.name": "Alice",
"profile.role": "admin",
"profile.lastLogin": new Date()
}
}
)
方法3:順次更新を行う
同じフィールドに対して、どうしても異なる演算子(カウント用の $inc とステータス用の $set など)が必要な場合は、データベースへの呼び出しを2回に分ける必要があります。これにより、最初の操作が完了してから2番目の操作が開始されることが保証されます。
// ステップ1:ステータスを更新
await db.collection('tasks').updateOne({ _id: taskId }, { $set: { status: 'complete' } });
// ステップ2:完了回数をインクリメント
await db.collection('tasks').updateOne({ _id: taskId }, { $inc: { updateCount: 1 } });
方法4:更新パイプライン(Update Pipelines)を活用する (MongoDB 4.2以降)
現代的な MongoDB バージョンでは、更新に集計パイプライン(Aggregation Pipeline)を使用できます。パイプラインはステージを厳密な順序で処理するため、通常であれば競合を引き起こすような複雑な変換も実行可能です。
db.products.updateOne(
{ _id: 505 },
[
{ $set: { price: { $add: ["$price", 5.99] } } },
{ $set: { updatedAt: "$$NOW" } }
]
)
解決策の確認
mongosh で修正内容をテストしてください。更新が成功すると、modifiedCount が1以上であるレスポンスが返されます。
{
acknowledged: true,
matchedCount: 1,
modifiedCount: 1,
upsertedCount: 0
}
今後の競合を避けるためのプロのヒント
- Mongoose フックの監査: Mongoose を使用している場合は、
pre('update')フックを確認してください。mongoose-timestampのようなプラグインは、updatedAtに対して$setを自動挿入することが多く、手動での更新と衝突する可能性があります。 - 更新のフラット化: ネストされたオブジェクトよりも、ドット記法(
"user.address.zip": 90210)を優先しましょう。これにより、ドキュメントの他の部分と重複する可能性を減らすことができます。 - オブジェクトのデバッグ: 更新オブジェクトを動的に構築する場合は、ドライバーに渡す前にログを出力してください。500行もあるサービスファイルの中で探すよりも、JSON ログの中で重複キーを見つける方がはるかに簡単です。

