看了 5 種分散式事務方案,我司最終選擇了 Seata,真香!

chengxy-nds 發表於 2020-11-27

好長時間沒發文了,最近著實是有點忙,當爹的第 43 天,身心疲憊。這又趕上年底,公司衝 KPI 強制技術部加班到十點,晚上孩子隔兩三個小時一醒,基本沒睡囫圇覺的機會,天天處於迷糊的狀態,孩子還時不時起一些奇奇怪怪的疹子,總讓人擔驚受怕的。

本就不多的寫文章時間又被無限分割,哎~ 打工人真是太難了。

小眼神幾個意思

本來不知道寫點啥,正好手頭有個新專案試著用阿里的 Seata 中介軟體做分散式事務,那就做一個實踐分享吧!

介紹 Seata 之前在簡單回顧一下分散式事務的基本概念。

分散式事務的產生

我們先看看百度上對於分散式事務的定義:分散式事務是指事務的參與者、支援事務的伺服器、資源伺服器以及事務管理器分別位於不同的分散式系統的不同節點之上。

看了 5 種分散式事務方案,我司最終選擇了 Seata,真香!

額~ 有點抽象,簡單的畫個圖好理解一下,拿下單減庫存、扣餘額來說舉例:

當系統的體量很小時,單體架構完全可以滿足現有業務需求,所有的業務共用一個資料庫,整個下單流程或許只用在一個方法裡同一個事務下運算元據庫即可。此時做到所有操作要麼全部提交 或 要麼全部回滾很容易。

看了 5 種分散式事務方案,我司最終選擇了 Seata,真香!

分庫分表、SOA

可隨著業務量的不斷增長,單體架構漸漸扛不住巨大的流量,此時就需要對資料庫、表做 分庫分表處理,將應用 SOA 服務化拆分。也就產生了訂單中心、使用者中心、庫存中心等,由此帶來的問題就是業務間相互隔離,每個業務都維護著自己的資料庫,資料的交換隻能進行 RPC 呼叫。

當使用者再次下單時,需同時對訂單庫 order、庫存庫 storage、使用者庫 account 進行操作,可此時我們只能保證自己本地的資料一致性,無法保證呼叫其他服務的操作是否成功,所以為了保證整個下單流程的資料一致性,就需要分散式事務介入。

看了 5 種分散式事務方案,我司最終選擇了 Seata,真香!

Seata 優勢

實現分散式事務的方案比較多,常見的比如基於 XA 協議的 2PC3PC,基於業務層的 TCC,還有應用訊息佇列 + 訊息表實現的最終一致性方案,還有今天要說的 Seata 中介軟體,下邊看看各個方案的優缺點。

2PC

基於 XA 協議實現的分散式事務,XA 協議中分為兩部分:事務管理器和本地資源管理器。其中本地資源管理器往往由資料庫實現,比如 Oracle、MYSQL 這些資料庫都實現了 XA 介面,而事務管理器則作為一個全域性的排程者。

兩階段提交(2PC),對業務侵⼊很小,它最⼤的優勢就是對使⽤⽅透明,使用者可以像使⽤本地事務⼀樣使⽤基於 XA 協議的分散式事務,能夠嚴格保障事務 ACID 特性。

看了 5 種分散式事務方案,我司最終選擇了 Seata,真香!

2PC的缺點也是顯而易見,它是一個強一致性的同步阻塞協議,事務執⾏過程中需要將所需資源全部鎖定,也就是俗稱的 剛性事務。所以它比較適⽤於執⾏時間確定的短事務,整體效能比較差。

一旦事務協調者當機或者發生網路抖動,會讓參與者一直處於鎖定資源的狀態或者只有一部分參與者提交成功,導致資料的不一致。因此,在⾼併發效能⾄上的場景中,基於 XA 協議的分散式事務並不是最佳選擇。

看了 5 種分散式事務方案,我司最終選擇了 Seata,真香!

3PC

三段提交(3PC)是二階段提交(2PC)的一種改進版本 ,為解決兩階段提交協議的阻塞問題,上邊提到兩段提交,當協調者崩潰時,參與者不能做出最後的選擇,就會一直保持阻塞鎖定資源。

