Seata 是一款开源的分布式事务解决方案,致力于在微服务架构下提供高性能和简单易用的分布式事务服务。
官网:Apache Seata
一、部署
由于网络问题一直拉取docker镜像失败,所以这里采用了下载zip包直接部署的方式
版本说明 · alibaba/spring-cloud-alibaba Wiki · GitHub (需要和springcloud的版本对应)
1.下载
上传服务器并解压
2.修改配置,nacos作注册中心,db存储
修改conf/application.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
服务
cd bin
sh seata-seaver.sh
然后在nacos新建配置文件seataServer.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依赖
<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服务端的配置
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:
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
由于项目里的全局处理器通常都会将所有异常拦截,然后返回统一封装结果,而这会导致异常无法抛出
/** * 全局异常处理器 * * @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熔断降级方法
由于项目对远程调用接口还做了熔断降级操作,导致调用方仍然识别不到异常,所以这里将熔断降级方法修改下,让其能正常抛异常
@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)
-
调用方测试接口
/** * 测试全局事务 * @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)); }
@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接口
@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); }
这里当时遇到了一个坑 :
-
如果不写@RequestParam(“error”) ,会识别成POST请求,然后报错不支持POST请求
-
如果写了@RequestParam,但是没设置value属性,即写@RequestParam Boolean error,也会报错
参考:Feign 调用报 RequestParam.value() was empty on parameter 0-CSDN博客
实现:
正常调用:
/** * 测试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接口正常------------------"); }
熔断降级:
@Override public ResponseResult<String> testSeata(Boolean error) { if (error) { throw new RuntimeException("降级方法中---模拟被调用方异常"); } return ResponseResult.success("----------------testSeata接口远程调用熔断-----------------"); }
-
5.测试结果
分别测试了调用方异常、被调用方异常的情况,均能实现全局事务回滚(两边的数据库都回滚了),如下图所示
下面是seata控制台的信息(存于数据库里)
这里我测试的结果是 只有调用方和被调用方都有事务回滚 才会有信息,而且会定期清除