Spring Cloud笔记-Spring Cloud Alibaba Seata处理分布式事务(二十)

1.分布式事务问题

在分布式架构的系统中,通常会存在分布式事务的问题,也就是一次业务操作可能需要跨多个数据源或多个系统进行,这就是分布式事务问题,多个数据源在物理上是分开的,但是在业务上必须保证是整体的,否则就会出现错误的数据。

2.Seata简介

Seata官网:http://seata.io/zh-cn/

Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。

Seata在分布式事务处理过程中,会用到一个全局唯一事务ID(Transaction ID)+三个组件(TC、TM、RM)。

TC(事务协调者):维护全局和分支事务的状态,驱动全局事务提交或回滚。

TM(事务管理器):定义全局事务的范围:开始全局事务、提交或回滚全局事务。

RM(资源管理器):管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。

Seata分布式事务处理流程:

  1. TM向TC申请开启一个全局事务,TC会创建一个全局唯一的事务ID:XID
  2. XID在整个分布式事务进行过程中始终存在,用来标识哪个事务
  3. RM向TC注册分支事务,将其纳入XID的管辖范围
  4. TM向TC发送针对XID的全局提交或回滚协议
  5. TC调度XID管辖的全部分支事务完成提交或回滚

这块,阳哥举了个例子,感觉不错,记录一下。把每个Microservice看做一个组,每个组里有若干学生,把TC当做阳哥(授课老师),把TM当做班主任。

  1. 班主任向授课老师申请:“我们现在可以开一个班级吗?”。授课老师说:“可以,现在疫情期间,我们网络授课,我新建一个网络班级吧,班级ID是XID”
  2. 班主任把XID在整个链路上(全部的小组)传播,告知所有报班学生XID值是多少
  3. 收到XID的学生,通过这个XID,进入直播间学习,授课老师可以看到有哪些学生参与了此次课程,也就是授课老师确定XID的管辖范围
  4. 班主任向授课老师说:“现在人到齐了,可以开始上课了”或者班主任向授课老师说:“今天好多同学没来,先暂停一天”
  5. 假设开始上课,整个流程正常执行,上课1小时后,授课老师讲课完毕,告知所有学生:“今天的课到这里就结束了,记得投币,三连啊……”,也就代表整个事务执行完成

3.Seata-Server安装

下载地址:https://github.com/seata/seata/releases,有Windows和Linux版本。下载之后,进入conf目录下,修改file.conf的service模块和store模块,目的是:自定义事务组名称、事务日志存储模式改为DB、配置数据库连接信息。修改registry.conf的registry模块。

service模块:修改vgroup_mapping.my_test_tx_group的值为***_tx_group,其中***表示任意英文字符(高版本没有service模块,这个我没有研究)。

store模块:修改mode="db",表示使用db存储日志信息。在下面的db模块中,写上自己的url、user、password,其他的默认。

registry模块:修改type="nacos",nacos模块指明nacos连接信息。

新建一个名称为seata的数据库,在Seata的GitHub地址,找到script/server/db/mysql.sql文件,在seata数据库里执行SQL完成初始化操作。建议使用MySQL 5.7版本,MySQL 8可能不支持。


这里我还是用Docker吧。

因为要修改配置文件,但是容器内vi命令无法使用,之前说过挂载的方式(这里我也用过挂载方式,不过出问题了,一直没有解决),这里介绍另一种方式:将容器的文件复制到容器外,在容器外修改完成后,再复制进去。这里要修改的文件有:registry.conf和file.conf。

启动MySQL 5.7的镜像,创建一个名称为seata的数据库,脚本可以在Seata的GitHub地址获取,找到script/server/db/mysql.sql文件,在seata数据库里执行SQL完成初始化操作。Docker启动nacos-server,最后启动seata-server。

