原文地址
https://docs.mongodb.com/manual/tutorial/perform-two-phase-commits/
譯者注
mongodb只能對保證單個文檔操作的原子性掰盘,無法支持傳統(tǒng)數(shù)據(jù)庫的事務(wù)宴倍,但是有時候又需要保證多個文檔的操作的原子性太闺,這就比較蛋疼了,官方文檔以轉(zhuǎn)賬為例給出了一個叫做"兩階段提交"(Two Phase Commits)的transaction-like的解決方案,翻譯如下伶贰。
摘要
這篇文章將會說明如何使用"兩階段提交"的模式來解決多文檔更新問題(多文檔事務(wù))天通。另外,這個過程中還可以添加類似于回滾(rollback-like)的功能儡蔓。
譯注:在MongoDB中,一條數(shù)據(jù)記錄(類似于mysql中的一行)叫做一個文檔
背景
對于MongoDB數(shù)據(jù)庫來說乏悄,單文檔的操作是能夠保證原子性的浙值;但是設(shè)計多個文檔的操作(即多文檔事務(wù))卻不是原子性的。其實單文檔操作的原子性已經(jīng)可以給大多數(shù)實際用例(譯注:用例是指軟件開發(fā)中用戶的某一個需求)提供足夠的支持檩小,因為單個文檔的結(jié)構(gòu)可以很復(fù)雜甚至包括很多內(nèi)嵌的文檔开呐。
雖然單文檔原子操作的功能已經(jīng)很強大了,但是仍然會有些情況需要多文檔事務(wù)规求。當執(zhí)行一個由一系列操作組成的事務(wù)時筐付,某些問題就產(chǎn)生了,比如:
- 原子性:如果一個操作失敗了阻肿,事務(wù)中之前的操作必須“回滾”(rollback)(即"all or nothing"瓦戚,要么全部發(fā)生要么全部不發(fā)生)
- 一致性:如果某個嚴重的故障(比如網(wǎng)絡(luò)故障,硬件故障等)打斷了事務(wù)丛塌,數(shù)據(jù)庫必須能夠回復(fù)到一致的狀態(tài)
在需要多文檔事務(wù)的情況下较解,你可以在你的應(yīng)用中實現(xiàn)兩階段提交來給這些多文檔更新提供支持。兩階段提交能夠保證數(shù)據(jù)的一致性赴邻,并且在出現(xiàn)錯誤的情況下印衔,可以將數(shù)據(jù)恢復(fù)到事務(wù)之前的狀態(tài)。
注意:因為MongoDB中只支持單文檔原子操作姥敛,所以兩階段提交只能提供類似于事務(wù)的語義(transaction-like semantics)奸焙。應(yīng)用還是可以在兩階段提交或者回滾的中間點上返回中間數(shù)據(jù)。
模式
概述
試想一下這樣的情景彤敛,你想從賬戶A轉(zhuǎn)賬給賬戶B与帆。在傳統(tǒng)的關(guān)系數(shù)據(jù)庫中,你可以在一個事務(wù)中從賬戶A上減去金額并且在賬戶B上增加相應(yīng)金額墨榄。在MongoDB中玄糟,你可以通過兩階段提交實現(xiàn)類似的效果。
這個例子使用如下兩個集合(譯注:在MongoDB中集合的概念類似于mysql中的一張表):
- 一個叫做accounts的集合用于存儲賬戶信息渠概。
- 一個叫做transactions的集合用于存儲轉(zhuǎn)賬事務(wù)相關(guān)的信息
初始化源賬戶與目標賬戶
在accounts集合中插入一個文檔代表賬戶A茶凳,再插入一個文檔代表賬戶B嫂拴。
db.accounts.insert(
[
{ _id: "A", balance: 1000, pendingTransactions: [] },
{ _id: "B", balance: 1000, pendingTransactions: [] }
]
)
這個操作會返回一個代表操作狀態(tài)的BultWriteResult()對象。如果插入成功的話贮喧,這個對象的nInserted字段的值為2
初始化轉(zhuǎn)賬記錄
對于每一筆轉(zhuǎn)賬筒狠,在transactions集合中插入一個包含轉(zhuǎn)賬信息的文檔。這個文檔包含如下字段:
- source字段與destination字段箱沦,這兩個字段來源于accounts集合中源賬戶與目標賬戶的_id字段辩恼;
- value字段,代表轉(zhuǎn)賬金額谓形;
- state字段灶伊,代表轉(zhuǎn)賬的當前狀態(tài)。它可以取的值有initial寒跳,pending聘萨,applied,done童太,canceling米辐,和canceled;
- lastModified字段书释,代表最近修改時間
為了初始化從賬戶A到賬戶B的轉(zhuǎn)賬翘贮,在transactions集合中插入一個包含轉(zhuǎn)賬信息的文檔,其中state字段為initial爆惧,lastModified字段為當前日期:
db.transactions.insert(
{ _id: 1, source: "A", destination: "B", value: 100, state: "initial", lastModified: new Date() }
)
這個操作會返回一個WriteResult()對象狸页。在插入成功的情況下,WriteResult()對象的nInserted字段的值為1
使用兩階段提交在賬戶之間轉(zhuǎn)賬
1.檢索要開始的事務(wù)
從transactions集合中找到一個state為initial的transaction(文檔)扯再。當前在transactions集合中只有一個文檔芍耘,即我們剛剛插入進去的那個。如果集合中還包含額外的文檔熄阻,那么這個查詢會返回任一個state為initial的transaction除非你指定其他的查詢條件齿穗。
var t = db.transactions.findOne( { state: "initial" } )
在mongo的shell客戶端中輸入變量t打印出它的內(nèi)容。你會得到與下面類似的文檔除了lastModified的值會是你之前插入做的時間:
{ "_id" : 1, "source" : "A", "destination" : "B", "value" : 100, "state" : "initial", "lastModified" : ISODate("2014-07-11T20:39:26.345Z") }
2.將tracsaction的state更新為pending
將transaction中的state從initial修改為pending并且使用$currentData
操作符將lastModified字段設(shè)置為當前時間饺律。
db.transactions.update(
{ _id: t._id, state: "initial" },
{
$set: { state: "pending" },
$currentDate: { lastModified: true }
}
)
這個操作返回一個WriteResult()對象,它代表操作的狀態(tài)跺株。如果更新成功的話复濒,它的nMatched和nModified字段都為1
在上面的代碼中,state:"initial"
條件是為了保證沒有其他進程已經(jīng)修改該條記錄乒省。如果nMatched和nModified都是0的話巧颈,則返回第一步得到一條不同的事務(wù)并且重新開始這個步驟。
3.在accounts上執(zhí)行transaction
如果transaction還沒有被執(zhí)行的話袖扛,則使用update()方法執(zhí)行事務(wù)砸泛。在update條件中十籍,要記得包括pendingTransaction:{$ne:t._id}
條件,這么做的目的是為了避免因為意外情況重復(fù)執(zhí)行同一個事務(wù)唇礁。
在執(zhí)行事務(wù)時勾栗,要同時更新account文檔的balance字段和pendingTransactions字段。
更新源賬戶盏筐,要從它的balance字段中減去transaction的value字段围俘,并且將transaction的_id字段添加到賬戶的pendingTransactions數(shù)組字段中。
db.accounts.update(
{ _id: t.source, pendingTransactions: { $ne: t._id } },
{ $inc: { balance: -t.value }, $push: { pendingTransactions: t._id } }
)
更新成功之后琢融,方法會返回WriteResult()對象界牡,它的nMatched與nModified字段都為1
用類似的方式更新目標賬戶(只不過此時balance是加上transaction的value):
db.accounts.update(
{ _id: t.destination, pendingTransactions: { $ne: t._id } },
{ $inc: { balance: t.value }, $push: { pendingTransactions: t._id } }
)
更新成功之后,方法會返回WriteResult()對象漾抬,它的nMatched與nModified字段都為1
4.將transaction的state更新為applied
使用下面的update()操作更新transaction的state字段與lastModified字段:
db.transactions.update(
{ _id: t._id, state: "pending" },
{
$set: { state: "applied" },
$currentDate: { lastModified: true }
}
)
更新成功之后宿亡,方法會返回WriteResult()對象,它的nMatched與nModified字段都為1
5.更新源賬戶和目標賬戶的pendingTransactions數(shù)組
將transaction的_id從源賬戶和目標賬戶的pendingTransactions數(shù)組中移除纳令。
更新源賬戶:
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.將transaction的state更新為done
將transaction的state設(shè)置為done并且更新lastModified字段,事務(wù)完成馒过。
db.transactions.update(
{ _id: t._id, state: "applied" },
{
$set: { state: "done" },
$currentDate: { lastModified: true }
}
)
更新成功之后臭脓,方法會返回WriteResult()對象,它的nMatched與nModified字段都為1
從故障中恢復(fù)
事務(wù)程序中最重要的部分不是上面的原型示例腹忽,而是當是誒執(zhí)行失敗時来累,能夠從各種各樣的故障中恢復(fù)。這一部分展示了幾個可能的故障以及從這些故障中恢復(fù)的步驟窘奏。
恢復(fù)操作
在兩階段提交的模式下嘹锁,應(yīng)用程序可以通過一系列的步驟恢復(fù)事務(wù),達到一致的狀態(tài)着裹。在應(yīng)用啟動時運行恢復(fù)操作领猾,也可以選擇隔一段時間運行一次,這樣可以彌補任何沒能完成的事務(wù)骇扇。
到達一致性狀態(tài)所需的時間取決于應(yīng)用恢復(fù)每個事務(wù)的時間摔竿。
下面的恢復(fù)程序使用lastModified字段作為一個transaction是否需要恢復(fù)的依據(jù)。具體來說少孝,如果一個狀態(tài)為pending或者applied的transaction在30s內(nèi)還沒有被更新继低,則程序認為它需要恢復(fù)。你也可以使用其他條件來確定一個事務(wù)是否需要恢復(fù)稍走。
處于pending狀態(tài)的事務(wù)
假設(shè)有一個故障發(fā)生在"將transaction的state更新為pending"步驟之后袁翁,在"將transaction的state更新為applied"之前柴底。為了將事務(wù)從這個故障中恢復(fù),先將它從transactions集合中檢索出來粱胜,然后恢復(fù):
var dateThreshold = new Date();
dateThreshold.setMinutes(dateThreshold.getMinutes() - 30);
var t = db.transactions.findOne( { state: "pending", lastModified: { $lt: dateThreshold } } );
然后從步驟"在accounts上執(zhí)行transaction"開始恢復(fù)柄驻。
處于applied狀態(tài)的transaction
假設(shè)一個故障發(fā)生在步驟"將transaction的state更新為applied"之后,在"將transaction的state更新為done"之前年柠。為了將事務(wù)從這個故障中恢復(fù)凿歼,先將它從transactions集合中檢索出來,然后恢復(fù):
var dateThreshold = new Date();
dateThreshold.setMinutes(dateThreshold.getMinutes() - 30);
var t = db.transactions.findOne( { state: "applied", lastModified: { $lt: dateThreshold } } );
然后從步驟"更新源賬戶和目標賬戶的pendingTransactions數(shù)組"開始恢復(fù)冗恨。
回滾操作
在一些情況下答憔,你需要回滾或者說撤銷事務(wù)。舉個例子掀抹,在事務(wù)期間如果其中一個賬戶不存在或者被停用了虐拓,這個時候就只能取消事務(wù)了。
處于applied狀態(tài)的transaction
在"將transaction的state更新為applied"步驟之后傲武,就不應(yīng)該再回滾事務(wù)了蓉驹。相反,讓這個事務(wù)完成并且創(chuàng)建一個新的事務(wù)來抵消掉原事務(wù)揪利。
處于pending狀態(tài)的事務(wù)
在"將transaction的state更新為pending"步驟之后态兴,但是在"將transaction的state更新為applied"步驟之前,你可以使用下面的步驟回滾事務(wù):
1.將transaction的state更新為canceling
更新transaction的state從pending更新為canceling疟位。
db.transactions.update(
{ _id: t._id, state: "pending" },
{
$set: { state: "canceling" },
$currentDate: { lastModified: true }
}
)
更新成功之后瞻润,方法會返回WriteResult()對象,它的nMatched與nModified字段都為1
2.在源賬戶與目標賬戶上撤銷事務(wù)
如果事務(wù)已經(jīng)被執(zhí)行的話甜刻,在兩個賬戶上反過來執(zhí)行事務(wù)绍撞。更新條件要記得包含pendingTransaction:t._id
,這樣做是為了保證只有被執(zhí)行過事務(wù)的賬戶被更新得院。
更新目標賬戶傻铣,從它的balance字段中減去transaction的value字段并且從pendingTransactions數(shù)組中移除事務(wù)的_id:
db.accounts.update(
{ _id: t.destination, pendingTransactions: t._id },
{
$inc: { balance: -t.value },
$pull: { pendingTransactions: t._id }
}
)
更新成功之后,方法會返回WriteResult()對象祥绞,它的nMatched與nModified字段都為1非洲。如果處在pending狀態(tài)的transaction還沒有在這個賬戶上執(zhí)行,那么就沒有文檔能夠滿足更新條件蜕径,此時nMatched和nModified的值就是0.
源賬戶也按同樣的方式更新怪蔑,只不過此時balance是加上transaction的value:
db.accounts.update(
{ _id: t.source, pendingTransactions: t._id },
{
$inc: { balance: t.value},
$pull: { pendingTransactions: t._id }
}
)
更新成功之后,方法會返回WriteResult()對象丧荐,它的nMatched與nModified字段都為1。如果處在pending狀態(tài)的transaction還沒有在這個賬戶上執(zhí)行喧枷,那么就沒有文檔能夠滿足更新條件虹统,此時nMatched和nModified的值就是0.
3.將transaction的state更新為canceled
將transaction的state從canceling更新為cancelled弓坞,完成回滾。
db.transactions.update(
{ _id: t._id, state: "canceling" },
{
$set: { state: "cancelled" },
$currentDate: { lastModified: true }
}
)
更新成功之后车荔,方法會返回WriteResult()對象渡冻,它的nMatched與nModified字段都為1
多個應(yīng)用
事務(wù)能夠保證多個應(yīng)用在創(chuàng)建與運行操作時不會導(dǎo)致數(shù)據(jù)的不一致與沖突。在我們的程序中忧便,在更新與檢索transaction文檔時總是會加上state字段來防止事務(wù)被多個應(yīng)用重復(fù)執(zhí)行族吻。
舉個例子,應(yīng)用App1與App2都想獲得同一個狀態(tài)為initial的transaction珠增。App1在App2啟動之前就執(zhí)行完了整個事務(wù)超歌。當App2試著執(zhí)行"將transaction的state更新為pending"操作時就會因為state:intial
條件而無法檢索到任何文檔,并且nMatched和nModified會返回為0.這個結(jié)果會讓App2返回到第一步重新檢索一條事務(wù)蒂教。
當多個應(yīng)用在運行時巍举,要保證在任何時間點上都只有一個應(yīng)用在處理給定事務(wù)。這樣的話凝垛,在更新條件中除了要指定state以外懊悯,還可以在transaction文檔中創(chuàng)建一個標識來表明哪一個應(yīng)用在處理該事務(wù)。使用findAndModify()方法在一步中修改transaction并且得到它:
t = db.transactions.findAndModify(
{
query: { state: "initial", application: { $exists: false } },
update:
{
$set: { state: "pending", application: "App1" },
$currentDate: { lastModified: true }
},
new: true
}
)
這樣就保證了只有唯一一個滿足application字段標識的應(yīng)用才可以執(zhí)行事務(wù)梦皮。
如果App1在執(zhí)行事務(wù)的過程中失敗了炭分,你可以使用恢復(fù)程序,但是應(yīng)用在恢復(fù)事務(wù)之前應(yīng)該確保他啊“擁有”該事務(wù)剑肯。舉個例子捧毛,為了找到并且恢復(fù)一個處于pending狀態(tài)的事務(wù),使用類似如下的查詢:
var dateThreshold = new Date();
dateThreshold.setMinutes(dateThreshold.getMinutes() - 30);
db.transactions.find(
{
application: "App1",
state: "pending",
lastModified: { $lt: dateThreshold }
}
)
在生產(chǎn)中使用兩階段提交
上面的事務(wù)的例子被有意地構(gòu)造得很簡單退子。舉個例子岖妄,它假定了關(guān)于賬戶的操作總是可以回滾的并且賬戶的余額可以為負值。
生產(chǎn)中的實現(xiàn)將會復(fù)雜得多寂祥。比如賬戶往往需要余額荐虐,信用預(yù)支(pending credits)與待提款(pending debits)的信息。
對于所有的事務(wù)丸凭,你要確保在部署中使用了合適的write concern等級福扬。