1. 程式人生 > 程式設計 >SpringCloud與Seata分散式事務初體驗

SpringCloud與Seata分散式事務初體驗

在本篇文章中我們在SpringCloud環境下通過使用Seata來模擬使用者購買商品時由於使用者餘額不足導致本次訂單提交失敗,來驗證下在MySQL資料庫內事務是否會回滾

本章文章只涉及所需要測試的服務列表以及Seata配置部分。

使用者提交訂單購買商品大致分為以下幾個步驟:

  1. 減少庫存
  2. 扣除金額
  3. 提交訂單

1. 準備環境

2. 準備測試服務

為了方便學習的同學檢視原始碼,我們本章節原始碼採用Maven Module(多模組)的方式進行構建。

我們用於測試的服務所使用的第三方依賴都一致,各個服務的pom.xml檔案內容如下所示:

<dependencies>
  <!--Web-->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency
>
<!--openfeign介面定義--> <dependency> <groupId>org.minbox.chapter</groupId> <artifactId>openfeign-service</artifactId> <version>0.0.1-SNAPSHOT</version> </dependency> <!--公共依賴--> <dependency> <groupId>org.minbox.chapter</groupId
>
<artifactId>common-service</artifactId> <version>0.0.1-SNAPSHOT</version> </dependency> <!--seata--> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-seata</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> </dependency> <!--Eureka Client--> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId> </dependency> <dependency> <groupId>com.zaxxer</groupId> <artifactId>HikariCP</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> <dependency> <groupId>org.minbox.framework</groupId> <artifactId>api-boot-starter-mybatis-enhance</artifactId> </dependency> </dependencies> 複製程式碼

2.1 Openfeign介面定義模組

由於我們服務之間採用的Openfeign方式進行相互呼叫,所以建立了一個模組openfeign-service來提供服務介面的定義

  • 賬戶服務提供的介面定義

賬戶服務對外所提供的Openfeign介面定義如下所示:

/**
 * 賬戶服務介面
 *
 * @author 恆宇少年
 */
@FeignClient(name = "account-service")
@RequestMapping(value = "/account")
public interface AccountClient {
    /**
     * 扣除指定賬戶金額
     *
     * @param accountId 賬戶編號
     * @param money     金額
     */
    @PostMapping
    void deduction(@RequestParam("accountId") Integer accountId,@RequestParam("money") Double money);
}
複製程式碼
  • 商品服務提供的介面定義

    商品服務對外所提供的Openfeign介面定義如下所示:

    /**
     * 商品服務介面定義
     *
     * @author 恆宇少年
     */
    @FeignClient(name = "good-service")
    @RequestMapping(value = "/good")
    public interface GoodClient {
        /**
         * 查詢商品基本資訊
         *
         * @param goodId {@link Good#getId()}
         * @return {@link Good}
         */
        @GetMapping
        Good findById(@RequestParam("goodId") Integer goodId);
    
        /**
         * 減少商品的庫存
         *
         * @param goodId {@link Good#getId()}
         * @param stock  減少庫存的數量
         */
        @PostMapping
        void reduceStock(@RequestParam("goodId") Integer goodId,@RequestParam("stock") int stock);
    }
    複製程式碼

2.2 公共模組

公共模組common-service內所提供的類是共用的,各個服務都可以呼叫,其中最為重要的是將Seata所提供的資料來源代理(DataSourceProxy)例項化配置放到了這個模組中,資料庫代理相關配置程式碼如下所示:

/**
 * Seata所需資料庫代理配置類
 *
 * @author 恆宇少年
 */
@Configuration
public class DataSourceProxyAutoConfiguration {
    /**
     * 資料來源屬性配置
     * {@link DataSourceProperties}
     */
    private DataSourceProperties dataSourceProperties;

    public DataSourceProxyAutoConfiguration(DataSourceProperties dataSourceProperties) {
        this.dataSourceProperties = dataSourceProperties;
    }

