1. 程式人生 > >Spring Cloud Alibaba | 微服務分散式事務之Seata

Spring Cloud Alibaba | 微服務分散式事務之Seata

Spring Cloud Alibaba | 微服務分散式事務之Seata

本篇實戰所使用Spring有關版本:

SpringBoot:2.1.7.RELEASE

Spring Cloud:Greenwich.SR2

Spring CLoud Alibaba:2.1.0.RELEASE

1. 概述

在構建微服務的過程中,不管是使用什麼框架、元件來構建,都繞不開一個問題,跨服務的業務操作如何保持資料一致性。

2. 什麼是分散式事務?

首先,設想一個傳統的單體應用,無論多少內部呼叫,最後終歸是在同一個資料庫上進行操作來完成一向業務操作,如圖:

隨著業務量的發展,業務需求和架構發生了巨大的變化,整體架構由原來的單體應用逐漸拆分成為了微服務,原來的3個服務被從一個單體架構上拆開了,成為了3個獨立的服務,分別使用獨立的資料來源,也不在之前共享同一個資料來源了,具體的業務將由三個服務的呼叫來完成,如圖:

此時,每一個服務的內部資料一致性仍然有本地事務來保證。但是面對整個業務流程上的事務應該如何保證呢?這就是在微服務架構下面臨的挑戰,如何保證在微服務中的資料一致性。

3. 常見的分散式事務解決方案

3.1 兩階段提交方案/XA方案

所謂的 XA 方案,即兩階段提交,有一個事務管理器的概念,負責協調多個數據庫(資源管理器)的事務,事務管理器先問問各個資料庫你準備好了嗎?如果每個資料庫都回復 ok,那麼就正式提交事務,在各個資料庫上執行操作;如果任何其中一個數據庫回答不 ok,那麼就回滾事務。

分散式系統的一個難點是如何保證架構下多個節點在進行事務性操作的時候保持一致性。為實現這個目的,二階段提交演算法的成立基於以下假設:

  • 該分散式系統中,存在一個節點作為協調者(Coordinator),其他節點作為參與者(Cohorts)。且節點之間可以進行網路通訊。

  • 所有節點都採用預寫式日誌,且日誌被寫入後即被保持在可靠的儲存裝置上,即使節點損壞不會導致日誌資料的消失。

  • 所有節點不會永久性損壞,即使損壞後仍然可以恢復。

3.2 TCC 方案

TCC的全稱是:Try、Confirm、Cancel。

  • Try 階段:這個階段說的是對各個服務的資源做檢測以及對資源進行鎖定或者預留。
  • Confirm 階段:這個階段說的是在各個服務中執行實際的操作。
  • Cancel 階段:如果任何一個服務的業務方法執行出錯,那麼這裡就需要進行補償,就是執行已經執行成功的業務邏輯的回滾操作。(把那些執行成功的回滾)

這種方案說實話幾乎很少人使用,但是也有使用的場景。因為這個事務回滾實際上是嚴重依賴於你自己寫程式碼來回滾和補償了,會造成補償程式碼巨大。

TCC的理論有點抽象,下面我們藉助一個賬務拆分這個實際業務場景對TCC事務的流程做一個描述,希望對理解TCC有所幫助。

業務流程:分別位於三個不同分庫的帳戶A、B、C,A和B一起向C轉帳共80元:

Try:嘗試執行業務。

完成所有業務檢查(一致性):檢查A、B、C的帳戶狀態是否正常,帳戶A的餘額是否不少於30元,帳戶B的餘額是否不少於50元。

預留必須業務資源(準隔離性):帳戶A的凍結金額增加30元,帳戶B的凍結金額增加50元,這樣就保證不會出現其他併發程序扣減了這兩個帳戶的餘額而導致在後續的真正轉帳操作過程中,帳戶A和B的可用餘額不夠的情況。

Confirm:確認執行業務。

真正執行業務:如果Try階段帳戶A、B、C狀態正常,且帳戶A、B餘額夠用,則執行帳戶A給賬戶C轉賬30元、帳戶B給賬戶C轉賬50元的轉帳操作。

