14、Dubbo实战:Seata分布式事务方案AT模式

一直说写有关最新技术的文章,但前面似乎都有点偏了,只能说算主流技术,今天这个主题,我觉得应该名副其实。分布式微服务的深水区并不是单个微服务的设计,而是服务间的数据一致性问题!解决了这个问题,才算是把分布式正式收编了!但分布式事务解决方案并没有统一的标准,只能说根据业务特点来适配,有实时的,非实时的,同步或异步的,之前已经实现了异步MQ的分布式事务方案,今天来看看Seata方案,自19年初才推出,还几易其名,目前还不算特别完善,但其光环太耀眼,作为一名IT人,还是有必要来瞧一瞧的。单说Seata,就有AT、TCC、Saga和XA模式,看来是盘大菜。

工具:

Idea201902/JDK11/Gradle5.6.2/Mysql8.0.11/Lombok0.26/Postman7.5.0/SpringBoot2.1.9/Nacos1.1.3/Seata0.8.1/SeataServer0.8.1/Dubbo2.7.3

**难度:**新手--战士--老兵--大师

目标:

1、 多模块微服务Dubbo框架整合Seata实现分布式事务的AT模式;

2、 使用Seata实现订单模块与其他模块的关联型事务的TCC模式;


步骤:

为了更好的遇到各种问题,同时保持时效性,我尽量使用最新的软件版本。代码地址:其中的day17,https://github.com/xiexiaobiao/dubbo-project.git

1、 先搬来点背景材料,分布式事务典型场景如下图,一个business主事务发起多个分支事务,并需要保证一致的commit或rollback:*

Seata框架,有三个模块,分别是

  • TM-TransactionManager事务管理器:定义全局事务的范围,开启、提交或回滚全局事务;
  • RM -ResourceManager资源管理器:管理分支事务的资源,注册分支事务到TC,与TC通信反馈分支事务状态,驱动分支事务提交或回滚;
  • TC-TransactionCoordinator事务协调器:维护全局和分支事务,驱动全局提交或回滚;*

分布式事务流程:

I.TM 请求TC 发起一个全局事务,同时TC生成一个 XID作为全局事务ID.

II.XID将分发给事务调用链上的所有微服务.

III. RM响应全局事务XID向TC注册本地分支事务.

IV.TM向TC发出提交或回滚全局事务XID的请求.

V.TC响应全局事务XID,驱动所有分支事务提交或 回滚本地分支事务.

其中TM 和 RM 是作为 Seata 的客户端与业务系统集成在一起,TC 作为 Seata 的服务端独立部署。

再说seata的AT模式:AT 模式是一种无侵入的分布式事务解决方案。在 AT 模式下,用户只需关注自己的“业务 SQL”,用户的 “业务 SQL” 作为一阶段,Seata 框架会自动生成事务的二阶段提交和回滚操作。

  • 在一阶段,Seata 会拦截“业务 SQL”,首先解析 SQL 语义,找到“业务 SQL”要更新的业务数据,在业务数据被更新前,将其保存成“before image”,然后执行“业务 SQL”更新业务数据,在业务数据更新之后,再将其保存成“after image”,最后生成行锁。以上操作全部在一个数据库事务内完成,这样保证了一阶段操作的原子性。*
  • 二阶段如果是提交的话,因为“业务 SQL”在一阶段已经提交至数据库, 所以 Seata 框架只需将一阶段保存的快照数据和行锁删掉,完成数据清理即可。*
  • 二阶段如果是回滚的话,Seata 就需要回滚一阶段已经执行的“业务 SQL”,还原业务数据。回滚方式便是用“before image”还原业务数据;但在还原前要首先要校验脏写,对比“数据库当前业务数据”和 “after image”,如果两份数据完全一致就说明没有脏写,可以还原业务数据,如果不一致就说明有脏写,出现脏写就需要转人工处理。*

2、 为了单一化技术点,我直接新建一个gradle项目,以官方例子为原型做抽取制作,模拟电商业务,整体架构为多模块微服务Dubbo框架,建立5个module,common为公共模块,account为用户账户处理,order为订单处理,storage为库存处理,business为业务处理,整体的处理逻辑为第一图;

