go-zero微服務實戰系列(十、分散式事務如何實現)

萬俊峰Kevin發表於2022-07-08

在分散式應用場景中,分散式事務問題是不可迴避的,在目前流行的微服務場景下更是如此。比如在我們的商城系統中,下單操作涉及建立訂單和庫存扣減操作兩個操作,而訂單服務和商品服務是兩個獨立的微服務,因為每個微服務獨佔一個資料庫例項,所以下單操作就涉及到分散式事務問題,即要把整個下單操作看成一個整體,要麼都成功要麼都不成功。本篇文章我們就一起來學習下分散式事務的相關知識。

基於訊息實現最終一致性

我們去店裡就餐的時候,付錢點餐後往往服務員會先給我們一張小票,然後拿著小票去出餐口等待出餐。為什麼要把付錢和取餐兩個動作分開呢?很重要的一個原因是使他們的接客能力更強,對應到服務來說就是使併發處理能力更強。只要我們拿著小票,最終我們是可以拿到我們點的餐的,依靠小票這個憑證(訊息)實現最終一致性。

對應到我們的下單操作來說,當使用者下單後,我們可以先生成訂單,然後發一條扣減庫存的訊息到訊息佇列中,這時候訂單就算完成,但實際還沒有扣減庫存,因為庫存的扣減和下單操作是非同步的,也就是這個時候產生了資料的不一致。當消費到了扣減庫存的訊息後進行庫存扣減操作,這個時候資料實現了最終一致性。

基於訊息實現最終一致性這種策略適用於併發量比較高同時對於資料一致性要求不高的場景。我們商城中的一些非主幹邏輯可以採用這種方式來提升吞吐,比如購買商品後獲取優惠券等非核心邏輯並不需要資料的強一致,可以非同步的給使用者發放優惠券。

如果在消費到訊息後,執行操作的時候失敗了該怎麼辦呢?首先需要做重試,如果重試多次後仍然失敗,這個時候需要發出告警或者記錄日誌,需要人工介入處理。

如果對資料有強一致要求的話,那這種方式是不適用的,請看下下面的兩階段提交協議。

XA協議

說起XA協議,這個名詞你未必聽說過,但一提到2PC你肯定聽說過,這套方案依賴於底層資料庫的支援,DB這層首先得要實現XA協議。比如MySQL InnoDB就是支援XA協議的資料庫方案,可以把XA理解為一個強一致的中心化原子提交協議

原子性的概念就是把一系列操作合併成一個整體,要麼都執行,要麼都不執行。而所謂的2PC就是把一個事務分成兩步來提交,第一步做準備動作,第二步做提交/回滾,這兩步之間的協調是由一箇中心化的Coordinator來管理,保證多步操作的原子性。

第一步(Prepare):Coordinator向各個分散式事務的參與者下達Prepare指令,各個事務分別將SQL語句在資料庫執行但不提交,並且將準備就緒狀態上報給Coordinator。

第二步(Commit/Rollback):如果所有節點都已就緒,那麼Coordinator就下達Commit指令,各參與者提交本地事務,如果有任何一個節點不能就緒,Coordinator則下達Rollback指令進行本地回滾。

在我們的下單操作中,我們需要建立訂單同時商品需要扣減庫存,接下來我們來看下2PC是怎麼解決這個問題的。2PC引入了一個事務協調者的角色,來協調訂單和商品服務。所謂的兩階段是指準備階段和提交階段,在準備階段,協調者分別給訂單服務和商品服務傳送準備命令,訂單和商品服務收到準備命令後,開始執行準備操作,準備階段需要做哪些事情呢?你可以理解為,除了提交資料庫事務以外的所有工作,都要在準備階段完成。比如訂單服務在準備階段需要完成:

  1. 在訂單庫開啟一個資料庫事務;
  2. 在訂單表中寫入訂單資料

注意這裡我們沒有提交訂單資料庫事務,最後給書屋協調者返回準備成功。協調者在收到兩個服務準備成功的響應後,開始進入第二階段。進入提交階段,提交階段就比較簡單了,協調者再給這兩個系統傳送提交命令,每個系統提交自己的資料庫事務然後給協調者返回提交成功響應,協調者收到有響應之後,給客戶端返回成功的響應,整個分散式事務就結束了,以下是這個過程的時序圖:

以上是正常情況,接下來才是重點,異常情況怎麼辦呢?我們還是分兩階段來說明,在準備階段,如果任何非同步出現錯誤或者超時,協調者就會給兩個服務傳送回滾事務請求,兩個服務在收到請求之後,回滾自己的資料庫事務,分散式事務執行失敗,兩個服務的資料庫事務都回滾了,相關的所有資料回滾到分散式事務執行之前的狀態,就像這個分散式事務沒有執行一樣,以下是異常情況的時序圖:

如果準備階段成功,進入提交階段,這個時候整個分散式事務就只能成功,不能失敗。如果發生網路傳輸失敗的情況,需要反覆重試,直到提交成功為止,如果這個階段發生當機,包括兩個資料庫當機或者訂單服務、商品服務當機,還是可能出現訂單庫完成了提交,但商品庫因為當機自動回滾,導致資料不一致的情況,但是,因為提交的過程非常簡單,執行非常迅速,出現這種情況的概率比較低,所以,從實用的角度來說,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完成了一個下單過程分散式事務的例子,程式碼實現也非常簡單易懂。對於分散式事務希望大家能先搞明白其中的原理,瞭解了原理後,不管使用什麼框架那都不在話下了。

希望本篇文章對你有所幫助,謝謝。

每週一、週四更新

程式碼倉庫: https://github.com/zhoushuguang/lebron

專案地址

https://github.com/zeromicro/go-zero

歡迎使用 go-zerostar 支援我們!

微信交流群

關注『微服務實踐』公眾號並點選 交流群 獲取社群群二維碼。

相關文章