2PC 中只有協調者有超時機制,3PC 在協調者和參與者中都引入了超時機制,協調者出現故障後,參與者就不會一直阻塞。而且在第一階段和第二階段中又插入了一個準備階段(如下圖,看著有點囉嗦),保證了在最後提交階段之前各參與節點的狀態是一致的。

看了 5 種分散式事務方案,我司最終選擇了 Seata,真香!

雖然 3PC 用超時機制,解決了協調者故障後參與者的阻塞問題,但與此同時卻多了一次網路通訊,效能上反而變得更差,也不太推薦。

TCC

所謂的 TCC 程式設計模式,也是兩階段提交的一個變種,不同的是 TCC 為在業務層編寫程式碼實現的兩階段提交。TCC 分別指 TryConfirmCancel ,一個業務操作要對應的寫這三個方法。

以下單扣庫存為例,Try 階段去佔庫存,Confirm 階段則實際扣庫存,如果庫存扣減失敗 Cancel 階段進行回滾,釋放庫存。

TCC 不存在資源阻塞的問題,因為每個方法都直接進行事務的提交,一旦出現異常通過則 Cancel 來進行回滾補償,這也就是常說的補償性事務。

原本一個方法,現在卻需要三個方法來支援,可以看到 TCC 對業務的侵入性很強,而且這種模式並不能很好地被複用,會導致開發量激增。還要考慮到網路波動等原因,為保證請求一定送達都會有重試機制,所以考慮到介面的冪等性。

訊息事務(最終一致性)

訊息事務其實就是基於訊息中介軟體的兩階段提交,將本地事務和發訊息放在同一個事務裡,保證本地操作和傳送訊息同時成功。
下單扣庫存原理圖:

看了 5 種分散式事務方案,我司最終選擇了 Seata,真香!

  • 訂單系統向 MQ 傳送一條預備扣減庫存訊息,MQ 儲存預備訊息並返回成功 ACK
  • 接收到預備訊息執行成功 ACK,訂單系統執行本地下單操作,為防止訊息傳送成功而本地事務失敗,訂單系統會實現 MQ 的回撥介面,其內不斷的檢查本地事務是否執行成功,如果失敗則 rollback 回滾預備訊息;成功則對訊息進行最終 commit 提交。
  • 庫存系統消費扣減庫存訊息,執行本地事務,如果扣減失敗,訊息會重新投,一旦超出重試次數,則本地表持久化失敗訊息,並啟動定時任務做補償。

基於訊息中介軟體的兩階段提交方案,通常用在高併發場景下使用,犧牲資料的強一致性換取效能的大幅提升,不過實現這種方式的成本和複雜度是比較高的,還要看實際業務情況。

Seata

Seata 也是從兩段提交演變而來的一種分散式事務解決方案,提供了 ATTCCSAGAXA 等事務模式,這裡重點介紹 AT模式。

既然 Seata 是兩段提交,那我們看看它在每個階段都做了點啥?下邊我們還以下單扣庫存、扣餘額舉例。

看了 5 種分散式事務方案,我司最終選擇了 Seata,真香!

先介紹 Seata 分散式事務的幾種角色:

  • Transaction Coordinator(TC): 全域性事務協調者,用來協調全域性事務和各個分支事務(不同服務)的狀態, 驅動全域性事務和各個分支事務的回滾或提交。

  • Transaction Manager™ : 事務管理者,業務層中用來開啟/提交/回滾一個整體事務(在呼叫服務的方法中用註解開啟事務)。

  • Resource Manager(RM): 資源管理者,一般指業務資料庫代表了一個分支事務(Branch Transaction),管理分支事務與 TC 進行協調註冊分支事務並且彙報分支事務的狀態,驅動分支事務的提交或回滾。

Seata 實現分散式事務,設計了一個關鍵角色 UNDO_LOG (回滾日誌記錄表),我們在每個應用分散式事務的業務庫中建立這張表,這個表的核心作用就是,將業務資料在更新前後的資料映象組織成回滾日誌,備份在 UNDO_LOG 表中,以便業務異常能隨時回滾。

第一個階段

