MongoDB官方文檔翻譯系列之,mongodb官方文檔
簡介
本篇文檔提供了一個使用二階段提交將資料寫入多個文檔的方法來處理多文檔更新或“多文檔事務”。在此基礎上,你可以擴充實作類別似資料復原的功能。
背景
在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.”步驟之後,你最好不要復原事務了。取而代之的方式應該是,完成這個事務,然後新啟一個事務,將上個事務的源賬戶和目標賬戶調換一下,再做一次轉賬。
pending狀態的事務
在 “將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 }
}
)
在生產環境下使用二階段提交
本文中的賬戶事務例子故意體現的很簡單。比如,我們假設總能夠對賬戶做復原操作,並且賬戶餘額是負數。
生產環境中的實現可能會更複雜一些,例如真實情境下賬戶需要的資訊還包括當前餘額、待轉出、待轉入。
對於所有的事務來說,在你部署時需要設定一個合適的寫入模式。(譯者註:我覺得涉及事務的地方最好還是使用安全寫比較靠譜)
著作權聲明:本文為博主原創文章,未經博主允許不得轉載。