springcloud集成seata实现分布式事务

Seata 是一款开源的分布式事务解决方案,致力于在微服务架构下提供高性能和简单易用的分布式事务服务。

官网:Apache Seata

文章目录

一、部署

由于网络问题一直拉取docker镜像失败,所以这里采用了下载zip包直接部署的方式

版本说明 · alibaba/spring-cloud-alibaba Wiki · GitHub (需要和springcloud的版本对应)

1.下载

直接部署 | Apache Seata

上传服务器并解压

2.修改配置,nacos作注册中心,db存储

修改conf/application.yml

yml 复制代码
server:
  port: 7091

spring:
  application:
    name: seata-server

logging:
  config: classpath:logback-spring.xml
  file:
    path: ${user.home}/logs/seata
  extend:
    logstash-appender:
      destination: 192.168.100.52:4560
    kafka-appender:
      bootstrap-servers: 192.168.100.52:9092
      topic: logback_to_logstash

console:
  user:
    username: seata
    password: seata

seata:
  config:
    # support: nacos, consul, apollo, zk, etcd3
    type: nacos
    nacos:
      server-addr: 192.168.100.53:8848
      namespace: 17a4ea5e-f549-4e4a-97a4-52ee2a9f466c
      group: spmp-system
      username: nacos
      password: nacos
      data-id: seataServer.properties

  registry:
    # support: nacos, eureka, redis, zk, consul, etcd3, sofa
    type: nacos
    nacos:
      application: seata-server
      server-addr: 192.168.100.53:8848
      group: spmp-system
      namespace: 17a4ea5e-f549-4e4a-97a4-52ee2a9f466c
      # tc集群名称
      cluster: default
      username: nacos
      password: nacos
