事務與一致性:剛性or柔性

轉發自 https://cloud.tencent.com/developer/article/1038871sql

 

在高併發場景下,分佈式儲存和處理已是經常使用手段。但分佈式的結構勢必會帶來「不一致」的麻煩問題,而事務正是解決這一問題而引入的一種概念和方案。咱們常把它當作併發操做的基本單位。數據庫

從MySQL事務提及(剛性事務)

提到事務,腦海裏第一個反應固然是數據庫裏的Transaction了。緊接着就是事務的四大特性:ACID (原子性,一致性,隔離性,持久性),因此咱們先從這四大特性提及。編程

原子性

原子性是咱們對事務最直觀的理解:事務就是一系列的操做,要麼所有都執行,要麼所有都不執行。緩存

想要保證事務的原子性,就意味着須要在操做發生異常時,對該事務全部以前執行過的操做進行回滾。網絡

在MySQL中,這個回滾是經過回滾日誌(Undo Log)實現的。簡單的說,回滾日誌就是記錄了你全部操做的逆操做,在須要回滾時,就把這個事務的回滾日誌裏的操做所有執行一次。架構

好比你的事務裏每個create其實都對應了一個效果跟其相反的delete語句,他們被記錄在回滾日誌裏,當事務發生異常觸發ROLLBACK時,就按照日誌邏輯地將回滾日誌裏的操做所有執行,從而達到「撤銷」操做的效果。併發

事務的狀態

宏觀上看事務是具備原子性的,是一個密不可分的最小單位。可是它是有幾種不一樣的狀態的:Active,Commited,Failed,它要麼在執行中,要麼執行成功,要麼就失敗。異步

深刻事務的內部,他就變爲一系列操做的集合,再也不具備原子性了,包括了不少的中間狀態,好比部分提交,參考以下的事務狀態圖:分佈式

  • Active 事務的初始狀態,表示正在執行
  • Partially Commited 部分執行,或者說在最後一條語句執行後
  • Failed 發現操做異常,事務沒法繼續執行後
  • Commited 成功執行整個事務
  • Aborted 事務被回滾,數據庫恢復到執行前狀態後

並行事務的原子性

正常狀況下事務都是並行執行的,這就會出現不少複雜的新問題。高併發

首先是事務依賴,舉一個直觀的例子來講明:

假設事務T1對數據A進行了讀寫,而後(T1尚未執行完)在同時,T2讀取了數據A,而後成功提交了事務。這時候T1發生了異常,進行回滾。咱們能夠看到事務T2是依賴於T1所修改的數據的,若是要保證T1的原子性,那就須要同時對T2進行回滾,可是它已經被提交了,咱們無法再回滾了,這種問題被稱爲「不可恢復安排」。

爲了不這種狀況的出現,在出現事務的依賴時,必須遵循如下的原則:

若是事務T2依賴於事務T2,那麼T1必須在T2提交以前完成提交操做。

接下來咱們還不得不面對級聯回滾,也就是出現了多個事務都依賴於事務A的時候,若是A回滾,那麼這些事務必須也一併回滾。這會致使大量的工做撤回,至於這件事情如何處理才合適,咱們會在後面介紹。

持久性

這是理解起來相對簡單的一個特性,持久性就是指,事務一旦被提交,那麼數據必定會被寫入到數據庫中並持久儲存起來。

另外,當事務被提交後就沒法再回滾,若是想要撤銷一個已經提交的事務,那就只能執行一個效果與其相反的事務,這也是持久性的一種體現。關於這點,MySQL依然是經過日誌實現的。

重作日誌

重作日誌由兩部分組成,一是內存中的重作日誌緩衝區,另外一個是磁盤上的重作日誌文件。

這個緩衝區和日誌的關係跟咱們平常IO中使用的buffer是差很少的:當咱們在事務中嘗試對數據進行更改時,首先將數據從磁盤讀入內存,更新內存緩存的數據,而後會生成一條重作日誌(本次修改的逆操做)緩存,放在重作日誌緩衝區中。當事務真正提交時,再將剛纔緩衝區中的日誌寫入重作日誌中作持久化保存,最後再把內存中的數據變更同步到磁盤上。