    /**
     * 配置資料來源代理,用於事務回滾
     *
     * @return The default datasource
     * @see DataSourceProxy
     */
    @Primary
    @Bean("dataSource")
    public DataSource dataSource() {
        HikariDataSource dataSource = new HikariDataSource();
        dataSource.setJdbcUrl(dataSourceProperties.getUrl());
        dataSource.setUsername(dataSourceProperties.getUsername());
        dataSource.setPassword(dataSourceProperties.getPassword());
        dataSource.setDriverClassName(dataSourceProperties.getDriverClassName());
        return new DataSourceProxy(dataSource);
    }
}
複製程式碼

該配置類在所需要的服務中使用@Import註解進行匯入使用。

2.3 賬戶服務

  • 服務介面實現

    賬戶服務用於提供介面的服務實現,通過實現openfeign-service內提供的AccountClient服務定義介面來對應提供服務實現,實現介面如下所示:

    /**
     * 賬戶介面實現
     *
     * @author 恆宇少年
     */
    @RestController
    public class AccountController implements AccountClient {
        /**
         * 賬戶業務邏輯
         */
        @Autowired
        private AccountService accountService;
    
        @Override
        public void deduction(Integer accountId,Double money) {
            accountService.deduction(accountId,money);
        }
    }
    複製程式碼
  • 服務配置(application.yml)

    # 服務名
    spring:
      application:
        name: account-service
      # seata分組
      cloud:
        alibaba:
          seata:
            tx-service-group: minbox-seata
      # 資料來源
      datasource:
        url: jdbc:mysql://localhost:3306/test
        username: root
        password: 123456
        type: com.zaxxer.hikari.HikariDataSource
        driver-class-name: com.mysql.cj.jdbc.Driver
    
    # eureka
    eureka:
      client:
        service-url:
          defaultZone: http://service:[email protected]:10001/eureka/
    複製程式碼

    通過spring.cloud.alibaba.seata.tx-service-group我們可以指定服務所屬事務的分組,該配置非必填,預設為spring.application.name配置的內容加上字串-fescar-service-group,如:account-service-fescar-service-group,詳見com.alibaba.cloud.seata.GlobalTransactionAutoConfiguration配置類原始碼。

    在我本地測試環境的Eureka Server10.180.98.83伺服器上,這裡需要修改成你們自己的地址,資料庫連線資訊也需要修改成你們自己的配置。

  • 匯入Seata資料來源代理配置

    /**
     * @author 恆宇少年
     */
    @SpringBootApplication
    @Import(DataSourceProxyAutoConfiguration.class)
    public class AccountServiceApplication {
        /**
         * logger instance
         */
        static Logger logger = LoggerFactory.getLogger(AccountServiceApplication.class);
    
        public static void main(String[] args) {
            SpringApplication.run(AccountServiceApplication.class,args);
            logger.info("賬戶服務啟動成功.");
        }
    }
    複製程式碼

    通過@Import匯入我們common-service內提供的Seata資料來源代理配置類DataSourceProxyAutoConfiguration

