用Go輕鬆完成一個SAGA分散式事務,保姆級教程

葉東富發表於2021-07-05

銀行跨行轉賬業務是一個典型分散式事務場景,假設A需要跨行轉賬給B,那麼就涉及兩個銀行的資料,無法通過一個資料庫的本地事務保證轉賬的ACID,只能夠通過分散式事務來解決。

分散式事務

分散式事務在分散式環境下,為了滿足可用性、效能與降級服務的需要,降低一致性與隔離性的要求,一方面遵循 BASE 理論:

  • 基本業務可用性(Basic Availability)
  • 柔性狀態(Soft state)
  • 最終一致性(Eventual consistency)

另一方面,分散式事務也部分遵循 ACID 規範:

  • 原子性:嚴格遵循
  • 一致性:事務完成後的一致性嚴格遵循;事務中的一致性可適當放寬
  • 隔離性:並行事務間不可影響;事務中間結果可見性允許安全放寬
  • 永續性:嚴格遵循

SAGA

Saga是這一篇資料庫論文SAGAS提到的一個分散式事務方案。其核心思想是將長事務拆分為多個本地短事務,由Saga事務協調器協調,如果各個本地事務成功完成那就正常完成,如果某個步驟失敗,則根據相反順序一次呼叫補償操作。

目前可用於SAGA的開源框架,主要為Java語言,其中以seata為代表。我們的例子採用go語言,使用的分散式事務框架為github.com/yedf/dtm,它對分散式事務的支援非常優雅。下面來詳細講解SAGA的組成:

DTM事務框架裡,有3個角色,與經典的XA分散式事務一樣:

  • AP/應用程式,發起全域性事務,定義全域性事務包含哪些事務分支
  • RM/資源管理器,負責分支事務各項資源的管理
  • TM/事務管理器,負責協調全域性事務的正確執行,包括SAGA正向/逆向操作的執行

下面看一個成功完成的SAGA時序圖,就很容易理解SAGA分散式事務:

用Go輕鬆完成一個SAGA分散式事務,保姆級教程

SAGA實踐

對於我們要進行的銀行轉賬的例子,我們將在正向操作中,進行轉入轉出,在補償操作中,做相反的調整。

首先我們建立賬戶餘額表:

CREATE TABLE dtm_busi.`user_account` (
  `id` int(11) AUTO_INCREMENT PRIMARY KEY,
  `user_id` int(11) not NULL UNIQUE ,
  `balance` decimal(10,2) NOT NULL DEFAULT '0.00',
  `create_time` datetime DEFAULT now(),
  `update_time` datetime DEFAULT now()
);

我們先編寫核心業務程式碼,調整使用者的賬戶餘額

func qsAdjustBalance(uid int, amount int) (interface{}, error) {
    _, err := dtmcli.SdbExec(sdbGet(), "update dtm_busi.user_account set balance = balance + ? where user_id = ?", amount, uid)
    return dtmcli.ResultSuccess, err
}

下面我們來編寫具體的正向操作/補償操作的處理函式

    app.POST(qsBusiAPI+"/TransIn", common.WrapHandler(func(c *gin.Context) (interface{}, error) {
        return qsAdjustBalance(2, 30)
    }))
    app.POST(qsBusiAPI+"/TransInCompensate", common.WrapHandler(func(c *gin.Context) (interface{}, error) {
        return qsAdjustBalance(2, -30)
    }))
    app.POST(qsBusiAPI+"/TransOut", common.WrapHandler(func(c *gin.Context) (interface{}, error) {
        return qsAdjustBalance(1, -30)
    }))
    app.POST(qsBusiAPI+"/TransOutCompensate", common.WrapHandler(func(c *gin.Context) (interface{}, error) {
        return qsAdjustBalance(1, 30)
    }))

到此各個子事務的處理函式已經OK了,然後是開啟SAGA事務,進行分支呼叫

    req := &gin.H{"amount": 30} // 微服務的載荷
    // DtmServer為DTM服務的地址
    saga := dtmcli.NewSaga(DtmServer, dtmcli.MustGenGid(DtmServer)).
        // 新增一個TransOut的子事務,正向操作為url: qsBusi+"/TransOut", 逆向操作為url: qsBusi+"/TransOutCompensate"
        Add(qsBusi+"/TransOut", qsBusi+"/TransOutCompensate", req).
        // 新增一個TransIn的子事務,正向操作為url: qsBusi+"/TransOut", 逆向操作為url: qsBusi+"/TransInCompensate"
        Add(qsBusi+"/TransIn", qsBusi+"/TransInCompensate", req)
    // 提交saga事務,dtm會完成所有的子事務/回滾所有的子事務
    err := saga.Submit()