上面這個流程用圖片描述以下:

再具體一點,InnoDB中,重作日誌都是以512B的塊形式儲存的,由於磁盤的扇取也是512B,因此重作日誌的寫入就保證了原子性,即使機器斷電也不會出現日誌僅僅寫入一半而留下髒數據的狀況。

另外須要注意的一點是,在原子性一節中提到的回滾日誌也是須要持久化儲存的,所以他們也會建立對應的重作日誌,在發生錯誤後,數據庫重啓時,會從重作日誌中找出未被更新到的數據庫磁盤上的日誌,從新執行來知足事務的持久性。

*事務日誌

在數據庫系統中,事務的原子性和一致性是由事務日誌實現的,在具體的實現上,使用的就是以前提到的回滾日誌和重作日誌,它們保證了兩點:

  • 發生錯誤或者須要回滾的事務可以成功回滾(原子性)
  • 事務提交後,數據還沒來得及寫入磁盤就宕機時,重啓後可以成功恢復數據(一致性)

在數據庫中這二者每每一塊兒工做,所以咱們能夠把他們看做一個總體。一條事務日誌的內容能夠抽象成下面這樣:

一條記錄同時保存了對應數據修改先後的值,就能夠很是方便的實現回滾和重作兩種功能。

隔離性

事務的隔離性會跟併發等相關概念聯繫的很是密切,由於它主要就是爲了保證並行事務處理可以達到「互不干擾」的效果。

咱們在一致性中討論過事務在併發狀況下執行時,可能發生的一系列問題:雖然單個事務執行並無錯誤,可是它的執行可能會牽連到其餘事務的執行,最終致使數據庫的總體一致性出現誤差。

談到這裏咱們就要看看事務之間的互相干擾都有哪些層級,也就是咱們數據庫中很是重要的概念:

事務的隔離級別

事務的隔離級別,實際上是數據庫對數據隔離性能的一種約束,選擇不一樣的隔離級別會影響數據一致性的程度,同時也會影響數據庫的操做性能。

標準SQL中定義瞭如下4種隔離級別:

  • 未提交讀
使用查詢語句不會加鎖,可能會讀到未提交的行(髒讀)
  • 提交讀
會發生不可重複讀
  • 可重複讀
屢次讀取同一範圍的數據會返回第一次查詢的快照,不會返回不一樣的數據行,可是可能發生幻讀幻讀 : 是指當事務不是獨立執行時發生的一種現象,例如第一個事務對一個表中的數據進行了修改,這種修改涉及到表中的所有數據行。 同時,第二個事務也修改這個表中的數據,這種修改是向表中插入一行新數據。那麼,之後就會發生操做第一個事務的用戶發現表中還有沒有修改的數據行,就好象 發生了幻覺同樣。
  • 串行化
隱式地將所有的查詢語句都加上了共享鎖。

從上到下一致性逐漸加強,可是數據庫的讀寫性能也逐漸變差

大部分數據庫中使用提交讀做爲默認的隔離級別,這是出於性能和一致性的平衡,而MySQL中則默認採用可重複讀做爲配置。

對於開發者而言,沒必要去了解每一個隔離級別具體的實現,但要可以根據不一樣的場景選擇最合適的隔離級別。

隔離的實現

隔離的實現說到底實際上是併發控制,所以不一樣隔離級別的實現,其實就是採用了不一樣的併發控制機制。

1.鎖

這個天然是最簡單的,也是至關經常使用的併發控制機制了。

不過在一個事務中,天然是不可能把整個數據庫都加鎖的,而是隻對要訪問的數據加鎖(具體的粒度有行、表等)。而這些資源鎖也是理所固然地分爲共享鎖(讀鎖)和互斥鎖(寫鎖)兩種。

讀鎖能夠保證操做併發執行而不受影響,寫鎖則保證了更新數據庫時不會受到其餘事務的干擾。

2.時間戳

用時間戳實現隔離性,須要爲記錄配置兩個字段

  • 讀時間戳:用於保存全部訪問該記錄的事務中的最大時間戳(最後讀取時間)
  • 寫時間戳:用於保存將記錄改到當前值的事務的時間戳(最後修改時間)

