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控制台的信息(存于数据库里)

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

相关推荐
IT毕设实战小研1 小时前
基于Spring Boot 4s店车辆管理系统 租车管理系统 停车位管理系统 智慧车辆管理系统
java·开发语言·spring boot·后端·spring·毕业设计·课程设计
一只爱撸猫的程序猿2 小时前
使用Spring AI配合MCP(Model Context Protocol)构建一个"智能代码审查助手"
spring boot·aigc·ai编程
甄超锋2 小时前
Java ArrayList的介绍及用法
java·windows·spring boot·python·spring·spring cloud·tomcat
喂完待续3 小时前
Apache Hudi:数据湖的实时革命
大数据·数据仓库·分布式·架构·apache·数据库架构
武昌库里写JAVA5 小时前
JAVA面试汇总(四)JVM(一)
java·vue.js·spring boot·sql·学习
Pitayafruit6 小时前
Spring AI 进阶之路03:集成RAG构建高效知识库
spring boot·后端·llm
zru_96026 小时前
Spring Boot 单元测试:@SpyBean 使用教程
spring boot·单元测试·log4j
甄超锋6 小时前
Java Maven更换国内源
java·开发语言·spring boot·spring·spring cloud·tomcat·maven
曾经的三心草7 小时前
微服务的编程测评系统11-jmeter-redis-竞赛列表
redis·jmeter·微服务
还是鼠鼠7 小时前
tlias智能学习辅助系统--Maven 高级-私服介绍与资源上传下载
java·spring boot·后端·spring·maven