MongoDB のトランザクション内で SELECT ... FOR UPDATE を 使用する方法

Renato Riccio

MongoDB 4.0 でマルチドキュメント ACID トランザクションが導入されて以来、MongoDB のコンサルタントをしている私のもとには、「トランザクション内で読み取ろうとしているドキュメントが、コミット前に他の操作によって変更されてしまうのを確実に防止するにはどうすればよいですか?」という質問が数多く寄せられています。

この質問に答える前に、まずトランザクションの内外における MongoDB の並列処理の仕組みをご説明します。この仕組みを知ることで、答えが理解しやすくなります。

ロック

MongoDB は複数の粒度のロックを採用しているため、データベースやコレクションをはじめとするリソースへの共有アクセスが可能です。ロックの種類について詳しくは、MongoDB のドキュメントを参照してください。

トランザクションの中で実行される全ての読み取りと書き込みは、アクセスするコレクションのインテント排他ロック(IX ロック)を取得する必要があります。しかし、対象のコレクションやデータベースに対する排他ロック(Xロック)や共有ロック(Sロック)を別の操作が保持しているために、インテント排他ロックを取得できないことがあります。このような場合は、デッドロックのリスクを回避するために、トランザクションは 5 ミリ秒だけ待機し、それ以上待ってもロックが取得できない場合には処理を中止します。5ms はデフォルト値であり、 maxTransactionLockRequestTimeoutMillis で変更できます。ただし、コレクションの排他ロックや共有ロックを取得する操作はあまり一般的ではありません。もっと高い確率でトランザクションの競合を引き起こす操作があります。次に、それらの操作について取り上げます。

書き込みの競合とトランザクション

WiredTiger は、メモリに過剰な負荷がかかって更新を中止するときと同様に、更新操作の際も、まずは書き込みを試み、競合する更新操作が検出された場合には更新を中止します。スローされる例外の型名をとって、これを writeConflict といいます。トランザクションの外部では、このような書き込みは、成功するまで MongoDB 内で自動的に再試行されます。

トランザクションの場合には少し事情が異なります。以下の例で考えてみます。

Write Conflicts and Transactions 開始されたトランザクション(t1)がドキュメント(D1)に変更を加える前に、別の書き込み操作によって同じドキュメントが変更された場合、トランザクション(t1)は writeConflict の対象となる可能性があります。この状況は、別の書き込み操作がトランザクション内で実行されるかどうかに関係なく発生する可能性があります。左側の図は独立したステートメントとして、右側の図は第 2 のトランザクション(t2)として、この状況を示しています。

以下に示したのは、mongodb シェルから見た writeConflict エラーの例です。

> coll1.update( { _id: 1 },{ $set: { a: 5 } });
WriteCommandError({
   "errorLabels" : [
       "TransientTransactionError"
   ],
   "operationTime" : Timestamp(1566806753, 1),
   "ok" : 0,
   "errmsg" : "WriteConflict",
   "code" : 112,
   "codeName" : "WriteConflict",
   "$clusterTime" : {
       "clusterTime" : Timestamp(1566806753, 1),
       "signature" : {
           "hash" : BinData(0,"AAAAAAAAAAAAAAAAAAAAAAAAAAA="),
           "keyId" : NumberLong(0)
       }
   }
})

writeConflict がトランザクション内で発生した場合には、ストレージエンジンは自動的に再試行するのではなく、ドライバーにエラーを返します。

MongoDB の新しいトランザクションコールバック API を使用すると、TransientTransactionErrorwriteConflicts など)が発生した場合に自動的に再試行を行うことができます。 writeConflict を受け取ったドライバーは、このコールバック API を使用して、トランザクション全体を安全に再試行できます。MongoDB の新しいコールバック API について詳しくは、お使いのドライバーのドキュメントを参照してください。

トランザクション内で変更されたドキュメントに対し、トランザクション外から書き込みを試みて writeConflict が発生した場合、その操作はストレージエンジン内で自動的に再試行されます。再試行は成功するまで繰り返されます。

冒頭の質問に対する答え

トランザクション内で書き込み操作を行う際の動作が明らかになったので、本題である読み取り操作の実行過程に話を移します。

読み取り保証(read concern) スナップショットを使用してトランザクションを開始すると、クラスタ全体で一貫した特定の時点のデータが読み取りの対象となることが保証されます。トランザクション内で読み取るデータは、トランザクション外で行われる、いずれの書き込み操作の影響も受けません。