2.4 商品服務

  • 服務介面實現

    商品服務提供商品的查詢以及庫存扣減介面服務,實現openfeign-service提供的GoodClient服務介面定義如下所示:

    /**
     * 商品介面定義實現
     *
     * @author 恆宇少年
     */
    @RestController
    public class GoodController implements GoodClient {
        /**
         * 商品業務邏輯
         */
        @Autowired
        private GoodService goodService;
    
        /**
         * 查詢商品資訊
         *
         * @param goodId {@link Good#getId()}
         * @return
         */
        @Override
        public Good findById(Integer goodId) {
            return goodService.findById(goodId);
        }
    
        /**
         * 扣減商品庫存
         *
         * @param goodId {@link Good#getId()}
         * @param stock  減少庫存的數量
         */
        @Override
        public void reduceStock(Integer goodId,int stock) {
            goodService.reduceStock(goodId,stock);
        }
    }
    複製程式碼
  • 服務配置(application.yml)

    spring:
      application:
        name: good-service
      cloud:
        alibaba:
          seata:
            tx-service-group: minbox-seata
      datasource:
        url: jdbc:mysql://localhost:3306/test
        username: root
        password: 123456
        type: com.zaxxer.hikari.HikariDataSource
        driver-class-name: com.mysql.cj.jdbc.Driver
    
    
    eureka:
      client:
        service-url:
          defaultZone: http://service:[email protected]:10001/eureka/
    server:
      port: 8081
    複製程式碼
  • 匯入Seata資料來源代理配置

    /**
     * @author 恆宇少年
     */
    @SpringBootApplication
    @Import(DataSourceProxyAutoConfiguration.class)
    public class GoodServiceApplication {
        /**
         * logger instance
         */
        static Logger logger = LoggerFactory.getLogger(GoodServiceApplication.class);
    
        public static void main(String[] args) {
            SpringApplication.run(GoodServiceApplication.class,args);
            logger.info("商品服務啟動成功.");
        }
    }
    複製程式碼

2.5 訂單服務

  • 服務介面

    訂單服務提供了下單的介面,通過呼叫該介面完成下單功能,下單介面會通過Openfeign呼叫account-servicegood-service所提供的服務介面來完成資料驗證,如下所示:

    /**
     * @author 恆宇少年
     */
    @RestController
    @RequestMapping(value = "/order")
    public class OrderController {
        /**
         * 賬戶服務介面
         */
        @Autowired
        private AccountClient accountClient;
        /**
         * 商品服務介面
         */
        @Autowired
        private GoodClient goodClient;
        /**
         * 訂單業務邏輯
         */
        @Autowired
        private OrderService orderService;
    
        /**
         * 通過{@link GoodClient#reduceStock(Integer,int)}方法減少商品的庫存,判斷庫存剩餘數量
         * 通過{@link AccountClient#deduction(Integer,Double)}方法扣除商品所需要的金額,金額不足由account-service丟擲異常
         *
         * @param goodId    {@link Good#getId()}
         * @param accountId {@link Account#getId()}
         * @param buyCount  購買數量
         * @return
         */
        @PostMapping
        @GlobalTransactional
        public String submitOrder(
                @RequestParam("goodId") Integer goodId,@RequestParam("accountId") Integer accountId,@RequestParam("buyCount") int buyCount) {
    
            Good good = goodClient.findById(goodId);
    
            Double orderPrice = buyCount * good.getPrice();
    
            goodClient.reduceStock(goodId,buyCount);
    
            accountClient.deduction(accountId,orderPrice);
    
            Order order = toOrder(goodId,accountId,orderPrice);
            orderService.addOrder(order);
            return "下單成功.";
        }
    
        private Order toOrder(Integer goodId,Integer accountId,Double orderPrice) {
            Order order = new Order();
            order.setGoodId(goodId);
            order.setAccountId(accountId);
            order.setPrice(orderPrice);
            return order;
        }
    }
    複製程式碼
  • 服務配置(application.yml)

    spring:
      application:
        name: order-service
      cloud:
        alibaba:
          seata:
            tx-service-group: minbox-seata
      datasource:
        url: jdbc:mysql://localhost:3306/test
        username: root
        password: 123456
        type: com.zaxxer.hikari.HikariDataSource
        driver-class-name: com.mysql.cj.jdbc.Driver
    
    
    eureka:
      client:
        service-url:
          defaultZone: http://service:[email protected]:10001/eureka/
    server:
      port: 8082
    複製程式碼
  • 啟用Openfeign & 匯入Seata資料來源代理配置

    /**
     * @author 恆宇少年
     */
    @SpringBootApplication
    @EnableFeignClients(basePackages = "org.minbox.chapter.seata.openfeign")
    @Import(DataSourceProxyAutoConfiguration.class)
    public class OrderServiceApplication {
        /**
         * logger instance
         */
        static Logger logger = LoggerFactory.getLogger(OrderServiceApplication.class);
    
        public static void main(String[] args) {
            SpringApplication.run(OrderServiceApplication.class,args);
            logger.info("訂單服務啟動成功.");
        }
    }
    複製程式碼

    我們僅在order-service呼叫了其他服務的Openfeign介面,所以我們只需要在order-service內通過@EnableFeignClients註解啟用Openfeign介面實現代理。

