天天看點

資料庫怎麼保證(分布式)事務一緻性

淺談事務與一緻性問題 原文位址 https://www.jianshu.com/p/f0a1b00a6002

在高并發場景下,分布式儲存和處理已經是常用手段。但分布式的結構勢必會帶來“不一緻”的麻煩問題,而事務正是解決這一問題而引入的一種概念和方案。我們常把它當做并發操作的基本機關。

從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,如果沒有一個好的處理方式,復原将會是一件難以面對的事情。

是以我們可以看到,在分布式環境下,事務的設計方案變得更加複雜,也更加重要了,下面我們來談談分布式事務的一些常見實作方式:

兩階段送出(2PC)

原理

兩階段送出是一種送出協定,在這種協定下,事務的實作被拆分成了幾個不同的子產品,一般分為協調器和若幹的事務執行者,如下圖:

資料庫怎麼保證(分布式)事務一緻性

在分布式系統中,每個節點雖然可以知道自己操作是否成功,但是卻無法得知其他節點上操作是否成功,是以當一個事務跨越了多個節點的時候,就需要一個協調者,能夠掌控到所有節點的執行情況,進而保證事務的ACID特性。

現在我們來分析2PC協定條件下,轉賬問題是如何被解決的(我們假設A是你的支付寶餘額,B是你的餘額寶)。

A發起請求到協調器,協調器開始工作

準備憑證

協調器将prepare資訊寫到本地日志,這就是復原日志了。

向所有的參與者發起prepare資訊,當然對于不同的執行者,這個prepare資訊是不同的,這取決于他們的資料執行個體上要發生什麼樣的變動,比如這個例子中,A得到的prepare消息是通知支付寶餘額資料庫扣除100元,而B得到的prepare消息是通知餘額寶資料庫增加100元。

執行者收到prepare消息之後,執行本機的具體事務,但不會commit,如果成功則向協調者發送yes回執,否則發送no。

協調者判斷收集到的所有回執,如果均為yes,就向所有的執行者發送commit消息,執行器收到該消息後就會正式執行送出。反之,如果收到任何一個no,就向所有的實行者發送abort消息,執行器收到後會放棄送出并復原相應的改動。

協調器上儲存的復原日志,可以用于某個執行器失敗後恢複的工作的場景,此時執行器可能會再次向協調器發送回執來确定自己的執行狀态。

問題

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

涉及多次節點間的通信,假設網絡延遲比較高,通信時長基本是不可忍受的

事務時間變長了,也意味着資源上鎖的時間變長了,性能大打折扣

如果參與者多了,協調器的工作效率會下降,而整個流程也變得複雜起來

其實分布式事務的種種實作方案基本都借鑒了2PC的思路,但很快人們就發現一個問題,在分布式的系統中,如果仍然采用事務模型來進行資料的修改,性能将受到不可避免的影響,這在高并發的場景下是不能接受的。

最終一緻性(柔性事務)

剛才我們講了分布式事務在高并發場景下的敗北,其實根據CAP原則我們很容易明白,想要保證可用性的同時保證一緻性是不可能的,于是現在大多數的分布式系統中都對一緻性做出了妥協:

我們不追求整個操作過程中每一時刻的一緻性(強一緻性),轉而追求最終結果的一緻性(最終一緻性)。

也即是說,在整個事務執行的流程中,我們是可以接受的短暫的資料不一緻的,隻要最後的結果沒問題就行。

至此,我們對于事務的研究,從滿足ACID的剛性事務,拓展到BASE(基本可用,軟狀态,最終一緻性)的柔性事務。

BASE

BASE原則是在分布式場景下,為了保證高可用性,而做出的一種“妥協性”思想。總的來說是允許局部的錯誤和故障,但要保證全局的穩定。事實上目前大多數的分布式系統,或者說大多數的大型系統裡,都在運用這種思想了。

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

重試與幂等

在接下來講到的各種思路中,我們都無法避免一個問題,那就是接口調用或者說操作的失敗,分布式情況下系統的狀态往往不如單機條件下确定,是以可能經常需要重試,而不是一失敗就復原。

是以我們必須盡可能的避免重試對系統穩定性和性能的影響,于是有了幂等這個概念:

幂等

數學定義:**f(x) = f(f(x))**的性質

程式設計定義:對同一個系統,使用同樣的條件,一次請求和重複的多次請求對系統資源的影響是一緻的

然後我們需要探讨一下保證幂等常用的思路,我們以微網誌點贊這個操作為實際例子來看一下(點贊是不能重複的):

MVCC

資料更新時需要比較持有資料的版本号,版本号不一緻的話是無法操作成功的。

每個版本隻有一次執行成功的機會,一旦失敗了就要重新擷取版本号。

這樣每次點贊操作都對應着一個不同的版本号,即便失敗重複嘗試,也不會出現點贊數錯誤增加或減少的情況。

去重

這個主要依賴資料庫的索引唯一性(鍵),以點贊操作為例,可以對[user_id,weibo_id]這個組合做一張“點贊操作表”,如果成功點贊,就添加一條新記錄。

