在分布式應用場景中,分布式事務問題是不可回避的,在目前流行的微服務場景下更是如此。比如在我們的商城系統中,下單操作涉及建立訂單和庫存扣減操作兩個操作,而訂單服務和商品服務是兩個獨立的微服務,因為每個微服務獨占一個資料庫執行個體,是以下單操作就涉及到分布式事務問題,即要把整個下單操作看成一個整體,要麼都成功要麼都不成功。本篇文章我們就一起來學習下分布式事務的相關知識。
基于消息實作最終一緻性
我們去店裡就餐的時候,付錢點餐後往往服務員會先給我們一張發票,然後拿着發票去出餐口等待出餐。為什麼要把付錢和取餐兩個動作分開呢?很重要的一個原因是使他們的接客能力更強,對應到服務來說就是使并發處理能力更強。隻要我們拿着發票,最終我們是可以拿到我們點的餐的,依靠發票這個憑證(消息)實作最終一緻性。
對應到我們的下單操作來說,當使用者下單後,我們可以先生成訂單,然後發一條扣減庫存的消息到消息隊列中,這時候訂單就算完成,但實際還沒有扣減庫存,因為庫存的扣減和下單操作是異步的,也就是這個時候産生了資料的不一緻。當消費到了扣減庫存的消息後進行庫存扣減操作,這個時候資料實作了最終一緻性。