3. 服務連線Seata Server

服務想要連線到Seata Server需要新增兩個配置檔案,分別是registry.conffile.conf

  • registry.conf

    註冊到Seata Server的配置檔案,裡麵包含了註冊方式、配置檔案讀取方式,內容如下所示:

    registry {
      # file、nacos、eureka、redis、zk、consul
      type = "file"
    
      file {
        name = "file.conf"
      }
    
    }
    
    config {
      type = "file"
    
      file {
        name = "file.conf"
      }
    }
    複製程式碼
  • file.conf

    該配置檔案內包含了使用file方式連線到Eureka Server的配置資訊以及儲存分散式事務資訊的方式,如下所示:

    transport {
      # tcp udt unix-domain-socket
      type = "TCP"
      #NIO NATIVE
      server = "NIO"
      #enable heartbeat
      heartbeat = true
      #thread factory for netty
      thread-factory {
        boss-thread-prefix = "NettyBoss"
        worker-thread-prefix = "NettyServerNIOWorker"
        server-executor-thread-prefix = "NettyServerBizHandler"
        share-boss-worker = false
        client-selector-thread-prefix = "NettyClientSelector"
        client-selector-thread-size = 1
        client-worker-thread-prefix = "NettyClientWorkerThread"
        # netty boss thread size,will not be used for UDT
        boss-thread-size = 1
        #auto default pin or 8
        worker-thread-size = 8
      }
    }
    ## transaction log store
    store {
      ## store mode: file、db
      mode = "file"
    
      ## file store
      file {
        dir = "sessionStore"
    
        # branch session size,if exceeded first try compress lockkey,still exceeded throws exceptions
        max-branch-session-size = 16384
        # globe session size,if exceeded throws exceptions
        max-global-session-size = 512
        # file buffer size,if exceeded allocate new buffer
        file-write-buffer-cache-size = 16384
        # when recover batch read size
        session.reload.read_size = 100
        # async,sync
        flush-disk-mode = async
      }
    
      ## database store
      db {
        datasource = "druid"
        db-type = "mysql"
        driver-class-name = "com.mysql.jdbc.Driver"
        url = "jdbc:mysql://10.180.98.83:3306/iot-transactional"
        user = "dev"
        password = "dev2019."
      }
    
    }
    service {
      vgroup_mapping.minbox-seata = "default"
      default.grouplist = "10.180.98.83:8091"
      enableDegrade = false
      disable = false
    }
    client {
      async.commit.buffer.limit = 10000
      lock {
        retry.internal = 10
        retry.times = 30
      }
    }
    複製程式碼

    配置檔案內service部分需要注意,我們在application.yml配置檔案內配置了事務分組為minbox-seata,在這裡需要進行對應配置vgroup_mapping.minbox-seata = "default",通過default.grouplist = "10.180.98.83:8091"配置Seata Server的服務列表。

將上面兩個配置檔案在各個服務resources目錄下建立。

4. 編寫下單邏輯

