MongoDB 2.6マニュアルのMongoDB CRUD TutorialsからPerform Two Phase Commitsを和訳してみました。
2相コミットを行う
概要
本文書は複数ドキュメント1更新や"複数ドキュメントトランザクション"のための、2相コミット方法を用いるパターンを提供する。さらにあなたはこの方法をロールバック風の機能をもたらすためにこの処理を拡張することもできる。
背景
MongoDBデータベースでは単一ドキュメントへの操作は常に原子的である。しかしながら複数ドキュメントへの操作、しばしば"複数ドキュメントトランザクション"と言われる操作は原子的でない。ドキュメントはかなり複雑で複数の"入れ子になった"ドキュメントを含むことができるので、単一ドキュメントの原子性は多くの実務的なユースケースに必要なサポートを提供している。
とはいえ単一ドキュメントへの原子的操作の威力にも関わらず、複数ドキュメントトランザクションを要する場合が存在する。逐次的な操作からなるトランザクションを実行するとき、次のような課題がある:
- 原子性: ある操作が失敗したら、トランザクション内のそれまでの操作も元の状態に「ロールバック」しなければならない。
- 一貫性: 重要な失敗(すなわちネットワークやハードウェア)がトランザクションを中断した場合、データベースが一貫性を保った状態で復元できなければならない。
複数ドキュメントトランザクションを要する状況のために、あなたはアプリケーションで2相コミットを実装することができる。2相コミットを用いることでデータの一貫性と、エラーが生じた場合はトランザクションの前の状態に復帰させることを保証する。この手続きの間、ドキュメントは未決定の状態として表現することができる。
MongoDBでは単一ドキュメント操作は原子的であるため、2相コミットはトランザクション風の意味論を可能にするだけである。アプリケーションは2相コミットやロールバックの間、中間点の状態に戻ることが可能である。
パターン
概観
口座Aから口座Bに送金するシナリオを考える。リレーショナルデータベースであれば、Aから預金を減らし、Bに預金を加えることを単一の複数状態トランザクションで行うことができる。MongoDBでは同様の結果を得るような2相コミットをエミュレートできる。
このチュートリアルの例では以下の2つのコレクションを用いる:
- 口座情報を保持する accounts コレクション
- 送金トランザクションを保持する transactions コレクション
送金元口座と送金先口座を初期化する
accounts コレクションに口座Aと口座Bのドキュメントを挿入する。
db.accounts.insert(
[
{ _id: "A", balance: 1000, pendingTransactions: [] },
{ _id: "B", balance: 1000, pendingTransactions: [] }
]
)
この操作は操作の状態を表すBulkWriteResult()オブジェクトを返す。挿入に成功するとBulkWriteResult()のnInsertedが2になる。
送金記録を初期化する
送金を実施するごとに、 transactions コレクションに送金情報を挿入する。このドキュメントには次のフィールドが含まれる:
- source と destination フィールドには送金元と送金先の口座への参照として accounts コレクションの口座情報の _id フィールドが入る。
- value フィールドには送金額が入る。
- state フィールドには送金の現在の状態が入る。取りうる値は initial, pending, applied, done, canceling と canceled である。
- lastModified フィールドには最終変更日時が入る。
db.transactions.insert(
{ _id: 1, source: "A", destination: "B", value: 100, state: "initial", lastModified: new Date() }
)
この操作は操作の状態を表すWriteResult()を返す。挿入に成功するとWriteResult()のnInsertedが1になる。
口座間の送金を2相コミットを用いて行う
1. 開始するトランザクションを取り出す
transactions コレクションから initial 状態のトランザクションを見つける。現在 transactions コレクションには送金記録を初期化するで追加したドキュメントがひとつあるだけである。もしそのコレクションに複数のドキュメントが追加されていたら、さらに条件を追加しない限り initial 状態の他のトランザクションも返る。
var t = db.transactions.findOne( { state: "initial" } )
mongoシェルで t をタイプしてその変数内のコンテンツを表示してみよう。これにより次のようにドキュメントが表示されるが lastModified フィールドはあなたが挿入した時刻が反映される。
{ "_id" : 1, "source" : "A", "destination" : "B", "value" : 100, "state" : "initial", "lastModified" : ISODate("2014-07-11T20:39:26.345Z") }
2. トランザクション状態をPendingにする
トランザクションの state を initial から pending にセットし、$currentDateオペレーターを用いて lastModified フィールドに現在時刻をセットする。
db.transactions.update(
{ _id: t._id, state: "initial" },
{
$set: { state: "pending" },
$currentDate: { lastModified: true }
}
)
この操作は操作の状態を表すWriteResult()を返す。挿入に成功するとnMatchedとnModifiedが1になる。
update文の state: "initial" 条件は他のプロセスがこの記録を更新しないことを確実にする。もしnMatchedとnModifiedが0なら、最初のステップに戻り別のトランザクションを取り出し再度ここまでの処理を実行する。
3. トランザクションを両口座に適用する
トランザクション t を両口座にupdate()メソッドを用いて適用する。本ステップを複数回実行するような場合にトランザクションが再度適用されないよう更新条件に _pendingTransactions : { $ne : t.id } を入れる。
トランザクションを口座に適用することで、口座の balance と pendingTransactions フィールドが更新される。
送金元口座を更新する。 balance からトランザクションの value を減らし pendingTransactions 配列にトランザクションの _id を追加する。
db.accounts.update(
{ _id: t.source, pendingTransactions: { $ne: t._id } },
{ $inc: { balance: -t.value }, $push: { pendingTransactions: t._id } }
)
更新が成功すると、このメソッドはWriteResult()を返しnMatchedとnModifiedが1になる。
送金先口座を更新する。 balance にトランザクションの value を加え pendingTransactions 配列にトランザクションの _id を追加する。
db.accounts.update(
{ _id: t.destination, pendingTransactions: { $ne: t._id } },
{ $inc: { balance: t.value }, $push: { pendingTransactions: t._id } }
)
更新が成功すると、このメソッドはWriteResult()を返しnMatchedとnModifiedが1になる。
4. トランザクション状態をAppliedにする
以下のupdate()操作でトランザクションの state を applied にセットし、 lastModified フィールドを更新する。
db.transactions.update(
{ _id: t._id, state: "pending" },
{
$set: { state: "applied" },
$currentDate: { lastModified: true }
}
)
更新が成功すると、このメソッドはWriteResult()を返しnMatchedとnModifiedが1になる。
5. 両口座のPendingトランザクションリストを更新する
適用されたトランザクションの _id を両口座の pendingTransactions から削除する。
送金元口座を更新する。
db.accounts.update(
{ _id: t.source, pendingTransactions: t._id },
{ $pull: { pendingTransactions: t._id } }
)
更新が成功すると、このメソッドはWriteResult()を返しnMatchedとnModifiedが1になる。
送金先口座を更新する。
db.accounts.update(
{ _id: t.destination, pendingTransactions: t._id },
{ $pull: { pendingTransactions: t._id } }
)
更新が成功すると、このメソッドはWriteResult()を返しnMatchedとnModifiedが1になる。
6. トランザクション状態をDoneにする
トランザクションを完了する。 state を done にし、 lastModified を更新する:
db.transactions.update(
{ _id: t._id, state: "applied" },
{
$set: { state: "done" },
$currentDate: { lastModified: true }
}
)
更新が成功すると、このメソッドはWriteResult()を返しnMatchedとnModifiedが1になる。
失敗シナリオからの回復
トランザクション手続きの最も重要なパートは上述の例ではなく、むしろトランザクションが成功裏に完了しなかった時の様々な失敗シナリオからの回復の可能性にある。本節ではありうる失敗の概観と回復の手順を示す。
回復操作
2相コミットはトランザクションを再開し一貫性のある状態に到達することを可能にする。未完了のトランザクションをとらえるために、回復操作をアプリケーション開始時や一定間隔で実行する。
一貫性のある状態に到達するために要する時間はアプリケーションが各トランザクションの回復に要するのにどの程度かかるかに依存する。
以下の回復手続きでは lastModified の日時をトランザクションの回復が必要かどうかを示すインジケーターとして用いている; 特にpendingやapplied状態のトランザクションが30分更新されていなかった場合、回復が必要と判断する。あなたは異なる条件でこの判断を行うこともできる。
Pending状態のトランザクション
"2. トランザクション状態をPendingにする"ステップのあと、"4. トランザクション状態をAppliedにする"ステップの前までで生じた失敗から回復するためには、 transactions コレクションからpending状態のトランザクションを取得する。
var dateThreshold = new Date();
dateThreshold.setMinutes(dateThreshold.getMinutes() - 30);
var t = db.transactions.findOne( { state: "pending", lastModified: { $lt: dateThreshold } } );
そして"3. トランザクションを両口座に適用する"ステップから再開する。
Applied状態のトランザクション
"4. トランザクション状態をAppliedにする"ステップのあと、"6. トランザクション状態をDoneにする"ステップの前までで生じた失敗から回復するためには、 transactions コレクションからapplied状態のトランザクションを取得する。
var dateThreshold = new Date();
dateThreshold.setMinutes(dateThreshold.getMinutes() - 30);
var t = db.transactions.findOne( { state: "applied", lastModified: { $lt: dateThreshold } } );
そして"5. 両口座のPendingトランザクションリストを更新する"ステップから再開する。
ロールバック操作
いくつかの場合では、あなたは"ロールバック"またはトランザクションを取り消す必要がある; アプリケーションに"キャンセル"機能が必要である、あるいはトランザクションの口座のなかに存在しないまたは停止しているものがある場合である。
Applied状態のトランザクション
"4. トランザクション状態をAppliedにする"ステップの後では、あなたはトランザクションをロールバックすべきで ない 。その代わりにトランザクションを完遂させ、新しく逆のトランザクションを送金元と送金先を逆にすることで作成する。
Pending状態のトランザクション
"2. トランザクション状態をPendingにする"ステップの後から"4. トランザクション状態をAppliedにする"ステップの前までは、あなたはトランザクションを次の手続きでロールバックできる:
1. トランザクション状態をcancelingにする
トランザクションの state を pending から canceling に更新する。
db.transactions.update(
{ _id: t._id, state: "pending" },
{
$set: { state: "canceling" },
$currentDate: { lastModified: true }
}
)
更新が成功すると、このメソッドはWriteResult()を返しnMatchedとnModifiedが1になる。
2. 両口座でトランザクションを取り消す
両口座のトランザクションを取り消すには、既に適用されたトランザクション t を逆適用する。既にトランザクションが適用された口座だけを更新するよう、更新条件に _pendingTransactions : t.id を入れる。
送金先口座を更新する。送金先口座の balance からトランザクションの value を減らし、トランザクションの _id を口座の pendingTransactions 配列から取り除く。
db.accounts.update(
{ _id: t.destination, pendingTransactions: t._id },
{
$inc: { balance: -t.value },
$pull: { pendingTransactions: t._id }
}
)
更新が成功すると、このメソッドはWriteResult()を返しnMatchedとnModifiedが1になる。もし過去にこの口座にトランザクションが適用されていなければ、この更新条件にマッチするドキュメントはなく、nMatchedとnModifiedは0になる。
送金元口座を更新する。送金元口座の balance にトランザクションの value を加え、トランザクションの _id を口座の pendingTransactions 配列から取り除く。
db.accounts.update(
{ _id: t.source, pendingTransactions: t._id },
{
$inc: { balance: t.value},
$pull: { pendingTransactions: t._id }
}
)
更新が成功すると、このメソッドはWriteResult()を返しnMatchedとnModifiedが1になる。もし過去にこの口座にトランザクションが適用されていなければ、この更新条件にマッチするドキュメントはなく、nMatchedとnModifiedは0になる。
3. トランザクション状態をcanceledにする
ロールバックを完了させるために、トランザクションの state を canceling から canceled にする。
db.transactions.update(
{ _id: t._id, state: "canceling" },
{
$set: { state: "cancelled" },
$currentDate: { lastModified: true }
}
)
更新が成功すると、このメソッドはWriteResult()を返しnMatchedとnModifiedが1になる。
複数のアプリケーション
複数のアプリケーションが作ることが出来て、データの一貫性を壊したり衝突を起こすことなく各々平行に実行できるようなトランザクションというものも時に存在する。我々の手続きでは、トランザクションドキュメントを更新または検索するにあたり、複数アプリケーションからのトランザクションの再適用を防ぐために更新条件に state フィールドを含める。
例えば、アプリケーション App1 と App2 の両方が同じ initial 状態のトランザクションをつかんでいるとする。 App1 は App2 が始める前にトランザクションを適用する。 App2 が"2. トランザクション状態をPendingにする"ステップを実行しようとするとき、更新条件に state : "initial" を含めることで何のドキュメントも得られずnMatchedとnModifiedが0になる。このことは App2 に異なるトランザクションで最初のステップからやり直すべきだと知らせている。
複数のアプリケーションが実行している場合、常に単一のアプリケーションだけがトランザクションを操作できることが重要である。更新条件で予期される状態に加えてどのアプリケーションがトランザクションを操作したかを示すマーカーを残すことができる。FindAndModify()メソッドを使い、トランザクションの取得と更新を1ステップで行う:
t = db.transactions.findAndModify(
{
query: { state: "initial", application: { $exists: false } },
update:
{
$set: { state: "pending", application: "App1" },
$currentDate: { lastModified: true }
},
new: true
}
)
トランザクションの application フィールドでマッチしたアプリケーションだけがトランザクションを適用することを保証するよう修正する。
もし App1 がトランザクション実行を失敗したときあなたは回復操作を行うことができるが、その際アプリケーションを適用したアプリケーションだけが行えることを保証する。例えばpending状態のトランザクションを見つけて再実行する際のクエリーは以下のようにする:
var dateThreshold = new Date();
dateThreshold.setMinutes(dateThreshold.getMinutes() - 30);
db.transactions.find(
{
application: "App1",
state: "pending",
lastModified: { $lt: dateThreshold }
}
)
2相コミットを実製品で用いる
本例のトランザクションはあえて単純にしている。例えば口座のロールバックは常に可能で、口座の残高が負の値を取りうることを仮定している。
実製品の実装はより複雑になるだろう。典型的には口座は現在の残高や引き落とし予定のクレジットや振込み金額の情報を必要とするだろう。
あなたのアプリケーションの要求に合わせて適切なwrite Concernのレベルを使うようにしよう。
Copyright © 2011-2015 MongoDB, Inc. Licensed under Creative Commons. MongoDB, Mongo, and the leaf logo are registered trademarks of MongoDB, Inc.
-
本文書ではmongodbのdocumentをドキュメントと表記する。 ↩