3、 在build.gradle中引入依赖,强烈建议边写代码边逐步引入,比如使用到druid才加入druid的依赖,这样才能知道每个依赖的作用和用法;

4、 建表,项目文件中已有SQL.script,几个业务模块的对应的表,比较简单,略重点关注下undo_log,此表为RM存储事务执行前后的日志表,为AT模式所必须,用于事务提交和回滚,其中最关键字段即xid(全局事务ID)和branch_id(分支事务ID)另外,我将各模块DB独立,是为了模拟分布式DB环境;

5、 使用common模块的mbg快速生成各模块的Entity、Service、Impl、Mapper、Dao和Controller,可参考往期文章《》注意每次生成时,需修改配置;

6、 common模块:放公共的对象,如全局Enum,Exception,Dto等,还有Dubbo的接口;

7、 storage模块:com.biao.mall.storage.conf.SeataAutoConfig进行Seata配置:;

  • 先通过SpringBoot自动取得DataSourceProperties,并获取JDBC的连接信息;
  • 注入Druid连接池对象,并对DruidDataSource做属性设置,如线程池参数,超时参数等;
  • 注入RM的DataSourceProxy代理,来代理DruidDataSource;
  • 初始化Mybatis的SqlSessionFactory,这里使用的是DataSourceProxy实参,并将Mapper文件加入,映射Entity和Table;
  • 分支通过GlobalTransactionScanner来扫描XID,并注册本地事务;
@Configuration
public class SeataAutoConfig {

    private DataSourceProperties dataSourceProperties;

    @Autowired
    public SeataAutoConfig(DataSourceProperties dataSourceProperties){
        this.dataSourceProperties = dataSourceProperties;
    }

    /**
     * init durid datasource
     * @Return: druidDataSource  datasource instance
     */
    @Bean
    @Primary
    public DruidDataSource druidDataSource(){
        DruidDataSource druidDataSource = new DruidDataSource();
        druidDataSource.setUrl(dataSourceProperties.getUrl());
        druidDataSource.setUsername(dataSourceProperties.getUsername());
        druidDataSource.setPassword(dataSourceProperties.getPassword());
        druidDataSource.setDriverClassName(dataSourceProperties.getDriverClassName());
        druidDataSource.setInitialSize(0);
        druidDataSource.setMaxActive(180);
        druidDataSource.setMaxWait(60000);
        druidDataSource.setMinIdle(0);
        druidDataSource.setValidationQuery("Select 1 from DUAL");
        druidDataSource.setTestOnBorrow(false);
        druidDataSource.setTestOnReturn(false);
        druidDataSource.setTestWhileIdle(true);
        druidDataSource.setTimeBetweenEvictionRunsMillis(60000);
        druidDataSource.setMinEvictableIdleTimeMillis(25200000);
        druidDataSource.setRemoveAbandoned(true);
        druidDataSource.setRemoveAbandonedTimeout(1800);
        druidDataSource.setLogAbandoned(true);
        return druidDataSource;
    }

    /**
     * init datasource proxy
     * @Param: druidDataSource  datasource bean instance
     * @Return: DataSourceProxy  datasource proxy
     */
    @Bean
    public DataSourceProxy dataSourceProxy(DruidDataSource druidDataSource){
        return new DataSourceProxy(druidDataSource);
    }

    /**
     * init mybatis sqlSessionFactory
     * @Param: dataSourceProxy  datasource proxy
     * @Return: DataSourceProxy  datasource proxy
     */
    @Bean
    public SqlSessionFactory sqlSessionFactory(DataSourceProxy dataSourceProxy) throws Exception {
        SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
        factoryBean.setDataSource(dataSourceProxy);
        factoryBean.setMapperLocations(new PathMatchingResourcePatternResolver()
                .getResources("classpath:/mapper/*Mapper.xml"));
        factoryBean.setTransactionFactory(new JdbcTransactionFactory());
        return factoryBean.getObject();
    }

