エラーの内容
MongoServerError: WriteConflict error: this operation conflicted with another operation. Please retry your operation or multi-document transaction.
2つの同時トランザクションが同じドキュメントに同時に書き込もうとし、あなたのトランザクションが負けた状態です。MongoDBは楽観的同時実行制御を採用しており、両トランザクションを並行して実行した後、コミット時に競合を検出し、一方を中断します。これは想定された動作であり、ドライバーのバグではありません。キャッチしてリトライするのはアプリケーション側の責任です。
よくある原因:
- 複数のワーカーが注文を処理し、同じ在庫ドキュメントを同時に更新している
- 並行するAPIリクエストが数ミリ秒以内に同じユーザーアカウントを更新している
- 共有データセットに対して重複するトランザクションを実行するバッチジョブ
手順を追った修正方法
ステップ1:リトライロジックの実装(まずここから)
WriteConflictは一時的なエラーです — MongoDBは明示的にリトライを推奨しており、通常は次の試行で成功します。メッセージ文字列のパターンマッチングではなく、TransientTransactionErrorエラーラベルを確認してください。文字列マッチングはドライバーバージョン間で壊れる可能性がありますが、ラベルは安定しています。
Node.js(ネイティブドライバーまたはMongoose):
async function runWithRetry(session, fn, maxRetries = 3) {
let attempt = 0;
while (attempt setTimeout(r, 50 * attempt)); // 指数バックオフ
continue;
}
throw err;
}
}
}
// 使用例
const session = await mongoose.startSession();
try {
await runWithRetry(session, async () => {
await Order.findByIdAndUpdate(orderId, { status: 'processing' }, { session });
await Inventory.findByIdAndUpdate(itemId, { $inc: { stock: -1 } }, { session });
});
} finally {
await session.endSession();
}
Python(pymongo):
from pymongo.errors import PyMongoError
import time
def run_with_retry(client, fn, max_retries=3):
for attempt in range(max_retries):
with client.start_session() as session:
try:
with session.start_transaction():
fn(session)
return # コミット成功
except PyMongoError as e:
if e.has_error_label("TransientTransactionError") and attempt {
const price = await fetchPriceFromExternalAPI(); // ネットワーク呼び出し = 非推奨
await Order.create([{ price }], { session });
});
// 良い例:外部で取得し、内部で書き込む
const price = await fetchPriceFromExternalAPI();
await session.withTransaction(async () => {
await Order.create([{ price }], { session });
});
トランザクション時間を1秒未満に抑えることを目標にしてください。MongoDBのデフォルトtransactionLifetimeLimitSecondsは60秒ですが、同じドキュメントに触れる同時トランザクションが数件を超えると競合が急速に積み重なります。
ステップ3:一貫した順序でドキュメントにアクセスする
複数のトランザクションが同じドキュメントセットに触れる場合、常に同じソート順でアクセスしてください。これにより、繰り返し競合を引き起こすデッドロックサイクルを防ぎます。
// ドキュメントIDをソートしてから反復 — すべてのトランザクションで同じ順序を使用
const docIds = [id1, id2, id3].sort((a, b) => a.toString().localeCompare(b.toString()));
for (const id of docIds) {
await collection.updateOne({ _id: id }, update, { session });
}
ステップ4:可能な場合はトランザクションをアトミックな単一ドキュメント操作に置き換える
MongoDBの単一ドキュメント操作は常にアトミックであり、WriteConflictエラーは発生しません。トランザクションが1つのドキュメントにしか触れない場合、トランザクション自体が不要です。
// アトミックなデクリメント — トランザクション不要、WriteConflict発生なし
const result = await Inventory.findOneAndUpdate(
{ _id: itemId, stock: { $gt: 0 } },
{ $inc: { stock: -1 } },
{ returnDocument: 'after' }
);
if (!result) {
throw new Error('在庫切れ');
}
ステップ5:トランザクションのライフタイム設定を確認する
MongoDBはtransactionLifetimeLimitSecondsを超えたトランザクションを中断します。現在の値を確認し、競合が積み重なっている場合は短縮してください — 制限を短くすることでより早く失敗し、ロックを早く解放できます:
// 現在の値を確認
db.adminCommand({ getParameter: 1, transactionLifetimeLimitSeconds: 1 })
// 30秒に短縮して早期失敗とロック解放を促進
db.adminCommand({ setParameter: 1, transactionLifetimeLimitSeconds: 30 })
修正の確認
リトライラッパーをデプロイしたら、競合がアプリケーションエラーとして表面化するのではなく、適切にキャッチされていることを確認してください:
# ログ内のWriteConflict発生回数をカウント(JSONログ形式)
grep -c "WriteConflict" /var/log/mongodb/mongod.log
# serverStatusでトランザクションメトリクスを確認
db.serverStatus().metrics.operation
# リアルタイムで監視
watch -n 2 'mongo --quiet --eval "printjson(db.serverStatus().metrics.operation)"'
リトライラッパーに本番環境でのリトライ率追跡を組み込んでください。総トランザクション試行回数の5%を超えるリトライ率は警告サインです。その場合、リトライでは問題を解決できません — ループを絞り込むのではなく、データモデルの修正が必要なホットドキュメント問題を抱えています。
継続的な競合:リトライでは不十分な場合
設計上、一部のドキュメントは競合を引き起こしやすい構造になっています — グローバルカウンター、共有カート、毎秒数千回の書き込みを受けるライブリーダーボードなどです。リトライは繰り返し衝突し続けます。代わりにデータモデルを再考する必要があります:
- バケットパターン:ホットドキュメントをNシャードに分割し、書き込みごとにランダムに1つを選び、定期的にマージする
- キューベースのシリアライゼーション:ホットドキュメントへの書き込みをジョブキューを持つ単一ワーカーを通じてルーティングする
- 事前集計カウンター:増分をユーザーごとまたはセッションごとのドキュメントに書き込み、読み取り時に集計する
次に進む前のチェックリスト
- リトライラッパーが
TransientTransactionErrorラベルを確認している(文字列マッチングではなく) - リトライに指数バックオフを使用している — タイトなループではない
- トランザクションブロック内にネットワーク呼び出し、ファイルI/O、重い計算処理がない
- 同時トランザクション間で一貫したソート順でドキュメントにアクセスしている
- 単一ドキュメントの更新が読み取り→変更→書き込みの代わりにアトミック演算子(
$inc、$set)を使用している - 本番環境でリトライ率を監視し、5%を超えた場合にアラートが出るようにしている

