分布式事务seata的配置与详解

这篇文章花了我数个小时才编写完成,seata的使用有不少坑。本篇是针对单服务情况的说明使用,今后会推出多集群高可用包括异地容灾等说明。

事务的概念

本地事务

事务指的就是一个操作单元,在这个操作单元中的所有操作最终要保持一致的行为,要么所有操作都成功,要么所有的操作都被撤销。数据库事务的四大特性:

  • A:原子性(Atomicity),一个事务中的所有操作,要么全部完成,要么全部不完成

  • C:一致性(Consistency),在一个事务执行之前和执行之后数据库都必须处于一致性状态

  • I:隔离性(Isolation),在并发环境中,当不同的事务同时操作相同的数据时,事务之间互不影响

  • D:持久性(Durability),指的是只要事务成功结束,它对数据库所做的更新就必须永久的保存下来

分布式事务

分布式事务指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。

seata

2019 年 1 月,阿里巴巴中间件团队发起了开源项目 Fescar(Fast & EaSy Commit And Rollback),其愿景是让分布式事务的使用像本地事务的使用一样,简单和高效,并逐步解决开发者们 遇到的分布式事务方面的所有难题。后来更名为 Seata,意为:Simple Extensible Autonomous Transaction Architecture,是一套分布式事务解决方案。 Seata的设计目标是对业务无侵入,因此从业务无侵入的2PC方案着手,在传统2PC的基础上演进。 它把一个分布式事务理解成一个包含了若干分支事务的全局事务。全局事务的职责是协调其下管辖的分 支事务达成一致,要么一起成功提交,要么一起失败回滚。此外,通常分支事务本身就是一个关系数据 库的本地事务。

2PC即两阶段提交协议,是将整个事务流程分为两个阶段,准备阶段(Prepare phase)、提交阶段(commit phase),2是指两个阶段,P是指准备阶段,C是指提交阶段。

Seata主要由三个重要组件组成:
  • TC:Transaction Coordinator 事务协调器,管理全局的分支事务的状态,用于全局性事务的提交 和回滚。

  • TM:Transaction Manager 事务管理器,用于开启、提交或者回滚全局事务。

  • RM:Resource Manager 资源管理器,用于分支事务上的资源管理,向TC注册分支事务,上报分 支事务的状态,接受TC的命令来提交或者回滚分支事务。

Seata的四种模式:
  • XA:强一致性,基于数据库隔离,无代码侵入,在一阶段不提交事务

  • AT:默认模式,基于全局锁隔离,无代码侵入,一阶段提交事务,在提交事务前,会记录undolog日志,性能比XA模式好,二阶段TC通知回滚,则根据undolog回滚,通知提交,则删除undolog日志。

  • TCC:性能最好,不需要依赖关系型数据库,但代码入侵读高。Try:冻结可用数据,Confirm:确认提交数据,删除冻结数据 Canel:恢复数据,将冻结数据恢复

  • Seaga: 用于长事务,例如A项目调另外一个公司的项目接口。

Seata实现2PC与传统2PC的差别:
  • 架构层次方面,传统2PC方案的 RM 实际上是在数据库层,RM本质上就是数据库自身,通过XA协 议实现,而 Seata的RM是以jar包的形式作为中间件层部署在应用程序这一侧的。

  • 两阶段提交方面,传统2PC无论第二阶段的决议是commit还是rollback,事务性资源的锁都要保 持到Phase2完成才释放。而Seata的做法是在Phase1 就将本地事务提交,这样就可以省去Phase2 持锁的时间,整体提高效率。

seata的安装配置(准备工作)

下载地址:seata下载

注:这里我使用的是window版,便于演示操作

在解压后的目录中找到conf文件夹,里面有以下文件

其中application.example.yml是示例配置文件,我们要修改的是application.yml,seata本质也是java编写的jar包

配置之前别忘记为seata创建对应的数据库,数据库脚本在这个位置

这里我将我的配置贴出来:

#  Copyright 1999-2019 Seata.io Group.
#
#  Licensed under the Apache License, Version 2.0 (the "License");
#  you may not use this file except in compliance with the License.
#  You may obtain a copy of the License at
#
#  http://www.apache.org/licenses/LICENSE-2.0
#
#  Unless required by applicable law or agreed to in writing, software
#  distributed under the License is distributed on an "AS IS" BASIS,
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#  See the License for the specific language governing permissions and
#  limitations under the License.
server:
  port: 8084