比如:下邊我們更新 user 表的 name 欄位。

update user set name = '小富最帥' where name = '程式設計師內點事'

首先 Seata 的 JDBC 資料來源代理通過對業務 SQL 解析,提取 SQL 的後設資料,也就是得到 SQL 的型別(UPDATE),表(user),條件(where name = '程式設計師內點事')等相關的資訊。

第一個階段的流程圖

先查詢資料前映象,根據解析得到的條件資訊,生成查詢語句,定位一條資料。

select  name from user where name = '程式設計師內點事'

資料前映象

緊接著執行業務 SQL,根據前映象資料主鍵查詢出後映象資料

select name from user where id = 1

資料後映象

把業務資料在更新前後的資料映象組織成回滾日誌,將業務資料的更新和回滾日誌在同一個本地事務中提交,分別插入到業務表和 UNDO_LOG 表中。

回滾記錄資料格式如下:包括 afterImage 前映象、beforeImage 後映象、 branchId 分支事務ID、xid 全域性事務ID

{
    "branchId":641789253,
    "xid":"xid:xxx",
    "undoItems":[
        {
            "afterImage":{
                "rows":[
                    {
                        "fields":[
                            {
                                "name":"id",
                                "type":4,
                                "value":1
                            }
                        ]
                    }
                ],
                "tableName":"product"
            },
            "beforeImage":{
                "rows":[
                    {
                        "fields":[
                            {
                                "name":"id",
                                "type":4,
                                "value":1
                            }
                        ]
                    }
                ],
                "tableName":"product"
            },
            "sqlType":"UPDATE"
        }
    ]
}

這樣就可以保證,任何提交的業務資料的更新一定有相應的回滾日誌。

在本地事務提交前,各分支事務需向 全域性事務協調者 TC 註冊分支 ( Branch Id) ,為要修改的記錄申請 全域性鎖 ,要為這條資料加鎖,利用 SELECT FOR UPDATE 語句。而如果一直拿不到鎖那就需要回滾本地事務。TM 開啟事務後會生成全域性唯一的 XID,會在各個呼叫的服務間進行傳遞。

有了這樣的機制,本地事務分支(Branch Transaction)便可以在全域性事務的第一階段提交,並馬上釋放本地事務鎖定的資源。相比於傳統的 XA 事務在第二階段釋放資源,Seata 降低了鎖範圍提高效率,即使第二階段發生異常需要回滾,也可以快速 從UNDO_LOG 表中找到對應回滾資料並反解析成 SQL 來達到回滾補償。

最後本地事務提交,業務資料的更新和前面生成的 UNDO LOG 資料一併提交,並將本地事務提交的結果上報給全域性事務協調者 TC。

第二個階段

第二階段是根據各分支的決議做提交或回滾:

如果決議是全域性提交,此時各分支事務已提交併成功,這時 全域性事務協調者(TC) 會向分支傳送第二階段的請求。收到 TC 的分支提交請求,該請求會被放入一個非同步任務佇列中,並馬上返回提交成功結果給 TC。非同步佇列中會非同步和批量地根據 Branch ID 查詢並刪除相應 UNDO LOG 回滾記錄。

看了 5 種分散式事務方案,我司最終選擇了 Seata,真香!

如果決議是全域性回滾,過程比全域性提交麻煩一點,RM 服務方收到 TC 全域性協調者發來的回滾請求,通過 XIDBranch ID 找到相應的回滾日誌記錄,通過回滾記錄生成反向的更新 SQL 並執行,以完成分支的回滾。

注意:這裡刪除回滾日誌記錄操作,一定是在本地業務事務執行之後

看了 5 種分散式事務方案,我司最終選擇了 Seata,真香!

上邊說了幾種分散式事務各自的優缺點,下邊實踐一下分散式事務中間 Seata 感受一下。

Seata 實踐

Seata 是一個需獨立部署的中介軟體,所以先搭 Seata Server,這裡以最新的 seata-server-1.4.0 版本為例,下載地址:https://seata.io/en-us/blog/download.html

解壓後的檔案我們只需要關心 \seata\conf 目錄下的 file.confregistry.conf 檔案。

Seata Server

file.conf

