簡介
本篇文檔提供了一個使用二階段提交將數據寫入多個文檔的方法來處理多文檔更新或“多文檔事務”。在此基礎上,你可以擴展實現類似數據回滾的功能。
背景
在MongoDB數據庫中,作用於單個document的操作總是原子性的;但是,涉及到多個document的操作,也就是我們常說的“多文檔事務”,是非原子性的。 由於document可以設計的非常複雜並且能包含多個“內嵌”document,因此單文檔原子性對很多實際場景提供了必要的支持。(譯者注:比如你要批量更新某批商品的出廠日期,可以將這些商品信息放在同一個document中做內嵌。但是我幾乎沒有使用過這種方法,會有很多額外的問題,比如頻繁操作會導致document move。)
儘管單文檔原子操作能滿足不少需求,但是在很多場景下仍然需要多文檔事務的支持。當執行一個由幾個順序操作組成的事務時,可能會出現某些問題,例如:
- 原子性: 如果某個操作失敗了,同一個事務內發生在它之前的所有操作必須“回滾”到最初的狀態(即“要麼全OK,要麼什麼也不做”)。
- 一致性: 如果發生了嚴重故障將事務中斷(網絡、硬件故障),數據庫必須恢復到一致的狀態。
對於需要多文檔事務的場景,你可以在應用中實現二階段提交來提供支持。二階段提交可以保證數據的一致性,如果發生錯誤,事務前的狀態是可恢復的。在事務執行過程中,無論發生什麼情況都可以還原到數據和狀態的準備階段。
注意
因爲在MongoDB中只有單文檔操作是原子性的,二階段提交只能提供類似事務的語義。在二階段提交或回滾進行中,應用程序可以返回任意步驟點的中間數據。
模式
概述
考慮這樣一個場景,你想從賬戶A轉賬給賬戶B。在關係型數據庫系統中,你可以在單個多語句事務中先減少賬戶A的資金然後爲賬戶B增加資金。在MongoDB中,你可以模擬實現一個二階段提交得到同樣的結果。
本節中的所有示例使用下面兩個集合:
1. 集合accounts保存賬戶信息。
2. 集合transactions保存轉賬事務信息。
初始化源賬戶和目標賬戶
將賬戶A和賬戶B的信息寫入到集合accounts。
db.accounts.insert(
[
{ _id: "A", balance: 1000, pendingTransactions: [] },
{ _id: "B", balance: 1000, pendingTransactions: [] }
]
)
上面的語句返回一個BulkWriteResult()對象,包含了本次操作的狀態信息。如果成功寫入,BulkWriteResult()對象中的 nInserted的值爲2。(譯者注:在2.6版本後寫操作都會返回WriteResult對象,批量寫會返回BulkWriteResult,具體請見相關章節)
初始化轉帳數據
將每筆轉賬信息寫入到transactions表,轉賬數據包含以下字段:
- source 和 destination字段, 指向accounts集合中的_id值
- value字段,表示轉賬金額,影響源賬戶和目標賬戶的餘額
- state 字段,表示轉賬操作當前狀態,state字段可選值範圍爲initial, pending, applied, done, canceling和 canceled
- lastModified 字段,表示最後更新時間
將賬戶A向賬戶B轉賬100的操作信息初始化到transactions集合, state字段值爲"initial", lastModified字段值設爲當前時間:
db.transactions.insert(
{ _id: 1, source: "A", destination: "B", value: 100, state: "initial", lastModified: new Date() }
)
上面的語句返回一個WriteResult()對象,包含了本次操作的狀態信息,如果寫入成功, WriteResult()對象的 nInserted值爲1。
使用二階段提交轉賬
? 獲取transaction集合的數據
從transactions集合查找一條state字段值爲initial的數據。當前transactions集合中只有一條數據,也就是說我們在上文 初始化轉賬數據 這個步驟只寫入了一條數據。如果集合中有另外的數據,下面的查詢會返回任意state字段爲initial的數據,除非你附加一些別的查詢條件。
var t = db.transactions.findOne( { state: "initial" } )
在 mongo shell中定義變量t來打印返回的內容。上邊的語句會得到如下輸出:
{ "_id" : 1, "source" : "A", "destination" : "B", "value" : 100, "state" : "initial", "lastModified" : ISODate("2014-07-11T20:39:26.345Z") }
? 將transaction數據的state字段設爲pending
將transaction數據的state字段從initial設爲pending,並用 $currentDate 操作將lastModified字段設爲當前時間。
db.transactions.update(
{ _id: t._id, state: "initial" },
{
$set: { state: "pending" },
$currentDate: { lastModified: true }
}
)
這個更新操作會返回一個WriteResult()對象,包含本次更新操作的狀態信息,如果更新成功,nMatched 和 nModified 顯示爲1。
在這個更新語句中state: "initial" 條件確保沒有其它線程更新過本條數據。如果nMatched和 nModified爲0,回到第一步重新獲取一條數據然後繼續按步驟進行。
? 對賬戶進行轉賬
如果賬戶不包含transaction信息,用 update()方法更新帳戶信息, 在更新條件中帶有pendingTransactions: {$ne: t._id },這是爲了避免重複同一次轉賬。
同時更新balance字段和pendingTransactions字段來實現轉賬。
更新源賬戶信息,爲balance字段減去transaction 數據的value 值,並將transaction 的_id寫入到pendingTransactions字段的數組中。
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 值,並將transaction 的_id寫入到pendingTransactions字段的數組中。
db.accounts.update(
{ _id: t.destination, pendingTransactions: { $ne: t._id } },
{ $inc: { balance: t.value }, $push: { pendingTransactions: t._id } }
)
操作成功後,方法會返回 WriteResult() 對象, nMatched 和nModified 值爲1。
? 將transaction數據的state設爲applied
用下面的update()操作將transaction數據的state 值設爲applied operation to set the transaction’s state to applied,並更新lastModified字段值爲當前時間:
db.transactions.update(
{ _id: t._id, state: "pending" },
{
$set: { state: "applied" },
$currentDate: { lastModified: true }
}
)
操作成功後,方法會返回 WriteResult() 對象, nMatched 和nModified 值爲1。
? 將transaction 數據的_id值從兩個賬戶的pendingTransactions字段中移除
從兩個賬戶中的pendingTransactions 字段中移除state值爲applied的 transaction數據的 _id值。
更新源賬戶
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。
? 更新transaction數據的 state值爲done.
將transaction 數據的state設爲 done ,更新lastModified爲當前時間,這也標誌着本次事務的結束。
db.transactions.update(
{ _id: t._id, state: "applied" },
{
$set: { state: "done" },
$currentDate: { lastModified: true }
}
)
操作成功後,方法會返回 WriteResult() 對象, nMatched 和nModified 值爲1。
從失敗場景恢復
其實最重要的部分不是上面示例中比較順的場景,重要的當事務未成功完成時有沒有可能從各種各樣失敗情況中恢復。這部分會概括各種可能出現的失敗場景,並教你一些步驟,如何從這些事件中恢復。
恢復操作
二階段提交模式允許應用程序有序的運行一些操作來恢復事務並達到一致性狀態。在應用啓動時運行恢復程序,可能是個定期執行的程序,用來捕獲任何未完成的事務。
在一致性問題上對於時間的需求取決於應用間隔多長時間爲每個事務進行恢復。
接下來舉例的恢復程序根據lastModified字段做爲指標來決定pending狀態的事務是否需要進行恢復; 再具體點,如果pending 或 applied 狀態的事務在30分鐘內未更新過,恢復程序會認爲這些事務需要進行恢復。你可以用不同的條件來決定事務是否需要恢復。
pending狀態的事務
要恢復發生在上文舉例的“將transaction數據的state設爲pending.” 步驟之後,但發生在 “將transaction數據的 state設爲applied.“步驟之前的錯誤,先從transactions集合中獲取一條pending狀態的數據:
var dateThreshold = new Date();
dateThreshold.setMinutes(dateThreshold.getMinutes() - 30);
var t = db.transactions.findOne( { state: "pending", lastModified: { $lt: dateThreshold } } );
然後從上文的 “對賬戶進行轉賬“步驟開始繼續執行
applied狀態的事務
要恢復發生在上文舉例的 “將transaction數據的state設爲applied”步驟之後,但發生在 but before “將transaction數據的state設爲done.“步驟之前的錯誤,先從transactions集合中獲取一條applied狀態的數據:
var dateThreshold = new Date();
dateThreshold.setMinutes(dateThreshold.getMinutes() - 30);
var t = db.transactions.findOne( { state: "applied", lastModified: { $lt: dateThreshold } } );
然後從上文的“U將transactions數據的_id值從兩個賬戶信息的pendingTransactions字段中刪除.“步驟開始繼續執行
回滾操作
在一些情況下,你可能需要“回滾”或取消事務;舉例來說,比如應用程序主觀的需要去“取消”事務,或者事務中的某個賬戶不存在,或者說在事務進行中賬戶不復存在了。
applied狀態的事務
在 “將transaction數據state設爲applied.”步驟之後,你最好不要回滾事務了。取而代之的方式應該是,完成這個事務,然後新啓一個事務,將上個事務的源賬戶和目標賬戶調換一下,再做一次轉賬。
在 “將transaction數據 state設爲pending.”步驟之後,在 “將 transaction數據 state設爲applied.”步驟之前,你可以根據下面的流程來回滾事務:
? 將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。
? 在兩個賬戶上撤消事務
對兩個賬戶做反向操作來撤消事務,在update條件的中加上pendingTransactions: t._id來篩選滿足條件的數據。
更新目標賬戶信息,在balance 字段上減去transaction 數據的value 值,並將transaction 數據的 _id 從pendingTransactions 數組中移除。
db.accounts.update(
{ _id: t.destination, pendingTransactions: t._id },
{
$inc: { balance: -t.value },
$pull: { pendingTransactions: t._id }
}
)
操作成功後,方法會返回 WriteResult() 對象, nMatched 和nModified 值爲1。 如果轉賬事務在之前沒有發生在該賬戶上,那麼上面的更新操作匹配不到數據, nMatched and nModified 值會是0。
更新源賬戶信息,在balance字段上加上transaction數據的 value值,並將transaction 數據的 _id 從pendingTransactions 數組中移除。
db.accounts.update(
{ _id: t.source, pendingTransactions: t._id },
{
$inc: { balance: t.value},
$pull: { pendingTransactions: t._id }
}
)
操作成功後,方法會返回 WriteResult() 對象, nMatched 和nModified 值爲1。如果轉賬事務在之前沒有發生在該賬戶上,那麼上面的更新操作匹配不到數據, nMatched and nModified 值會是0。
? 將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。
多應用
事務的存在,從某種程度上說,是爲了便於多個應用併發的創建和執行操作,而不會引發數據不一定和數據衝突。在我們的程序中,更新或獲取transaction集合的數據時,更新條件中都會包含state 字段條件,這能防止多應用衝突的申請transaction 數據。
例如,App1和App2同時獲取了某條相同的state爲initial的transaction 數據。在App2開工前,App1執行了完整的事務,當App2試圖執行步驟 “將transaction數據state設爲pending.”時,由於更新條件中包含有state: "initial"語句,更新操作匹配不到數據,nMatched 和nModified值會是0。這會讓App2返回到第一步去獲取另一條transaction數據重新開始事務流程。
當多個應用運行時,最關鍵的是在任意時刻只能有唯一一個應用能操作一條給定的transaction 數據。同樣的,除了在更新條件中包含預期的事務狀態之外,你還可以爲transaction 數據創建一個標記來鑑別正在操作該transaction數據的應用。用findAndModify()方法原子性的修改並返回transaction數據:
t = db.transactions.findAndModify(
{
query: { state: "initial", application: { $exists: false } },
update:
{
$set: { state: "pending", application: "App1" },
$currentDate: { lastModified: true }
},
new: true
}
)
修改之前例子中的事務操作,可以確保只有匹配上application字段標識的應用才能操作相應的transaction數據。
如果App1在事務執行過程中失敗了,你可以用恢復程序進行恢復,但是在恢復之前,應用程序必須確定它們“擁有”相應的transaction數據。例如要找到並繼續執行一個pending狀態的事務, 使用類似下面的查詢:
var dateThreshold = new Date();
dateThreshold.setMinutes(dateThreshold.getMinutes() - 30);
db.transactions.find(
{
application: "App1",
state: "pending",
lastModified: { $lt: dateThreshold }
}
)
在生產環境下使用二階段提交
本文中的賬戶事務例子故意體現的很簡單。比如,我們假設總能夠對賬戶做回滾操作,並且賬戶餘額是負數。
生產環境中的實現可能會更復雜一些,例如真實場景下賬戶需要的信息還包括當前餘額、待轉出、待轉入。
對於所有的事務來說,在你部署時需要設置一個合適的寫模式。(譯者注:我覺得涉及事務的地方最好還是使用安全寫比較靠譜)