本篇文章是用來補充一下,前面忘了講的觀念,記得在第一篇時,我們有提過下面這句話。
MongoDB 不支持事務操作
但事實上這段話有很多觀念要來說明說明,不然很難讓人了解事務操作是啥,所以我們這篇要用來補充一下這個主題。
咱們首先先來了解一下,事務是啥?根據wiki
的定義。
資料庫事務是資料庫管理系統執行過程中的一個邏輯單位,由一個有限的資料庫操作序列構成。
這邊用白話文來簡單說明一下,事實操作你可以把他想成一個工作流程,例如煮菜,你首先要先洗菜、切菜、丟到鍋子、加調味料,『煮菜』這名詞就是一個事務,它裡面包含了剛剛說明的流程。
我們轉回的在資料庫中的事務,假設我們是個證券商,我們收到使用者的下單通知,那我們資料庫會著麼進行? 我們下面來試試列出該事務操作過程。其中我們有兩個資料表accounts
為使用者的帳戶資料、第二個為orders
下單資料,呃對了先不管交割日這鬼,也就是付錢日。
orders
新增一筆訂單。accounts
針對該使用者的帳戶進行扣款。那如果發生錯誤時,事務會著麼處理?
根據以上的例子,我們拿來繼續使用,假設我們在第二個步驟,準備要扣款時,系統突然gg
了,那要著麼樣?在一些資料庫中,當整個事務提交給資料庫時,它會保證這整個事務要嘛全部完成,要嘛全部沒完成。
也就是說,如果我們第二個步驟掛掉時,我們一開始在orders
新增的一筆訂單會取消,會保持整個事務的完整性,不會只完成一半。
最後這邊我們來看一下事務操作的四個特性ACID
,來腦補一下,以下內容為wiki
,並且自已寫寫說明。
對mongodb
不支援事務,但它還是有支援一些符合各別特性的操作,總共有三個。
document
上有提供原子性操作findAndModify
mongodb
有提供單個document
,操作,也就是說如果你要針對該document
進行更新,要麼全部更新完成,不然就全部不更新,我們簡單用個範例來說明如何設計成,符合原子性的功能。
我們把上面的例子拿下來用。
假設我們是個證券商,我們收到使用者的下單通知,那我們資料庫會著麼進行? 我們下面來試試列出該事務操作過程。其中我們有兩個資料表
accounts
為使用者的帳戶資料、第二個為orders
下單資料,呃對了先不管交割日這鬼,也就是付錢日。
但注意一點,如果我們是建立將accounts
與orders
分成兩個collection
來建立,那我們就沒辦法使用mongodb
所提供的原子性操作,因為就變為多document
的操作。
所以我們需要將它修改為都存放在同一個collection
,沒錯也就是進行反正規化,資料大概會變成這樣。
{ "user" : "mark" ,
balance : 10000 ,
orders : [
{ "id" : 1 , "total" : 1000 , "date" : "20160101" },
{ "id" : 2 , "total" : 2000 , "date" : "20160103"}
]
}
然後我們進行交易時,我們需要先檢查balance
確定是否有足的錢,然後在新增一筆下單到orders
欄位中,最後才修改balance
,而我們這時需要用到findAndModify
,它可以確保這筆交易的,在確定完balance
後,不會有其它線程來更新它的balance
。
我們來說一下沒用findAndModify
會發生什麼情況,假設balance
有10000
,有一筆下單要6000
元,然後A
是來處理這筆訂單的線程,我們來模擬情境一下。
balance
是否有足夠的錢,嗯嗯~~還有10000
很夠的。5000
元,現在balance
只剩5000
。orders
欄位,然後再進行扣款。balance
現在不夠錢了 ! 錢呢 !?嗯記好,這時要用findAndModify
才不會發生上面這種鳥事。下面為更新的程式碼。
db.accounts.findAndModify({
"query" : { "user" : "mark" , "balance" : { "$gt" : 6000 }},
"update" : { "$set" : { "balance" : 4000 },
"$push" : { "orders" :
{"id":3,"total":6000,"date":"20160110"} } }
})
document
使用$isolate
mongodb
還有提供一個東東,它可以讓你在更新大量document
時,其它的線程無法針對這些更新的文檔進行讀與寫,也就是支援隔離性(Isolation)。
但當然它也是有缺點的,有以下三個缺點。
Two Phase Commits
來模擬事務操作mongodb
官方,有提供一種範例方法,讓我們手動的來建立事務操作,它可以讓我們在進行大量更新時,如果發生錯誤,則之前更新的會全部還原,這種方法就叫Two Phase Commits
。
我們直接拿官方的例子來說明,假設有兩個銀行帳號。
db.accounts.save({name:"A", balance:1000, pendingTransactions: []})
db.accounts.save({name:"B", balance:1000, pendingTransactions: []})
然後我們這時要將帳號A
轉帳100元
到帳處B
,我們這邊將用two phase commits
來一步一步的完成這筆交易
initial
首先我們會在一個新的collection
名為transaction
新增一筆資料,記錄這該筆事務的資訊,並且設定state
為initial
。
db.transactions.save(
{source:"A", destination:"B", value:100, state:"initial"}
)
accounts
前,先修改初始狀態為Pending
首先先尋找出狀態為inital
的事務。
t =db.transactions.findOne({state:"initial"})
結果。
{ "_id" :ObjectId("4d7bc7a8b8a04f5126961522"), "source" :"A",
"destination" :"B", "value" :100, "state" :"initial"}
然後在針對該事務,將status
更新為pending
。
db.transactions.update({_id:t._id},{$set:{state:"pending"}})
這時我們的事務資訊更新為如下。
{ "_id" :ObjectId("4d7bc7a8b8a04f5126961522"), "source" :"A",
"destination" :"B", "value" :100, "state" :"pending"}
然後我們就可以開始更新兩個帳戶,並且將事務資訊,記錄到pendingTransactions
這個欄位。
db.accounts.update({name:t.source,
pendingTransactions: { $ne: t._id }},
{$inc:{ balance: -t.value },
$push:{pendingTransactions:t._id }})
db.accounts.update({name:t.destination,
pendingTransactions: { $ne: t._id }},
{$inc:{ balance: t.value },
$push:{pendingTransactions:t._id }})
首先先看看這行,這行是要先尋找出我們指令要更新的帳戶,其中pendingTransactions: { $ne: t._id }
代表的意思為pendingTransactions
裡不含t._id
才找出來。
{name:t.source, pendingTransactions: { $ne: t._id }}
然後下面這兩行,才是更新欄位,會將balance
增加t.value
,然後將該事務的id
存放至pendingTransactions
內。
{$inc:{ balance: t.value },
$push:{pendingTransactions:t._id }})
然後最後這是該階段帳戶的結果。
{ "_id" :ObjectId("4d7bc97fb8a04f5126961523"), "balance" :900, "name" :"A",
"pendingTransactions" :[ ObjectId("4d7bc7a8b8a04f5126961522") ] }
{ "_id" :ObjectId("4d7bc984b8a04f5126961524"), "balance" :1100, "name" :"B",
"pendingTransactions" :[ ObjectId("4d7bc7a8b8a04f5126961522") ] }
committed
db.transactions.update({_id:t._id},{$set:{state:"committed"}})
結果如下,state
修改為committed
。
{ "_id" :ObjectId("4d7bc7a8b8a04f5126961522"), "destination" :"B",
"source" :"A", "state" :"committed", "value" :100}
done
首先我們將帳戶內的事務資訊給刪除,因為已經不需要了。
db.accounts.update({name:t.source},{$pull:{pendingTransactions: t._id}})
db.accounts.update({name:t.destination},{$pull:{pendingTransactions: t._id}})
結果如下。
{ "_id" :ObjectId("4d7bc97fb8a04f5126961523"), "balance" :900, "name" :"A",
"pendingTransactions" :[ ] }
{ "_id" :ObjectId("4d7bc984b8a04f5126961524"), "balance" :1100, "name" :"B",
"pendingTransactions" :[ ] }
然後我們最後再將事務狀態修改為done
。
db.transactions.update({_id:t._id},{$set:{state:"done"}})
結果如下。
{ "_id" :ObjectId("4d7bc7a8b8a04f5126961522"), "destination" :"B",
"source" :"A", "state" :"done", "value" :100}
上面這一整串Step1
到Step5
的流程就是two phase commit
的流程。
上面都是跑正常的轉帳流程,還看不出這麻煩的流程可以做啥,所以我們這時來看看,如果中途轉帳時發生錯誤時,則流程要著麼樣跑,可以回復成原始模樣。
我們只直接用如果已經更新帳戶內的balance
後發生錯誤要著麼進行回復。
canceling
db.transactions.update({_id:t._id},{$set:{state:"canceling"}})
db.accounts.update({name:t.source,
pendingTransactions: t._id},
{$inc:{balance: t.value},
$pull:{pendingTransactions:t._id}})
db.accounts.update({name:t.destination,
pendingTransactions: t._id},
{$inc:{balance: -t.value},
$pull:{pendingTransactions:t._id}})
然後我們可以看一下回復後的結果,嗯沒錯,就是原始的樣子。
{ "_id" :ObjectId("4d7bc97fb8a04f5126961523"), "balance" :1000,
"name" :"A", "pendingTransactions" :[ ] }
{ "_id" :ObjectId("4d7bc984b8a04f5126961524"), "balance" :1000,
"name" :"B", "pendingTransactions" :[ ] }
canceled
最後再修改事務狀態,然後收工。
db.transactions.update({_id:t._id},{$set:{state:"canceled"}})
這篇文章說簡單的解釋了事務操作是啥以及特性,並且也有說明沒有事務操作會發生什麼事情,然後也說明了事務操作在mongodb
內的假實現,因為他並沒有完全的實現,所以我很喜歡叫他假實現。
基本上這個鐵人賽已經進入尾尾聲了,基本上最後一篇已經不太會說技術的東西,也只是這三十天的心得,請見諒,因為我累囉~~~~~