#  server:
#    service-port: 8091 #If not configured, the default is '${server.port} + 1000'
  security:
    secretKey: SeataSecretKey0c382ef121d778043159209298fd40bf3850a017
    tokenValidityInMilliseconds: 1800000
    ignore:
      urls: /,/**/*.css,/**/*.js,/**/*.html,/**/*.map,/**/*.svg,/**/*.png,/**/*.ico,/console-fe/public/**,/api/v1/auth/login

此时启动seata服务端,已经可以在nacos服务列表看到seata-server服务

shell 复制代码
cd bin
sh seata-seaver.sh

然后在nacos新建配置文件seataServer.properties

properties 复制代码
store.mode=db
store.db.dbType=mysql
store.db.datasource=druid
store.db.driverClassName=com.mysql.cj.jdbc.Driver
store.db.url=jdbc:mysql://192.168.100.52:3306/seata?characterEncoding=UTF8&autoReconnect=true&serverTimezone=Asia/Shanghai
store.db.user=seata
store.db.password=seata

这里注意先建数据库seata,然后执行建表sql,脚本在script/server/db/下的mysql.sql

然后重启seata服务端

可以从seata启动日志 logs/start.out 看到读取配置的相关信息

二、集成到springcloud项目

这里我们拿项目里其中两个微服务来测试,如图所示,服务1被调用方服务2调用方

1.引入依赖

两个微服务的pom文件里都需要引入seata依赖

xml 复制代码
<dependency>
    <groupId>io.seata</groupId>
    <artifactId>seata-spring-boot-starter</artifactId>
    <version>1.6.1</version>
</dependency>
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
    <version>2021.0.5.0</version>
    <exclusions>
        <exclusion>
            <groupId>io.seata</groupId>
            <artifactId>seata-spring-boot-starter</artifactId>
        </exclusion>
    </exclusions>
</dependency>

2.修改配置

修改两个微服务的配置文件,这里对应上前面seata服务端的配置

yml 复制代码
seata:
  registry:
      type: nacos
      nacos:
          application: seata-server
          server-addr: 192.168.100.53:8848
          group: spmp-system
          namespace: 17a4ea5e-f549-4e4a-97a4-52ee2a9f466c
          username: nacos
          password: nacos
  config:
      type: nacos
      nacos:
          server-addr: 192.168.100.53:8848
          group: spmp-system
          namespace: 17a4ea5e-f549-4e4a-97a4-52ee2a9f466c
          dataId: seataServer.properties
          username: nacos
          password: nacos
  tx-service-group: spmp-system

3.新建数据表

两个服务都需要新建undo_log表,在事务回滚时需要用到,建表sql:

sql 复制代码
CREATE TABLE `undo_log` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `branch_id` bigint(20) NOT NULL,
  `xid` varchar(100) NOT NULL,
  `context` varchar(128) NOT NULL,
  `rollback_info` longblob NOT NULL,
  `log_status` int(11) NOT NULL,
  `log_created` datetime NOT NULL,
  `log_modified` datetime NOT NULL,
  `ext` varchar(100) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

4.编写代码

  • 修改全局异常处理器GlobalExceptionHandler

    由于项目里的全局处理器通常都会将所有异常拦截,然后返回统一封装结果,而这会导致异常无法抛出

    java 复制代码
    /**
     * 全局异常处理器
     *
     * @author ruoyi
     */
    @RestControllerAdvice
    public class GlobalExceptionHandler {
        private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
    
        /**
         * 先判断是否是seata全局事务异常,如果是,就直接抛给调用方,让调用方回滚事务
         * @param e
         * @throws Exception
         */
        private void checkSeataError(Exception e) throws Exception {
            log.info("seata全局事务ID: {}", RootContext.getXID());
            // 如果是在一次全局事务里出异常了,就不要包装返回值,将异常抛给调用方,让调用方回滚事务
            if (StrUtil.isNotBlank(RootContext.getXID())) {
                throw e;
            }
        }
    
        /**
         * 请求方式不支持
         */
        @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
        public AjaxResult handleHttpRequestMethodNotSupported(HttpRequestMethodNotSupportedException e, HttpServletRequest request) throws Exception {
            checkSeataError(e);
            String requestUri = request.getRequestURI();
            log.error("请求地址'{}',不支持'{}'请求", requestUri, e.getMethod());
            return AjaxResult.error(e.getMessage());
        }
    
        /**
         * 业务异常
         */
        @ExceptionHandler(ServiceException.class)
        public AjaxResult handleServiceException(ServiceException e, HttpServletRequest request) throws Exception {
            checkSeataError(e);
            log.error(e.getMessage(), e);
            Integer code = e.getCode();
            return StringUtils.isNotNull(code) ? AjaxResult.error(code, StrUtil.isEmpty(e.getMessage()) ? e.getCause().getMessage() : e.getMessage()) : AjaxResult.error(StrUtil.isEmpty(e.getMessage()) ? e.getCause().getMessage() : e.getMessage());
        }
    
        /**
         * 请求参数类型不匹配
         */
        @ExceptionHandler(MethodArgumentTypeMismatchException.class)
        public AjaxResult handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e, HttpServletRequest request) throws Exception {
            checkSeataError(e);
            String requestUri = request.getRequestURI();
            String value = Convert.toStr(e.getValue());
            if (StringUtils.isNotEmpty(value)) {
                value = EscapeUtil.clean(value);
            }
            log.error("请求参数类型不匹配'{}',发生系统异常.", requestUri, e);
            return AjaxResult.error(String.format("请求参数类型不匹配,参数[%s]要求类型为:'%s',但输入值为:'%s'", e.getName(), e.getRequiredType().getName(), value));
        }
    
        /**
         * 切面异常统一捕获
         */
        @ExceptionHandler(AspectException.class)
        public ResponseResult<?> handleAspectException(AspectException aspectException) {
            aspectException.printStackTrace();
            return ResponseResult.error(aspectException.getResultStatus(), null);
        }
    
        /**
         * 系统基类异常捕获
         */
        @ExceptionHandler(BasesException.class)
        public ResponseResult<?> handleBasesException(BasesException basesException) throws Exception {
            checkSeataError(basesException);
            basesException.printStackTrace();
            return ResponseResult.error(basesException.getResultStatus(), null);
        }
    
        /**
         * 拦截未知的运行时异常
         */
        @ExceptionHandler(RuntimeException.class)
        public AjaxResult handleRuntimeException(RuntimeException e, HttpServletRequest request) throws Exception {
            checkSeataError(e);
            String requestUri = request.getRequestURI();
            log.error("请求地址'{}',发生未知异常.", requestUri, e);
            return AjaxResult.error(e.getMessage());
        }
    
        /**
         * 系统异常
         */
        @ExceptionHandler(Exception.class)
        public AjaxResult handleException(Exception e, HttpServletRequest request) throws Exception {
            checkSeataError(e);
            String requestUri = request.getRequestURI();
            log.error("请求地址'{}',发生系统异常.", requestUri, e);
            return AjaxResult.error(e.getMessage());
        }
    
        /**
         * 自定义验证异常
         */
        @ExceptionHandler(BindException.class)
        public AjaxResult handleBindException(BindException e) throws Exception {
            checkSeataError(e);
            log.error(e.getMessage(), e);
            String message = e.getAllErrors().get(0).getDefaultMessage();
            return AjaxResult.error(message);
        }
    
        /**
         * 自定义验证异常
         */
        @ExceptionHandler(MethodArgumentNotValidException.class)
        public Object handleMethodArgumentNotValidException(MethodArgumentNotValidException e) throws Exception {
            checkSeataError(e);
            log.error(e.getMessage(), e);
            String message = e.getBindingResult().getFieldError().getDefaultMessage();
            return ResponseResult.error(message);
        }
    
        /**
         * 内部认证异常
         */
        @ExceptionHandler(InnerAuthException.class)
        public AjaxResult handleInnerAuthException(InnerAuthException e) throws Exception {
            checkSeataError(e);
            return AjaxResult.error(e.getMessage());
        }
        
        ......
    }
  • 修改Feign熔断降级方法

    由于项目对远程调用接口还做了熔断降级操作,导致调用方仍然识别不到异常,所以这里将熔断降级方法修改下,让其能正常抛异常

    java 复制代码
    @Component
    @Slf4j
    public class ConstructionProviderFallback implements IConstructionProvider {
        @Override
        public ResponseResult<String> testSeata(Boolean error) {
            if (error) {
                throw new RuntimeException("降级方法中---模拟被调用方异常");
            }
            return ResponseResult.success("----------------testSeata接口远程调用熔断-----------------");
        }
    }
  • 启动类增加AOP注解

    由于全局事务注解@GlobalTransactional底层是基于AOP实现,所以需要给两个服务的启动类都加上AOP注解

    @EnableAspectJAutoProxy(exposeProxy = true, proxyTargetClass = true)

  • 调用方测试接口

    java 复制代码
    /**
     * 测试全局事务
     * @return
     */
    @ApiOperation("测试全局事务")
    @GetMapping("/testSeata")
    @ApiImplicitParam(name = "type", value = "1:模拟调用方异常 其他:模拟被调用方异常")
    public ResponseResult<Boolean> testSeata(@RequestParam Integer type) {
        SecurityTest securityTest = new SecurityTest();
        securityTest.setTestColumn("测试全局事务");
        securityTest.setOrganizeId(1L);
        return ResponseResult.success(testSeataService.testSeata(type,securityTest));
    }
    java 复制代码
    @GlobalTransactional
    @Override
    public Boolean testSeata(Integer type, SecurityTest securityTest) {
        log.info("seata全局事务ID: {}", RootContext.getXID());
        if (type!=null&&type==1) {
            //先远程调用construction服务保存远程服务数据
            constructionProvider.testSeata(false);
            //再保存自己服务数据
            securityTestService.save(securityTest);
            //模拟调用方异常
            throw new RuntimeException("模拟调用方异常");
        } else {
            //先保存自己服务数据
            securityTestService.save(securityTest);
            //再远程调用construction服务保存远程服务数据,且模拟被调用方异常
            constructionProvider.testSeata(true);
        }
        return true;
    }

    这里测试两种情况,调用方异常事务回滚,还有被调用方异常事务回滚

  • 被调用方提供的Feign接口

    java 复制代码
    @Service(value = "IConstructionProvider")
    @FeignClient(value = ConstructionProviderConstant.MATE_CLOUD_CONSTRUCTION, fallback = ConstructionProviderFallback.class)
    public interface IConstructionProvider {
    
        /**
         * 测试全局事务
         * @param error
         * @return
         */
        @GetMapping(ConstructionProviderConstant.TEST_SEATA)
        ResponseResult<String> testSeata(@RequestParam("error") Boolean error);
    
    }

    这里当时遇到了一个坑

    实现:

    正常调用:

    java 复制代码
    /**
     * 测试Seata全局事务
     * @param error 是否模拟被调用方异常
     * @return
     */
    @Override
    @ApiOperation(value = "测试Seata全局事务", notes = "测试Seata全局事务", httpMethod = "GET")
    @GetMapping(ConstructionProviderConstant.TEST_SEATA)
    @SentinelResource(value = ConstructionProviderConstant.TEST_SEATA, fallbackClass = ConstructionProviderFallback.class, fallback = "testFeign")
    public ResponseResult<String> testSeata(@RequestParam(value = "error") Boolean error) {
        SecurityTest1 test = new SecurityTest1();
        test.setTestColumn("seata");
        test.setOrganizeId(1L);
        securityTestService.save(test);
        if (error) {
            throw new RuntimeException("模拟被调用方异常");
        }
        return ResponseResult.success("---------------testSeata接口正常------------------");
    }

    熔断降级:

    java 复制代码
    @Override
    public ResponseResult<String> testSeata(Boolean error) {
        if (error) {
            throw new RuntimeException("降级方法中---模拟被调用方异常");
        }
        return ResponseResult.success("----------------testSeata接口远程调用熔断-----------------");
    }

