MongoDB兩階段提交模擬事務(wù)(官方文檔譯文)

原文地址


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中的一張表):

  1. 一個叫做accounts的集合用于存儲賬戶信息渠概。
  2. 一個叫做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等級福扬。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市惜犀,隨后出現(xiàn)的幾起案子铛碑,更是在濱河造成了極大的恐慌,老刑警劉巖虽界,帶你破解...
    沈念sama閱讀 221,888評論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件汽烦,死亡現(xiàn)場離奇詭異,居然都是意外死亡莉御,警方通過查閱死者的電腦和手機撇吞,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,677評論 3 399
  • 文/潘曉璐 我一進店門俗冻,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人牍颈,你說我怎么就攤上這事迄薄。” “怎么了煮岁?”我有些...
    開封第一講書人閱讀 168,386評論 0 360
  • 文/不壞的土叔 我叫張陵讥蔽,是天一觀的道長。 經(jīng)常有香客問我画机,道長冶伞,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 59,726評論 1 297
  • 正文 為了忘掉前任色罚,我火速辦了婚禮碰缔,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘戳护。我一直安慰自己金抡,他們只是感情好,可當我...
    茶點故事閱讀 68,729評論 6 397
  • 文/花漫 我一把揭開白布腌且。 她就那樣靜靜地躺著梗肝,像睡著了一般。 火紅的嫁衣襯著肌膚如雪铺董。 梳的紋絲不亂的頭發(fā)上巫击,一...
    開封第一講書人閱讀 52,337評論 1 310
  • 那天,我揣著相機與錄音精续,去河邊找鬼坝锰。 笑死,一個胖子當著我的面吹牛重付,可吹牛的內(nèi)容都是我干的顷级。 我是一名探鬼主播,決...
    沈念sama閱讀 40,902評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼确垫,長吁一口氣:“原來是場噩夢啊……” “哼弓颈!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起删掀,我...
    開封第一講書人閱讀 39,807評論 0 276
  • 序言:老撾萬榮一對情侶失蹤翔冀,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后披泪,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體纤子,經(jīng)...
    沈念sama閱讀 46,349評論 1 318
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,439評論 3 340
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了控硼。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片跌捆。...
    茶點故事閱讀 40,567評論 1 352
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖象颖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情姆钉,我是刑警寧澤说订,帶...
    沈念sama閱讀 36,242評論 5 350
  • 正文 年R本政府宣布,位于F島的核電站潮瓶,受9級特大地震影響陶冷,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜毯辅,卻給世界環(huán)境...
    茶點故事閱讀 41,933評論 3 334
  • 文/蒙蒙 一埂伦、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧思恐,春花似錦沾谜、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,420評論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至描焰,卻和暖如春媳否,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背荆秦。 一陣腳步聲響...
    開封第一講書人閱讀 33,531評論 1 272
  • 我被黑心中介騙來泰國打工篱竭, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人步绸。 一個月前我還...
    沈念sama閱讀 48,995評論 3 377
  • 正文 我出身青樓掺逼,卻偏偏與公主長得像,于是被迫代替她去往敵國和親靡努。 傳聞我的和親對象是個殘疾皇子坪圾,可洞房花燭夜當晚...
    茶點故事閱讀 45,585評論 2 359

推薦閱讀更多精彩內(nèi)容