SpringCloud與Seata分散式事務初體驗
在本篇文章中我們在SpringCloud
環境下通過使用Seata
來模擬使用者購買商品
時由於使用者餘額不足導致本次訂單提交失敗,來驗證下在MySQL
資料庫內事務是否會回滾
。
本章文章只涉及所需要測試的服務列表
以及Seata
配置部分。
使用者提交訂單購買商品大致分為以下幾個步驟:
- 減少庫存
- 扣除金額
- 提交訂單
1. 準備環境
-
Seata Server
如果對
Seata Server
部署方式還不瞭解,請訪問:blog.yuqiyu.com/SpringCloud… -
Eureka Server
服務註冊中心,如果對
Eureka Server
部署方式還不瞭解,請訪問blog.yuqiyu.com/SpringCloud…
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 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:[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-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:[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.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","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. 本章原始碼
請訪問gitee.com/hengboy/spr…檢視本章原始碼,建議使用git clone https://gitee.com/hengboy/spring-cloud-chapter.git
將原始碼下載到本地。
本文由部落格一文多發平臺 OpenWrite 釋出!