在前面說了那麼多,只是做了準備工作,我們要為每個參與下單的服務新增對應的業務邏輯。

  • 賬戶服務

    account-service內新增賬戶餘額扣除業務邏輯類,AccountService如下所示:

    /**
     * 賬戶業務邏輯處理
     *
     * @author 恆宇少年
     */
    @Service
    @Transactional(rollbackFor = Exception.class)
    public class AccountService {
        
        @Autowired
        private EnhanceMapper<Account,Integer> mapper;
    
        /**
         * {@link EnhanceMapper} 具體使用檢視ApiBoot官網檔案http://apiboot.minbox.io/zh-cn/docs/api-boot-mybatis-enhance.html
         *
         * @param accountId {@link Account#getId()}
         * @param money     扣除的金額
         */
        public void deduction(Integer accountId,Double money) {
            Account account = mapper.selectOne(accountId);
            if (ObjectUtils.isEmpty(account)) {
                throw new RuntimeException("賬戶:" + accountId + ",不存在.");
            }
            if (account.getMoney() - money < 0) {
                throw new RuntimeException("賬戶:" + accountId + ",餘額不足.");
            }
            account.setMoney(account.getMoney().doubleValue() - money);
            mapper.update(account);
        }
    }
    複製程式碼
  • 商品服務

    good-service內新增查詢商品、扣減商品庫存的邏輯類,GoodService如下所示:

    /**
     * 商品業務邏輯實現
     *
     * @author 恆宇少年
     */
    @Service
    @Transactional(rollbackFor = Exception.class)
    public class GoodService {
    
        @Autowired
        private EnhanceMapper<Good,Integer> mapper;
    
        /**
         * 查詢商品詳情
         *
         * @param goodId {@link Good#getId()}
         * @return {@link Good}
         */
        public Good findById(Integer goodId) {
            return mapper.selectOne(goodId);
        }
    
        /**
         * {@link EnhanceMapper} 具體使用檢視ApiBoot官網檔案http://apiboot.minbox.io/zh-cn/docs/api-boot-mybatis-enhance.html
         * 扣除商品庫存
         *
         * @param goodId {@link Good#getId()}
         * @param stock  扣除的庫存數量
         */
        public void reduceStock(Integer goodId,int stock) {
            Good good = mapper.selectOne(goodId);
            if (ObjectUtils.isEmpty(good)) {
                throw new RuntimeException("商品:" + goodId + ",不存在.");
            }
            if (good.getStock() - stock < 0) {
                throw new RuntimeException("商品:" + goodId + "庫存不足.");
            }
            good.setStock(good.getStock() - stock);
            mapper.update(good);
    
        }
    }
    複製程式碼

5. 提交訂單測試

我們在執行測試之前在資料庫內的seata_accountseata_good表內對應新增兩條測試資料,如下所示:

-- seata_good
INSERT INTO `seata_good` VALUES (1,'華為Meta 30',10,5000.00);	

-- seata_account
INSERT INTO `seata_account` VALUES (1,10000.00,'2019-10-11 02:37:35',NULL);
複製程式碼

5.1 啟動服務

將我們本章所使用good-serverorder-serviceaccount-service三個服務啟動。

5.2 測試點:正常購買

我們新增的賬戶餘額測試資料夠我們購買兩件商品,我們先來購買一件商品驗證下介面訪問是否成功,通過如下命令訪問下單介面:

~ curl -X POST http://localhost:8082/order\?goodId\=1\&accountId\=1\&buyCount\=1
下單成功.
複製程式碼

通過我們訪問/order下單介面,根據響應的內容我們確定商品已經購買成功。

通過檢視order-service控制檯內容:

2019-10-11 16:52:15.477  INFO 13142 --- [nio-8082-exec-4] i.seata.tm.api.DefaultGlobalTransaction  : [10.180.98.83:8091:2024417333] commit status:Committed
2019-10-11 16:52:16.412  INFO 13142 --- [atch_RMROLE_2_8] i.s.core.rpc.netty.RmMessageListener     : onMessage:xid=10.180.98.83:8091:2024417333,branchId=2024417341,branchType=AT,resourceId=jdbc:mysql://localhost:3306/test,applicationData=null
2019-10-11 16:52:16.412  INFO 13142 --- [atch_RMROLE_2_8] io.seata.rm.AbstractRMHandler            : Branch committing: 10.180.98.83:8091:2024417333 2024417341 jdbc:mysql://localhost:3306/test null
2019-10-11 16:52:16.412  INFO 13142 --- [atch_RMROLE_2_8] io.seata.rm.AbstractRMHandler            : Branch commit result: PhaseTwo_Committed
複製程式碼