[root@localhost ~]# docker pull seataio/seata-server:1.2.0 # 下载seata-server 1.2.0的镜像
[root@localhost ~]# docker run -d -h 192.168.0.123 -p 8091:8091 seataio/seata-server:1.2.0 # 运行seata-server,记得指定-h,否则后面找不到服务
[root@localhost /]# docker exec -it 容器id sh # 进到容器里面
/seata-server # ls -l # 查看文件
total 0
drwxr-xr-x    4 root     root            32 Jan  1  1970 classes
drwxr-xr-x    1 root     root            57 May 10 13:32 libs
drwxr-xr-x    4 root     root           151 Jan  1  1970 resources
drwxr-xr-x    2 root     root            23 Jul 18 09:36 sessionStore
/seata-server # cd resources/ # 进导resources文件夹下
/seata-server/resources # ls -l # 查看文件
total 24
drwxr-xr-x    3 root     root            22 Jan  1  1970 META-INF
-rw-r--r--    1 root     root          1327 Jan  1  1970 README-zh.md
-rw-r--r--    1 root     root          1324 Jan  1  1970 README.md
-rw-r--r--    1 root     root          1165 Jan  1  1970 file.conf
-rw-r--r--    1 root     root          2929 Jan  1  1970 file.conf.example
drwxr-xr-x    3 root     root            19 Jan  1  1970 io
-rw-r--r--    1 root     root          2152 Jan  1  1970 logback.xml
-rw-r--r--    1 root     root          1631 Jan  1  1970 registry.conf
/seata-server/resources # exit # 退出容器,这里进一遍容器的目的是查看一下配置文件的位置,等下复制时候要用
[root@localhost /]# docker cp 容器id:/seata-server/resources/file.conf / # 拷贝容器内/seata-server/resources/file.conf文件到容器外的根路径下
[root@localhost /]# docker cp 容器id:/seata-server/resources/registry.conf / # 拷贝容器内/seata-server/resources/registry.conf文件到容器外的根路径下

然后,我们在虚拟机的根路径下就看到了registry.conf和file.conf了,对它们进行修改,这里我去掉了多余的部分。

file.conf

## transaction log store, only used in seata-server
store {
  ## store mode: file、db
  mode = "db"

  ## database store property
  db {
    ## the implement of javax.sql.DataSource, such as DruidDataSource(druid)/BasicDataSource(dbcp) etc.
    datasource = "druid"
    ## mysql/oracle/postgresql/h2/oceanbase etc.
    dbType = "mysql"
    driverClassName = "com.mysql.jdbc.Driver"
    url = "jdbc:mysql://192.168.0.123:3306/seata"
    user = "root"
    password = "root"
    minConn = 5
    maxConn = 30
    globalTable = "global_table"
    branchTable = "branch_table"
    lockTable = "lock_table"
    queryLimit = 100
    maxWait = 5000
  }
}

registry.conf

registry {
  # file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
  type = "nacos"

  nacos {
    application = "seata-server"
    serverAddr = "192.168.0.123:8848"
    namespace = ""
    cluster = "default"
    username = ""
    password = ""
  }
}

config {
  # file、nacos 、apollo、zk、consul、etcd3
  type = "file"

  file {
    name = "file.conf"
  }
}

配置文件已经修改完了,我们将文件再复制到容器里面。

[root@localhost /]# docker cp file.conf 容器id:/seata-server/resources/file.conf
[root@localhost /]# docker cp registry.conf 容器id:/seata-server/resources/registry.conf
[root@localhost /]# docker restart dc50d1acd7ed # 将容器重启一下,这样就以新的配置文件进行启动了
[root@localhost /]# docker logs -f 容器id # 查看容器启动日志,如果没有报错,说明启动成功了

回到Nacos的控制台的服务列表查看服务注册情况,如果有seata-server说明注册成功了。

4.订单/库存/账户业务数据库准备

说明:创建3个微服务模块:订单、库存、账户。用户下单,订单微服务创建一个订单,调用库存微服务减库存,调用账户服务扣账户余额,最后订单服务修改订单状态为完成。该操作会修改3个数据库,做两次远程调用,因此会存在分布式事务问题。

创建3个数据库seata_order、seata_storage、seata_account。在3个库下创建对应的业务表和回滚日志表,完整SQL在下面。

-- 创建3个数据库
CREATE DATABASE seata_order;
CREATE DATABASE seata_storage;
CREATE DATABASE seata_account;