file.conf 檔案用於配置持久化事務日誌的模式,目前提供 filedbredis 三種方式。

file.conf 檔案配置

注意:在選擇 db 方式後,需要在對應資料庫建立 globalTable(持久化全域性事務)、branchTable(持久化各提交分支的事務)、 lockTable(持久化各分支鎖定資源事務)三張表。

-- the table to store GlobalSession data
-- 持久化全域性事務
CREATE TABLE IF NOT EXISTS `global_table`
(
    `xid`                       VARCHAR(128) NOT NULL,
    `transaction_id`            BIGINT,
    `status`                    TINYINT      NOT NULL,
    `application_id`            VARCHAR(32),
    `transaction_service_group` VARCHAR(32),
    `transaction_name`          VARCHAR(128),
    `timeout`                   INT,
    `begin_time`                BIGINT,
    `application_data`          VARCHAR(2000),
    `gmt_create`                DATETIME,
    `gmt_modified`              DATETIME,
    PRIMARY KEY (`xid`),
    KEY `idx_gmt_modified_status` (`gmt_modified`, `status`),
    KEY `idx_transaction_id` (`transaction_id`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8;

-- the table to store BranchSession data
-- 持久化各提交分支的事務
CREATE TABLE IF NOT EXISTS `branch_table`
(
    `branch_id`         BIGINT       NOT NULL,
    `xid`               VARCHAR(128) NOT NULL,
    `transaction_id`    BIGINT,
    `resource_group_id` VARCHAR(32),
    `resource_id`       VARCHAR(256),
    `branch_type`       VARCHAR(8),
    `status`            TINYINT,
    `client_id`         VARCHAR(64),
    `application_data`  VARCHAR(2000),
    `gmt_create`        DATETIME(6),
    `gmt_modified`      DATETIME(6),
    PRIMARY KEY (`branch_id`),
    KEY `idx_xid` (`xid`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8;

-- the table to store lock data
-- 持久化每個分支鎖表事務
CREATE TABLE IF NOT EXISTS `lock_table`
(
    `row_key`        VARCHAR(128) NOT NULL,
    `xid`            VARCHAR(96),
    `transaction_id` BIGINT,
    `branch_id`      BIGINT       NOT NULL,
    `resource_id`    VARCHAR(256),
    `table_name`     VARCHAR(32),
    `pk`             VARCHAR(36),
    `gmt_create`     DATETIME,
    `gmt_modified`   DATETIME,
    PRIMARY KEY (`row_key`),
    KEY `idx_branch_id` (`branch_id`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8;

registry.conf

registry.conf 檔案設定 註冊中心 和 配置中心:

目前註冊中心支援 nacoseurekarediszkconsuletcd3sofa 七種,這裡我使用的 eureka作為註冊中心 ; 配置中心支援 nacosapollozkconsuletcd3 五種方式。

registry.conf 檔案配置

配置完以後在 \seata\bin 目錄下啟動 seata-server 即可,到這 Seata 的服務端就搭建好了。

Seata Client

Seata Server 環境搭建完,接下來我們新建三個服務 order-server(下單服務)、storage-server(扣減庫存服務)、account-server(賬戶金額服務),分別服務註冊到 eureka

每個服務的大體核心配置如下:

spring:
    application:
        name: storage-server
    cloud:
        alibaba:
            seata:
                tx-service-group: my_test_tx_group
    datasource:
        driver-class-name: com.mysql.jdbc.Driver
        url: jdbc:mysql://47.93.6.1:3306/seat-storage
        username: root
        password: root

# eureka 註冊中心
eureka:
    client:
        serviceUrl:
            defaultZone: http://${eureka.instance.hostname}:8761/eureka/
    instance:
        hostname: 47.93.6.5
        prefer-ip-address: true

業務大致流程:使用者發起下單請求,本地 order 訂單服務建立訂單記錄,並通過 RPC 遠端呼叫 storage 扣減庫存服務和 account 扣賬戶餘額服務,只有三個服務同時執行成功,才是一個完整的下單流程。如果某個服執行失敗,則其他服務全部回滾。

Seata 對業務程式碼的侵入性非常小,程式碼中使用只需用 @GlobalTransactional 註解開啟一個全域性事務即可。

@Override
@GlobalTransactional(name = "create-order", rollbackFor = Exception.class)
public void create(Order order) {

    String xid = RootContext.getXID();

    LOGGER.info("------->交易開始");
    //本地方法
    orderDao.create(order);

    //遠端方法 扣減庫存
    storageApi.decrease(order.getProductId(), order.getCount());

    //遠端方法 扣減賬戶餘額
    LOGGER.info("------->扣減賬戶開始order中");
    accountApi.decrease(order.getUserId(), order.getMoney());
    LOGGER.info("------->扣減賬戶結束order中");

    LOGGER.info("------->交易結束");
    LOGGER.info("全域性事務 xid: {}", xid);
}

前邊說過 Seata AT 模式實現分散式事務,必須在相關的業務庫中建立 undo_log 表來存資料回滾日誌,表結構如下:

-- for AT mode you must to init this sql for you business database. the seata server not need it.
CREATE TABLE IF NOT EXISTS `undo_log`
(
    `id`            BIGINT(20)   NOT NULL AUTO_INCREMENT COMMENT 'increment id',
    `branch_id`     BIGINT(20)   NOT NULL COMMENT 'branch transaction id',
    `xid`           VARCHAR(100) NOT NULL COMMENT 'global transaction id',
    `context`       VARCHAR(128) NOT NULL COMMENT 'undo_log context,such as serialization',
    `rollback_info` LONGBLOB     NOT NULL COMMENT 'rollback info',
    `log_status`    INT(11)      NOT NULL COMMENT '0:normal status,1:defense status',
    `log_created`   DATETIME     NOT NULL COMMENT 'create datetime',
    `log_modified`  DATETIME     NOT NULL COMMENT 'modify datetime',
    PRIMARY KEY (`id`),
    UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = InnoDB
  AUTO_INCREMENT = 1
  DEFAULT CHARSET = utf8 COMMENT ='AT transaction mode undo table';

到這環境搭建的工作就完事了,完整案例會在後邊貼出 GitHub 地址,就不在這佔用篇幅了。

測試 Seata

專案中的服務呼叫過程如下圖:

服務呼叫過程

啟動各個服務後,我們直接請求下單介面看看效果,只要 order 訂單表建立記錄成功,storage 庫存表 used 欄位數量遞增、account 餘額表 used 欄位數量遞增則表示下單流程成功。

原始資料

請求後正向流程是沒問題的,資料和預想的一樣

下單資料

而且發現 TM 事務管理者 order-server 服務的控制檯也列印出了兩階段提交的日誌

控制檯兩次提交

那麼再看看如果其中一個服務異常,會不會正常回滾呢?在 account-server 服務中模擬超時異常,看能否實現全域性事務回滾。

全域性事務回滾

發現資料全沒執行成功,說明全域性事務回滾也成功了

看了 5 種分散式事務方案,我司最終選擇了 Seata,真香!

那看一下 undo_log 回滾記錄表的變化情況,由於 Seata 刪除回滾日誌的速度很快,所以要想在表中看見回滾日誌,必須要在某一個服務上打斷點才看的更明顯。

回滾記錄

總結

上邊簡單介紹了 2PC3PCTCCMQSeata 這五種分散式事務解決方案,還詳細的實踐了 Seata 中介軟體。但不管我們選哪一種方案,在專案中應用都要謹慎再謹慎,除特定的資料強一致性場景外,能不用盡量就不要用,因為無論它們效能如何優越,一旦專案套上分散式事務,整體效率會幾倍的下降,在高併發情況下弊端尤為明顯。

本案例 github 地址:https://github.com/chengxy-nds/Springboot-Notebook/tree/master/springboot-seata-transaction

如果有一絲收穫,歡迎 點贊、轉發 ,您的認可是我最大的動力。

整理了幾百本各類技術電子書,有需要的同學可以,關注公號回覆 [ 666 ] 自取。還有想要加技術群的可以加我好友,和大佬侃技術、不定期內推,一起學起來。

看了 5 種分散式事務方案,我司最終選擇了 Seata,真香!