【編者按】在分布式存儲解決方案中談事務一直是件很痛苦的事情,而事務也成了大部分NoSQL解決方案短板所在。近日,MongoDB公司的Antoine Girbal在其個人博客上撰文,分享了在MongoDB文檔間實施魯棒可擴展事務的5個解決方案――同步字段、作業隊列、二階段提交、Log Reconciliation和版本控制。
免費訂閱“CSDN大數據”微信公眾號,實時了解最新的大數據進展!
CSDN大數據,專注大數據資訊、技術和經驗的分享和討論,提供Hadoop、Spark、Imapala、Storm、HBase、MongoDB、Solr、機器學習、智能算法等相關大數據觀點,大數據技術,大數據平臺,大數據實踐,大數據產業資訊等服務。
以下為譯文:
事務問題
數據庫支持數據塊間的事務是有原因的。典型的場景是應用需要修改幾個獨立的比特時,如果只有一些而不是全部改變存儲到了數據庫,那么這就會出現不一致問題。因此ACID的概念是:
引入NoSQL數據庫后,文檔間ACID事務的支持通常就取消了。許多鍵/值存儲仍有ACID,但它只適用于單個條目,取消ACID的主要原因是其可擴展限制。如果文檔橫跨幾個服務器,事務將會很難實施以及性能。假設事務橫跨數十個服務器,一些數據庫是遠程的,一些是不可靠的,想象下這會變的多難,多慢!
在單個文檔等級上,MongoDB支持ACID。更準確的說,默認情況下是“ACI”,打開“j”WriteConcern選項后是ACID。Mongo有豐富的查詢語言,橫跨多個文檔,因此人們一直在尋找多文檔事務來使用他們的SQL代碼。一個常見的辦法是利用文檔的性質:不需要很多行、很多關系,你可以將所有的東西嵌入到一個大文檔中,Denormalization將帶你回歸事務。
這個技術解決了從一對一關系到一對多關系的很多事務問題。這也可能使應用更簡單,數據庫更快,所以這是雙贏。不過當數據庫必須分離時,該怎么辦?
減少ACID
其實大部分應用都可以歸結為:
這樣問題就簡化為魯棒性、可擴性、最終一致性。
解決方案 1:字段同步
這種解決方案的使用場景最簡單,最常見:文檔間有些字段需要保持“同步”。例如,你有一個用戶名為“John”的用戶文檔,文檔代表John發表過的評論。如果用戶可以更換用戶名,那么這個改變需要發送給所有文檔,即使進程中有應用錯誤或數據庫錯誤。
為了實現這一目標,一個簡單的辦法是在主文檔(這個情況下主文檔是用戶文檔)中使用一個新字段(如“syncing”)。給“syncing”設置一個日期時間戳,記錄用戶文檔的更新。
db.user.update({ _id: userId }, { $set:{ syncing: currentTime }, { rest of updates ... } })
然后應用會修改所有的評論文檔。結束后,需要移除標識:
db.user.update({ _id: userId }, {$unset: { syncing: 1 } })
現在假設進程中出現了問題:有些評論使用的是舊用戶名。不過這些地方仍然會保留標識,所以應用知道哪些進程需要重新進行。因此,你需要后臺進程在指定的時間(如1小時)檢查“syncing”文件是否有未完成的地方。索引應設為“sparse”,這樣只有實際設置的文檔需要被索引,索引量就會比較小。
db.user.ensureIndex({ syncing: 1 }, { sparse: true })
因此,系統通常可以保持事情在短時間內同步,在系統故障的情況下,時間周期為一個小時。如果時間不重要,當探測到“syncing”標志時,應用可以輕易修復文檔。
解決方案2:作業隊列
以上原理良好工作的前提是應用不需要很多內容,只依賴于通用進程(如:復制一個值)。一些事務需要執行特定變化,這些變化稍后很難識別。例如,用戶文檔包括一個朋友列表:
{ _id: userId, friends: [ userId1,userId2, ... ]}
現在A和B決定成為朋友:你需要把B添加到A的列表,也需要把A添加到B的列表。如果兩者沒有同時發生也沒有關系(只要沒有引發困擾)。針對這種情況和大多數事務問題的解決方案是使用作業隊列,作業隊列也存儲在MongoDB。一個作業文檔就像這樣:
{ _id: jobId, ts: timeStamp, state: "TODO", type: "ADD_FRIEND", details: { users: [ userA, userB ]} }
或者是原始線程可以插入作業轉發改變,或者是“worker”線程可以撿起工作。worker使用findAndModify()獲取最原始的未加工的工作,findAndModify()是完全原子性的。操作中findAndModify()將工作標注為將被處理,同時也會表明worker name、當前時間以便于追蹤。{ state: 1, ts: 1 } 上的索引使這些調用很迅速。
db.job.findAndModify({ query: { state: "TODO" }, sort: { ts: 1 }, update: { $set: { state: "PROCESSING", worker: { name: "worker1", ts: startTime } } } })
之后worker以一種冪等的方式對雙方用戶文檔進行修改,這些改變能應用很多次,并且有同樣的效果――這很重要!為了這個目的,我們只需要使用一個$addToSet。一種更通用的替代方式是在查詢端添加一個測試,檢測修改是否執行了。
db.user.update({ _id: userA }, {$addToSet: { friends: userB } })
最后一步是刪除作業或標注作業完成。再保留一段時間作業是一種安全的方式,唯一的缺點是隨著時間的流逝,先前的索引會變得越來越大,盡管你可以在指定域{ undone: 1 } 上使用稀疏索引,并且根據實際情況修改查詢。
db.job.update({ _id: jobId }, { $set: { state: "DONE" } })
如果進程在某一時刻故障了,作業仍然會在隊列中,并標注為處理中。后臺進程停止一段時間后會將作業標注為需要再次處理,然后作業會重新從頭開始。
解決方案3 :二階段提交
二階段提交是一個眾所周知的解決方案,很多分布式系統都采用了這種解決方案。MongoDB簡化了這種解決方案的實施,因為靈活的框架,我們可以將所有需要執行的數據全都放入文檔中。我幾年前就寫過關于這種方法的文章,你可以去MongoDB Cookbook中查閱《 執行二階段提交》(Perform Two Phase Commits)或者到MonoBD Manual中查閱《 執行二階段提交》(Perform Two Phase Commits)。
解決方案4: Log Reconciliation
很多財務系統常用的解決方案是 log reconciliation。這種解決方案將事務寫作簡單的日志,這避免了復雜性和潛在的故障。然后從上次良好狀態以來所有的變化推測當前賬戶的狀態。在極端情況下,你可以清空賬戶,然后通過實施從第一天以來所有的變化重建賬戶……這聽起來很恐怖,但是可行。賬戶文件需要一個“緩存”來提高速度,還需要一個seqId,seqId計算如下:
{ _id: accountId, cache: { balance:10000, seqId: 115 } }
執行事務時,一個典型的財務系統會給事務寫一個條目,會給與事務有關的賬戶寫一個“賬戶變化”條目。這個方法需要進一步的寫保證,“作業隊列”解決方案可以實現寫保證,事務中所有的作業在所有賬戶更改寫入前都會保持不變。不過有了MongoDB,我們可以寫一個包括事務和賬戶更改的文檔。這個文檔應該嵌入tx集合,如下:
{ _id: ObjectId, ts: timestamp , proc: "UNCOMMITTED", state: "VALID", changes: [ { account: 1234, type: "withdraw", value: -100, seqId: 801, cachedBal: null }, { account: 2345, type: "deposit", value: 100, seqId: 203, cachedBal: null } ] }
幾個重點:
關鍵是確保即使事務沒有按順序發生,緩存平衡也可以安全的計算/取消,還有就是事務狀態可能改變。因此我們每個賬戶使用一個seqId,這確保了賬戶更改按確定的順序發生,可以避免復雜的鎖。在寫事務前,應用首先通過簡單地查詢推斷每個賬戶的下一個sqlId:
db.tx.find({ "changes.account": 1234 }, { "changes.$.seqId": 1 }).sort({ "changes.seqId": -1 }).limit(1)
然后每個sqlId都本地增長,然后寫作事務的一部分。如果另一個線程也可能同時包括同樣的seqId,獨特的索引會確保寫失敗,線程會進行重試直到順利完成任務。另一種方法是在賬戶集中保存一個當前seqId,然后用 findAndModify()獲得下一個seqId,這通常會比較慢,除非你對賬戶有很多爭用。注意如果因為某種原因事務沒有寫時,seqId可能會被跳過去,不過只有沒有副本情況下才會成為。
下面我們談談reconciliation的基礎。后臺進程確保所有未提交的事務都會繼續進行。只有所有賬戶的低seqId的事務都提交后一個事務才會被標注為提交。事務被標記為提交后就會變成不可變的。下面來談談好的方面:獲得賬戶平衡。首先我們獲得好的平衡,我們可以通過索引進行查詢:
db.tx.find({ "changes.account": 1234, proc: "COMMITTED" }, { "changes.$": 1 }).sort({ "changes.seqId": -1 }).limit(1)
我們通過較大seqId的事務獲得所有將發生的更改:
db.tx.find({ "changes.account": 1234, "changes.seqId": { $gt: lastGoodSeqId } }, { "changes.$": 1 }).sort({ "changes.seqId": 1 })
我們可以使用這些解決展示即將發生的損耗。如果我們只想簡單的了解將來的平衡點在哪,我們可以讓MongoDB收集所有變更展示總數:
db.tx.aggregate([{ $match: { "changes.account": 1234, "changes.seqId": { $gt: lastGoodSeqId }, state: "VALID" }}, { $unwind: "changes" }, { $match: { "account": 1234 }}, { $group: { _id: "total", total: { $sum: "$value" } }}])
為了確保系統快速、計算量小,后臺工作者要確保所有的事務都達到提交狀態,平衡得到緩存。理想情況下一個事務是不可逆的,取而代之的是提交一個逆向事務來實施事務。不過只要所有的進一步事務狀態和緩存都是正確設置的,取消是可行的。
解決方案5:版本控制
有時變得很復雜,以至于不能再JSON中表示,這些變更可能涉及很多有著復雜關系的文件(如樹結構)。如果僅是部分變化(如破壞樹)將會很混亂,這種情況下我們需要隔離。獲取隔離性的一種方式是插入有著高版本號的新文檔,取代對現有文檔的更新。可以通過同日志和解同樣的技術很容易、很安全的獲得新版本號。通常{ itemId: 1, version: 1}上有一個獨特的索引。
嵌入文檔的應用從子文檔開始,到主文檔結束(如根節點)。當獲取數據時,應用檢查主文檔的版本號,忽略高于版本號高于此版本號的文檔。未完成的事務可以保持原狀,可以忽略,可以清楚。
總結
綜上所述,我們提供了在文檔間實施魯棒可擴展事物的五種解決方案:
此外,我們還提到了很多次MongoDB最終將支持真正的原子性和文檔間的隔離事務。這已經作為分區的一部分了,但目前還只是內部的……只有文檔在同一分區時這一特性才可能實現,否則我們將回到不可擴展的SQL世界。
原文鏈接: How to Implement Robust and Scalable Transactions Across Documents with MongoDB(編譯/蔡仁君 責編/仲浩)