這樣的事務在並行執行時,用的是樂觀鎖,先任由事務對數據進行修改,在寫回去的時候在判斷記錄的時間戳有沒有修改,若是沒有被修改,就寫入,不然,就生成一個新的時間戳並再次嘗試更新數據。

PostgreSQL就使用了這種思想來控制事務。

3.多版本和快照隔離

經過維護多個版本的數據,數據庫即可以容許事務併發執行遇到互斥鎖時,轉而讀取舊版本的數據快照。這樣就能顯著地提高讀取的性能。咱們簡稱這一手段爲MVCC。

級聯回滾

以前在討論原子性問題時,討論過級聯回滾的問題,那是由於事務之間產生了依賴而致使的。所以咱們將事務隔離以後,就不會再產生須要級聯回滾的場景了。

好比一個事務寫入了A數據,那麼這時候是須要加共享鎖的,所以其它的事務沒法讀取A,當事務A回滾時不用考慮對其它事務的影響,由於其它的事務並不可能讀到數據。

一致性

好了,這時候咱們終於迴歸到了本文所想討論的主題上來。「一致性」在數據庫領域有兩個意義,一個是ACID中的C,另外一個是CAP的C,前者是咱們常常討論的,也是廣泛意義上的數據庫事務一致性,然後一個將是以後會展開討論的,有關分佈式事務的一致性。

ACID

事務的一致性定義基本能夠理解爲是事務對數據完整性約束的遵循。這些約束可能包括主鍵約束、外鍵約束或是一些用戶自定義約束。事務執行的先後都是合法的數據狀態,不會違背任何的數據完整性,這就是「一致」的意思。

固然這個含義中也隱含着對開發者的要求,就是不能寫出錯誤的事務邏輯,好比銀行的轉帳不能只加錢不減錢,這是應用層面的一致性要求。

CAP

CAP定理是分佈式系統理論的基礎。CAP告訴咱們,對於一個分佈式系統(或者因爲網絡隔離等緣由產生的分區系統),它沒法同時保證一致性、可用性和分區容忍性,而是必需要捨棄其中的一個。

p.s. 對於分佈式系統通常咱們是不可能捨棄分區容忍性的(由於分區的狀況是沒法避免的),因此通常是根據業務,在一致性和可用性中二選一。

這裏說的一致性,具體在數據庫上,就是分佈式數據庫中,每個節點對於同一個數據必須有相同的拷貝(每一個庫裏的同一個數據內容必須是一致的)。

分佈式事務

如今咱們來看一看,當數據分佈式儲存後,操做所帶來的一些問題。

衆所周知,如今大型服務出於性能和容災的考慮,都會使用分佈式的服務架構,這意味着一個服務會有多個數據庫,分開儲存不一樣的數據,這種狀況下就很容易出現數據不一致的問題了,一個最簡單的例子:

A要B給轉100元。可是A和B的記錄被分在了不一樣的數據庫實例上,若是這時候執行的某個事務中途出現了bug,若是沒有一個好的處理方式,回滾將會是一件難以面對的事情。

因此咱們能夠看到,在分佈式環境下,事務的設計方案變得更加複雜,也更加劇要了,下面咱們來談談分佈式事務的一些常見實現方式:

XA分佈式事務(兩階段提交 2PC)

原理

兩階段提交是一種提交協議,在這種協議下,事務的實現被拆分紅了幾個不一樣的模塊,通常分爲協調器和若干的事務執行者,以下圖:

在分佈式系統中,每一個節點雖然能夠知道本身操做是否成功,可是卻沒法得知其餘節點上操做是否成功,所以當一個事務跨越了多個節點的時候,就須要一個協調者,可以掌控到全部節點的執行狀況,進而保證事務的ACID特性。

