SpringCloud與Seata分佈式事務初體驗

在本篇文章中咱們在SpringCloud環境下經過使用Seata來模擬用戶購買商品時因爲用戶餘額不足致使本次訂單提交失敗,來驗證下在MySQL數據庫內事務是否會回滾html

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

用戶提交訂單購買商品大體分爲如下幾個步驟:node

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

1. 準備環境

2. 準備測試服務

爲了方便學習的同窗查看源碼,咱們本章節源碼採用Maven Module(多模塊)的方式進行構建。spring

咱們用於測試的服務所使用的第三方依賴都一致,各個服務的pom.xml文件內容以下所示:sql

<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:nodev2@10.180.98.83: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:nodev2@10.180.98.83: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:nodev2@10.180.98.83: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","status":500,"error":"Internal Server Error","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 發佈!

相關文章
相關標籤/搜索