如果出現了錯誤的重試,因為表的索引是唯一的,已經有了記錄自後就不會再次插入,自然也就不會出現錯誤的情況了。

異步確定

2PC的處理過程中一個很大的問題是,存在大量的同步等待,這便意味着操作之間的強耦合,一旦發生了失敗或是逾時,造成的影響往往是災難性的。但是分布式情況下,逾時和失敗又是很可能出現的情況,是以2PC手段沒法保證系統的可用性。

那麼怎麼優化呢?可以将操作解耦,使用消息隊列(或者某種可靠的通信機制)來連接配接不同的執行個體上的操作。這樣的通信機制使操作異步化,于是我們還需要一個能夠確定消息執行成功的確定機制,以上兩點的綜合就是現在最常用的柔性事務解決方案,我們暫且叫它“異步確定”(因為這種方案并非有一個統一的叫法),核心思路其實就是:用消息隊列保證最終一緻性。

下面我們一步一步深入,了解這種方案的基本思想和流程。

問題

我們依然使用經典的轉賬問題來展開讨論:A要向B轉100元,但是A和B的賬戶在不同的執行個體上存儲。

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

A所在的執行個體扣除A賬戶100元

向B所在的執行個體發送操作消息,通知它給B的賬戶增加100元

這是一個很理想的情況,其實我們有很多的問題要處理。

首先是原子性,其實很容易發現,無論順序如何,如果1和2這兩個操作有任何一個失敗了,那另一個操作也必然變得沒有意義,是以必須保證1和2這兩個操作的整體原子性。

這裡很多人會想,直接利用剛性事務的ACID特性,把1和2放在同一個事務裡不就ok了。但這是不可能的,原因如下:

網絡的2将軍問題:發送消息如果失敗了,發送方并沒有辦法知道,是接收方沒收到消息,還是接收方傳回響應的時候出現了故障,其實已經收到了?

在DB事務裡插入網絡操作,如果出現延遲,會導緻事務執行時間變長,對DB性能影響極大,嚴重的話可能block整個DB。

是以事情沒那麼簡單,是以在我們得做不少額外的工作才能解決這個問題,下面是現在常用的解決思路:消息表。

先說生産方(A的執行個體)

生産方添加一張消息表,用于記錄發送的消息以及消息的回執等内容。

生産者在向消費者發送業務操作資料時,同時也要在消息表裡增加一個消息記錄,這兩個都是對生産者DB的操作,我們要把它們放在同一個事務裡來保證一緻性。舉個例子,轉賬問題在A端上這個操作的sql就是這樣的(有點随意,會意即可):

begin transaction;

update account set amount = ($amount - 100) where user = A;

insert into message values(‘b’,‘account’,’-100’);

end transaction;

對于這張消息表,我們需要一個維護者,它的職責是,不斷地把表中未發送的消息放入消息隊列,另外檢測消息的執行是否逾時或失敗,如果遇到這種異常情況,就進行重試。注意:允許消息重複,但是不能丢失,順序也不會打亂。

再說消費方(B的執行個體)

消費方的接口(我們稱為下遊接口),必須實作幂等。這是因為生産方可能會發來很多的重試消息,我們必須保證重試操作不會對系統産生不良影響。如果之前說的幂等手段不适用,可以簡單的為消費方準備一個判重表,利用判重表的Insert操作來實作幂等(如果這麼做,請注意在業務中保證消費操作和Insert判重表操作的原子性)。

消費方完成操作後,利用消息隊列向生産方發送确認消息就ok。

可以看到這個實作方案對于業務的生産方來說,需要維護很多額外的操作,尤其是需要設計維護消息表,可能還要做背景任務處理等,某種程度上這會增加業務端不必要的邏輯耦合,以及性能負擔。

簡要工作流程如下圖所示:

資料庫怎麼保證(分布式)事務一緻性

事務消息

正如上文所說,異步確定的思路中,大多數操作其實與業務無關,可以封裝到消息隊列中去。于是産生了“事務消息”這一概念,也就衍生了很多能夠很好的支援分布式事務消息相關操作的消息隊列或者中間件,如RocketMQ和Notify。

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

首先,把消息發送分成了2個階段:準備和确認階段,于是生産方步驟變為如下3步:

發送prepared消息給MQ

執行本地事務

根據本地事務執行結果,确認或者取消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元)。

Try階段:嘗試執行。

業務檢查(一緻性):檢查A,B,C的賬戶狀态是否正常,以及A,B的賬戶餘額是否都不低于40元。

預留資源(準隔離性):賬戶A、B的餘額均當機40元。這樣保證其他并發事務不會把A、B的餘額扣成負數。

Confirm階段:确認執行。

真正執行事務:執行實際的業務操作:A、B賬戶減少40元,C賬戶增加80元。(這一步還是需要消息傳遞機制)

Cancel階段:取消執行。

釋放A,B賬戶上被成功當機的金額。

小結

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

總的來說,分布式事務更大的挑戰在于,相關業務邏輯的開發思路:可用性與一緻性的平衡。