SpringCloudAlibaba之Seata-下单扣库存分布式事务实战

业务需求

用户下订单服务、扣库存服务和扣账号余额服务,三个服务要保证原子性,要么全部成功,要么全部失败,利用Seata的分布式事务可以解决全局原子性的问题,由于订单、库存和账户属于强绑定业务,属于强一致性,所以必然选择Seata中的XA模式来解决当前问题,但是为了了解AT的模式,我们也将利用AT模式来演示当前业务。

SpringCloudAlibaba之Seata-AT和XA模式  

SpringCloudAlibaba之Seata-TCC和Saga

数据库表

表结构:库存表、订单表、账户表

库存表: 商品编码(commodity_code)、库存(count)

订单表:用户id(user_id)、商品编码(commodity_code)、购买数量(count)、价格(money)

账户表:用户编码(user_id)、账户余额(money)

以MySQL数据库为例,建表语句如下所示:

DROP TABLE IF EXISTS `stock_tbl`;CREATE TABLE `stock_tbl`(  `id`        int(11) NOT NULL AUTO_INCREMENT,  `commodity_code` varchar(255) DEFAULT NULL,  `count`          int(11) DEFAULT 0,  PRIMARY KEY (`id`),  UNIQUE KEY (`commodity_code`)) ENGINE=InnoDB DEFAULT CHARSET=utf8;
DROP TABLE IF EXISTS `order_tbl`;CREATE TABLE `order_tbl`(  `id`             int(11) NOT NULL AUTO_INCREMENT,  `user_id`        varchar(255) DEFAULT NULL,  `commodity_code` varchar(255) DEFAULT NULL,  `count`          int(11) DEFAULT 0,  `money`          int(11) DEFAULT 0,  PRIMARY KEY (`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8;
DROP TABLE IF EXISTS `account_tbl`;CREATE TABLE `account_tbl`(  `id`      int(11) NOT NULL AUTO_INCREMENT,  `user_id` varchar(255) DEFAULT NULL,  `money`   int(11) DEFAULT 0,  PRIMARY KEY (`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8;

将三张表分别在不同的数据库上执行,以达到模拟不同数据源的效果。

微服务构建

业务服务:当前模块的作用是调用库存和订单接口,在seata中充当了RM的角色,用于开启全局事务、提交事务和回滚事务,也是提供接口给到用户调用的入口,接口地址为

//count为用户购买商品的数量,固定买一个商品,商品编码在代码内部封装了http://localhost:8072/purchase?count=1

seata-biz微服务端口号为8072,服务注册到nacos注册中心,将seata注册到nacos请查看文章:SpringCloud Alibaba之Seata简介和安装配置

核心的购买业务逻辑代码如下:先扣库存再创建订单​​​​​​​

@GlobalTransactionalpublic void purchase(String userId, String commodityCode, int orderCount, boolean rollback) {    String xid = RootContext.getXID();    LOGGER.info("New Transaction Begins: " + xid);    //扣库存服务    String result = stockFeignClient.deduct(commodityCode, orderCount);
    if (!SUCCESS.equals(result)) {        throw new RuntimeException("库存服务调用失败,事务回滚!");    }    //创建订单服务    result = orderFeignClient.create(userId, commodityCode, orderCount);
    if (!SUCCESS.equals(result)) {        throw new RuntimeException("订单服务调用失败,事务回滚!");    }
    if (rollback) {        throw new RuntimeException("Force rollback ... ");    }}

要保证分布式事务需要添加注解 @GlobalTransactional代表开启了全局的事务,具体源码分析将会在后面的文章。再来看下调用库存和订单服务,使用了Feign的组件。​​​​​​​

//调用库存服务@FeignClient(name = "seata-stock")public interface StockFeignClient {    @GetMapping("/deduct")    String deduct(@RequestParam("commodityCode") String commodityCode, @RequestParam("count") int count);}
//调用订单服务@FeignClient(name = "seata-order")public interface OrderFeignClient {    @GetMapping("/create")    String create(@RequestParam("userId") String userId, @RequestParam("commodityCode") String commodityCode,                  @RequestParam("orderCount") int orderCount);}

启动业务服务,可以在nacos管理后台查看服务列表,这里已经将所有的服务列表都注册到nacos中了。

其中的seata-server为seata的服务端,也就是在seata中充当TC的角色,TC的作用是维护这事务的各种状态信息并作出提交和回滚的决策。

账户服务

当前服务提供给订单调用,订单在创建的时候需要对用户进行余额扣除操作,同样的也需要纳入到全局的分布式事务中,加入seata的依赖和配置信息。

订单服务

看下生成订单的核心逻辑​​​​​​​

 public void create(String userId, String commodityCode, Integer count) {    String xid = RootContext.getXID();    LOGGER.info("create order in transaction: " + xid);      // 定单总价 = 订购数量(count) * 商品单价(100)    int orderMoney = count * 100;    // 生成订单    jdbcTemplate.update("insert order_tbl(user_id,commodity_code,count,money) values(?,?,?,?)",        new Object[] {userId, commodityCode, count, orderMoney});    // 调用账户余额扣减    String result = accountFeignClient.reduce(userId, orderMoney);    if (!SUCCESS.equals(result)) {        throw new RuntimeException("Failed to call Account Service. ");    } }

调用了扣减账户余额服务,具体代码如下​​​​​​​

@FeignClient(name = "seata-account")public interface AccountFeignClient {    @GetMapping("/reduce")    String reduce(@RequestParam("userId") String userId, @RequestParam("money") int money);}

库存服务

//扣库存业务逻辑public void deduct(String commodityCode, int count) {    String xid = RootContext.getXID();    LOGGER.info("deduct stock balance in transaction: " + xid);    jdbcTemplate.update("update stock_tbl set count = count - ? where commodity_code = ?",        new Object[] {count, commodityCode});}

四个服务已经就绪,在验证之前需要配置数据源的代码,我们需要验证AT和XA的模式,他们两个在开发上的区别就是数据源代理不同,AT模式使用了DataSourceProxy,XA则使用了DataSourceProxyXA,其它都是一样子的,只是在AT的模式下,我们需要在分支事务对应的数据库创建undo_log表,因为回滚的时候,AT模式是基于修改之前的日志记录来回滚的。来看下数据源代理的配置代码,如下所示,每个数据源都需要配置代理。​​​​​​​

@Configurationpublic class StockXADataSourceConfiguration {
    @Bean    @ConfigurationProperties(prefix = "spring.datasource.druid")    public DruidDataSource druidDataSource() {        return new DruidDataSource();    }
    @Bean("dataSourceProxy")    public DataSource dataSource(DruidDataSource druidDataSource) {         //DataSourceProxy for AT mode         return new DataSourceProxy(druidDataSource);        // DataSourceProxyXA for XA mode        //return new DataSourceProxyXA(druidDataSource);    }
    @Bean("jdbcTemplate")    public JdbcTemplate jdbcTemplate(DataSource dataSourceProxy) {        return new JdbcTemplate(dataSourceProxy);    }}

先验证在AT数据源代理下分布式事务的演示过程。

Seata的AT模式演示流程

1、启动所有服务并注册到nacos,上文中我们已经看过一共4个微服务和1个seata-server服务。

2、初始化数据,将个人账户的余额设置为1000,库存设置为100,订单表为空

库存表数据如下图所示

个人账户表数据如下图所示

订单表数据如下图所示

3、访问业务服务提供的购买接口,设置购买数量为1

http://localhost:8072/purchase?count=1

先验证正确的逻辑,访问接口得到SUCCESS结果

再看下后台打印的一些日志信息

我们将扣减账户余额的接口添加一个错误逻辑,让它抛异常,然后看下数据是否全部都回滚了。

再来请求一下,并且将数据重置一下。可以看到订单服务失败,不是账户服务异常了吗,我们看下后台日志

看下订单服务报的错误,是因为账户服务错误,订单服务中有回滚的日志

库存服务也打印了回滚日志,后台数据没有任何变化

如果我们在异常上面打个断点,其实我们可以看到此时后端的数据是被更新了,undo_log表中也会有日志,一起来验证一下,此时需要设置feign的超时时间为60s以便调试。配置为​​​​​​​

feign:  client:    config:      default:        ConnectTimeOut: 10000        ReadTimeOut: 60000

此时我们要注意,我们的断点是打在了出异常的地方,也就是此时还没有出现异常,现在来看下后台数据库数据的变化。

发现库存被扣了一个

生成了一个订单

账户余额没有变,因为在账户的服务上打的断点,所以账户的事务根据就没有提交

所以可以得出结论,在AT模式下的一阶段部分事务已经提交,如果此时进行查询数据,则会查询到中间状态数据,如果没有发生异常则不会出现问题,但是如果发生了异常,则会根据undo_log中的数据进行回滚,此时回滚到之前的数据,那么刚才出现和查询的数据就是脏数据,所以seata的AT模式下可能会产生脏数据,它是一种补偿机制,并不是基于每个分支事务的状态来做判断,就是因为有这个缺点,所以XA就出现了,XA是基于每个分支事务的状态进行提交和回滚,没有中间的undo_log日志。下文将会继续探索XA模式下的案例和源码分析,案例的代码和AT一模一样,只是数据源代理更换一下即可。


版权声明:本文为zqy_zq_zxl原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。