spring:
  application:
    name: seata-server

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

console:
  user:
    username: seata
    password: seata
seata:
  config:
    # support: nacos, consul, apollo, zk, etcd3
    type: nacos
    nacos:
      server-addr: 124.221.85.86
      namespace: my-cloud-demo
      group: DEFAULT_GROUP
      username: nacos
      password: nacos
      context-path:
      ##if use MSE Nacos with auth, mutex with username/password attribute
      #access-key:
      #secret-key:
      data-id: seata-server.yaml
  registry:
    # support: nacos, eureka, redis, zk, consul, etcd3, sofa
    type: nacos
    nacos:
      application: seata-server
      server-addr: 124.221.85.86
      group: DEFAULT_GROUP
      namespace: my-cloud-demo
      cluster: DEFAULT
      username: nacos
      password: nacos
      context-path:
      ##if use MSE Nacos with auth, mutex with username/password attribute
      #access-key:
      #secret-key:
  service:
    vgroup-mapping:
      # 前面这个是事务组名 :后面的是对应的集群名映射名(GZ) 这两个很重要到时候客户端要设置一致
      seata-server-group: DEFAULT
    disable-global-transaction: true
    service-port: 8091 #If not configured, the default is '${server.port} + 1000'
    max-commit-retry-timeout: -1
    max-rollback-retry-timeout: -1
    rollback-retry-timeout-unlock-enable: false
    enable-check-auth: true
    enable-parallel-request-handle: true
    enable-parallel-handle-branch: false
    retry-dead-threshold: 130000
    xaer-nota-retry-timeout: 60000
    enableParallelRequestHandle: true
    recovery:
      committing-retry-period: 1000
      async-committing-retry-period: 1000
      rollbacking-retry-period: 1000
      timeout-retry-period: 1000
    undo:
      log-save-days: 7
      log-delete-period: 86400000
    session:
      branch-async-queue-size: 5000 #branch async remove queue size
      enable-branch-async-remove: false #enable to asynchronous remove branchSession
  store:
    # support: file 、 db 、 redis 、 raft
    mode: file
  #  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,/**/*.jpeg,/**/*.ico,/api/v1/auth/login,/metadata/v1/**

使用nacos作为配置以及注册中心,下面是我nacos上的配置文件:

#事务会话信息存储方式
store:
  mode: db
  #事务锁信息存储方式
  lock:
    mode: db
  #事务回话信息存储方式
  session:
    mode: db
  #存储方式为db
  db:
    dbType: mysql
    datasource: druid
    driverClassName: com.mysql.cj.jdbc.Driver
# 下方你的数据库名称如果不是 seata 记得修正
    url: jdbc:mysql://xxx.xxx.xxx.xxx:3306/seata-server?useUnicode=true&rewriteBatchedStatements=true&useSSL=false
    user: root 
    password: password
    minConn: 5
    maxConn: 30
    queryLimit: 100
    maxWait: 5000
  # 下面 4 项对应的数据库中几张数据表
    globalTable: global_table
    branchTable: branch_table
    lockTable: lock_table
    distributedLockTable: distributed_lock

然后运行bin目录下的seata-server.bat运行seata

之后打开localhost:8084(根据你配置文件中的端口)

账户密码也在配置文件中,默认均为seata,登录seata

使用详解

为了演示使用,作者专门手撸了一个简单的springcloud的demo,使用经典的订单库存问题来说明

  • my-gateway 网关服务
  • my-order 订单服务
  • my-repertory 库存服务

在我搭建的时候还遇到了一个Mybatis-Plus版本所引发的问题,这是当时的报错信息 Invalid Value Type For Attribute ‘Factorybeanobjecttype‘: Java.Lang.String 原因是Mybatis-Plus中的Mybatis-Spring与Springboot3.2及以上版本的Factorybeanregistrysupport#Gettypeforfactorybeanfromattributes方法不适配,或者说Springboot已变更该方法导致的,Mybatis-Plus官方说在3.0.3版本已修复该问题,我们只需要在Pom中手动排除并更改Mybatis-Spring的版本即可

以及两个数据库中的两张表

源代码我会在文章最后给出,这里只拿关键代码说明