至此,一個完整的SAGA分散式事務編寫完成。

如果您想要完整執行一個成功的示例,那麼按照yedf/dtm專案的說明搭建好環境之後,通過下面命令執行saga的例子即可:

go run app/main.go quick_start

處理網路異常

假設提交給dtm的事務中,呼叫轉入操作時,出現短暫的故障怎麼辦?按照SAGA事務的協議,dtm會重試未完成的操作,這時我們要如何處理?故障有可能是轉入操作完成後出網路故障,也有可能是轉入操作完成中出現機器當機。如何處理才能夠保障賬戶餘額的調整是正確無問題的?

DTM提供了子事務屏障功能,保證多次重試,只會有一次成功提交。(子事務屏障不僅保證冪等,還能夠解決空補償等問題,詳情參考分散式事務最經典的七種解決方案的子事務屏障環節)

我們把處理函式調整為:

func sagaBarrierAdjustBalance(sdb *sql.Tx, uid int, amount int) (interface{}, error) {
    _, err := dtmcli.StxExec(sdb, "update dtm_busi.user_account set balance = balance + ? where user_id = ?", amount, uid)
    return dtmcli.ResultSuccess, err

}

func sagaBarrierTransIn(c *gin.Context) (interface{}, error) {
    return dtmcli.ThroughBarrierCall(sdbGet(), MustGetTrans(c), func(sdb *sql.Tx) (interface{}, error) {
        return sagaBarrierAdjustBalance(sdb, 1, reqFrom(c).Amount)
    })
}

func sagaBarrierTransInCompensate(c *gin.Context) (interface{}, error) {
    return dtmcli.ThroughBarrierCall(sdbGet(), MustGetTrans(c), func(sdb *sql.Tx) (interface{}, error) {
        return sagaBarrierAdjustBalance(sdb, 1, -reqFrom(c).Amount)
    })
}

這裡的dtmcli.TroughBarrierCall呼叫會使用子事務屏障技術,保證第三個引數裡的回撥函式僅被處理一次​

您可以嘗試多次呼叫這個TransIn服務,僅有一次餘額調整。您可以執行以下命令,執行新的處理方式:

go run app/main.go saga_barrier

處理回滾

假如銀行將金額準備轉入使用者2時,發現使用者2的賬戶異常,返回失敗,會怎麼樣?我們調整處理函式,讓轉入操作返回失敗

func sagaBarrierTransIn(c *gin.Context) (interface{}, error) {
    return dtmcli.ResultFailure, nil
}

我們給出事務失敗互動的時序圖

用Go輕鬆完成一個SAGA分散式事務,保姆級教程

這裡有一點,TransIn的正向操作什麼都沒有做,就返回了失敗,此時呼叫TransIn的補償操作,會不會導致反向調整出錯了呢?

不用擔心,前面的子事務屏障技術,能夠保證TransIn的錯誤如果發生在提交之前,則補償為空操作;TransIn的錯誤如果發生在提交之後,則補償操作會將資料提交一次。

您可以將返回錯誤的TransIn改成:

func sagaBarrierTransIn(c *gin.Context) (interface{}, error) {
    dtmcli.ThroughBarrierCall(sdbGet(), MustGetTrans(c), func(sdb *sql.Tx) (interface{}, error) {
        return sagaBarrierAdjustBalance(sdb, 1, 30)
    })
    return dtmcli.ResultFailure, nil
}

最後的結果餘額依舊會是對的,原理可以參考:分散式事務最經典的七種解決方案的子事務屏障環節

小結

在這篇文章裡,我們介紹了SAGA的理論知識,也通過一個例子,完整給出了編寫一個SAGA事務的過程,涵蓋了正常成功完成,異常情況,以及成功回滾的情況。相信讀者通過這邊文章,對SAGA已經有了深入的理解。

文中使用的dtm是新開源的Golang分散式事務管理框架,功能強大,支援TCC、SAGA、XA、事務訊息等事務模式,支援Go、python、PHP、node、csharp等語言的。同時提供了非常簡單易用的介面。

閱讀完此篇乾貨,歡迎大家訪問專案github.com/yedf/dtm,給顆星星支援!

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章