基于消息實作最終一緻性這種政策适用于并發量比較高同時對于資料一緻性要求不高的場景。我們商城中的一些非主幹邏輯可以采用這種方式來提升吞吐,比如購買商品後擷取優惠券等非核心邏輯并不需要資料的強一緻,可以異步的給使用者發放優惠券。
如果在消費到消息後,執行操作的時候失敗了該怎麼辦呢?首先需要做重試,如果重試多次後仍然失敗,這個時候需要發出告警或者記錄日志,需要人工介入處理。
如果對資料有強一緻要求的話,那這種方式是不适用的,請看下下面的兩階段送出協定。
XA協定
說起XA協定,這個名詞你未必聽說過,但一提到2PC你肯定聽說過,這套方案依賴于底層資料庫的支援,DB這層首先得要實作XA協定。比如MySQL InnoDB就是支援XA協定的資料庫方案,可以把XA了解為一個強一緻的中心化原子送出協定。
原子性的概念就是把一系列操作合并成一個整體,要麼都執行,要麼都不執行。而所謂的2PC就是把一個事務分成兩步來送出,第一步做準備動作,第二步做送出/復原,這兩步之間的協調是由一個中心化的Coordinator來管理,保證多步操作的原子性。
第一步(Prepare):Coordinator向各個分布式事務的參與者下達Prepare指令,各個事務分别将SQL語句在資料庫執行但不送出,并且将準備就緒狀态上報給Coordinator。
第二步(Commit/Rollback):如果所有節點都已就緒,那麼Coordinator就下達Commit指令,各參與者送出本地事務,如果有任何一個節點不能就緒,Coordinator則下達Rollback指令進行本地復原。
在我們的下單操作中,我們需要建立訂單同時商品需要扣減庫存,接下來我們來看下2PC是怎麼解決這個問題的。2PC引入了一個事務協調者的角色,來協調訂單和商品服務。所謂的兩階段是指準備階段和送出階段,在準備階段,協調者分别給訂單服務和商品服務發送準備指令,訂單和商品服務收到準備指令後,開始執行準備操作,準備階段需要做哪些事情呢?你可以了解為,除了送出資料庫事務以外的所有工作,都要在準備階段完成。比如訂單服務在準備階段需要完成:
- 在訂單庫開啟一個資料庫事務;
- 在訂單表中寫入訂單資料
注意這裡我們沒有送出訂單資料庫事務,最後給書屋協調者傳回準備成功。協調者在收到兩個服務準備成功的響應後,開始進入第二階段。進入送出階段,送出階段就比較簡單了,協調者再給這兩個系統發送送出指令,每個系統送出自己的資料庫事務然後給協調者傳回送出成功響應,協調者收到有響應之後,給用戶端傳回成功的響應,整個分布式事務就結束了,以下是這個過程的時序圖:
以上是正常情況,接下來才是重點,異常情況怎麼辦呢?我們還是分兩階段來說明,在準備階段,如果任何異步出現錯誤或者逾時,協調者就會給兩個服務發送復原事務請求,兩個服務在收到請求之後,復原自己的資料庫事務,分布式事務執行失敗,兩個服務的資料庫事務都復原了,相關的所有資料復原到分布式事務執行之前的狀态,就像這個分布式事務沒有執行一樣,以下是異常情況的時序圖:
如果準備階段成功,進入送出階段,這個時候整個分布式事務就隻能成功,不能失敗。如果發生網絡傳輸失敗的情況,需要反複重試,直到送出成功為止,如果這個階段發生當機,包括兩個資料庫當機或者訂單服務、商品服務當機,還是可能出現訂單庫完成了送出,但商品庫因為當機自動復原,導緻資料不一緻的情況,但是,因為送出的過程非常簡單,執行非常迅速,出現這種情況的機率比較低,是以,從實用的角度來說,2PC這種分布式事務方法,實際的資料一緻性還是非常好的。
但這種分布式事務有一個天然缺陷,導緻XA特别不适合用在網際網路高并發的場景裡面,因為每個本地事務在Prepare階段,都要一直占用一個資料庫的連接配接資源,這個資源直到第二階段Commit或者Rollback之後才會被釋放。但網際網路場景的特性是什麼?是高并發,因為并發量特别高,是以每個事務必須盡快釋放掉所持有的資料庫連接配接資源。事務執行時間越短越好,這樣才能讓别的事務盡快被執行。
是以,隻有在需要強一緻,并且并發量不大的場景下,才考慮2PC。
2PC也有一些改進版本,比如3PC,大體思想和2PC是差不多的,解決了2PC的一些問題,但是也會帶來新的問題,實作起來也更複雜,限于篇幅我們沒法每個都詳細的去講解,在了解了2PC的基礎上,大家可以自行搜尋相關資料進行學習。
分布式事務架構
想要自己實作一套比較完善且沒有bug的分布式事務邏輯還是比較複雜的,好在我們不用重複造輪子,已經有一些現成的架構可以幫我們實作分布式事務,這裡主要介紹使用和go-zero結合比較好的DTM。
引用DTM官網的的介紹,DTM是一款變革性的分布式事務架構,提供了傻瓜式的使用方式,極大地降低了分布式事務的使用門檻,改了變了”能不用分布式事務就不用“的行業現狀,優雅的解決了服務間的資料一緻性問題。
本文作者在寫這篇文章之前聽過DTM,但從來沒有使用過,大概花了十幾分鐘看了下官方文檔,就能照葫蘆畫瓢地使用起來了,也足以說明DTM的使用是非常簡單的,相信聰明的你肯定也是一看就會。接下來我們就使用DTM基于TCC來實作分布式事務。
首先需要安裝dtm,我使用的是mac,直接使用如下指令安裝:
brew install dtm
給DTM建立配置檔案dtm.yml,内容如下:
MicroService:
Driver: 'dtm-driver-gozero' # 配置dtm使用go-zero的微服務協定
Target: 'etcd://localhost:2379/dtmservice' # 把dtm注冊到etcd的這個位址
EndPoint: 'localhost:36790' # dtm的本地位址
# 啟動dtm
dtm -c /opt/homebrew/etc/dtm.yml
在seckill-rmq中消費到訂單資料後進行下單和扣庫存操作,這裡改成基于TCC的分布式事務方式,注意 dtmServer 和DTM配置檔案中的Target對應:
var dtmServer = "etcd://localhost:2379/dtmservice"
由于TCC由三個部分組成,分别是Try、Confirm和Cancel,是以在訂單服務和商品服務中我們給這三個階段分别提供了對應的RPC方法,
在Try對應的方法中主要做一些資料的Check操作,Check資料滿足下單要求後,執行Confirm對應的方法,Confirm對應的方法是真正實作業務邏輯的,如果失敗復原則執行Cancel對應的方法,Cancel方法主要是對Confirm方法的資料進行補償。代碼如下:
var dtmServer = "etcd://localhost:2379/dtmservice"
func (s *Service) consumeDTM(ch chan *KafkaData) {
defer s.waiter.Done()
productServer, err := s.c.ProductRPC.BuildTarget()
if err != nil {
log.Fatalf("s.c.ProductRPC.BuildTarget error: %v", err)
}
orderServer, err := s.c.OrderRPC.BuildTarget()
if err != nil {
log.Fatalf("s.c.OrderRPC.BuildTarget error: %v", err)
}
for {
m, ok := <-ch
if !ok {
log.Fatal("seckill rmq exit")
}
fmt.Printf("consume msg: %+v\n", m)
gid := dtmgrpc.MustGenGid(dtmServer)
err := dtmgrpc.TccGlobalTransaction(dtmServer, gid, func(tcc *dtmgrpc.TccGrpc) error {
if e := tcc.CallBranch(
&product.UpdateProductStockRequest{ProductId: m.Pid, Num: 1},
productServer+"/product.Product/CheckProductStock",
productServer+"/product.Product/UpdateProductStock",
productServer+"/product.Product/RollbackProductStock",
&product.UpdateProductStockRequest{}); err != nil {
logx.Errorf("tcc.CallBranch server: %s error: %v", productServer, err)
return e
}
if e := tcc.CallBranch(
&order.CreateOrderRequest{Uid: m.Uid, Pid: m.Pid},
orderServer+"/order.Order/CreateOrderCheck",
orderServer+"/order.Order/CreateOrder",
orderServer+"/order.Order/RollbackOrder",
&order.CreateOrderResponse{},
); err != nil {
logx.Errorf("tcc.CallBranch server: %s error: %v", orderServer, err)
return e
}
return nil
})
logger.FatalIfError(err)
}
}
結束語
本篇文章主要和大家一起學習了分布式事務相關的知識。在并發比較高且對資料沒有強一緻性要求的場景下我們可以通過消息隊列的方式實作分布式事務達到最終一緻性,如果對資料有強一緻性的要求,可以使用2PC,但是資料強一緻的保證必然會損失性能,是以一般隻有在并發量不大,且對資料有強一緻性要求時才會使用2PC。3PC、TCC等都是針對2PC的一些缺點進行了優化改造,由于篇幅限制是以這裡沒有詳細展開來講,感興趣的朋友可以自行搜尋相關資料進行學習。最後基于TCC使用DTM完成了一個下單過程分布式事務的例子,代碼實作也非常簡單易懂。對于分布式事務希望大家能先搞明白其中的原理,了解了原理後,不管使用什麼架構那都不在話下了。
希望本篇文章對你有所幫助,謝謝。
每周一、周四更新
代碼倉庫: github.com/zhoushuguan…
項目位址
github.com/zeromicro/g…