如今咱們來分析2PC協議條件下,轉帳問題是如何被解決的(咱們假設A是你的支付寶餘額,B是你的餘額寶)。

  1. A發起請求到協調器,協調器開始工做
  2. 準備憑證
    • 協調器將prepare信息寫到本地日誌,這就是回滾日誌了。
    • 向全部的參與者發起prepare信息,固然對於不一樣的執行者,這個prepare信息是不一樣的,這取決於他們的數據實例上要發生什麼樣的變更,好比這個例子中,A獲得的prepare消息是通知支付寶餘額數據庫扣除100元,而B獲得的prepare消息是通知餘額寶數據庫增長100元。
  3. 執行者收到prepare消息以後,執行本機的具體事務,但不會commit,若是成功則向協調者發送yes回執,不然發送no
  4. 協調者判斷收集到的全部回執,若是均爲yes,就向全部的執行者發送commit消息,執行器收到該消息後就會正式執行提交。反之,若是收到任何一個no,就向全部的實行者發送abort消息,執行器收到後會放棄提交併回滾相應的改動。

協調器上保存的回滾日誌,能夠用於某個執行器失敗後恢復的工做的場景,此時執行器可能會再次向協調器發送回執來肯定本身的執行狀態。

問題

2PC實現的思路卻是很簡單,不過這個思路中存在着幾個很是嚴重的問題,所以幾乎不被使用:

  1. 涉及屢次節點間的通訊,假設網絡延遲比較高,通訊時長基本是不可忍受的
  2. 事務時間變長了,也意味着資源上鎖的時間變長了,性能大打折扣
  3. 若是參與者多了,協調器的工做效率會降低,而整個流程也變得複雜起來

其實分佈式事務的種種實現方案基本都借鑑了2PC的思路,但很快人們就發現一個問題,在分佈式的系統中,若是仍然採用事務模型來進行數據的修改,性能將受到不可避免的影響,這在高併發的場景下是不能接受的。

最終一致性(柔性事務)

剛纔咱們講了分佈式事務在高併發場景下的敗北,其實根據CAP原則咱們很容易明白,想要保證可用性的同時保證一致性是不可能的,因而如今大多數的分佈式系統中都對一致性作出了妥協:

咱們不追求整個操做過程當中每一時刻的一致性(強一致性),轉而追求最終結果的一致性(最終一致性)。

也便是說,在整個事務執行的流程中,咱們是能夠接受的短暫的數據不一致的,只要最後的結果沒問題就行。

至此,咱們對於事務的研究,從知足ACID的剛性事務,拓展到BASE(基本可用,軟狀態,最終一致性)的柔性事務。

BASE

BASE原則是在分佈式場景下,爲了保證高可用性,而作出的一種「妥協性」思想。總的來講是容許局部的錯誤和故障,但要保證全局的穩定。事實上當前大多數的分佈式系統,或者說大多數的大型系統裏,都在運用這種思想了。

在展開柔性事務以前,咱們先來補充一些基礎知識。

重試與冪等

在接下來說到的各類思路中,咱們都沒法避免一個問題,那就是接口調用或者說操做的失敗,分佈式狀況下系統的狀態每每不如單機條件下肯定,因此可能常常須要重試,而不是一失敗就回滾。

所以咱們必須儘量的避免重試對系統穩定性和性能的影響,因而有了冪等這個概念:

冪等

  • 數學定義:f(x) = f(f(x))的性質
  • 編程定義:對同一個系統,使用一樣的條件,一次請求和重複的屢次請求對系統資源的影響是一致的

而後咱們須要探討一下保證冪等經常使用的思路,咱們以微博點贊這個操做爲實際例子來看一下(點贊是不能重複的):

  1. MVCC
數據更新時須要比較持有數據的版本號,版本號不一致的話是沒法操做成功的。
每一個版本只有一次執行成功的機會,一旦失敗了就要從新獲取版本號。
這樣每次點贊操做都對應着一個不一樣的版本號,即使失敗重複嘗試,也不會出現點贊數錯誤增長或減小的狀況。
  1. 去重
這個主要依賴數據庫的索引惟一性(鍵),以點贊操做爲例,能夠對[`user_id`,`weibo_id`]這個組合作一張「點贊操做表」,若是成功點贊,就添加一條新記錄。
若是出現了錯誤的重試,由於表的索引是惟一的,已經有了記錄自後就不會再次插入,天然也就不會出現錯誤的狀況了。

異步確保

2PC的處理過程當中一個很大的問題是,存在大量的同步等待,這便意味着操做之間的強耦合,一旦發生了失敗或是超時,形成的影響每每是災難性的。可是分佈式狀況下,超時和失敗又是極可能出現的狀況,因此2PC手段無法保證系統的可用性。