我們可以看到本次事務已經成功Committed

再去驗證下資料庫內的賬戶餘額商品庫存是否有所扣減。

5.3 測試點:庫存不足

測試商品添加了10個庫存,在之前測試已經銷售掉了一件商品,我們測試購買數量超過庫存數量時,是否有回滾日誌,執行如下命令:

~ curl -X POST http://localhost:8082/order\?goodId\=1\&accountId\=1\&buyCount\=10
{"timestamp":"2019-10-11T08:57:13.775+0000","status":500,"error":"Internal Server Error","message":"status 500 reading GoodClient#reduceStock(Integer,int)","path":"/order"}
複製程式碼

在我們good-service服務控制檯已經列印了商品庫存不足的異常資訊:

java.lang.RuntimeException: 商品:1庫存不足.
	at org.minbox.chapter.seata.service.GoodService.reduceStock(GoodService.java:42) ~[classes/:na]
	....
複製程式碼

我們再看order-service的控制檯列印日誌:

Begin new global transaction [10.180.98.83:8091:2024417350]
2019-10-11 16:57:13.771  INFO 13142 --- [nio-8082-exec-5] i.seata.tm.api.DefaultGlobalTransaction  : [10.180.98.83:8091:2024417350] rollback status:Rollbacked
複製程式碼

通過日誌可以檢視本次事務進行了回滾

由於庫存的驗證在賬戶餘額扣減之前,所以我們本次並不能從資料庫的資料來判斷事務是真的回滾。

5.4 測試點:餘額不足

既然商品庫存不足我們不能直接驗證資料庫事務回滾,我們從賬戶餘額不足來下手,在之前成功購買了一件商品,賬戶的餘額還夠購買一件商品,商品庫存目前是9件,我們本次測試購買5件商品,這樣就會出現購買商品庫存充足餘額不足的應用場景,執行如下命令發起請求:

~ curl -X POST http://localhost:8082/order\?goodId\=1\&accountId\=1\&buyCount\=5
{"timestamp":"2019-10-11T09:03:00.794+0000","message":"status 500 reading AccountClient#deduction(Integer,Double)","path":"/order"}
複製程式碼

我們通過檢視account-service控制檯日誌可以看到:

java.lang.RuntimeException: 賬戶:1,餘額不足.
	at org.minbox.chapter.seata.service.AccountService.deduction(AccountService.java:33) ~[classes/:na]
複製程式碼

已經丟擲了餘額不足的異常。

通過檢視good-serviceorder-serivce控制檯日誌,可以看到事務進行了回滾操作。

接下來檢視seata_account表資料,我們發現賬戶餘額沒有改變,賬戶服務的事務回滾驗證成功

檢視seata_good表資料,我們發現商品的庫存也沒有改變,商品服務的事務回滾驗證成功

6. 總結

本章主要來驗證分散式事務框架SeataMySQL下提交與回滾有效性,是否能夠完成我們預期的效果,Seata作為SpringCloud Alibaba的核心框架,更新頻率比較高,快速的解決使用過程中遇到的問題,是一個潛力股,不錯的選擇。

由於本章設計的程式碼比較多,請結合原始碼進行學習。

7. 本章原始碼

請訪問gitee.com/hengboy/spr…檢視本章原始碼,建議使用git clone https://gitee.com/hengboy/spring-cloud-chapter.git將原始碼下載到本地。

本文由部落格一文多發平臺 OpenWrite 釋出!