    /**
     * init global transaction scanner
     * @Return: GlobalTransactionScanner
     */
    @Bean
    public GlobalTransactionScanner globalTransactionScanner(){
        return new GlobalTransactionScanner("${spring.application.name}", "my_test_tx_group");
    }
}

com.biao.mall.storage.dubbo.StorageDubboServiceImpl:Dubbo微服务storage的具体实现,@Service注解为com.apache.dubbo.config.annotation.Service,将该服务注册到注册中心,本项目注册中心使用Nacos,不是ZK。

@Service(version = "1.0.0",protocol = "${dubbo.protocol.id}",
        application = "${dubbo.application.id}",registry = "${dubbo.registry.id}")
public class StorageDubboServiceImpl implements StorageDubboService {

    @Autowired
    private ProductService  productService;

    @Override
    public ObjectResponse decreaseStorage(CommodityDTO commodityDTO) {
        System.out.println("全局事务id :" + RootContext.getXID());
        return productService.decreaseStorage(commodityDTO);
    }
}

另外注意, com.biao.mall.storage.impl.ProductServiceImpl中,这里的本地方法,并不需要@Transactional注解。

8、 account模块和order模块和storage模块类似,只是order模块中com.biao.mall.order.impl.OrdersServiceImpl多了一个通过@Reference调用account服务的注解,其他,略;

9、 business模块:SeataAutoConfig中因无本地事务,只需一个GlobalTransactionScanner,BusinessServiceImpl中:;

  • 类注解@Service是Spring的注解;
  • 通过@Reference从注册中心获取storage和order服务;
  • handleBusiness方法上通过@GlobalTransactional发起全局事务,方法内就是具体使用storage和order服务;
@Service
public class BusinessServiceImpl implements BusinessService {

    @Reference(version = "1.0.0")
    private StorageDubboService storageDubboService;

    @Reference(version = "1.0.0")
    private OrderDubboService orderDubboService;

    private boolean flag;

    @Override
    @GlobalTransactional(timeoutMills = 30000,name = "dubbo-seata-at-springboot")
    public ObjectResponse handleBusiness(BusinessDTO businessDTO) {
        System.out.println("开始全局事务,XID = " + RootContext.getXID());
        ObjectResponse<Object> objectResponse = new ObjectResponse<>();
        //1,减库存
        CommodityDTO commodityDTO = new CommodityDTO();
        commodityDTO.setCommodityCode(businessDTO.getCommodityCode());
        commodityDTO.setCount(businessDTO.getCount());
        ObjectResponse storageResponse = storageDubboService.decreaseStorage(commodityDTO);
        //2,创建订单
        OrderDTO orderDTO = new OrderDTO();
        orderDTO.setUserId(businessDTO.getUserId());
        orderDTO.setCommodityCode(businessDTO.getCommodityCode());
        orderDTO.setOrderCount(businessDTO.getCount());
        orderDTO.setOrderAmount(businessDTO.getAmount());
        ObjectResponse<OrderDTO> response = orderDubboService.createOrder(orderDTO);

        //打开注释测试事务发生异常后,全局回滚功能
//        if (!flag) {
//            throw new RuntimeException("测试抛异常后,分布式事务回滚!");
//        }
        if (storageResponse.getStatus() != 200 || response.getStatus() != 200) {
            throw new DefaultException(RspStatusEnum.FAIL);
        }

        objectResponse.setStatus(RspStatusEnum.SUCCESS.getCode());
        objectResponse.setMessage(RspStatusEnum.SUCCESS.getMessage());
        objectResponse.setData(response.getData());
        return objectResponse;
    }
}

10、 写个BusinessController的方法,用于测试:;

    @PostMapping("/buy")
    ObjectResponse handleBusiness(@RequestBody BusinessDTO businessDTO){
        LOGGER.info("请求参数:{}",businessDTO.toString());
        return businessService.handleBusiness(businessDTO);
    }

11、 下载安装TC,即Seata的服务端,需要独立部署运行,下载地址:https://github.com/seata/seata/releases,解压,支持window和linux下直接启动运行,如下linux命令,运行参数将指定port、host和imageFile的存储方式:;

