在本篇文章中咱們在SpringCloud
環境下經過使用Seata
來模擬用戶購買商品
時因爲用戶餘額不足致使本次訂單提交失敗,來驗證下在MySQL
數據庫內事務是否會回滾
。html
本章文章只涉及所須要測試的服務列表
以及Seata
配置部分。java
用戶提交訂單購買商品大體分爲如下幾個步驟:node
若是對Seata Server
部署方式還不瞭解,請訪問:{% post_link seata-init-env %}mysql
服務註冊中心,若是對Eureka Server
部署方式還不瞭解,請訪問{% post_link eureka-server %}git
爲了方便學習的同窗查看源碼,咱們本章節源碼採用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>
因爲咱們服務之間採用的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
接口定義以下所示:
/**
*
*/
@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`)實例化配置放到了這個模塊中,數據庫代理相關配置代碼以下所示:
/**
*
*/
@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`服務定義接口來對應提供服務實現,實現接口以下所示:
/**
*
*/
@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數據源代理配置**
/**
*/
@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`服務接口定義以下所示:
/**
*
*/
@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數據源代理配置**
/**
*/
@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`所提供的服務接口來完成數據驗證,以下所示:
/**
*/
@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數據源代理配置**
/**
*/
@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`以下所示:
/**
*
*/
@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`以下所示:
/**
*
*/
@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`將源碼下載到本地。