那麼怎麼優化呢?能夠將操做解耦,使用消息隊列(或者某種可靠的通訊機制)來鏈接不一樣的實例上的操做。這樣的通訊機制使操做異步化,因而咱們還須要一個可以確保消息執行成功的確保機制,以上兩點的綜合就是如今最經常使用的柔性事務解決方案,咱們暫且叫它「異步確保」(由於這種方案並不是有一個統一的叫法),核心思路其實就是:用消息隊列保證最終一致性。

下面咱們一步一步深刻,瞭解這種方案的基本思想和流程。

問題

咱們依然使用經典的轉帳問題來展開討論:A要向B轉100元,可是A和B的帳戶在不一樣的實例上存儲。

用異步確保的思想,操做的流程應該如此處理:

  1. A所在的實例扣除A帳戶100元
  2. 向B所在的實例發送操做消息,通知它給B的帳戶增長100元

這是一個很理想的狀況,其實咱們有不少的問題要處理。

首先是原子性,其實很容易發現,不管順序如何,若是1和2這兩個操做有任何一個失敗了,那另外一個操做也必然變得沒有意義,因此必須保證1和2這兩個操做的總體原子性。

這裏不少人會想,直接利用剛性事務的ACID特性,把1和2放在同一個事務裏不就ok了。但這是不可能的,緣由以下:

  • 網絡的2將軍問題:發送消息若是失敗了,發送方並無辦法知道,是接收方沒收到消息,仍是接收方返回響應的時候出現了故障,其實已經收到了?
  • 在DB事務裏插入網絡操做,若是出現延遲,會致使事務執行時間變長,對DB性能影響極大,嚴重的話可能block整個DB。

因此事情沒那麼簡單,因此在咱們得作很多額外的工做才能解決這個問題,下面是如今經常使用的解決思路:消息表。

先說生產方(A的實例)

  1. 生產方添加一張消息表,用於記錄發送的消息以及消息的回執等內容。
  2. 生產者在向消費者發送業務操做數據時,同時也要在消息表裏增長一個消息記錄,這兩個都是對生產者DB的操做,咱們要把它們放在同一個事務裏來保證一致性。舉個例子,轉帳問題在A端上這個操做的sql就是這樣的(有點隨意,會意便可):
```
begin transaction;
update account set amount = ($amount - 100) where user = A;
insert into message values('b','account','-100');
end transaction;
```
  1. 對於這張消息表,咱們須要一個維護者,它的職責是,不斷地把表中未發送的消息放入消息隊列,另外檢測消息的執行是否超時或失敗,若是遇到這種異常狀況,就進行重試。注意:容許消息重複,可是不能丟失,順序也不會打亂。

再說消費方(B的實例)

  1. 消費方的接口(咱們稱爲下游接口),必須實現冪等。這是由於生產方可能會發來不少的重試消息,咱們必須保證重試操做不會對系統產生不良影響。若是以前說的冪等手段不適用,能夠簡單的爲消費方準備一個判重表,利用判重表的Insert操做來實現冪等(若是這麼作,請注意在業務中保證消費操做和Insert判重表操做的原子性)。
  2. 消費方完成操做後,利用消息隊列向生產方發送確認消息就ok。

能夠看到這個實現方案對於業務的生產方來講,須要維護不少額外的操做,尤爲是須要設計維護消息表,可能還要作後臺任務處理等,某種程度上這會增長業務端沒必要要的邏輯耦合,以及性能負擔。

簡要工做流程以下圖所示:

事務消息

正如上文所說,異步確保的思路中,大多數操做其實與業務無關,能夠封裝到消息隊列中去。因而產生了「事務消息」這一律念,也就衍生了不少可以很好的支持分佈式事務消息相關操做的消息隊列或者中間件,如RocketMQ和Notify。

咱們來看看事務消息是如何優化和整合異步確保的邏輯的。

首先,把消息發送分紅了2個階段:準備和確認階段,因而生產方步驟變爲以下3步:

  1. 發送prepared消息給MQ
  2. 執行本地事務
  3. 根據本地事務執行結果,確認或者取消prepared消息