sh seata-server.sh -p 8091 -h 127.0.0.1 -m file

12、 测试,按顺序启动:Nacos-->SeataServer-->account-->order-->storage-->business,启动后的效果;

Nacos注册的服务信息,注意Dubbo是区分provider和consumer的,这是不同于SpringCloud的地方,所以同一服务不同身份就有两个了:*

可以看到各RM向TC注册的信息:*

Postman提交至Controller:*

提交运行后,一阶段更新DB,二阶段只需释放锁:*

数据库情况:*

13、 回滚测试:将com.biao.mall.business.service.BusinessServiceImpl中回滚测试代码注释去掉!手动抛出异常,再次Postman提交,可见:;

  • business先开启了全局事务,并传播了XID,二阶段向TC提交rollback状态;
  • order中,可以看到分支事务是一阶段提交了,异常后,二阶段根据XID做了rollback;
  • order有SQL信息,是application.yml中设置了logging.level为debug;
  • 数据库信息不变,贴图,略;*

14、 测试undo_log表用途:com.biao.mall.business.service.BusinessServiceImpl加个断点:![*][nbsp11];

其他模块正常启动,postman提交:*看undo_log表,这里只是个临时的数据,二阶段后会删除:*

15、 未完下篇待续;


复盘记:

1、 Seata只能支持RPC模式的事务,对MQ模式的分布式事务不能实施,比较好的搭配是Dubbo+Seata;

2、 启动应用向SeataServer注册,不一定能一次成功,有时要尝试多次,可见稳定性一般!;

3、 依赖冲突问题:报错提示:ClasspathcontainsmultipleSLF4Jbindings,因其来自于以下两个jar,logback-classic-1.2.3.jar!/org/slf4j/impl/StaticLoggerBinder.class,slf4j-nop-1.7.28.jar!/org/slf4j/impl/StaticLoggerBinder.class由于logback是主流,不排除,直接去掉slf4j-nop依赖,问题解决!;

4、 报错:NoSuchMethodError:org.yaml.snakeyaml.nodes.ScalarNode.getScalarStyle特别注意这种情况很多时候也是依赖冲突,而不是缺少类,处理方法:;

a.先百度,需要加入snakeyaml依赖,结果还是报错,

b.再全局搜索,双击shift键,查找ScalarNode类,发现出现在两个地方,估计冲突了,

c.在Idea中使用依赖分析命令,order为module名,snakeyaml为依赖名:

gradle :order:dependencyInsight --dependency snakeyaml

发现有多方引入的情况,结果是dubbo本身也使用了snakeyaml,直接在dubbo依赖中使用exclude语法排除,问题解决!

5、 报错:NoSuchBeanDefinitionException:Noqualifyingbeanoftype'com.biao.mall.order.dao.OrdersDao'available:表面上看是Mapper类无Bean实例,确定加了@Mapper和@Repository注解,还是错误!想到既然是缺少注入的Bean,可能是缺少mybatis-plus依赖导致,添加mybatis-plus-boot-starter,问题解决!;

6、 报错:io.seata.common.exception.NotSupportYetException:notsupportregistertype:null,需添加registry.conf和file.conf;

7、 seataserver安装和启动方法:https://github.com/seata/seata/wiki/Quick-Start;

8、 报错:com.alibaba.nacos.api.exception.NacosException:java.lang.ClassNotFoundException,添加Nacos相关依赖dubbo-registry-nacos/spring-context-support/nacos-api/nacos-client;

9、 dubbo的service是明显区分consumer和provider的,如果使用Nacos做注册中心,可以通过detail查看其服务角色,还有其提供的方法;

10、 com.biao.mall.storage.conf.SeataAutoConfig中设置Mapper路径,需使用getResources("classpath:/mapper/*Mapper.xml"));不可使用getResources("${mybatis.mapper-locations}"))配置方式,会告警:Property'mapperLocations'wasspecifiedbutmatchingresourcesarenotfound,最后导致Mapper文件无法加载,Dao方法读取失败,应用运行会异常,我估计是Bean加载顺序问题,但没有验证,sorry;