-- 切换seata_order库,创建t_order表和undo_log表
USE seata_order;
CREATE TABLE t_order(
    `id` BIGINT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY,
    `user_id` BIGINT(11) DEFAULT NULL COMMENT '用户id',
    `product_id` BIGINT(11) DEFAULT NULL COMMENT '产品id',
    `count` BIGINT(11) DEFAULT NULL COMMENT '数量',
    `money` BIGINT(11) DEFAULT NULL COMMENT '金额',
    `status` BIGINT(11) DEFAULT NULL COMMENT '订单状态: 0创建中 1已完结'
) ENGINE = INNODB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
CREATE TABLE IF NOT EXISTS `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`)
) ENGINE = InnoDB
  AUTO_INCREMENT = 1
  DEFAULT CHARSET = utf8 COMMENT ='AT transaction mode undo table';
	
-- 切换seata_storage库,创建t_storage表和undo_log表,对t_storage插入一条数据
USE seata_storage;
CREATE TABLE t_storage(
    `id` BIGINT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY,
    `product_id` BIGINT(11) DEFAULT NULL COMMENT '产品id',
    `total` BIGINT(11) DEFAULT NULL COMMENT '总库存',
    `used` BIGINT(11) DEFAULT NULL COMMENT '已用库存',
    `residue` BIGINT(11) DEFAULT NULL COMMENT '剩余库存'
) ENGINE = INNODB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
INSERT INTO t_storage( `id`,`product_id`, `total`,`used`,`residue`) values(1,1,100,0,100);
CREATE TABLE IF NOT EXISTS `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`)
) ENGINE = InnoDB
  AUTO_INCREMENT = 1
  DEFAULT CHARSET = utf8 COMMENT ='AT transaction mode undo table';
	
-- 切换seata_account库,创建t_account表和undo_log表,对t_account插入一条数据
USE seata_account;
CREATE TABLE t_account(
    `id` BIGINT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY,
    `user_id` BIGINT(11) DEFAULT NULL COMMENT '用户id',
    `total` BIGINT(11) DEFAULT NULL COMMENT '总额度',
    `used` BIGINT(11) DEFAULT NULL COMMENT '已用额度',
    `residue` BIGINT(11) DEFAULT NULL COMMENT '剩余额度'
) ENGINE = INNODB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
INSERT INTO t_account( `id`,`user_id`, `total`,`used`,`residue`) values(1,1,1000,0,1000);
CREATE TABLE IF NOT EXISTS `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`)
) ENGINE = InnoDB
  AUTO_INCREMENT = 1
  DEFAULT CHARSET = utf8 COMMENT ='AT transaction mode undo table';

5.订单/库存/账户业务微服务准备

1.创建订单微服务模块

新建cloudalibaba-seata-order-service2001模块,修改pom.xml。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>cloud2020</artifactId>
        <groupId>com.atguigu.springcloud</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>
    <artifactId>cloudalibaba-seata-order-service2001</artifactId>
    <dependencies>
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>io.seata</groupId>
                    <artifactId>seata-all</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>io.seata</groupId>
            <artifactId>seata-all</artifactId>
            <version>1.2.0</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
</project>

添加application.yml、file.conf、registry.conf文件到resources目录下。

application.yml

server:
  port: 2001
spring:
  application:
    name: seata-order-service
  cloud:
    alibaba:
      seata:
        # 自定义事务组名称需要与seata-server中的对应
        tx-service-group: my_test_tx_group # 因为seata的file.conf文件中没有service模块,事务组名默认为my_test_tx_group
        # service要与tx-service-group对齐,vgroupMapping和grouplist在service的下一级,my_test_tx_group在再下一级
        service:
          vgroupMapping:
            # 要和tx-service-group的值一致
            my_test_tx_group: default
          grouplist:
            # seata server的地址配置,此处可以集群配置是个数组
            default: 192.168.0.123:8091
    nacos:
      discovery:
        server-addr: 192.168.0.123:8848  # nacos的地址
  datasource:
    # 当前数据源操作类型
    type: com.alibaba.druid.pool.DruidDataSource
    # mysql驱动类
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://192.168.0.123:3306/seata_order?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=GMT%2B8
    username: root
    password: root
feign:
  hystrix:
    enabled: false