当没有使用分布式事务时

正常情况下 进行购买,请求对应接口

查看数据库,发现用户xueya的账号余额正常扣除,商品库存正常减一

模拟异常情况,当订单服务出现异常时

再次请求服务,系统报错,查看数据库会发现用户金额没变,但是库存少了,这很明显是不对的

引入seata

第一步、在需要全局事务的模块引入pom

<dependency> #注意版本,我这里是2022.0.0.0
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
        </dependency>

第二步、同时修改配置文件

seata:
  registry: # TC服务注册中心的配置,微服务根据这些信息去注册中心获取tc服务地址
    type: nacos # 注册中心类型 nacos
    nacos:
      server-addr: xxx.xxx.xxx.xxx:8848 # nacos地址
      namespace: my-cloud-demo # namespace,默认为空
      group: DEFAULT_GROUP # 分组,默认是DEFAULT_GROUP
      application: seata-server # seata服务名称
      username: nacos
      password: nacos
  tx-service-group: seata-server-group # 事务组名称
  service:
    vgroup-mapping: # 事务组与cluster的映射关系,这个地方巨坑,作者搞了半天
      seata-server-group: DEFAULT
  data-source-proxy-mode: AT

第三步、在事务设计的数据库中新建一张undo_log表,注意并不是在seata数据库新建,用来存储undolog

-- repertory.undo_log definition