不做任何業務檢查:這時已經不需要做業務檢查,Try階段已經完成了業務檢查。

只使用Try階段預留的業務資源:只需要使用Try階段帳戶A和帳戶B凍結的金額即可。

Cancel:取消執行業務。

釋放Try階段預留的業務資源:如果Try階段部分成功,比如帳戶A的餘額夠用,且凍結相應金額成功,帳戶B的餘額不夠而凍結失敗,則需要對帳戶A做Cancel操作,將帳戶A被凍結的金額解凍掉。

4. Spring Cloud Alibaba Seata

Seata 的方案其實一個 XA 兩階段提交的改進版,具體區別如下:

架構的層面:

XA 方案的 RM 實際上是在資料庫層,RM 本質上就是資料庫自身(通過提供支援 XA 的驅動程式來供應用使用)。

而 Seata 的 RM 是以二方包的形式作為中介軟體層部署在應用程式這一側的,不依賴與資料庫本身對協議的支援,當然也不需要資料庫支援 XA 協議。這點對於微服務化的架構來說是非常重要的:應用層不需要為本地事務和分散式事務兩類不同場景來適配兩套不同的資料庫驅動。

這個設計,剝離了分散式事務方案對資料庫在 協議支援 上的要求。

兩階段提交:

無論 Phase2 的決議是 commit 還是 rollback,事務性資源的鎖都要保持到 Phase2 完成才釋放。

設想一個正常執行的業務,大概率是 90% 以上的事務最終應該是成功提交的,我們是否可以在 Phase1 就將本地事務提交呢?這樣 90% 以上的情況下,可以省去 Phase2 持鎖的時間,整體提高效率。

  • 分支事務中資料的 本地鎖 由本地事務管理,在分支事務 Phase1 結束時釋放。
  • 同時,隨著本地事務結束,連線 也得以釋放。
  • 分支事務中資料的 全域性鎖 在事務協調器側管理,在決議 Phase2 全域性提交時,全域性鎖馬上可以釋放。只有在決議全域性回滾的情況下,全域性鎖 才被持有至分支的 Phase2 結束。

這個設計,極大地減少了分支事務對資源(資料和連線)的鎖定時間,給整體併發和吞吐的提升提供了基礎。

5. Seata實戰案例

5.1 目標介紹

在本節,我們將通過一個實戰案例來具體介紹Seata的使用方式,我們將模擬一個簡單的使用者購買商品下單場景,建立3個子工程,分別是 order-server (下單服務)、storage-server(庫存服務)和 pay-server (支付服務),具體流程圖如圖:

5.2 環境準備

在本次實戰中,我們使用Nacos做為服務中心和配置中心,Nacos部署請參考本書的第十一章,這裡不再贅述。

接下來我們需要部署Seata的Server端,下載地址為:https://github.com/seata/seata/releases ,建議選擇最新版本下載,目前筆者看到的最新版本為 v0.8.0 ,下載 seata-server-0.8.0.tar.gz 解壓後,開啟 conf 資料夾,我們需對其中的一些配置做出修改。

5.2.1 registry.conf 檔案修改,如下:

registry {
    type = "nacos"
    nacos {
    serverAddr = "192.168.0.128"
    namespace = "public"
    cluster = "default"
    }
}

config {
    type = "nacos"
    nacos {
    serverAddr = "192.168.0.128"
    namespace = "public"
    cluster = "default"
    }
}

這裡我們選擇使用Nacos作為服務中心和配置中心,這裡做出對應的配置,同時可以看到Seata的註冊服務支援:file 、nacos 、eureka、redis、zk、consul、etcd3、sofa等方式,配置支援:file、nacos 、apollo、zk、consul、etcd3等方式。

5.2.2 file.conf 檔案修改

這裡我們需要其中配置的資料庫相關配置,具體如下:

## database store
db {
    ## the implement of javax.sql.DataSource, such as DruidDataSource(druid)/BasicDataSource(dbcp) etc.
    datasource = "dbcp"
    ## mysql/oracle/h2/oceanbase etc.
    db-type = "mysql"
    driver-class-name = "com.mysql.jdbc.Driver"
    url = "jdbc:mysql://192.168.0.128:3306/seata"
    user = "root"
    password = "123456"
    min-conn = 1
    max-conn = 3
    global.table = "global_table"
    branch.table = "branch_table"
    lock-table = "lock_table"
    query-limit = 100
}

這裡資料庫預設是使用mysql,需要配置對應的資料庫連線、使用者名稱和密碼等。

5.2.3 nacos-config.txt 檔案修改,具體如下:

service.vgroup_mapping.spring-cloud-pay-server=default
service.vgroup_mapping.spring-cloud-order-server=default
service.vgroup_mapping.spring-cloud-storage-server=default

這裡的語法為:service.vgroup_mapping.${your-service-gruop}=default ,中間的${your-service-gruop}為自己定義的服務組名稱,這裡需要我們在程式的配置檔案中配置,筆者這裡直接使用程式的spring.application.name

5.2.4 資料庫初始化

需要在剛才配置的資料庫中執行資料初始指令碼 db_store.sql ,這個是全域性事務控制的表,需要提前初始化。

這裡我們只是做演示,理論上上面三個業務服務應該分屬不同的資料庫,這裡我們只是在同一臺數據庫下面建立三個 Schema ,分別為 db_account 、 db_order 和 db_storage ,具體如圖:

5.2.5 服務啟動

因為我們是使用的Nacos作為配置中心,所以這裡需要先執行指令碼來初始化Nacos的相關配置,命令如下:

cd conf
sh nacos-config.sh 192.168.0.128

執行成功後可以開啟Nacos的控制檯,在配置列表中,可以看到初始化了很多 Group 為 SEATA_GROUP 的配置,如圖:

初始化成功後,可以使用下面的命令啟動Seata的Server端:

cd bin
sh seata-server.sh -p 8091 -m file

啟動後在 Nacos 的服務列表下面可以看到一個名為 serverAddr 的服務

到這裡,我們的環境準備工作就做完了,接下來開始程式碼實戰。

5.3 程式碼實戰

由於本示例程式碼偏多,這裡僅介紹核心程式碼和一些需要注意的程式碼,其餘程式碼各位讀者可以訪問本書配套的程式碼倉庫獲取。

子工程common用來放置一些公共類,主要包含檢視 VO 類和響應類 OperationResponse.java。

5.3.1 父工程 seata-nacos-jpa 依賴 pom.xml 檔案