logging:
  level:
    io:
      seata: info
mybatis:
  mapperLocations: classpath*:mapper/*.xml

file.conf

transport {
  # tcp udt unix-domain-socket
  type = "TCP"
  #NIO NATIVE
  server = "NIO"
  #enable heartbeat
  heartbeat = true
  # the client batch send request enable
  enableClientBatchSendRequest = true
  #thread factory for netty
  threadFactory {
    bossThreadPrefix = "NettyBoss"
    workerThreadPrefix = "NettyServerNIOWorker"
    serverExecutorThread-prefix = "NettyServerBizHandler"
    shareBossWorker = false
    clientSelectorThreadPrefix = "NettyClientSelector"
    clientSelectorThreadSize = 1
    clientWorkerThreadPrefix = "NettyClientWorkerThread"
    # netty boss thread size,will not be used for UDT
    bossThreadSize = 1
    #auto default pin or 8
    workerThreadSize = "default"
  }
  shutdown {
    # when destroy server, wait seconds
    wait = 3
  }
  serialization = "seata"
  compressor = "none"
}
service {
  vgroupMapping.my_test_tx_group = "default"
  default.grouplist = "192.168.0.123:8091"
  enableDegrade = false
  disableGlobalTransaction = false
}

client {
  rm {
    asyncCommitBufferLimit = 10000
    lock {
      retryInterval = 10
      retryTimes = 30
      retryPolicyBranchRollbackOnConflict = true
    }
    reportRetryCount = 5
    tableMetaCheckEnable = false
    reportSuccessEnable = false
    sagaBranchRegisterEnable = false
  }
  tm {
    commitRetryCount = 5
    rollbackRetryCount = 5
    degradeCheck = false
    degradeCheckPeriod = 2000
    degradeCheckAllowTimes = 10
  }
  undo {
    dataValidation = true
    onlyCareUpdateColumns = true
    logSerialization = "jackson"
    logTable = "undo_log"
  }
  log {
    exceptionRate = 100
  }
}

registry.conf

registry {
  # file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
  type = "nacos"

  nacos {
    application = "seata-server"
    serverAddr = "192.168.0.123:8848"
    namespace = ""
    username = ""
    password = ""
  }
}

config {
  # file、nacos 、apollo、zk、consul、etcd3、springCloudConfig
  type = "file"
  
  file {
    name = "file.conf"
  }
}

添加主启动类。

package com.springcloud.alibaba;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;

@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
@MapperScan("com.springcloud.alibaba.dao")
public class SeataOrderMain2001 {
    public static void main(String[] args) {
        SpringApplication.run(SeataOrderMain2001.class, args);
    }
}

添加实体类CommonResult和Order。

package com.springcloud.alibaba.domain;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class CommonResult<T> {
    private Integer code;
    private String message;
    private T data;

    public CommonResult(Integer code, String message) {
        this(code, message, null);
    }
}
package com.springcloud.alibaba.domain;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.math.BigDecimal;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Order {
    private Long id;
    private Long userId;
    private Long productId;
    private Integer count;
    private BigDecimal money;
    private Integer status; // 订单状态 0:创建中 1:已完结
}

新建dao接口和对应的mapper.xml(在resources下新建mapper文件夹添加OrderMapper.xml)。

package com.springcloud.alibaba.dao;

import com.springcloud.alibaba.domain.Order;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

@Mapper
public interface OrderDao {
    /**
     * 新建订单
     */
    int create(Order order);

    /**
     * 修改订单状态,从0改为1
     */
    int update(@Param("id") Long id, @Param("status") Integer status);
}
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.springcloud.alibaba.dao.OrderDao">
    <resultMap id="BaseResultMap" type="com.springcloud.alibaba.domain.Order">
        <id column="id" property="id" jdbcType="BIGINT"></id>
        <result column="user_id" property="userId" jdbcType="BIGINT"></result>
        <result column="product_id" property="productId" jdbcType="BIGINT"></result>
        <result column="count" property="count" jdbcType="INTEGER"></result>
        <result column="money" property="money" jdbcType="DECIMAL"></result>
        <result column="status" property="status" jdbcType="INTEGER"></result>
    </resultMap>

    <insert id="create" parameterType="com.springcloud.alibaba.domain.Order" useGeneratedKeys="true" keyProperty="id">
        insert into t_order(user_id,product_id,count,money,status) values (#{userId},#{productId},#{count},#{money},0);
    </insert>

    <update id="update">
        update t_order set status=1 where id=#{id} and status=#{status};
    </update>
</mapper>

创建OrderService.java接口,StorageService.java接口、AccountService.java接口,因为使用OpenFeign,所以要在Order模块里编写StorageService.java接口、AccountService.java接口,再添加一个OrderServiceImpl.java实现类,暂时注释掉@GlobalTransactional注解。

package com.springcloud.alibaba.service;

import com.springcloud.alibaba.domain.Order;

public interface OrderService {
    /**
     * 创建订单
     */
    void create(Order order);
}
package com.springcloud.alibaba.service;

import com.springcloud.alibaba.domain.CommonResult;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

@FeignClient(value = "seata-storage-service")
public interface StorageService {
    /**
     * 减库存
     */
    @PostMapping(value = "/storage/decrease")
    CommonResult decrease(@RequestParam("productId") Long productId, @RequestParam("count") Integer count);
}
package com.springcloud.alibaba.service;

import com.springcloud.alibaba.domain.CommonResult;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

import java.math.BigDecimal;

@FeignClient(value = "seata-account-service")
public interface AccountService {
    /**
     * 减余额
     */
    @PostMapping(value = "/account/decrease")
    CommonResult decrease(@RequestParam("userId") Long userId, @RequestParam("money") BigDecimal money);
}
package com.springcloud.alibaba.service.impl;

import com.springcloud.alibaba.dao.OrderDao;
import com.springcloud.alibaba.domain.Order;
import com.springcloud.alibaba.service.AccountService;
import com.springcloud.alibaba.service.OrderService;
import com.springcloud.alibaba.service.StorageService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;

@Service
@Slf4j
public class OrderServiceImpl implements OrderService {
    @Resource
    private OrderDao orderDao;
    @Resource
    private AccountService accountService;
    @Resource
    private StorageService storageService;

    /**
     * 创建订单->调用库存服务扣减库存->调用账户服务扣减账户余额->修改订单状态
     * 简单说:
     * 下订单->减库存->减余额->改状态
     * @GlobalTransactional seata开启分布式事务,异常时回滚,name保证唯一即可
     * @param order 订单对象
     */
    @Override
    // @GlobalTransactional(name = "wsy-create-order", rollbackFor = Exception.class)
    public void create(Order order) {
        // 1 新建订单
        log.info("----->开始新建订单");
        orderDao.create(order);
        // 2 扣减库存
        log.info("----->订单微服务开始调用库存,做扣减Count");
        storageService.decrease(order.getProductId(), order.getCount());
        log.info("----->订单微服务开始调用库存,做扣减End");
        // 3 扣减账户
        log.info("----->订单微服务开始调用账户,做扣减Money");
        accountService.decrease(order.getUserId(), order.getMoney());
        log.info("----->订单微服务开始调用账户,做扣减End");
        // 4 修改订单状态,从0到1,1代表已完成
        log.info("----->修改订单状态开始");
        orderDao.update(order.getId(), 0);
        log.info("----->下订单结束了,O(∩_∩)O哈哈~");
    }
}

创建OrderController.java。

package com.springcloud.alibaba.controller;

import com.springcloud.alibaba.domain.CommonResult;
import com.springcloud.alibaba.domain.Order;
import com.springcloud.alibaba.service.OrderService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;

@RestController
public class OrderController {
    @Resource
    private OrderService orderService;

    /**
     * 创建订单
     */
    @GetMapping("/order/create")
    public CommonResult create(Order order) {
        orderService.create(order);
        return new CommonResult(200, "订单创建成功");
    }
}

添加数据源配置类,使用Seata对数据源进行代理。

package com.springcloud.alibaba.config;

import com.alibaba.druid.pool.DruidDataSource;
import io.seata.rm.datasource.DataSourceProxy;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.core.io.support.ResourcePatternResolver;

import javax.sql.DataSource;

@Configuration
public class DataSourceProxyConfig {
    @Value("${mybatis.mapperLocations}")
    private String mapperLocations;

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource")
    public DataSource druidDataSource() {
        return new DruidDataSource();
    }

    @Bean
    public DataSourceProxy dataSourceProxy(DataSource druidDataSource) {
        return new DataSourceProxy(druidDataSource);
    }

    @Bean
    public SqlSessionFactory sqlSessionFactoryBean(DataSourceProxy dataSourceProxy) throws Exception {
        SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
        bean.setDataSource(dataSourceProxy);
        ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
        bean.setMapperLocations(resolver.getResources(mapperLocations));
        return bean.getObject();
    }
}

2.创建库存微服务模块

新建cloudalibaba-seata-storage-service2002模块,pom.xml和cloudalibaba-seata-order-service2001一样,application.yml修改端口号、服务名称、数据库名称,file.conf和registry.conf和cloudalibaba-seata-order-service2001一样。

添加主启动类。

package com.springcloud.alibaba;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;

@SpringBootApplication
@EnableFeignClients
@EnableDiscoveryClient
@MapperScan("com.springcloud.alibaba.dao")
public class SeataStorageMain2002 {
    public static void main(String[] args) {
        SpringApplication.run(SeataStorageMain2002.class, args);
    }
}

添加实体类CommonResult和Storage。

package com.springcloud.alibaba.domain;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class CommonResult<T> {
    private Integer code;
    private String message;
    private T data;

    public CommonResult(Integer code, String message) {
        this(code, message, null);
    }
}
package com.springcloud.alibaba.domain;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Storage {
    private Long id;
    private Long productId;
    private Integer total;
    private Integer used;
    private Integer residue;
}

添加StorageDao.java接口以及对应的mapper.xml(在resources下新建mapper文件夹添加StorageMapper.xml)。

package com.springcloud.alibaba.dao;

import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

@Mapper
public interface StorageDao {
    void decrease(@Param("productId") Long productId, @Param("count") Integer count);
}
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.springcloud.alibaba.dao.StorageDao">

    <resultMap id="BaseResultMap" type="com.springcloud.alibaba.domain.Storage">
        <id column="id" property="id" jdbcType="BIGINT"/>
        <result column="product_id" property="productId" jdbcType="BIGINT"/>
        <result column="total" property="total" jdbcType="INTEGER"/>
        <result column="used" property="used" jdbcType="INTEGER"/>
        <result column="residue" property="residue" jdbcType="INTEGER"/>
    </resultMap>

    <update id="decrease">
        update t_storage
        set used = used + #{count}, residue = residue - #{count}
        where product_id= #{productId};
    </update>
</mapper>

添加StorageService.java接口和StorageServiceImpl.java实现类。

package com.springcloud.alibaba.service;

public interface StorageService {
    void decrease(Long productId, Integer count);
}
package com.springcloud.alibaba.service.impl;

import com.springcloud.alibaba.dao.StorageDao;
import com.springcloud.alibaba.service.StorageService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;

@Service
@Slf4j
public class StorageServiceImpl implements StorageService {
    @Resource
    private StorageDao storageDao;

    @Override
    public void decrease(Long productId, Integer count) {
        log.info("----> StorageService中扣减库存");
        storageDao.decrease(productId, count);
        log.info("----> StorageService中扣减库存完成");
    }
}

添加StorageController.java。

package com.springcloud.alibaba.controller;

import com.springcloud.alibaba.domain.CommonResult;
import com.springcloud.alibaba.service.StorageService;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;

@RestController
public class StorageController {
    @Resource
    private StorageService storageService;

    @RequestMapping("/storage/decrease")
    public CommonResult decrease(Long productId, Integer count) {
        storageService.decrease(productId, count);
        return new CommonResult(200, "扣减库存成功!");
    }
}

添加数据源配置类,使用Seata对数据源进行代理,和cloudalibaba-seata-order-service2001一样。

3.创建账号微服务模块

新建cloudalibaba-seata-account-service2003模块,pom.xml和cloudalibaba-seata-order-service2001一样,application.yml修改端口号、服务名称、数据库连接,file.conf和registry.conf和cloudalibaba-seata-order-service2001一样。

添加主启动类。

package com.springcloud.alibaba;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;

@SpringBootApplication
@EnableFeignClients
@EnableDiscoveryClient
@MapperScan("com.springcloud.alibaba.dao")
public class SeataAccountMain2003 {
    public static void main(String[] args) {
        SpringApplication.run(SeataAccountMain2003.class, args);
    }
}

添加实体类CommonResult和Account。

package com.springcloud.alibaba.domain;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class CommonResult<T> {
    private Integer code;
    private String message;
    private T data;

    public CommonResult(Integer code, String message) {
        this(code, message, null);
    }
}
package com.springcloud.alibaba.domain;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.math.BigDecimal;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Account {
    private Long id;
    private Long userId;
    private BigDecimal total;
    private BigDecimal used;
    private BigDecimal residue;
}

添加AccountDao.java接口以及对应的mapper.xml(在resources下新建mapper文件夹添加AccountMapper.xml)。

package com.springcloud.alibaba.dao;

import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

import java.math.BigDecimal;

@Mapper
public interface AccountDao {
    void decrease(@Param("userId") Long userId, @Param("money") BigDecimal money);
}
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.springcloud.alibaba.dao.AccountDao">
    <resultMap id="BaseResultMap" type="com.springcloud.alibaba.domain.Account">
        <id column="id" property="id" jdbcType="BIGINT"/>
        <result column="user_id" property="userId" jdbcType="BIGINT"/>
        <result column="total" property="total" jdbcType="DECIMAL"/>
        <result column="used" property="used" jdbcType="DECIMAL"/>
        <result column="residue" property="residue" jdbcType="DECIMAL"/>
    </resultMap>

    <update id="decrease">
        update t_account
        set used = used + #{money}, residue = residue - #{money}
        where user_id = #{userId};
    </update>
</mapper>

添加AccountService.java接口和AccountServiceImpl.java实现类。

package com.springcloud.alibaba.service;

import java.math.BigDecimal;

public interface AccountService {
    void decrease(Long userId, BigDecimal money);
}
package com.springcloud.alibaba.service.impl;

import com.springcloud.alibaba.dao.AccountDao;
import com.springcloud.alibaba.service.AccountService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.math.BigDecimal;

@Service
@Slf4j
public class AccountServiceImpl implements AccountService {
    @Resource
    private AccountDao accountDao;

    @Override
    public void decrease(Long userId, BigDecimal money) {
        log.info("---> AccountService中扣减账户余额");
        accountDao.decrease(userId, money);
        log.info("---> AccountService中扣减账户余额完成");
    }
}

添加AccountController.java。

package com.springcloud.alibaba.controller;

import com.springcloud.alibaba.domain.CommonResult;
import com.springcloud.alibaba.service.AccountService;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import java.math.BigDecimal;

@RestController
public class AccountController {
    @Resource
    private AccountService accountService;

    @RequestMapping("/account/decrease")
    public CommonResult decrease(@RequestParam("userId") Long userId, @RequestParam("money") BigDecimal money) {
        accountService.decrease(userId, money);
        return new CommonResult(200, "扣减库存成功!");
    }
}

添加数据源配置类,使用Seata对数据源进行代理,和cloudalibaba-seata-order-service2001一样。

6.Test

初始的时候,t_order表是空的,t_storage表有一条记录,t_account表有一条记录。

然后启动Nacos、MySQL、Seata服务,再启动3个微服务。

正常情况下,通过浏览器发送一个请求:http://localhost:2001/order/create?userId=1&productId=1&count=10&money=100,请求发送给cloudalibaba-seata-order-service2001模块,2001模块通过Feign调用2002模块的接口和2003模块的接口。

如果一切正常的话,通过微服务的控制台,可以看到日志信息,查看数据库,可以看到订单创建完成, 库存扣减完成,账户扣减完成。

下面我们来测试一下出错的情况。

给2003模块添加一个sleep,并重启2003模块,浏览器访问http://localhost:2001/order/create?userId=1&productId=1&count=10&money=100,发现页面报错了,查看数据库,生成了一条订单记录,但是订单状态是未完成,用户账户发生了扣款,库存也发生了扣减,此时,订单还是未完成的,这就矛盾了。

package com.springcloud.alibaba.service.impl;

import com.springcloud.alibaba.dao.AccountDao;
import com.springcloud.alibaba.service.AccountService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.math.BigDecimal;

@Service
@Slf4j
public class AccountServiceImpl implements AccountService {
    @Resource
    private AccountDao accountDao;

    @Override
    public void decrease(Long userId, BigDecimal money) {
        log.info("---> AccountService中扣减账户余额");
        try {
            Thread.sleep(5000); // sleep5秒
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        accountDao.decrease(userId, money);
        log.info("---> AccountService中扣减账户余额完成");
    }
}

下面,我们来验证一下添加了@GlobalTransactional后的效果,将2001模块的OrderServiceImpl.java里的@GlobalTransactional注释打开,重启2001模块。浏览器访问http://localhost:2001/order/create?userId=1&productId=1&count=10&money=100

页面依旧报错,查看数据库,订单表没有多出订单,库存也没有被多扣,账户余额也没有被多扣,一切正常。另外,在控制台能看到如下内容。

2020-07-18 21:10:00.446  INFO 24784 --- [tch_RMROLE_1_16] i.s.core.rpc.netty.RmMessageListener     : onMessage:xid=192.168.0.123:8091:2017231421,branchId=2017231423,branchType=AT,resourceId=jdbc:mysql://192.168.0.123:3306/seata_order,applicationData=null
2020-07-18 21:10:00.446  INFO 24784 --- [tch_RMROLE_1_16] io.seata.rm.AbstractRMHandler            : Branch Rollbacking: 192.168.0.123:8091:2017231421 2017231423 jdbc:mysql://192.168.0.123:3306/seata_order
2020-07-18 21:10:00.458  INFO 24784 --- [tch_RMROLE_1_16] i.s.r.d.undo.AbstractUndoLogManager      : xid 192.168.0.123:8091:2017231421 branch 2017231423, undo_log deleted with GlobalFinished
2020-07-18 21:10:00.459  INFO 24784 --- [tch_RMROLE_1_16] io.seata.rm.AbstractRMHandler            : Branch Rollbacked result: PhaseTwo_Rollbacked
2020-07-18 21:10:00.481  INFO 24784 --- [nio-2001-exec-3] i.seata.tm.api.DefaultGlobalTransaction  : [192.168.0.123:8091:2017231421] rollback status: Rollbacked

7.补充

Seata支持4种模式:AT模式、TCC模式、SAGA模式、XA模式,它们有各自的应用场景,默认使用的是AT模式。它是一种两阶段提交协议的演变。

  • 一阶段:业务数据和回滚日志记录在同一个本地事务中提交,释放本地锁和连接资源。

  • 二阶段:

    • 提交异步化,非常快速地完成。
    • 回滚通过一阶段的回滚日志进行反向补偿。

在第一阶段,Seata会拦截业务SQL,然后对SQL进行解析,找到需要更新的业务数据,在数据被更新前,保存一个before image快照,然后执行业务SQL,SQL执行完毕后,保存一个after image快照,最后生成一个行锁,以上操作都在一个事务内进行,保证操作的原子性。这里的before image和after image有点像Spring AOP思想,将业务用通知方法包裹起来,程序员不比关心before image和after image。

在第二阶段,如果程序执行成功的话,Seata只需要删除before image和after image以及行锁即可,因为一阶段的SQL已经提交了,所以二阶段提交操作可以非常快速完成。

在第二阶段,如果程序报错了,Seata需要进行回滚,这里便用到了before image,在还原回before image的时候,需要校验脏写,对比当前数据库业务数据和after image,如果两份数据一样,说明没有发生脏写,此时直接还原回before iamge即可,如果不一样,说明有脏写,此时需要人工处理。

下面,我们在2003模块去掉Thead.sleep代码,在accountDao.decrease()方法后,添加一个断点,debug模式进行调试,当程序走到断点的时候,我们查看seata数据库的3张表的内容。

此时,查看undo_log表,可以发现也有信息,其中有一个字段是rollback_info,它里面存储的就是before image和after image的信息。

 

评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值