CREATE TABLE `undo_log` (
  `branch_id` bigint(20) NOT NULL COMMENT 'branch transaction id',
  `xid` varchar(100) NOT NULL COMMENT 'global transaction id',
  `context` varchar(128) NOT NULL COMMENT 'undo_log context,such as serialization',
  `rollback_info` longblob NOT NULL COMMENT 'rollback info',
  `log_status` int(11) NOT NULL COMMENT '0:normal status,1:defense status',
  `log_created` datetime(6) NOT NULL COMMENT 'create datetime',
  `log_modified` datetime(6) NOT NULL COMMENT 'modify datetime',
  UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT COMMENT='AT transaction mode undo table';

第四步、在对应方法上加上注解@GlobalTransactional开启全局事务

验证开始

验证前,我们清空日志打印,记录商品库存以及用户余额,如下:

并在异常处打断点,便于我们观察undo_log日志的生成,因为一旦数据回滚成功,undo_log就会被删除!! 开始订单请求,观看日志打印

Begin new global transaction [172.20.10.3:9084:5846205845556326929]

同时undo_log表生成日志数据

放行请求,异常发生,回滚成功,数据正常,undo_log被删除

而在seata的页面仪表盘中,作者后来重复了几次,所以事务id等不一致,这里会显示实时的事务信息以及全局锁所影响到的数据。下面的第二张全局锁信息图实在debug期间才会出现的,毕竟如果事务执行结束,就不会有锁了

本次测试进行到这里基本结束了,这次测试默认使用模式AT进行说明,其他模式适用情况不如AT广泛,我们留到日后再探。

附录

docker-compose一键编排部署
version: "3"
services:
  seata-server:
    image: seataio/seata-server:1.4.2
    container_name: seata-server
    hostname: seata-server
    networks:
      - seata-server
    ports:
      - "8091:8091"
    environment:
      #宿主机ip
      - SEATA_PORT=8091
      # 指定seata-server的事务日志存储方式, 支持db ,file(默认),redis
      - STORE_MODE=db
      #seata的被发现地址,该IP用于向注册中心注册时使用(也就是自身的ip)
      - SEATA_IP=127.0.0.1
      #给予权限
      #- privileged=true
    volumes:
      - /software/docker/seata/config/registry.conf:/seata-server/resources/registry.conf
      - /software/docker/seata/config/file.conf:/seata-server/resources/file.conf
      - /software/docker/seata/mysql-connector-java-8.0.16.jar:/seata-server/libs/mysql-connector-java-8.0.16.jar
      - /project/docker/seata/logs:/root/logs/seata
      - /etc/localtime:/etc/localtime
networks:
  seata-server:
    driver: bridge

项目代码部分

OrderController

package com.liangnianban.controller;

import com.liangnianban.service.OrderService;
import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * Created with IntelliJ IDEA.
 * @Author: xueya
 * @Package: com.liangnianban.controller
 * @Project: my-cloud-demo
 * @Date: 2024/6/17  15:43
 * @Description: 订单服务控制层
 */
@RestController
@AllArgsConstructor
@RequestMapping("order")
public class OrderController {

    private final OrderService orderService;

    /**
     * id 为商品id,此为购买商品接口
     * @param id
     * @return
     */
    @GetMapping("buy/{id}")
    public String buy(@PathVariable("id") Integer id){
        return orderService.buy(id);
    }

    @GetMapping("hello")
    public String hello(){
        return "hello success!";
    }
}

OrderServiceImpl

package com.liangnianban.service.impl;

import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.liangnianban.dao.OrderDao;
import com.liangnianban.entity.OrderEntity;
import com.liangnianban.feign.RepertoryFeign;
import com.liangnianban.service.OrderService;
import io.seata.spring.annotation.GlobalTransactional;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;


/**
 * Created with IntelliJ IDEA.
 * @Author: xueya
 * @Package: com.liangnianban.service.impl
 * @Project: my-cloud-demo
 * @Date: 2024/6/17  15:53
 * @Description: 订单实现层
 */
@Service
@AllArgsConstructor
public class OrderServiceImpl extends ServiceImpl<OrderDao, OrderEntity> implements OrderService {

    private final RepertoryFeign repertoryFeign;
    @Override
    @GlobalTransactional(name = "order.buy-repertory",rollbackFor = Exception.class)
    public String buy(Integer id) {
        //获取商品信息
        Integer price = repertoryFeign.getPriceById(id);
        //远程调用减库存
        repertoryFeign.updateStock(id,1);
        //模拟异常
        int a = 1/0;
        //扣除金额,这里假设用户账户为xueya
        OrderEntity orderEntity = baseMapper.selectOne(Wrappers.<OrderEntity>lambdaQuery().eq(OrderEntity::getAccountName, "xueya"));
        if (orderEntity != null){
            orderEntity.setAccountBalance(orderEntity.getAccountBalance().subtract(new BigDecimal(price)));
            baseMapper.updateById(orderEntity);
        }else{
            return "未查到当前用户信息";
        }
        return "购买成功!!";
    }
}

RepertoryFeign

package com.liangnianban.feign;

import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

/**
 * Created with IntelliJ IDEA.
 * @Author: xueya
 * @Package: com.liangnianban.feign
 * @Project: my-cloud-demo
 * @Date: 2024/6/17  16:22
 * @Description:
 */
@FeignClient(value = "my-repertory",path = "repertory")
public interface RepertoryFeign {

    @GetMapping("{id}/{num}")
    public void updateStock(@PathVariable("id") Integer id , @PathVariable("num") Integer num);

    @GetMapping("{id}")
    public Integer getPriceById(@PathVariable("id") Integer id);
}

order模块配置文件

server:
  port: 8081

spring:
  application:
    name: my-order
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://xxx:3306/order?useUnicode=true&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8&allowMultiQueries=true&useSSL=false
    username: root
    password: password
  cloud:
    nacos:
      server-addr: xxx:8848
      discovery:
        namespace: my-cloud-demo
mybatis:
  mapper-locations: classpath:mappers/*Mapper.xml
  configuration:
    map-underscore-to-camel-case: true #驼峰式命名
    jdbc-type-for-null: null
mybatis-plus:
  configuration:
    call-setters-on-nulls: true
    map-underscore-to-camel-case: true
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl #认输出到控制台
    logImpl: org.apache.ibatis.logging.slf4j.Slf4jImpl #默认输出到日志文件
  mapper-locations: classpath:mappers/*.xml
  type-aliases-package: com.liangnianban.entity
seata:
  registry: # TC服务注册中心的配置,微服务根据这些信息去注册中心获取tc服务地址
    type: nacos # 注册中心类型 nacos
    nacos:
      server-addr: xxx # nacos地址
      namespace: my-cloud-demo # namespace,默认为空
      group: DEFAULT_GROUP # 分组,默认是DEFAULT_GROUP
      application: seata-server # seata服务名称
      username: nacos
      password: nacos
  tx-service-group: seata-server-group # 事务组名称
  service:
    vgroup-mapping: # 事务组与cluster的映射关系
      seata-server-group: DEFAULT
  data-source-proxy-mode: AT

限于文章篇幅,repertory模块与order模块基本类似,这里就不放了