分散式系統如何保證資料的一致性
作者:buguge
連結:https://www.jianshu.com/p/93f8cbb6f500
來源:簡書
簡書著作權歸作者所有,任何形式的轉載都請聯絡作者獲得授權並註明出處。
由於網際網路目前越來越強調分散式架構,如果是交易類系統,面臨的將會是分散式事務上的挑戰。當然目前有很多開源的分散式事務產品,例如java JTA,但是這種解決方案的成本是非常高的,而且實現起來非常複雜,效率也比較低下。對於極端的情況:例如釋出,故障的時候都是沒有辦法保證強一致性的。
首先,在目前的網際網路應用中,我們通過一個比較常見的例子,讓大家更深入的瞭解一下分散式系統設計中關於資料一致性的問題。拿我們經常使用的功能來考慮吧,最近網購比較熱門,就以京東為例的,我們來看看京東的一個簡單的購物流程。
使用者在京東上下了一個訂單,發現自己在京東的賬戶裡面有餘額,然後使用餘額支付,支付成功之後,訂單狀態修改為支付成功,然後通知倉庫發貨。假設訂單系統,支付系統,倉庫系統是三個獨立的應用,是獨立部署的,系統之間通過遠端服務呼叫。
訂單的有三個狀態:I:初始 P:已支付 W:已出庫,訂單金額100, 會員帳戶餘額200
如果整個流程比較順利,正常情況下,訂單的狀態會變為I->P->W,會員帳戶餘額100,訂單出庫。
但是如果流程不順利了呢?考慮以下幾種情況
1:訂單系統呼叫支付系統支付訂單,支付成功,但是返回給訂單系統資料超時,訂單還是I(初始狀態),但是此時會員帳戶餘額100,會員肯定會馬上找京東罵京東,為啥不給老子發貨,我都付錢了
2:訂單系統呼叫支付系統成功,狀態也已經更新成功,但是通知倉庫發貨失敗,這個時候訂單是P(已支付)狀態,此時會員帳戶餘額是100,但是倉庫不會發貨。會員也要罵京東。
3:訂單系統呼叫支付系統成功,狀態也已經更新成功,然後通知倉庫發貨,倉庫告訴訂單系統,沒有貨了。這個時候資料狀態和第二種情況一樣。
對於情況一的問題,我們來分析一下解決方案,能想到的解決方案如下
1 假設呼叫支付系統支付訂單的時候先不扣錢,訂單狀態更新完成之後,在通知支付系統你扣錢
如果採用這種設計方案,那麼在同一時刻,這個使用者,又支付了另外一筆訂單,訂單價格200,順利完成了整個訂單支付流程,由於當前訂單的狀態已經變成了支付成功,但是實際使用者已經沒有錢支付了,這筆訂單的狀態就不一致了。即使使用者在同一個時刻沒有進行另外的訂單支付行為,通知支付系統扣錢這個動作也有可能完不成,因為也有可能失敗,反而增加了系統的複雜性。
2 訂單系統自動發起重試,多重試幾次,例如三次,直到扣款成功為止。
這個看起來也是不錯的考慮,但是和解決方案一樣,解決不了問題,還會帶來新的問題,假設訂單系統第一次呼叫支付系統成功,但是沒有辦法收到應答,訂單系統又發起呼叫,完了,重複支付,一次訂單支付了200。
假設支付系統正在釋出,你重試多少次都一樣,都會失敗。這個時候使用者在等待,你怎麼處理?
3 在第二種方案的基礎上,我們先解決訂單的重複支付行為,我們需要在支付系統上對訂單號進行控制,一筆訂單如果已經支付成功,不能在進行支付。返回重複支付標識。那麼訂單系統根據返回的標識,更新訂單狀態。
接下來解決重試問題,我們假設應用上重試三次,如果三次都失敗,先返回給使用者提示支付結果未知。假設這個時候使用者重新發起支付,訂單系統呼叫支付系統,發現訂單已經支付,那麼繼續下面的流程。如果會員沒有發起支付,系統定時(一分鐘一次)去核對訂單狀態,如果發現已經被支付,則繼續後續的流程。
這種方案,使用者體驗非常差,告訴使用者支付結果未知,使用者一定會罵你,你丫咋回事情,我明明支付了,你告訴我未知。假設告訴使用者支付失敗,萬一實際是成功的咋辦。你告訴使用者支付成功,萬一支付失敗咋辦。
4 第三種方案能夠解決訂單和支付資料的一致性問題,但是使用者體驗非常差。當然這種情況比較可能是少數,可以犧牲這一部分的使用者體驗,我們還有沒有更好的解決方案,既能照顧使用者體驗,又能夠保證資金的安全性。
我們再回來看看第一種方案,我們先不扣錢,但是有木有辦法讓這一部分錢不讓使用者使用,對了,我們先把這一部分錢凍結起來。訂單系統先呼叫支付系統成功的時候,支付系統先不扣錢,而是先把錢凍結起來,不讓使用者給其他訂單支付,然後等訂單系統把訂單狀態更新為支付成功的時候,再通知支付系統,你扣錢吧,這個時候支付系統扣錢,完成後續的操作。
看起來這個方案不錯,我們仔細在分析一下流程,這個方案還存在什麼問題,假設訂單系統在呼叫支付系統凍結的時候,支付系統凍結成功,但是訂單系統超時,這個時候返回給使用者,告知使用者支付失敗,如果使用者再次支付這筆訂單,那麼由於支付系統進行控制,告訴訂單系統凍結成功,訂單系統更新狀態,然後通知支付系統,扣錢吧。如果這個時候通知失敗,沒有有問題,反正錢都已經是凍結的了,使用者不能用,我只要定時掃描訂單和支付狀態,進行扣錢而已。
那麼如果變態的使用者重新拍下來一筆訂單,100塊錢,對新的訂單進行支付,這個時候由於先前那一筆訂單的錢被凍結了,這個時候使用者餘額剩餘100,凍結100,發現可用的餘額足夠,那就直接在對使用者扣錢。這個時候餘額剩餘0,凍結100。先前那一筆怎麼辦,一個辦法就是定時掃描,發現訂單狀態是初始的話,就對使用者的支付餘額進行解凍處理。這個時候使用者的餘額變成100,訂單資料和支付資料又一致了。 假設原先使用者餘額只有100,被凍結了,使用者重新下單,支付的時候就失敗了啊,的確會發生這一種情況,所以要儘可能的保證在第一次訂單結果不明確的情況,儘早解凍使用者餘額,比如10秒之內。但是不管如何快速,總有資料不一致的時刻,這個是沒有辦法避免的。
上面分析解決了第一個的問題以及相應的方案,發現在資料分佈的環境下,很難絕對的保證資料一致性(任何一段區間),但是有辦法通過一種補償機制,最終保證資料的一致性。
下面再分析一下第二個問題:訂單系統呼叫支付系統成功,狀態也已經更新成功,但是通知倉庫發貨失敗,這個時候訂單是P(已支付)狀態,此時會員帳戶餘額是100,但是倉庫不會發貨。會員也要罵京東。
通過上面的分析,這個相對來說是比較簡單的,我可以採取重試機制,如果發現通知倉庫發貨失敗,就一直重試。
這裡面有兩種方式:
1 非同步方式:通過類似MQ(訊息通知)的機制,這個是非同步的通知
2 同步呼叫:類似於遠端過程呼叫
對於同步的呼叫的方式,比較簡單,我們能夠及時獲取結果;對於非同步的通知,就必須採用請求,應答的方式進行,這一點在(關於分散式系統的資料一致性問題(一))裡面有介紹。這裡面就不再闡述。
來看看第三個問題:訂單系統呼叫支付系統成功,狀態也已經更新成功,然後通知倉庫發貨,倉庫告訴訂單系統,沒有貨了。這個時候資料狀態和第二種情況一樣。
我覺得這是一個很有意思的問題,我們還是考慮幾種解決的方案
1 在會員下單的時刻,就告訴倉庫,我要你把貨物留下來,
2 在會員支付訂單時候,在支付之前檢查倉庫有沒有貨,如果沒有貨,就告知會員木有貨物了
3 如果會員支付成功,這個時候沒有貨了,就會退款給使用者或者等待有貨的時候再發貨
正常情況,京東的倉庫一般都是有貨的,所以影響到的會員很少,但是在秒殺和營銷的時候,這個時候就不一定了,我們考慮假設倉庫有10臺iphone
1 在會員下單的時候,相當於庫存就減1,那麼使用者惡意拍下來,沒有去支付,就影響到了其他使用者的購買。京東可以設定一個訂單超時時間,如果這段時間內沒有支付,就自動取消訂單
2 在會員支付之前,檢查倉庫有貨,對於這種方案,對於使用者體驗不好,但是對於京東比較好,至少我東西都賣出去了。那些沒有及時付款的使用者,只能投訴了京東無故取消訂單,對於已經下了訂單,但沒有付錢的使用者,可能買不到貨。
3 第三種方案,這個方案體驗更不好,而且使用者感覺受到京東欺詐,但是對於京東來說,比第二種方案更有益,畢竟我還可以多賣出一點東西。
個人覺得,京東應該會採用第二種或者第三種方式來處理這類情況,我在微博上搜索了 “京東 無故取消訂單”,發現果真和我預料的處理方式。不過至於這裡的無故取消是不是技術上的原因我不知道,如果真的是技術上的原因,我覺得京東可以採用不同的處理方案。對於秒殺和促銷商品,可以考慮第一種方案,大多數人都會直接付款,畢竟便宜啊,如果使用者搶不到便宜的東西,抱怨當然很大了。這樣可以照顧大多數使用者的體驗。對於一般的訂單,可以採用第二種或者第三種方式,這種情況下,發生付款之後倉庫沒有貨的情況會比較少,並且就算髮生了,使用者也會覺得無所謂,大不了退錢嗎,這樣就可以實現自己的利益最大化而最低程度的減少使用者體驗。
而鐵道部在這個問題上,採用的是第一種方案,為什麼和京東不一樣,就是因為使用者體驗,如果使用者把票都買了,你告訴我木有票了,旅客會殺人的。哈哈,不過鐵道部不擔心票賣不出去,第一種方案對他影響沒有什麼。
說了這麼多,就是說 分散式環境下(資料分佈)要任何時刻保證資料一致性是不可能的,只能採取妥協的方案來保證資料最終一致性。這個也就是著名的CAP定理。
CAP定理是2000年,由 Eric Brewer 提出來的。Brewer認為在分散式的環境下設計和部署系統時,有3個核心的需求,以一種特殊的關係存在。這裡的分散式系統說的是在物理上分佈的系統,比如我們常見的web系統。
這3個核心的需求是:Consistency,Availability和Partition Tolerance,賦予了該理論另外一個名字 - CAP。
Consistency:一致性,這個和資料庫ACID的一致性類似,但這裡關注的所有資料節點上的資料一致性和正確性,而資料庫的ACID關注的是在在一個事務內,對資料的一些約束。
Availability:可用性,關注的在某個結點的資料是否可用,可以認為某一個節點的系統是否可用,通訊故障除外。
Partition Tolerance:分割槽容忍性,是否可以對資料進行分割槽。這是考慮到效能和可伸縮性。
為什麼不能完全保證這個三點了,個人覺得主要是因為一旦進行分割槽了,就說明了必須節點之間必須進行通訊,涉及到通訊,就無法確保在有限的時間內完成指定的行文,如果要求兩個操作之間要完整的進行,因為涉及到通訊,肯定存在某一個時刻只完成一部分的業務操作,在通訊完成的這一段時間內,資料就是不一致性的。如果要求保證一致性,那麼就必須在通訊完成這一段時間內保護資料,使得任何訪問這些資料的操作不可用。
如果想保證一致性和可用性,那麼資料就不能夠分割槽。一個簡單的理解就是所有的資料就必須存放在一個數據庫裡面,不能進行資料庫拆分。這個對於大資料量,高併發的網際網路應用來說,是不可接受的。
我們可以拿一個簡單的例子來說明:假設一個購物系統,賣家A和賣家B做了一筆交易100元,交易成功了,買家把錢給賣家。
這裡面存在兩張表的資料:Trade表Account表 ,涉及到三條資料Trade(100),Account A ,Account B
假設 trade表和account表在一個數據庫,那麼只需要使用資料庫的事務,就可以保證一致性,同時不會影響可用性。但是隨著交易量越來越大,我們可以考慮按照業務分庫,把交易庫和account庫單獨分開,這樣就涉及到trade庫和account庫進行通訊,也就是存在了分割槽,那麼我們就不可能同時保證可用性和一致性。
我們假設初始狀態
trade(buyer,seller,tradeNo,status) = trade(A,B,20121001,I)
account(accountNo,balance) = account(A,300)
account(accountNo,balance) = account(B,10)
在理想情況下,我們期望的狀態是
trade(buyer,seller,tradeNo,status) = trade(A,B,20121001,S)
account(accountNo,balance) = account(A,200)
account(accountNo,balance) = account(B,110)
但是考慮到一些異常情況
假設在trade(20121001,S)更新完成之後,帳戶A進行扣款之前,帳戶A進行了另外一筆300款錢的交易,把錢消費了,那麼就存在一個狀態
trade(buyer,seller,tradeNo,status) = trade(A,B,20121001,S)
account(accountNo,balance) = account(A,0)
account(accountNo,balance) = account(B,10)
產生了資料不一致的狀態
由於這個涉及到資金上的問題,對資金要求比較高,我們必須保證一致性,那麼怎麼辦,只能在進行trade(A,B,20121001)交易的時候,對於任何A的後續交易請求trade(A,X,X),必須等到A完成之後,才能夠進行處理,也就是說在進行trade(A,B,20121001)的時候,Account(A)的資料是不可用的。
任何架構師在設計分散式的系統的時候,都必須在這三者之間進行取捨。首先就是是否選擇分割槽,由於在一個數據分割槽內,根據資料庫的ACID特性,是可以保證一致性的,不會存在可用性和一致性的問題,唯一需要考慮的就是效能問題。對於可用性和一致性,大多數應用就必須保證可用性,畢竟是網際網路應用,犧牲了可用性,相當於間接的影響了使用者體驗,而唯一可以考慮就是一致性了。
犧牲一致性
對於犧牲一致性的情況最多的就是快取和資料庫的資料同步問題,我們把快取看做一個數據分割槽節點,資料庫看作另外一個節點,這兩個節點之間的資料在任何時刻都無法保證一致性的。在web2.0這樣的業務,開心網來舉例子,訪問一個使用者的資訊的時候,可以先訪問快取的資料,但是如果使用者修改了自己的一些資訊,首先修改的是資料庫,然後在通知快取進行更新,這段期間內就會導致的資料不一致,使用者可能訪問的是一個過期的快取,而不是最新的資料。但是由於這些業務對一致性的要求不太高,不會帶來太大的影響。
異常錯誤檢測和補償
還有一種犧牲一致性的方法就是通過一種錯誤補償機制來進行,可以拿上面購物的例子來說,假設我們把業務邏輯順序調整一下,先扣買家錢,然後更新交易狀態,在把錢打給賣家
我們假設初始狀態
account(accountNo,balance) = account(A,300)
account(accountNo,balance) = account(B,10)
trade(buyer,seller,tradeNo,status) = trade(A,B,20121001,I)
那麼有可能出現
account(accountNo,balance) = account(A,200)
trade(buyer,seller,tradeNo,status) = trade(A,B,20121001,S)
account(accountNo,balance) = account(B,10)
那麼就出現了A扣款成功,交易狀態也成功了,但是錢沒有打給B,這個時候可以通過一個時候的異常恢復機制,把錢打給B,最終的情況保證了一致性,在一定時間內資料可能是不一致的,但是不會影響太大。
上面的異常檢測恢復機制(事後補償),這種機制其實還是有限制,首先對於分割槽檢測操作,不同的業務涉及到的分割槽操作可能不一樣。所以這隻能作為一種思想,不能做一個通用的解決方案。