這裏有一個問題,就是若是1和2失敗了,仍是很容易回滾和取消的,可是第三步失敗或者超時了,要怎麼作呢?

以RocketMQ爲例,MQ會按期地掃描全部的prepared消息,詢問發送方,究竟是要確認發送這條消息,仍是要取消這條消息?這點底層是經過讓生產方實現一個約定好的Check接口來實現的,有點像訂閱者模式。

咱們能夠看出來,異步回調中,掃描消息表,確認或重發消息這個步驟被消息隊列實現了,減小了業務方開發的難度。

對於消費方,事務消息支持重試的特性,也就是說沒必要生產者去主動發起重試消息,消息隊列能夠自動幫你重試這些操做,能夠說是很是解放生產力了。

若是有極端狀況,好比消費端異常,不管怎麼重試都失敗,是否要回滾呢?其實最好的辦法就是人工介入,人工去處理這種機率極低的case,比開發一個高複雜的自動回滾系統要可靠的多,也更簡單。

事務補償(TCC)

除了比較經常使用的異步確保,咱們再介紹一種常見的實現柔性事務的思路,稱爲事務補償。

總結以前的內容,咱們不難發現,分佈式事務的難點在於,一方執行事務成功以後,沒法肯定其餘參與方對應的事務是否可以成功(除非犧牲系統可用性)。

事務補償的想法和回滾日誌有些相似。既然咱們沒辦法同時保證全部的參與方事務執行都成功,不如就讓他們隨意執行,誰成功了就提交本地事務。可是每一個參與方的每一個操做,都要註冊(注意是註冊,不是自動生成)一個對應的補償操做,這個補償操做由人爲定義,用於撤銷已執行事務帶來的影響。

當某一方的事務執行失敗時,全部已經成功提交了事務的參與方,須要按照順序(提交的倒序)去執行各自的補償事務,來將整個系統「回滾」到以前的狀態。

補償型思路的一個典型實現是TCC(Try-Confirm-Cancel)事務,其實說是事務,不如說是一種業務模式,由於Try,Confirm,Cancel這三個操做都必須由業務方實現。

  • Try:資源預留&鎖定。事務發起方將調用服務提供方的Try方法來鎖定業務所須要的全部資源。
  • Confirm:確認執行業務邏輯操做。這裏使用的資源必定都是在Try中預留的資源,Try + Confirm 組合起來是一次完整的業務邏輯。
  • Cancel:取消執行業務邏輯。這裏和普通的補償性事務不一樣,由於Try階段只是預留資源,並未真正執行操做,所以取消操做只須要釋放Try階段預留的資源,而不須要執行數據庫操做來補償。

其實TCC能夠認爲是應用層的2CP協議。網上關於TCC的相關邏輯說法不少,也比較混亂,這裏找到一個比較通俗廣泛的例子來解釋TCC的流程。固然實際應用中,根據業務的場景不一樣,TCC的實現也不一樣:它只是一種思路,而並不是是一種規範。

例子仍然是轉帳問題,咱們把範圍稍微擴大一點,如今咱們有三個用戶A,B,C分別位於三個不一樣的數據庫實例上,如今A,B要分別向C轉帳40元(一共80元)。

  1. Try階段:嘗試執行。
- 業務檢查(一致性):檢查A,B,C的帳戶狀態是否正常,以及A,B的帳戶餘額是否都不低於40元。 - 預留資源(準隔離性):帳戶A、B的餘額均凍結40元。這樣保證其餘併發事務不會把A、B的餘額扣成負數。
  1. Confirm階段:確認執行。
    • 真正執行事務:執行實際的業務操做:A、B帳戶減小40元,C帳戶增長80元。(這一步仍是須要消息傳遞機制)
  2. Cancel階段:取消執行。
    • 釋放A,B帳戶上被成功凍結的金額。

小結

分佈式的結構下,事務的實現依然沒有一個放之四海而皆準的標準。可是能夠看到一個統一的原則,那就是儘量的讓服務變得更具備彈性,可以靈活地應對多種狀況。

總的來講,分佈式事務更大的挑戰在於,相關業務邏輯的開發思路:可用性與一致性的平衡。

相關文章
相關標籤/搜索