Mongooseトランザクションのよくあるエラーと解決策:Node.js開発者必見
トランザクションとは?
データベースにおけるトランザクションは、複数のデータベース操作をアトミックな(不可分な)一つの処理単位として実行するための仕組みです。これにより、以下のACID特性の一部または全てを保証することができます。
- Durability (永続性): 成功したトランザクションの結果は、システム障害が発生しても失われずに永続的に保持されます。
- Isolation (独立性): 複数のトランザクションが同時に実行された場合でも、それぞれのトランザクションは互いに干渉せず、独立して実行されているように見えます。
- Consistency (一貫性): トランザクションが開始される前と完了した後で、データベースは常に一貫した状態を保ちます。
- Atomicity (原子性): トランザクション内の全ての操作が成功するか、あるいは全てが失敗して元に戻る(ロールバックされる)かのいずれかです。部分的に成功することはありません。例えば、銀行口座の送金処理で、Aさんの口座からお金が引き落とされたのにBさんの口座に入金されなかった、というような矛盾を防ぎます。
MongoDBはバージョン4.0以降でマルチドキュメントトランザクションをサポートしており、Mongooseもそれに対応しています。
Mongooseでトランザクションを使用するメリット
- エラーハンドリングの簡素化: トランザクション内のどこかでエラーが発生した場合、
try...catch
ブロックでエラーを捕捉し、トランザクションをロールバックすることで、手動で変更を取り消す手間を省けます。 - 複雑な操作の安全な実行: 複数のコレクションにまたがる複雑な操作を、安全に実行することができます。
- データの一貫性の保証: 複数の関連するドキュメント(例えば、注文と商品の在庫)を同時に更新する際に、一方の更新が失敗した場合でも、もう一方の更新が適用されないようにできます。
Mongooseでのトランザクションの基本的な使い方
Mongooseでトランザクションを使用するには、主に以下の手順を踏みます。
-
セッションの開始: トランザクションを開始するために、まずセッションを作成します。
const session = await mongoose.startSession();
または、特定のコネクションを使用する場合:
const connection = await mongoose.createConnection(mongodbUri).asPromise(); const session = await connection.startSession();
-
トランザクションの開始と実行: セッションを使ってトランザクションを開始し、その中でデータベース操作を実行します。通常は、
session.withTransaction()
ヘルパー関数かConnection#transaction()
関数を使用するのが推奨されます。これらは、トランザクションの開始、コミット、アボート、一時的なエラー発生時のリトライなどを自動的に処理してくれます。-
session.withTransaction()
の使用(推奨):try { await session.withTransaction(async () => { // トランザクション内で実行するデータベース操作 // 例: ドキュメントの作成、更新、削除など const newUser = await User.create([{ name: 'Alice', balance: 100 }], { session }); await Account.findOneAndUpdate({ userId: newUser[0]._id }, { $inc: { balance: -50 } }, { session }); }); console.log('トランザクションが成功しました。'); } catch (error) { console.error('トランザクションが失敗しました:', error); // withTransaction() は自動的にロールバックを処理します } finally { session.endSession(); // セッションを終了 }
ポイント:
withTransaction()
のコールバック関数内で実行される全てのMongoose操作(save()
,create()
,findOneAndUpdate()
,aggregate()
など)には、必ず{ session }
オプションを渡す必要があります。これにより、それらの操作がトランザクションの一部として実行されることが保証されます。 -
手動でのトランザクション制御(より詳細な制御が必要な場合):
session.startTransaction(); // トランザクションを手動で開始 try { // トランザクション内で実行するデータベース操作 const newUser = await User.create([{ name: 'Bob', balance: 200 }], { session }); await Account.findOneAndUpdate({ userId: newUser[0]._id }, { $inc: { balance: -75 } }, { session }); await session.commitTransaction(); // トランザクションをコミット console.log('トランザクションが成功しました。'); } catch (error) { await session.abortTransaction(); // エラーが発生した場合、トランザクションをロールバック console.error('トランザクションが失敗し、ロールバックされました:', error); } finally { session.endSession(); // セッションを終了 }
この方法では、
startTransaction()
、commitTransaction()
、abortTransaction()
を明示的に呼び出す必要があります。
-
- パフォーマンス: トランザクションは通常、通常の操作よりもオーバーヘッドが大きくなるため、パフォーマンスに影響を与える可能性があります。必要最小限の範囲で利用することが重要です。
{ session }
オプションの渡し忘れ: トランザクション内で実行するMongoose操作に{ session }
オプションを渡し忘れると、その操作はトランザクションの外部で実行されてしまい、アトミック性が保証されなくなります。これはよくある間違いなので注意が必要です。- レプリカセット: MongoDBのトランザクションはレプリカセット上で動作する必要があります。スタンドアロンのMongoDBインスタンスでは使用できません。開発環境でローカルにMongoDBをセットアップする場合、レプリカセットとして設定する必要があります。MongoDB Atlasのようなクラウドサービスを使用している場合は、自動的にレプリカセットで提供されるため、この点を気にする必要はありません。
- MongoDBのバージョン: トランザクションはMongoDB 4.0以降でサポートされています。それ以前のバージョンでは使用できません。
MongoNetworkError: command 'startSession' requires authentication または MongoError: command 'startSession' failed: transaction operations are only supported on replica sets
これは、トランザクションを使用する上で最も一般的なエラーの一つです。
-
トラブルシューティング:
- MongoDBがレプリカセットとして動作しているか確認する:
- ローカル環境でMongoDBを使用している場合、レプリカセットとして初期化する必要があります。例えば、以下のコマンドでレプリカセットを開始できます(
mongod
が起動している状態で)。mongo rs.initiate()
rs.status()
コマンドでレプリカセットの状態を確認できます。 - MongoDB Atlasなどのクラウドサービスを利用している場合、通常は自動的にレプリカセットが提供されるため、この問題は発生しにくいです。
- ローカル環境でMongoDBを使用している場合、レプリカセットとして初期化する必要があります。例えば、以下のコマンドでレプリカセットを開始できます(
- 接続文字列と認証情報を確認する:
mongoose.connect()
またはmongoose.createConnection()
に渡すMongoDBのURIが正しいか確認してください。特に、認証情報(ユーザー名、パスワード)が正しいか再確認します。- MongoDB Atlasを使用している場合、IPホワイトリストにアプリケーションのIPアドレスが追加されているか確認してください。
- MongoDBがレプリカセットとして動作しているか確認する:
-
原因:
- MongoDBがレプリカセットとして動作していない: MongoDBのトランザクションは、レプリカセット(Replica Set)上でしか動作しません。スタンドアロンのMongoDBインスタンスではサポートされていません。
- MongoDBインスタンスへの接続に問題がある: 接続文字列が間違っている、ファイアウォールでブロックされている、認証情報が正しくないなどの問題が考えられます。
MongoError: Transaction was aborted または MongooseError: Session has been aborted
これは、トランザクションが何らかの理由でロールバックされたことを示します。
-
トラブルシューティング:
- エラーの原因を特定する:
try...catch
ブロックを使用して、トランザクション内のどこでエラーが発生しているかを特定し、エラーメッセージを詳しく調べます。- アプリケーションのログを確認し、トランザクション内で発生した他のエラーがないか確認します。
- リトライロジックを実装する:
session.withTransaction()
は、一時的なエラー(TransientTransactionError
など)が発生した場合に自動的にリトライするロジックを内蔵しています。しかし、永続的なエラー(MongoError: TransactionAborted
など)の場合はリトライされません。- 必要に応じて、手動のトランザクション制御を使用している場合は、特定のエラーコード(例えば、
TransientTransactionError
に対応する112
)をチェックして、適切なリトライ戦略を実装することを検討します。
- 長時間実行される操作を避ける:
- トランザクション内で非常に時間がかかる操作(大規模なデータ処理など)を実行しないようにします。もし必要であれば、処理を分割するか、トランザクションの範囲を見直すことを検討します。
- 競合を減らすための設計:
- 同時に多くのトランザクションが同じリソースにアクセスするような設計を避け、できるだけトランザクションの粒度を細かく保つようにします。
- エラーの原因を特定する:
-
原因:
- トランザクション内の操作でエラーが発生した:
session.withTransaction()
のコールバック関数内で例外がスローされた場合、または手動でsession.abortTransaction()
が呼び出された場合に発生します。 - トランザクションのタイムアウト: トランザクションにはデフォルトのタイムアウト(MongoDBの
transactionLifetimeLimitSeconds
設定、通常は60秒)があります。操作がこの時間内に完了しないと、トランザクションは自動的にアボートされます。 - 競合状態(コンフリクト): 複数のトランザクションが同じドキュメントを同時に変更しようとした場合、MongoDBは競合を検出し、一方のトランザクションをアボートすることがあります。
- トランザクション内の操作でエラーが発生した:
{ session } オプションの渡し忘れ (Operation will not be part of the transaction)
これはMongooseトランザクションで非常によくある間違いです。
-
トラブルシューティング:
- 全てのMongoose操作に
{ session }
を渡す:await session.withTransaction(async () => { // 正しい例: await User.create([{ name: 'Alice' }], { session }); await Product.findByIdAndUpdate(productId, { $inc: { stock: -1 } }, { session }); await Order.create([{ userId: userId, productId: productId }], { session }); // 誤った例(トランザクション外で実行される): // await User.create([{ name: 'Bob' }]); // await Product.findByIdAndUpdate(productId, { $inc: { stock: -1 } }); });
Model.find().session(session)
のようにクエリチェーンの最後に.session(session)
を追加する方法もあります。
- 全てのMongoose操作に
-
原因:
- トランザクション内で実行したいMongooseの操作(
save()
,create()
,findOneAndUpdate()
,find()
,aggregate()
など)に、明示的に{ session }
オプションを渡していないため、その操作がトランザクションのコンテキスト外で実行されてしまう。
- トランザクション内で実行したいMongooseの操作(
MongooseError: Query was already executed.
これは、Mongooseのクエリが一度実行された後、同じクエリオブジェクトを再利用しようとした場合に発生することがあります。トランザクションに直接関連するわけではありませんが、トランザクション内で非同期操作を適切に管理しないと発生する可能性があります。
-
トラブルシューティング:
- 各操作を完全に
await
する:- トランザクション内の各データベース操作が完全に完了するまで待つように
await
を正しく使用します。
- トランザクション内の各データベース操作が完全に完了するまで待つように
- クエリインスタンスの再利用を避ける:
- 新しいデータベース操作には常に新しいクエリを構築します。
- 各操作を完全に
-
原因:
- トランザクションコールバック内で
await
を付けずに非同期Mongooseクエリを実行したり、同じクエリインスタンスを複数回実行しようとしたりする。
- トランザクションコールバック内で
MongoDBのバージョンやMongooseのバージョンが古い
-
トラブルシューティング:
- MongoDBのバージョンを確認し、必要であればアップグレードする:
- MongoDBのバージョンが4.0以上であることを確認します。
- Mongooseのバージョンを最新に保つ:
npm install mongoose@latest
などでMongooseを最新バージョンに更新することを検討します。
- MongoDBのバージョンを確認し、必要であればアップグレードする:
-
原因:
- MongoDB 4.0未満ではトランザクション機能がありません。
- Mongooseも、古いバージョンではトランザクション機能が完全にサポートされていないか、バグがある可能性があります。
-
トラブルシューティング:
- 接続オプションを確認・調整する:
- Mongooseのバージョンに応じて、不要になった接続オプション(例:
useNewUrlParser
,useUnifiedTopology
,useCreateIndex
,useFindAndModify
)を削除します。通常、Mongooseの最新バージョンではこれらのオプションはデフォルトで有効または非推奨になっています。
- Mongooseのバージョンに応じて、不要になった接続オプション(例:
- 接続オプションを確認・調整する:
-
原因:
- 古いバージョンのMongooseでは接続オプションとして
useUnifiedTopology: true
が必要でしたが、新しいMongooseでは不要になり、むしろエラーになる場合があります。
- 古いバージョンのMongooseでは接続オプションとして
Mongooseトランザクションのトラブルシューティングの鍵は、以下の点にあります。
- MongooseとMongoDBのバージョンを最新に保つ: 最新のバージョンでは、バグ修正や機能改善がされている場合があります。
try...catch
でエラーを捕捉し、適切にログを出力する: これにより、問題のデバッグが容易になります。- 全てのトランザクション内Mongoose操作に
{ session }
オプションを渡しているか確認する: これを忘れると、操作がトランザクションから外れてしまい、原子性が失われます。 - MongoDBがレプリカセットとして動作していることを確認する: これが最も基本的な要件です。
- エラーメッセージを注意深く読む: エラーメッセージは問題の根本原因を特定するための重要な手がかりです。
シナリオ例:ユーザー間の送金処理
ここでは、「あるユーザーの口座から別なユーザーの口座へお金を送金する」というシナリオを例に挙げます。この操作は以下のステップから構成されます。
- 送金元ユーザーの残高を減らす。
- 送金先ユーザーの残高を増やす。
もしこれらの操作がそれぞれ独立して行われると、例えば送金元の残高は減ったのに、送金先の残高が増える前にシステム障害が発生した場合、お金が「消えてしまう」という不整合が発生します。トランザクションを使えば、この2つの操作が完全に成功するか、あるいは完全に失敗して元に戻るか(ロールバック)のいずれかになり、データの一貫性が保たれます。
コード例の準備
まず、MongooseとMongoDBの接続設定、および使用するスキーマを定義します。
必要なパッケージのインストール
npm init -y
npm install mongoose
app.js
(または任意のファイル名)
const mongoose = require('mongoose');
// MongoDB接続URI (レプリカセット上で動作させる必要があります)
// ローカルでレプリカセットを設定している場合:
// const mongoUri = 'mongodb://localhost:27017/transaction_example?replicaSet=rs0';
// MongoDB Atlasなどのクラウドサービスの場合:
const mongoUri = 'mongodb+srv://<username>:<password>@<cluster-url>/transaction_example?retryWrites=true&w=majority';
// --- スキーマ定義 ---
const UserSchema = new mongoose.Schema({
name: { type: String, required: true },
balance: { type: Number, required: true, default: 0 }
});
const User = mongoose.model('User', UserSchema);
// --- 接続関数 ---
async function connectDB() {
try {
await mongoose.connect(mongoUri);
console.log('MongoDBに接続しました。');
// デモ用に既存データをクリアし、初期データを投入
await User.deleteMany({});
await User.create([
{ name: 'Alice', balance: 1000 },
{ name: 'Bob', balance: 500 }
]);
console.log('初期ユーザーデータを作成しました。');
} catch (error) {
console.error('MongoDB接続エラー:', error);
process.exit(1); // 接続失敗時はプロセスを終了
}
}
// --- メイン処理を実行する関数 ---
async function run() {
await connectDB();
// トランザクションの例を実行
await transferFunds('Alice', 'Bob', 200); // 成功するケース
await transferFunds('Bob', 'Alice', 1000); // 残高不足で失敗するケース
await mongoose.disconnect();
console.log('MongoDBから切断しました。');
}
run();
例1: session.withTransaction()
を使用した送金処理 (推奨)
session.withTransaction()
ヘルパーは、トランザクションの開始、コミット、アボート、および一時的なエラー発生時のリトライロジックを自動的に処理してくれるため、推奨される方法です。
// ... 上記のコードに追記 ...
/**
* ユーザー間で資金を送金する関数(トランザクションを使用)
* @param {string} fromUserName 送金元ユーザー名
* @param {string} toUserName 送金先ユーザー名
* @param {number} amount 送金額
*/
async function transferFunds(fromUserName, toUserName, amount) {
// セッションを開始
// `mongoose.startSession()` はデフォルトコネクションを使用
// 特定のコネクションを使用する場合は `connection.startSession()`
const session = await mongoose.startSession();
try {
// withTransaction() は自動的に startTransaction(), commitTransaction(), abortTransaction() を処理
await session.withTransaction(async () => {
console.log(`\n--- ${fromUserName}から${toUserName}へ${amount}円を送金中 ---`);
// 1. 送金元ユーザーを見つける
const fromUser = await User.findOne({ name: fromUserName }).session(session);
if (!fromUser) {
throw new Error(`${fromUserName}が見つかりません。`);
}
if (fromUser.balance < amount) {
// ここでエラーをスローすると、トランザクションは自動的にロールバックされる
throw new Error(`${fromUserName}の残高が不足しています。(現在の残高: ${fromUser.balance}円)`);
}
// 2. 送金先ユーザーを見つける
const toUser = await User.findOne({ name: toUserName }).session(session);
if (!toUser) {
throw new Error(`${toUserName}が見つかりません。`);
}
// 3. 送金元の残高を減らす
fromUser.balance -= amount;
await fromUser.save({ session }); // ここで `{ session }` オプションが非常に重要!
// 4. 送金先の残高を増やす
toUser.balance += amount;
await toUser.save({ session }); // ここで `{ session }` オプションが非常に重要!
console.log(`送金が成功しました: ${fromUserName}の新残高: ${fromUser.balance}円, ${toUserName}の新残高: ${toUser.balance}円`);
});
console.log(`トランザクションが正常にコミットされました。(${fromUserName} -> ${toUserName})`);
} catch (error) {
// withTransaction() はエラー発生時に自動的にロールバックを処理します。
console.error(`トランザクションが失敗しました。(${fromUserName} -> ${toUserName}):`, error.message);
// エラー後、データベースの状態を再確認する
const currentAlice = await User.findOne({ name: 'Alice' });
const currentBob = await User.findOne({ name: 'Bob' });
console.log(`失敗後のデータベース状態: Aliceの残高: ${currentAlice.balance}円, Bobの残高: ${currentBob.balance}円`);
} finally {
session.endSession(); // セッションを終了することを忘れない
}
}
解説:
finally { session.endSession(); }
: トランザクションが成功しようと失敗しようと、必ずセッションを終了させる必要があります。これにより、データベースリソースが適切に解放されます。throw new Error(...)
:session.withTransaction()
のコールバック内でエラーをスローすると、そのトランザクションは自動的にアボート(ロールバック)されます。つまり、それまでに行われた全ての変更は取り消され、データベースはトランザクション開始前の状態に戻ります。User.findOne({ name: fromUserName }).session(session);
/await fromUser.save({ session });
: 非常に重要です! トランザクション内で実行するMongooseの全ての操作(find()
,save()
,create()
,findByIdAndUpdate()
など)には、必ず{ session }
オプションを渡す必要があります。これを忘れると、その操作はトランザクションの外部で実行され、原子性が保証されなくなります。session()
メソッドをクエリにチェーンするか、オプションオブジェクトとして渡します。await session.withTransaction(async () => { ... });
: これがトランザクションのコア部分です。このコールバック関数内で実行される全てのデータベース操作が、単一のアトミックなトランザクションとして扱われます。const session = await mongoose.startSession();
: トランザクションを開始するために、まずセッションを作成します。
例2: 手動でトランザクションを制御する (startTransaction()
, commitTransaction()
, abortTransaction()
)
より詳細な制御が必要な場合や、特定の条件に基づいてコミットやアボートを明示的に行いたい場合にこの方法を使用します。
// ... 上記のコードに追記 ...
/**
* ユーザー間で資金を送金する関数(トランザクションを手動制御)
* @param {string} fromUserName 送金元ユーザー名
* @param {string} toUserName 送金先ユーザー名
* @param {number} amount 送金額
*/
async function transferFundsManual(fromUserName, toUserName, amount) {
const session = await mongoose.startSession();
session.startTransaction(); // トランザクションを手動で開始
try {
console.log(`\n--- [手動] ${fromUserName}から${toUserName}へ${amount}円を送金中 ---`);
const fromUser = await User.findOne({ name: fromUserName }).session(session);
if (!fromUser) {
throw new Error(`${fromUserName}が見つかりません。`);
}
if (fromUser.balance < amount) {
throw new Error(`${fromUserName}の残高が不足しています。(現在の残高: ${fromUser.balance}円)`);
}
const toUser = await User.findOne({ name: toUserName }).session(session);
if (!toUser) {
throw new Error(`${toUserName}が見つかりません。`);
}
fromUser.balance -= amount;
await fromUser.save({ session });
toUser.balance += amount;
await toUser.save({ session });
await session.commitTransaction(); // 全ての操作が成功したらコミット
console.log(`送金が成功しました: ${fromUser.name}の新残高: ${fromUser.balance}円, ${toUser.name}の新残高: ${toUser.balance}円`);
console.log(`トランザクションが正常にコミットされました。(${fromUserName} -> ${toUserName})`);
} catch (error) {
await session.abortTransaction(); // エラーが発生したらロールバック
console.error(`トランザクションが失敗し、ロールバックされました。(${fromUserName} -> ${toUserName}):`, error.message);
const currentAlice = await User.findOne({ name: 'Alice' });
const currentBob = await User.findOne({ name: 'Bob' });
console.log(`失敗後のデータベース状態: Aliceの残高: ${currentAlice.balance}円, Bobの残高: ${currentBob.balance}円`);
} finally {
session.endSession();
}
}
// `run()` 関数内でこの手動制御の例を呼び出すこともできます。
// await transferFundsManual('Alice', 'Bob', 150);
解説:
await session.abortTransaction();
:catch
ブロックでエラーが発生した場合に、トランザクション開始以降の全ての変更を取り消します。await session.commitTransaction();
:try
ブロック内で全ての操作が成功した場合に、変更をデータベースに永続化します。session.startTransaction();
: トランザクションを手動で開始します。
上記のコードを app.js
として保存し、ターミナルで実行します。
node app.js
出力例 (成功と失敗の組み合わせ)
MongoDBに接続しました。
初期ユーザーデータを作成しました。
--- AliceからBobへ200円を送金中 ---
送金が成功しました: Aliceの新残高: 800円, Bobの新残高: 700円
トランザクションが正常にコミットされました。(Alice -> Bob)
--- BobからAliceへ1000円を送金中 ---
トランザクションが失敗し、ロールバックされました。(Bob -> Alice): Bobの残高が不足しています。(現在の残高: 700円)
失敗後のデータベース状態: Aliceの残高: 800円, Bobの残高: 700円
MongoDBから切断しました。
この出力からわかるように、AliceからBobへの200円の送金は成功し、両者の残高が更新されています。しかし、BobからAliceへの1000円の送金はBobの残高不足で失敗し、トランザクションがロールバックされたため、残高は全く変更されていません。これがトランザクションの原子性(Atomicity)の力を示しています。
ローカル開発環境でレプリカセットをセットアップするには、例えば以下の手順を踏みます(MongoDBがインストールされている前提)。
- データディレクトリを作成:
mkdir -p /data/db/rs0-0 /data/db/rs0-1 /data/db/rs0-2
- 各インスタンスを異なるポートとデータディレクトリで起動:
(mongod --port 27017 --dbpath /data/db/rs0-0 --replSet rs0 --bind_ip localhost --oplogSize 128 --fork --logpath /var/log/mongodb/mongod-0.log mongod --port 27018 --dbpath /data/db/rs0-1 --replSet rs0 --bind_ip localhost --oplogSize 128 --fork --logpath /var/log/mongodb/mongod-1.log mongod --port 27019 --dbpath /data/db/rs0-2 --replSet rs0 --bind_ip localhost --oplogSize 128 --fork --logpath /var/log/mongodb/mongod-2.log
--fork
はバックグラウンドで実行、--logpath
はログファイル指定) - プライマリに接続し、レプリカセットを初期化:
```bash
mongo --port 27017
rs.initiate({
_id: "rs0",
members: [
{ _id: 0, host: "localhost:27017" },
{ _id: 1, host: "localhost:27018" },
{ _id: 2, host: "localhost:27019" }
]
})
exit
これで mongodb://localhost:27017/transaction_example?replicaSet=rs0
のURIで接続できるようになります。
MongooseのトランザクションはMongoDBのマルチドキュメントトランザクション機能を活用するものであり、ほとんどのケースでデータの一貫性を保証するための最善の方法です。しかし、MongoDBがトランザクションをサポートする以前や、トランザクションの要件を満たさない特定のケース(例: レプリカセットがないスタンドアロンのMongoDBインスタンス、非常に古いMongoDBバージョン、パフォーマンスの制約が厳しい場合など)では、代替手段が検討されることがあります。
ただし、これらの代替手段は厳密な意味でのアトミック性を保証しないため、データ不整合のリスクが伴うことを理解しておく必要があります。もし可能であれば、MongoDBトランザクションを使用することを強く推奨します。
単一ドキュメント操作 (Single Document Operations)
MongoDBの操作は、単一のドキュメントに対するものであれば、常にアトミックです。これは、ドキュメント全体の読み書きが不可分であることを意味します。
- Mongooseでの例:
この例では、const UserSchema = new mongoose.Schema({ name: String, email: String, // 注文履歴をユーザーのドキュメントに埋め込む orders: [{ productId: mongoose.Schema.Types.ObjectId, quantity: Number, orderDate: { type: Date, default: Date.now } }] }); const User = mongoose.model('User', UserSchema); async function addOrderToUser(userId, productId, quantity) { try { // 単一ドキュメントの更新操作はアトミック const user = await User.findByIdAndUpdate( userId, { $push: { orders: { productId, quantity } } }, { new: true } ); console.log('注文を追加しました:', user); } catch (error) { console.error('注文追加エラー:', error); } }
orders
配列への追加は単一ドキュメントに対する操作なのでアトミックです。 - メリット:
- 常にアトミック性が保証される。
- パフォーマンスが高い(複数のコレクションへのアクセスがないため)。
- トランザクションの複雑な設定(レプリカセットなど)が不要。
- 説明: 複数の関連データを一つのドキュメントに埋め込む(埋め込みドキュメント、Embedded Documents)ことで、そのデータに対する操作を単一ドキュメント操作として扱い、アトミック性を確保します。
Two-Phase Commit (2PC) または Manually Implemented Transactions
これは、トランザクション機能がないリレーショナルデータベースで以前から行われていた手法をMongoDBに応用したものです。MongoDBのネイティブトランザクションが利用できない場合にのみ、最終手段として検討されます。
-
Mongooseでの例 (概念的なもの、実際のプロダクションコードには推奨しない):
// 概念的な例: ユーザー間の送金処理を2PC風に実装 const TransferSchema = new mongoose.Schema({ fromUserId: mongoose.Schema.Types.ObjectId, toUserId: mongoose.Schema.Types.ObjectId, amount: Number, status: { type: String, enum: ['pending', 'committed', 'rolled_back'], default: 'pending' }, createdAt: { type: Date, default: Date.now } }); const Transfer = mongoose.model('Transfer', TransferSchema); async function transferFundsTwoPhase(fromUserId, toUserId, amount) { let transferRecord; try { // フェーズ1: 準備 - 送金記録を作成 transferRecord = await Transfer.create({ fromUserId, toUserId, amount, status: 'pending' }); console.log('送金記録を保留状態で作成:', transferRecord._id); // 1. 送金元の残高を減らす(これは単独操作なのでアトミック) const fromUser = await User.findById(fromUserId); if (!fromUser || fromUser.balance < amount) { throw new Error('残高不足またはユーザーが見つかりません。'); } await User.findByIdAndUpdate(fromUserId, { $inc: { balance: -amount } }); console.log('送金元残高を仮に減らしました。'); // 2. 送金先の残高を増やす(これは単独操作なのでアトミック) await User.findByIdAndUpdate(toUserId, { $inc: { balance: amount } }); console.log('送金先残高を仮に増やしました。'); // フェーズ2: コミット - 送金記録をコミット済みに更新 transferRecord.status = 'committed'; await transferRecord.save(); console.log('送金が完了しました。送金記録をコミット済みに更新。'); } catch (error) { console.error('送金エラー発生:', error.message); // ロールバック処理 if (transferRecord && transferRecord.status === 'pending') { try { console.log('エラーのためロールバック処理を開始...'); // 残高を元に戻す処理(複雑になる可能性がある) // 例: 送金元を戻す await User.findByIdAndUpdate(fromUserId, { $inc: { balance: amount } }); console.log('送金元残高を元に戻しました。'); // 送金記録をロールバック済みに更新 transferRecord.status = 'rolled_back'; await transferRecord.save(); console.log('送金記録をロールバック済みに更新。'); } catch (rollbackError) { console.error('ロールバック処理中にエラー:', rollbackError.message); // ここでさらに回復不能な状態になるリスクがある } } } }
この方法は非常に複雑で、エラーハンドリングや回復ロジックの実装が難しいため、MongoDBのネイティブトランザクションが利用できない場合にのみ検討すべきです。
-
デメリット:
- 実装が非常に複雑でエラーを起こしやすい。
- 開発者が手動で一貫性を管理する必要があるため、バグの温床になりやすい。
- システム障害やネットワークの問題が発生した場合、中途半端な状態(ファントムレコードなど)が残りやすい。
- パフォーマンスのオーバーヘッドが大きい。
- 厳密なアトミック性は保証されない(特にロールバック処理中に障害が発生した場合など)。
-
メリット:
- MongoDBのネイティブトランザクションが利用できない環境でも、ある程度の整合性を実現できる。
-
説明:
- Prepare (準備) フェーズ: 各操作を「準備済み」状態としてマークし、途中の状態を記録する。
- Commit (コミット) フェーズ: 全ての操作が準備完了したら、最終的な変更を適用する。
- 途中でエラーが発生した場合、記録された情報に基づいてRollback (ロールバック) 処理を行い、元の状態に戻す。
これはより高度なアーキテクチャパターンで、分散システムで最終的な一貫性(Eventual Consistency)を達成するためによく使用されます。
- デメリット:
- 複雑なアーキテクチャ設計が必要。
- 最終的な一貫性しか保証されないため、リアルタイムでの厳密な一貫性が求められるシステムには不向きな場合がある。
- 実装とデバッグが複雑。
- メリット:
- 非常にスケーラブル。
- 障害回復性が高い(イベントログから状態を再構築できる)。
- 分散システムに適している。
- 説明:
- システムの状態変更を「イベント」として記録し、これらのイベントをイベントログ(またはイベントストア)に永続化します。
- 各操作は個々のアトミックなイベントとして実行され、もし後続の操作が失敗した場合、以前の操作を「補償」する新しいイベントを発行します。
- 例えば、送金操作の場合、「残高引き落としイベント」と「残高追加イベント」を別々に発行し、どちらかが失敗した場合に「引き落としキャンセルイベント」を発行するなどです。
Mongooseにおける「代替手段」は、通常、MongoDBのネイティブトランザクションが利用できない場合の回避策です。
- 高度な設計: イベントソーシングは分散システムにおける強力なパターンですが、最終的な一貫性を前提とし、実装の複雑さも増します。
- 最終手段(非推奨): 2PCや手動での整合性管理は、実装が非常に複雑でエラーを起こしやすく、厳密なアトミック性も保証されません。
- 次善の策: 可能であれば、単一ドキュメントへの埋め込みによって、複数のデータ変更をアトミックな単一操作として実行できるようにデータモデルを設計します。
- 最も推奨される方法: MongoDBのネイティブトランザクション(Mongooseの
session.withTransaction()
または手動制御) を利用することです。これにより、データの一貫性と堅牢性が最も高まります。MongoDB 4.0以降とレプリカセットの要件を満たすことができれば、これを使用すべきです。