例えば、トランザクション t1 が、読み取る予定のデータ(ドキュメント D1 など)のスナップショットを開始時に取得するとします。その一方で、トランザクション t2 は独自のスナップショットを取得し、ドキュメント D1 を削除します。しかし、トランザクション t1 は引き続きドキュメント D1 を読み取ることができます。トランザクション t1 は、独自のスナップショットを参照しており、他のトランザクションの書き込み操作からは隔離されているためです。

したがって、トランザクション内でドキュメントを読み取っても、その途中で他の操作によってドキュメントが変更されない保証は実際にはありません。

それを解決する手段として、リレーショナルデータベースの世界では SELECT...FOR UPDATE が使用されます。このステートメントは、読み取っている行を更新されたかのようにロックし、トランザクションが終了するまで他の操作による変更や削除を防ぎます。

同じ動作は MongoDB でも再現できます。同じドキュメントへの書き込みを試みる他のトランザクションに writeConflict 例外が返されるように、ドキュメントに変更を加えることで実現します。

しかし、何を更新すればよいのでしょうか?既存のフィールドの値を単純に同じ値で書き換えることはできません。MongoDBは効率的に動作するため、実際に値が変更されない限り、ドキュメントの更新は行われません。

次の例を考えてみてください。コレクションには次のドキュメントがあります。

{ _id: 1, status: true }

このとき、トランザクション内で次の更新操作を実行します。

db.foo.update({ _id: 1 }, { $set: { status: true } })
WriteResult({ "nMatched" : 1, "nUpserted" : 0, "nModified" : 0 })

実際にはドキュメントは変更されていない(status は最初から true であった)ため、他の操作は、writeConflict が返されることなくドキュメントを変更できてしまいます。

読み取り中に「書き込みロック」をエミュレートする

他の操作によるドキュメントの変更を防ぐためには、ドキュメントに新しい値を書き込む必要があります。既存のフィールドをそのままの値に再設定しただけでは書き込みは発生しません。しかし、MongoDB のスキーマには柔軟性があるため、「書き込みロック」を取得するための専用の属性を簡単に設定できます。

では、その新しい属性に何の値を設定すればよいのでしょうか?値は、フィールドに既に存在する可能性のある値とは異なるものでなければなりません。MongoDBにはこの条件を満たすデータ型があります。それが ObjectId です。ObjectId は Unix エポック、ランダム値、カウンターの組み合わせに基づいて生成されるため、同じ値が複数回生成されるのは極めてまれです。すなわち、ObjectId を更新すると、そのフィールドが既存の値とは異なる値に設定されます。この更新後に、他の操作が書き込みを試みると writeConflict が発生します。

これをコードで表すと次のようになります。

var doc = db.foo.findOneAndUpdate({ _id: 1 },
     { $set: { myLock: { appName: "myApp", pseudoRandom: ObjectId() } } })

この方法では、ドキュメントの「ロックを解除」するためのステップを別途設ける必要がないのがメリットです。トランザクションをコミットまたは中止すると、ロックは自動的に解除されます。ロックの機能を果たすフィールド、すなわちロック用フィールド(この例では myLock )の値は何でもかまいません。重要なのは、既存の値を変更することです。また、ドキュメントのロック用フィールドを更新し、そのドキュメントをアプリケーションに返すという一連の操作で発生するデータベースへのラウンドトリップも、findOneAndUpdate を使用することで 1 回に抑えられています。

トランザクションからロック用フィールドの値を読み取る必要はありませんが、ロックフィールドを使用して、どのアプリケーションがロックを保持しているかの情報を保存できます。この例の myLock は、ロックを取得しているアプリケーションの名前とロック用フィールドとを持つオブジェクトです。

まとめ

MongoDB のトランザクション機能を利用したドキュメントのロックは、一貫性を確保するための強力な手段です。ただし、MongoDB がトランザクションをサポートしていても、これまでのベストプラクティスは引き続き重要です。トランザクションの使用は必要な場合のみに限定することをお勧めします。MongoDB の柔軟で情報量の多いドキュメント形式を活用することで、多くのケースでトランザクションの使用を回避できます。

MongoDB Atlas の M0 クラスタは無料でご利用いただけます。M0 クラスタで、このブログで紹介したコード例を是非お試しください。