SpringCloud與Seata分佈式事務初體驗

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

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

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

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

1. 準備環境

  • Seata Server

    若是對Seata Server部署方式還不瞭解,請訪問:{% post_link seata-init-env %}mysql

  • Eureka Server

    服務註冊中心,若是對Eureka Server部署方式還不瞭解,請訪問{% post_link eureka-server %}git

2. 準備測試服務

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

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

<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來提供服務接口的定義spring

  • 帳戶服務提供的接口定義

帳戶服務對外所提供的Openfeign接口定義以下所示:sql

/**
 * 帳戶服務接口
 *
 * @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 Server`在`10.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-service`、`good-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.conf`、`file.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_account`、`seata_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-server`、`order-service`、`account-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-service`、`order-serivce`控制檯日誌,能夠看到事務進行了回滾操做。

接下來查看`seata_account`表數據,咱們發現帳戶餘額沒有改變,帳戶服務的`事務回滾`**驗證成功**。

查看`seata_good`表數據,咱們發現商品的庫存也沒有改變,商品服務的`事務回滾`**驗證成功**。

## 6. 總結

本章主要來驗證分佈式事務框架`Seata`在`MySQL`下提交與回滾有效性,是否可以完成咱們預期的效果,`Seata`做爲`SpringCloud Alibaba`的核心框架,更新頻率比較高,快速的解決使用過程當中遇到的問題,是一個潛力股,不錯的選擇。

因爲本章設計的代碼比較多,請結合源碼進行學習。

## 7. 本章源碼

請訪問<a href="https://gitee.com/hengboy/spring-cloud-chapter" target="_blank">https://gitee.com/hengboy/spring-cloud-chapter</a>查看本章源碼,建議使用`git clone https://gitee.com/hengboy/spring-cloud-chapter.git`將源碼下載到本地。
相關文章
相關標籤/搜索