程式碼清單:Alibaba/seata-nacos-jpa/pom.xml


  <dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Spring Cloud Nacos Service Discovery -->
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
    </dependency>
    <!-- Spring Cloud Nacos Config -->
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
    </dependency>
    <!-- Spring Cloud Seata -->
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
    </dependency>

    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>${spring-cloud.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-alibaba-dependencies</artifactId>
            <version>${spring-cloud-alibaba.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

說明:本示例是使用 JPA 作為資料庫訪問 ORM 層, Mysql 作為資料庫,需引入 JPA 和 Mysql 相關依賴, spring-cloud-alibaba-dependencies 的版本是 2.1.0.RELEASE , 其中有關Seata的元件版本為 v0.7.1 ,雖然和服務端版本不符,經簡單測試,未發現問題。

5.3.2 資料來源配置

Seata 是通過代理資料來源實現事務分支,所以需要配置 io.seata.rm.datasource.DataSourceProxy 的 Bean,且是 @Primary預設的資料來源,否則事務不會回滾,無法實現分散式事務,資料來源配置類DataSourceProxyConfig.java如下:

程式碼清單:Alibaba/seata-nacos-jpa/order-server/src/main/java/com/springcloud/orderserver/config/DataSourceProxyConfig.java
***

@Configuration
public class DataSourceProxyConfig {
    @Bean
    @ConfigurationProperties(prefix = "spring.datasource")
    public DruidDataSource druidDataSource() {
        return new DruidDataSource();
    }

    @Primary
    @Bean
    public DataSourceProxy dataSource(DruidDataSource druidDataSource) {
        return new DataSourceProxy(druidDataSource);
    }
}

5.3.3 開啟全域性事務

我們在order-server服務中開始整個業務流程,需要在這裡的方法上增加全域性事務的註解@GlobalTransactional,具體程式碼如下:

程式碼清單:Alibaba/seata-nacos-jpa/order-server/src/main/java/com/springcloud/orderserver/service/impl/OrderServiceImpl.java
***

@Service
@Slf4j
public class OrderServiceImpl implements OrderService {

    @Autowired
    private RestTemplate restTemplate;

    @Autowired
    private OrderDao orderDao;

    private final String STORAGE_SERVICE_HOST = "http://spring-cloud-storage-server/storage";
    private final String PAY_SERVICE_HOST = "http://spring-cloud-pay-server/pay";

    @Override
    @GlobalTransactional
    public OperationResponse placeOrder(PlaceOrderRequestVO placeOrderRequestVO) {
        Integer amount = 1;
        Integer price = placeOrderRequestVO.getPrice();

        Order order = Order.builder()
                .userId(placeOrderRequestVO.getUserId())
                .productId(placeOrderRequestVO.getProductId())
                .status(OrderStatus.INIT)
                .payAmount(price)
                .build();

        order = orderDao.save(order);

        log.info("儲存訂單{}", order.getId() != null ? "成功" : "失敗");
        log.info("當前 XID: {}", RootContext.getXID());

        // 扣減庫存
        log.info("開始扣減庫存");
        ReduceStockRequestVO reduceStockRequestVO = ReduceStockRequestVO.builder()
                .productId(placeOrderRequestVO.getProductId())
                .amount(amount)
                .build();
        String storageReduceUrl = String.format("%s/reduceStock", STORAGE_SERVICE_HOST);
        OperationResponse storageOperationResponse = restTemplate.postForObject(storageReduceUrl, reduceStockRequestVO, OperationResponse.class);
        log.info("扣減庫存結果:{}", storageOperationResponse);

        // 扣減餘額
        log.info("開始扣減餘額");
        ReduceBalanceRequestVO reduceBalanceRequestVO = ReduceBalanceRequestVO.builder()
                .userId(placeOrderRequestVO.getUserId())
                .price(price)
                .build();

        String reduceBalanceUrl = String.format("%s/reduceBalance", PAY_SERVICE_HOST);
        OperationResponse balanceOperationResponse = restTemplate.postForObject(reduceBalanceUrl, reduceBalanceRequestVO, OperationResponse.class);
        log.info("扣減餘額結果:{}", balanceOperationResponse);

        Integer updateOrderRecord = orderDao.updateOrder(order.getId(), OrderStatus.SUCCESS);
        log.info("更新訂單:{} {}", order.getId(), updateOrderRecord > 0 ? "成功" : "失敗");

        return OperationResponse.builder()
                .success(balanceOperationResponse.isSuccess() && storageOperationResponse.isSuccess())
                .build();
    }
}

其次,我們需要在另外兩個服務的方法中增加註解@Transactional,表示開啟事務。

這裡的遠端服務呼叫是通過 RestTemplate ,需要在工程啟動時將 RestTemplate 注入 Spring 容器中管理。

5.3.4 配置檔案

工程中需在 resources 目錄下增加有關Seata的配置檔案 registry.conf ,如下:

程式碼清單:Alibaba/seata-nacos-jpa/order-server/src/main/resources/registry.conf
***

registry {
  type = "nacos"
  nacos {
    serverAddr = "192.168.0.128"
    namespace = "public"
    cluster = "default"
  }
}

config {
  type = "nacos"
  nacos {
    serverAddr = "192.168.0.128"
    namespace = "public"
    cluster = "default"
  }
}

在 bootstrap.yml 中的配置如下:

程式碼清單:Alibaba/seata-nacos-jpa/order-server/src/main/resources/bootstrap.yml


spring:
  application:
    name: spring-cloud-order-server
  cloud:
    nacos:
      # nacos config
      config:
        server-addr: 192.168.0.128
        namespace: public
        group: SEATA_GROUP
      # nacos discovery
      discovery:
        server-addr: 192.168.0.128
        namespace: public
        enabled: true
    alibaba:
      seata:
        tx-service-group: ${spring.application.name}
  • spring.cloud.nacos.config.group :這裡的 Group 是 SEATA_GROUP ,也就是我們前面在使用 nacos-config.sh 生成 Nacos 的配置時生成的配置,它的 Group 是 SEATA_GROUP。
  • spring.cloud.alibaba.seata.tx-service-group :這裡是我們之前在修改 Seata Server 端配置檔案 nacos-config.txt 時裡面配置的 service.vgroup_mapping.${your-service-gruop}=default 中間的 ${your-service-gruop} 。這兩處配置請務必一致,否則在啟動工程後會一直報錯 no available server to connect

5.3.5 業務資料庫初始化

資料庫初始指令碼位於:Alibaba/seata-nacos-jpa/sql ,請分別在三個不同的 Schema 中執行。

5.3.6 測試

測試工具我們選擇使用 PostMan ,啟動三個服務,順序無關 order-server、pay-server 和 storage-server 。

使用 PostMan 傳送測試請求,如圖:

資料庫初始化餘額為 10 ,這裡每次下單將會消耗 5 ,我們可以正常下單兩次,第三次應該下單失敗,並且回滾 db_order 中的資料。資料庫中資料如圖:

我們進行第三次下單操作,如圖:

這裡看到直接報錯500,檢視資料庫 db_order 中的資料,如圖:

可以看到,這裡的資料並未增加,我們看下子工程_rder-server的控制檯列印:

日誌已經過簡化處理

Hibernate: insert into orders (pay_amount, product_id, status, user_id) values (?, ?, ?, ?)
c.s.b.c.service.impl.OrderServiceImpl    : 儲存訂單成功
c.s.b.c.service.impl.OrderServiceImpl    : 當前 XID: 192.168.0.102:8091:2021674307
c.s.b.c.service.impl.OrderServiceImpl    : 開始扣減庫存
c.s.b.c.service.impl.OrderServiceImpl    : 扣減庫存結果:OperationResponse(success=true, message=操作成功, data=null)
c.s.b.c.service.impl.OrderServiceImpl    : 開始扣減餘額
i.s.core.rpc.netty.RmMessageListener     : onMessage:xid=192.168.0.102:8091:2021674307,branchId=2021674308,branchType=AT,resourceId=jdbc:mysql://192.168.0.128:3306/db_order,applicationData=null
io.seata.rm.AbstractRMHandler            : Branch Rollbacking: 192.168.0.102:8091:2021674307 2021674308 jdbc:mysql://192.168.0.128:3306/db_order
i.s.rm.datasource.undo.UndoLogManager    : xid 192.168.0.102:8091:2021674307 branch 2021674308, undo_log deleted with GlobalFinished
io.seata.rm.AbstractRMHandler            : Branch Rollbacked result: PhaseTwo_Rollbacked
i.seata.tm.api.DefaultGlobalTransaction  : [192.168.0.102:8091:2021674307] rollback status:Rollbacked

從日誌中沒有可以清楚的看到,在服務order-server是先執行了訂單寫入操作,並且呼叫扣減庫存的介面,通過檢視storage-server的日誌也可以發現,一樣是先執行了庫存修改操作,直到扣減餘額的時候發現餘額不足,開始對 xid 為 192.168.0.102:8091:2021674307 執行回滾操作,並且這個操作是全域性回滾。

6. 注意

目前在 Seata v0.8.0 的版本中,Server端尚未支援叢集部署,不建議應用於生產環境,並且開源團隊計劃在 v1.0.0 版本的時候可以使用與生產環境,各位讀者可以持續關注這個開源專案。

7. 示例程式碼

Github-示例程式碼

Gitee-示例程式碼

參考資料:Seata官方文件