5.测试结果

分别测试了调用方异常、被调用方异常的情况,均能实现全局事务回滚(两边的数据库都回滚了),如下图所示

下面是seata控制台的信息(存于数据库里)

这里我测试的结果是 只有调用方和被调用方都有事务回滚 才会有信息,而且会定期清除

相关推荐
HaiFan.4 小时前
SpringBoot 事务
java·数据库·spring boot·sql·mysql
Light605 小时前
云途领航:现代应用架构助力企业转型新篇
微服务·架构·saas·paas·iaas·ipaas·apaas
大梦百万秋5 小时前
Spring Boot实战:构建一个简单的RESTful API
spring boot·后端·restful
斌斌_____6 小时前
Spring Boot 配置文件的加载顺序
java·spring boot·后端
苹果醋36 小时前
React系列(八)——React进阶知识点拓展
运维·vue.js·spring boot·nginx·课程设计
等一场春雨8 小时前
springboot 3 websocket react 系统提示,选手实时数据更新监控
spring boot·websocket·react.js
weisian1519 小时前
Redis篇--常见问题篇7--缓存一致性2(分布式事务框架Seata)
redis·分布式·缓存
荆州克莱9 小时前
Golang的性能监控指标
spring boot·spring·spring cloud·css3·技术
AI人H哥会Java9 小时前
【Spring】控制反转(IoC)与依赖注入(DI)—IoC容器在系统中的位置
java·开发语言·spring boot·后端·spring