一文搞懂 Seata 分布式事务 TCC 模式及解决空回滚、幂等、悬挂问题

1.什么是TCC

TCC 基于分布式事务中的二阶段提交协议实现,它的全称为 Try-Confirm-Cancel,即资源预留(Try)、确认操作(Confirm)、取消操作(Cancel),他们的具体含义如下:

1. Try(prepare 行为): 对业务资源的检查并预留。

2. Confirm(commit行为): 对业务处理进行提交,只要 Try 成功,那么该步骤一定成功。

3. Cancel(rollback 行为): 对业务处理进行取消,即回滚操作,该步骤回对 Try 预留的资源进行释放。

TCC 是一种侵入式的分布式事务解决方案,以上三个操作都需要业务系统自行实现,对业务系统有着非常大的入侵性,设计相对复杂,对比AT,它的优点是 TCC 完全不依赖数据库,并且因为数据回滚问题都是在业务层面解决的,所以不需要使用全局锁,故执行速度更快。

2. Seata TCC 模式

一个分布式的全局事务,整体是 两阶段提交 的模型。全局事务是由若干分支事务组成的,分支事务要满足 两阶段提交 的模型要求,即需要每个分支事务都具备自己的:

一阶段prepare、二阶段 commit 或 rollback

在Seata中,AT模式与TCC模式事实上都是两阶段提交的具体实现,他们的区别在于:

AT 模式基于 支持本地 ACID 事务的关系型数据库:

一阶段 prepare 行为:在本地事务中,一并提交业务数据更新和相应回滚日志

记录。

二阶段 commit 行为:马上成功结束,自动异步批量清理回滚日志。

二阶段 rollback 行为:通过回滚日志,自动生成补偿操作,完成数据回滚。

TCC 模式不依赖于底层数据资源的事务支持:

一阶段 prepare 行为:调用自定义的 prepare 逻辑。

二阶段 commit 行为:调用自定义的 commit 逻辑。

二阶段 rollback 行为:调用自定义的 rollback 逻辑。

简单点概括,SEATA的TCC模式就是手工的AT模式,由我们自己实现prepare 、commit、rollback方法,不依赖AT模式的undo_log。

举例下单操作:

比如我们有个商品价格 money=30,账户表中有 money=100, 此时我们发起下单请求。

后台实现在 prepare 方法中冻结了一笔金额为30的订单,那么就账户表的 money 这时候就应该是100-30=70,但这里还需要一个冻结余额的字段:freeze_anount=0+30=30。

这时候如果后续有其他业务的try方法执行异常,需要回滚,那么就会由框架去调用我们实现的 rollback 方法,我们在 rollback 方法中需要自定义回滚的逻辑,账户表中money=70+30=100、freeze_anount=30-30=0。

如果后续业务全部执行成功,那么框架就会去调用 commit 方法,我们在 commit 方法中的自定义逻辑只需要减掉相应的冻结金额 freeze_anount=30-30=0。

三个方法都是我们自己实现的,但在调用的时候,我们自己只会调用 prepare 方法,commit 和 rollback 都是框架帮我们去调的。

2.1. TCC模式接口定义

注解TwoPhaseBusinessAction:

定义两阶段提交,在try阶段通过@TwoPhaseBusinessAction注解定义了分支事务的 prepareSaveOrder,commit和 rollback 方法。

name = 该tcc的bean名称,全局唯一。

commitMethod = commit 为二阶段确认方法。

rollbackMethod = rollback 为二阶段取消方法。

useTCCFence seata1.5.1的新特性 ,用于解决TCC幂等,悬挂,空回滚问题,需增加日志表tcc_fence_log,在1.5.1版本之前,需要自己添加一个表去解决这三个问题。我们此次也是基于1.5.1的版本讲解的。

BusinessActionContextParameter注解 传递参数到二阶段中,由BusinessActionContext对象接收。

java 复制代码
/**
* @author qingzhou
*
* 通过 @LocalTCC 这个注解,RM 初始化的时候会向 TC 注册一个分支事务。
*/
@LocalTCC
public interface OrderService {

    @TwoPhaseBusinessAction(name = "prepareSaveOrder", commitMethod = "commit", rollbackMethod = "rollback", useTCCFence = true)
    Order prepareSaveOrder(OrderVo orderVo, @BusinessActionContextParameter(paramName = "orderId") Long orderId);

    boolean commit(BusinessActionContext actionContext);

    boolean rollback(BusinessActionContext actionContext);
}

如果存在多个微服务相互调用的,需要在每个微服务上都定义以上的接口,而@GlobalTransactional只需要贴在调用链路的入口服务上,也就是全局事务发起者。

2.2. Seata 1.5.1 版本与旧版配置区别

2.2.1.数据库表

新增事务控制表 tcc_fence_log 解决TCC幂等,悬挂,空回滚问题

新增表 distributed_lock,用于 seata-server 异步任务调度

仓库地址:github.com/seata/seata...

sql 复制代码
CREATE TABLE IF NOT EXISTS `distributed_lock`
(
  `lock_key`       CHAR(20) NOT NULL,
  `lock_value`     VARCHAR(20) NOT NULL,
  `expire`         BIGINT,
  primary key (`lock_key`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;

INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('AsyncCommitting', ' ', 0);
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('RetryCommitting', ' ', 0);
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('RetryRollbacking', ' ', 0);
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('TxTimeoutCheck', ' ', 0);

CREATE TABLE `tcc_fence_log` (
  `xid` varchar(128) NOT NULL COMMENT 'global id',
  `branch_id` bigint(20) NOT NULL COMMENT 'branch id',
  `action_name` varchar(64) NOT NULL COMMENT 'action name',
  `status` tinyint(4) NOT NULL COMMENT 'status(tried:1;committed:2;rollbacked:3;suspended:4)',
  `gmt_create` datetime(3) NOT NULL COMMENT 'create time',
  `gmt_modified` datetime(3) NOT NULL COMMENT 'update time',
  PRIMARY KEY (`xid`,`branch_id`),
  KEY `idx_gmt_modified` (`gmt_modified`),
  KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

2.2.2.seata服务端配置文件

服务端配置文件变为 application.yml,这是因为seata1.5.1版本后由普通的java项目改成了Springboot项目

application.yml 配置内容只需修改seata的config和registry部分,内容其实跟旧版的差不多

plain 复制代码
server:
  port: 7091

spring:
  application:
    name: seata-server

logging:
  config: classpath:logback-spring.xml
  file:
    path: ${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: 127.0.0.1:8848
      namespace: 7e838c12-8554-4231-82d5-6d93573ddf32
      group: SEATA_GROUP
      username:
      password:
      ##if use MSE Nacos with auth, mutex with username/password attribute
      #access-key: ""
      #secret-key: ""
      data-id: seataServer.properties
  registry:
    # support: nacos 、 eureka 、 redis 、 zk  、 consul 、 etcd3 、 sofa
    type: nacos
    preferred-networks: 30.240.*
    nacos:
      application: seata-server
      server-addr: 127.0.0.1:8848
      group: SEATA_GROUP
      namespace:
      cluster: default
      username:
      password:
      ##if use MSE Nacos with auth, mutex with username/password attribute
      #access-key: ""
      #secret-key: ""
  store:
    # support: file 、 db 、 redis
    mode: db
#  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

2.2.3.nacos 上的seataServer.properties

nacos 上的seataServer.properties,在seata的github仓库中获取 config.txt

github.com/seata/seata...

config.txt 文件主要修改:

1.存储模式:

store.mode=db

2.数据库信息:

store.db.url=jdbc:mysql://127.0.0.1:3306/seata

store.db.user=root

store.db.password=root

3.事务分组一定要跟你项目中的yml文件的tx-service-group: default_tx_group对应上:

service.vgroupMapping.default_tx_group=default

内容如下:

plain 复制代码
#For details about configuration items, see https://seata.io/zh-cn/docs/user/configurations.html
#Transport configuration, for client and server
transport.type=TCP
transport.server=NIO
transport.heartbeat=true
transport.enableTmClientBatchSendRequest=false
transport.enableRmClientBatchSendRequest=true
transport.enableTcServerBatchSendResponse=false
transport.rpcRmRequestTimeout=30000
transport.rpcTmRequestTimeout=30000
transport.rpcTcRequestTimeout=30000
transport.threadFactory.bossThreadPrefix=NettyBoss
transport.threadFactory.workerThreadPrefix=NettyServerNIOWorker
transport.threadFactory.serverExecutorThreadPrefix=NettyServerBizHandler
transport.threadFactory.shareBossWorker=false
transport.threadFactory.clientSelectorThreadPrefix=NettyClientSelector
transport.threadFactory.clientSelectorThreadSize=1
transport.threadFactory.clientWorkerThreadPrefix=NettyClientWorkerThread
transport.threadFactory.bossThreadSize=1
transport.threadFactory.workerThreadSize=default
transport.shutdown.wait=3
transport.serialization=seata
transport.compressor=none

#Transaction routing rules configuration, only for the client
service.vgroupMapping.default_tx_group=default
#If you use a registry, you can ignore it
service.default.grouplist=127.0.0.1:8091
service.enableDegrade=false
service.disableGlobalTransaction=false

#Transaction rule configuration, only for the client
client.rm.asyncCommitBufferLimit=10000
client.rm.lock.retryInterval=10
client.rm.lock.retryTimes=30
client.rm.lock.retryPolicyBranchRollbackOnConflict=true
client.rm.reportRetryCount=5
client.rm.tableMetaCheckEnable=true
client.rm.tableMetaCheckerInterval=60000
client.rm.sqlParserType=druid
client.rm.reportSuccessEnable=false
client.rm.sagaBranchRegisterEnable=false
client.rm.sagaJsonParser=fastjson
client.rm.tccActionInterceptorOrder=-2147482648
client.tm.commitRetryCount=5
client.tm.rollbackRetryCount=5
client.tm.defaultGlobalTransactionTimeout=60000
client.tm.degradeCheck=false
client.tm.degradeCheckAllowTimes=10
client.tm.degradeCheckPeriod=2000
client.tm.interceptorOrder=-2147482648
client.undo.dataValidation=true
client.undo.logSerialization=jackson
client.undo.onlyCareUpdateColumns=true
server.undo.logSaveDays=7
server.undo.logDeletePeriod=86400000
client.undo.logTable=undo_log
client.undo.compress.enable=true
client.undo.compress.type=zip
client.undo.compress.threshold=64k
#For TCC transaction mode
tcc.fence.logTableName=tcc_fence_log
tcc.fence.cleanPeriod=1h

#Log rule configuration, for client and server
log.exceptionRate=100

#Transaction storage configuration, only for the server. The file, DB, and redis configuration values are optional.
store.mode=db
store.lock.mode=file
store.session.mode=file
#Used for password encryption
store.publicKey=

#If `store.mode,store.lock.mode,store.session.mode` are not equal to `file`, you can remove the configuration block.
store.file.dir=file_store/data
store.file.maxBranchSessionSize=16384
store.file.maxGlobalSessionSize=512
store.file.fileWriteBufferCacheSize=16384
store.file.flushDiskMode=async
store.file.sessionReloadReadSize=100

#These configurations are required if the `store mode` is `db`. If `store.mode,store.lock.mode,store.session.mode` are not equal to `db`, you can remove the configuration block.
store.db.datasource=druid
store.db.dbType=mysql
store.db.driverClassName=com.mysql.jdbc.Driver
store.db.url=jdbc:mysql://127.0.0.1:3306/seata?useUnicode=true&rewriteBatchedStatements=true
store.db.user=root
store.db.password=root
store.db.minConn=5
store.db.maxConn=30
store.db.globalTable=global_table
store.db.branchTable=branch_table
store.db.distributedLockTable=distributed_lock
store.db.queryLimit=100
store.db.lockTable=lock_table
store.db.maxWait=5000

#These configurations are required if the `store mode` is `redis`. If `store.mode,store.lock.mode,store.session.mode` are not equal to `redis`, you can remove the configuration block.
store.redis.mode=single
store.redis.single.host=127.0.0.1
store.redis.single.port=6379
store.redis.sentinel.masterName=
store.redis.sentinel.sentinelHosts=
store.redis.maxConn=10
store.redis.minConn=1
store.redis.maxTotal=100
store.redis.database=0
store.redis.password=
store.redis.queryLimit=100

#Transaction rule configuration, only for the server
server.recovery.committingRetryPeriod=1000
server.recovery.asynCommittingRetryPeriod=1000
server.recovery.rollbackingRetryPeriod=1000
server.recovery.timeoutRetryPeriod=1000
server.maxCommitRetryTimeout=-1
server.maxRollbackRetryTimeout=-1
server.rollbackRetryTimeoutUnlockEnable=false
server.distributedLockExpireTime=10000
server.xaerNotaRetryTimeout=60000
server.session.branchAsyncQueueSize=5000
server.session.enableBranchAsyncRemove=false

#Metrics configuration, only for the server
metrics.enabled=false
metrics.registryType=compact
metrics.exporterList=prometheus
metrics.exporterPrometheusPort=9898

直接粘贴到nacos的配置中:

项目中application文件配置,这个跟原来的基本一致,seata除了弃用了一些配置项,别的没什么变化

plain 复制代码
server:
  port: 8028

spring:
  application:
    name: tcc-order-service
  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848

  datasource:
    type: com.alibaba.druid.pool.DruidDataSource
    druid:
      driver-class-name: com.mysql.jdbc.Driver
      url: jdbc:mysql://localhost:3306/db_tcc_order?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
      username: root
      password: root
      initial-size: 10
      max-active: 100
      min-idle: 10
      max-wait: 60000
      pool-prepared-statements: true
      max-pool-prepared-statement-per-connection-size: 20
      time-between-eviction-runs-millis: 60000
      min-evictable-idle-time-millis: 300000
      test-while-idle: true
      test-on-borrow: false
      test-on-return: false
      stat-view-servlet:
        enabled: true
        url-pattern: /druid/*
      filter:
        stat:
          log-slow-sql: true
          slow-sql-millis: 1000
          merge-sql: false
        wall:
          config:
            multi-statement-allow: true

logging:
  level:
    com.tuling: debug

seata:
  application-id: ${spring.application.name}
  # seata 服务分组,要与服务端配置service.vgroup_mapping的后缀对应
  tx-service-group: default_tx_group
  registry:
    # 指定nacos作为注册中心
    type: nacos
    nacos:
      application: seata-server
      server-addr: 127.0.0.1:8848
      namespace:
      group: SEATA_GROUP

  config:
    # 指定nacos作为配置中心
    type: nacos
    nacos:
      server-addr: 127.0.0.1:8848
      namespace: 7e838c12-8554-4231-82d5-6d93573ddf32
      group: SEATA_GROUP
      data-id: seataServer.properties

#暴露actuator端点
management:
  endpoints:
    web:
      exposure:
        include: '*'

2.3.案例

以一个下单做为例子,主要步骤为:1.创建订单、2.扣减库存、3.扣减余额

以BusinessServiceImpl做为聚合服务去调用OrderServiceImpl,StorageFeignServiceImpl,AccountFeignServiceImpl。

2.3.1.BusinessServiceImpl聚合业务类

全局事务提交的入口,在方法 saveOrder() 上贴@GlobalTransactional注解

在saveOrder会分别去调用订单服务创建订单、库存服务扣减库存、账户服务扣减余额

java 复制代码
package com.qingzhou.tccorderservice.service.impl;

import com.qingzhou.datasource.entity.Order;
import com.qingzhou.tccorderservice.feign.AccountFeignService;
import com.qingzhou.tccorderservice.feign.StorageFeignService;
import com.qingzhou.tccorderservice.service.BussinessService;
import com.qingzhou.tccorderservice.service.OrderService;
import com.qingzhou.tccorderservice.util.UUIDGenerator;
import com.qingzhou.tccorderservice.vo.OrderVo;
import io.seata.core.context.RootContext;
import io.seata.spring.annotation.GlobalTransactional;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

/**
* @author qingzhou
*/
@Service
@Slf4j
public class BusinessServiceImpl implements BussinessService {

    @Autowired
    private AccountFeignService accountFeignService;

    @Autowired
    private StorageFeignService storageFeignService;

    @Autowired
    private OrderService orderService;


    @Override
    @GlobalTransactional(name="createOrder",rollbackFor=Exception.class)
    public Order saveOrder(OrderVo orderVo) {
        log.info("=============用户下单=================");
        log.info("当前 XID: {}", RootContext.getXID());

        //获取全局唯一订单号
        Long orderId = UUIDGenerator.generateUUID();
        //阶段一: 创建订单
        Order order = orderService.prepareSaveOrder(orderVo,orderId);
        //扣减库存
        storageFeignService.deduct(orderVo.getCommodityCode(), orderVo.getCount());
        //扣减余额
        accountFeignService.debit(orderVo.getUserId(), orderVo.getMoney());
        return order;
    }
}

2.3.2.OrderService订单服务

订单服务接口定义两阶段方法

java 复制代码
package com.qingzhou.tccorderservice.service;

import com.qingzhou.datasource.entity.Order;
import com.qingzhou.tccorderservice.vo.OrderVo;
import io.seata.rm.tcc.api.BusinessActionContext;
import io.seata.rm.tcc.api.BusinessActionContextParameter;
import io.seata.rm.tcc.api.LocalTCC;
import io.seata.rm.tcc.api.TwoPhaseBusinessAction;

/**
 * @author qingzhou
 *
 * 通过 @LocalTCC 这个注解,RM 初始化的时候会向 TC 注册一个分支事务。
 */
@LocalTCC
public interface OrderService {

    /**
     * TCC的try方法:保存订单信息,状态为支付中
     *
     * 定义两阶段提交,在try阶段通过@TwoPhaseBusinessAction注解定义了分支事务的 resourceId,commit和 cancel 方法
     *  name = 该tcc的bean名称,全局唯一
     *  commitMethod = commit 为二阶段确认方法
     *  rollbackMethod = rollback 为二阶段取消方法
     *  BusinessActionContextParameter注解 传递参数到二阶段中
     *  useTCCFence seata1.5.1的新特性,用于解决TCC幂等,悬挂,空回滚问题,需增加日志表tcc_fence_log
     */
    @TwoPhaseBusinessAction(name = "prepareSaveOrder", commitMethod = "commit", rollbackMethod = "rollback", useTCCFence = true)
    Order prepareSaveOrder(OrderVo orderVo, @BusinessActionContextParameter(paramName = "orderId") Long orderId);

    /**
     *
     * TCC的confirm方法:订单状态改为支付成功
     *
     * 二阶段确认方法可以另命名,但要保证与commitMethod一致
     * context可以传递try方法的参数
     *
     * @param actionContext
     * @return
     */
    boolean commit(BusinessActionContext actionContext);

    /**
     * TCC的cancel方法:订单状态改为支付失败
     * 二阶段取消方法可以另命名,但要保证与rollbackMethod一致
     *
     * @param actionContext
     * @return
     */
    boolean rollback(BusinessActionContext actionContext);
}

订单服务接口两阶段方法实现类。这里的实现,需要根据我们实际的业务去做处理,不同的业务实现不尽相同。

我们这里对订单的处理是加多一个字段为订单状态。

两阶段处理流程:

1.订单在一阶段修改为初始状态。

2.如果后续其他业务的try(一阶段)执行没什么问题,则二阶段框架会调用 commit 方法,把订单状态修改为下单成功。

3.如果后续其他业务的try(一阶段)执行异常,则二阶段框架会调用 rollback 方法,把订单状态修改为下单失败。

java 复制代码
package com.qingzhou.tccorderservice.service.impl;

import com.qingzhou.datasource.entity.Order;
import com.qingzhou.datasource.entity.OrderStatus;
import com.qingzhou.datasource.mapper.OrderMapper;
import com.qingzhou.tccorderservice.feign.AccountFeignService;
import com.qingzhou.tccorderservice.feign.StorageFeignService;
import com.qingzhou.tccorderservice.service.OrderService;
import com.qingzhou.tccorderservice.vo.OrderVo;
import io.seata.rm.tcc.api.BusinessActionContext;
import io.seata.rm.tcc.api.BusinessActionContextParameter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;


/**
 * @author qingzhou
 */
@Service
@Slf4j
public class OrderServiceImpl implements OrderService {
    
    @Autowired
    private OrderMapper orderMapper;
    
    @Autowired
    private AccountFeignService accountFeignService;
    
    @Autowired
    private StorageFeignService storageFeignService;

    @Autowired
    private OrderService orderService;


    @Override
    @Transactional(rollbackFor=Exception.class)
    public Order prepareSaveOrder(OrderVo orderVo,
                                  @BusinessActionContextParameter(paramName = "orderId") Long orderId) {

        // 保存订单
        Order order = new Order();
        order.setId(orderId);
        order.setUserId(orderVo.getUserId());
        order.setCommodityCode(orderVo.getCommodityCode());
        order.setCount(orderVo.getCount());
        order.setMoney(orderVo.getMoney());
        order.setStatus(OrderStatus.INIT.getValue());
        Integer saveOrderRecord = orderMapper.insert(order);
        log.info("保存订单{}", saveOrderRecord > 0 ? "成功" : "失败");

        return order;
    }

    @Override
    public boolean commit(BusinessActionContext actionContext) {
        // 获取订单id
        long orderId = Long.parseLong(actionContext.getActionContext("orderId").toString());
        //更新订单状态为支付成功
        Integer updateOrderRecord = orderMapper.updateOrderStatus(orderId,OrderStatus.SUCCESS.getValue());
        log.info("更新订单id:{} {}", orderId, updateOrderRecord > 0 ? "成功" : "失败");

        return true;
    }

    @Override
    public boolean rollback(BusinessActionContext actionContext) {
        //获取订单id
        long orderId = Long.parseLong(actionContext.getActionContext("orderId").toString());
        //更新订单状态为支付失败
        Integer updateOrderRecord = orderMapper.updateOrderStatus(orderId,OrderStatus.FAIL.getValue());
        log.info("更新订单id:{} {}", orderId, updateOrderRecord > 0 ? "成功" : "失败");

        return true;
    }


}

2.3.3.StorageService库存服务

库存服务接口,定义两阶段提交接口

java 复制代码
package com.qingzhou.tccstorageservice.service;

import io.seata.rm.tcc.api.BusinessActionContext;
import io.seata.rm.tcc.api.BusinessActionContextParameter;
import io.seata.rm.tcc.api.LocalTCC;
import io.seata.rm.tcc.api.TwoPhaseBusinessAction;

/**
 * @author qingzhou
 *
 * 通过 @LocalTCC 这个注解,RM 初始化的时候会向 TC 注册一个分支事务。
 */
@LocalTCC
public interface StorageService {

    /**
     * Try: 库存-扣减数量,冻结库存+扣减数量
     *
     * 定义两阶段提交,在try阶段通过@TwoPhaseBusinessAction注解定义了分支事务的 resourceId,commit和 cancel 方法
     *  name = 该tcc的bean名称,全局唯一
     *  commitMethod = commit 为二阶段确认方法
     *  rollbackMethod = rollback 为二阶段取消方法
     *  BusinessActionContextParameter注解 传递参数到二阶段中
     *
     * @param commodityCode 商品编号
     * @param count 扣减数量
     * @return
     */
    @TwoPhaseBusinessAction(name = "deduct", commitMethod = "commit", rollbackMethod = "rollback", useTCCFence = true)
    boolean deduct(@BusinessActionContextParameter(paramName = "commodityCode") String commodityCode,
                   @BusinessActionContextParameter(paramName = "count") int count);

    /**
     *
     * Confirm: 冻结库存-扣减数量
     * 二阶段确认方法可以另命名,但要保证与commitMethod一致
     * context可以传递try方法的参数
     *
     * @param actionContext
     * @return
     */
    boolean commit(BusinessActionContext actionContext);

    /**
     * Cancel: 库存+扣减数量,冻结库存-扣减数量
     * 二阶段取消方法可以另命名,但要保证与rollbackMethod一致
     *
     * @param actionContext
     * @return
     */
    boolean rollback(BusinessActionContext actionContext);
}

库存服务两阶段提交接口实现

处理方式,加多一个库存冻结字段 freeze_count

库存服务两阶段处理流程:

1.一阶段先对库存进行冻结:UPDATE storage_tbl SET count = count - #{count},freeze_count=freeze_count+#{count}

2.如果后续其他业务的try(一阶段)执行没问题,则二阶段框架会调用 commit 方法,把冻结的库存给释放掉:UPDATE storage_tbl SET freeze_count=freeze_count-#{count}

3.如果后续其他业务的try(一阶段)执行异常,则二阶段框架会调用 rollback 方法,把扣减的库存和冻结的一并回滚:UPDATE storage_tbl SET count = count + #{count},freeze_count=freeze_count-#{count}

java 复制代码
package com.qingzhou.tccstorageservice.service.impl;

import com.qingzhou.datasource.entity.Storage;
import com.qingzhou.datasource.mapper.StorageMapper;
import com.qingzhou.tccstorageservice.service.StorageService;
import io.seata.core.context.RootContext;
import io.seata.rm.tcc.api.BusinessActionContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
 * @author qingzhou
 */
@Service
@Slf4j
public class StorageServiceImpl implements StorageService {
    
    @Autowired
    private StorageMapper storageMapper;
    
    @Transactional
    @Override
    public boolean deduct(String commodityCode, int count){
        log.info("=============冻结库存=================");
        log.info("当前 XID: {}", RootContext.getXID());

        // 检查库存
        checkStock(commodityCode,count);
        
        log.info("开始冻结 {} 库存", commodityCode);
        //冻结库存
        Integer record = storageMapper.freezeStorage(commodityCode,count);
        log.info("冻结 {} 库存结果:{}", commodityCode, record > 0 ? "操作成功" : "扣减库存失败");
        return true;
    }

    @Override
    public boolean commit(BusinessActionContext actionContext) {
        log.info("=============扣减冻结库存=================");

        String commodityCode = actionContext.getActionContext("commodityCode").toString();
        int count = (int) actionContext.getActionContext("count");
        //扣减冻结库存
        storageMapper.reduceFreezeStorage(commodityCode,count);

        return true;
    }

    @Override
    public boolean rollback(BusinessActionContext actionContext) {
        log.info("=============解冻库存=================");

        String commodityCode = actionContext.getActionContext("commodityCode").toString();
        int count = (int) actionContext.getActionContext("count");
        //扣减冻结库存
        storageMapper.unfreezeStorage(commodityCode,count);

        return true;
    }

    private void checkStock(String commodityCode, int count){
        
        log.info("检查 {} 库存", commodityCode);
        Storage storage = storageMapper.findByCommodityCode(commodityCode);
        if (storage.getCount() < count) {
            log.warn("{} 库存不足,当前库存:{}", commodityCode, count);
            throw new RuntimeException("库存不足");
        }
        
    }
    
}

2.3.4.AccountService账户服务

账户服务接口,定义两阶段提交接口

java 复制代码
package com.qingzhou.tccaccountservice.service;

import io.seata.rm.tcc.api.BusinessActionContext;
import io.seata.rm.tcc.api.BusinessActionContextParameter;
import io.seata.rm.tcc.api.LocalTCC;
import io.seata.rm.tcc.api.TwoPhaseBusinessAction;

/**
 * @author qingzhou
 *
 * 通过 @LocalTCC 这个注解,RM 初始化的时候会向 TC 注册一个分支事务。
 */
@LocalTCC
public interface AccountService {

    /**
     * 用户账户扣款
     *
     * 定义两阶段提交,在try阶段通过@TwoPhaseBusinessAction注解定义了分支事务的 resourceId,commit和 cancel 方法
     *  name = 该tcc的bean名称,全局唯一
     *  commitMethod = commit 为二阶段确认方法
     *  rollbackMethod = rollback 为二阶段取消方法
     *
     * @param userId
     * @param money 从用户账户中扣除的金额
     * @return
     */
    @TwoPhaseBusinessAction(name = "debit", commitMethod = "commit", rollbackMethod = "rollback", useTCCFence = true)
    boolean debit(@BusinessActionContextParameter(paramName = "userId") String userId,
                  @BusinessActionContextParameter(paramName = "money") int money);

    /**
     * 提交事务,二阶段确认方法可以另命名,但要保证与commitMethod一致
     * context可以传递try方法的参数
     *
     * @param actionContext
     * @return
     */
    boolean commit(BusinessActionContext actionContext);

    /**
     * 回滚事务,二阶段取消方法可以另命名,但要保证与rollbackMethod一致
     *
     * @param actionContext
     * @return
     */
    boolean rollback(BusinessActionContext actionContext);
}

账户服务两阶段提交接口实现

处理方式,加多一个冻结金额字段 freeze_money

账户服务两阶段处理流程:

1.一阶段先对账户余额进行冻结:update account_tbl set money=money-#{money},freeze_money=freeze_money+#{money}

2.如果后续其他业务的try(一阶段)执行没问题,则二阶段框架会调用 commit 方法,把冻结的金额给释放掉:update account_tbl set freeze_money=freeze_money-#{money}

3.如果后续其他业务的try(一阶段)执行异常,则二阶段框架会调用 rollback 方法,把扣减的金额和冻结的一并回滚:update account_tbl set money=money+#{money},freeze_money=freeze_money-#{money}

java 复制代码
package com.qingzhou.tccaccountservice.service.impl;

import com.qingzhou.datasource.entity.Account;
import com.qingzhou.datasource.mapper.AccountMapper;
import com.qingzhou.tccaccountservice.service.AccountService;
import io.seata.core.context.RootContext;
import io.seata.rm.tcc.api.BusinessActionContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;


/**
 * @author qingzhou
 */
@Service
@Slf4j
public class AccountServiceImpl implements AccountService {
    

    @Autowired
    private AccountMapper accountMapper;
    
    /**
     * 扣减用户金额
     * @param userId
     * @param money
     */
    @Transactional
    @Override
    public boolean debit(String userId, int money){
        log.info("=============冻结用户账户余额=================");
        log.info("当前 XID: {}", RootContext.getXID());
    
        checkBalance(userId, money);
        
        log.info("开始冻结用户 {} 余额", userId);
        //冻结金额
        Integer record = accountMapper.freezeBalance(userId,money);

        log.info("冻结用户 {} 余额结果:{}", userId, record > 0 ? "操作成功" : "扣减余额失败");
        return true;
    }

    @Override
    public boolean commit(BusinessActionContext actionContext) {
        log.info("=============扣减冻结金额=================");

        String userId = actionContext.getActionContext("userId").toString();
        int money = (int) actionContext.getActionContext("money");
        //扣减冻结金额
        accountMapper.reduceFreezeBalance(userId,money);

        return true;
    }

    @Override
    public boolean rollback(BusinessActionContext actionContext) {
        log.info("=============解冻金额=================");

        String userId = actionContext.getActionContext("userId").toString();
        int money = (int) actionContext.getActionContext("money");
        //解冻金额
        accountMapper.unfreezeBalance(userId,money);

        return true;
    }

    private void checkBalance(String userId, int money){
        log.info("检查用户 {} 余额", userId);
        Account account = accountMapper.selectByUserId(userId);
        
        if (account.getMoney() < money) {
            log.warn("用户 {} 余额不足,当前余额:{}", userId, account.getMoney());
            throw new RuntimeException("余额不足");
        }
        
    }
}

以上为主要的代码逻辑,完整代码 link 自取:pan.baidu.com/s/1csd9l_g-... T取码:qmo3

3.TCC 模式存在的问题

seata 的 TCC 模式存在一些异常场景会导致出现空回滚、幂等、悬挂等问题。TCC 模式是分布式事务中非常重要的事务模式,但是幂等、悬挂和空回滚一直是TCC 模式需要考虑的问题,在1.5.1之前的版本需要我们自己加个表去解决,并且需要在编码阶段去处理,侵入性大并且也增加了开发难度。在 1.5.1 版本开始,seata帮我们解决了这些问题。方式就是添加 tcc_fence_log 事务控制表。

3.1.幂等性问题

TC执行confirm或cancel后,因为网络问题,没有收到RM返回的通知,TC会以为没有执行成功,这时候TC就会再次进行调用confirm或cancel,多次对数据做修改,导致幂等性问题。

同样的也是在 TCC 事务控制表中增加一个记录状态的字段 status,该字段有 3 个值,分别为:

\1. tried:1

\2. committed:2

\3. rollbacked:3

二阶段 Confirm/Cancel 方法执行后,将状态改为 committed 或 rollbacked 状态。当重复调用二阶段 Confirm/Cancel 方法时,判断事务状态即可解决幂等问题。

3.2.悬挂问题

悬挂简单点理解就是 cancel 比 try 先执行,造成 try 的资源无法回滚。

场景:try 执行的比较慢,导致调用 try 的服务超时了,这时候TC就去调了 cancel,调完 cancel 后 try 执行成功了,这时候 try 的资源无法回滚。

如上图所示,在执行参与者 A 的一阶段 Try 方法时,出现网路拥堵,由于 Seata 全局事务有超时限制,执行 Try 方法超时后,TM 决议全局回滚,回滚完成后如果此时 RPC 请求才到达参与者 A,执行 Try 方法进行资源预留,从而造成悬挂。

解决方案:这种情况不能够让 try 执行成功,因为只要 try 执行成功了就没法回滚了。

Seata 处理悬挂问题:

在 TCC 事务控制表记录状态的字段 status 中增加一个状态:suspended:4

当执行二阶段 Cancel 方法时,如果发现 TCC 事务控制表有相关记录,说明二阶段 Cancel 方法优先一阶段 Try 方法执行,因此插入一条 status=4 状态的记录,当一阶段 Try 方法后面执行时,判断 status=4 ,则说明有二阶段 Cancel 已执行,并返回 false 以阻止一阶段 Try 方法执行成功。

3.3.空回滚问题

空回滚指的是在一个分布式事务中,在没有调用参与方的 Try 方法的情况下,TM 驱动二阶段回滚调用了参与方的 Cancel 方法。

解决方案:要想防止空回滚,那么必须在 Cancel 方法中识别这是一个空回滚,在二阶段执行回滚 rollback 的时候,需要先检查一阶段是否有执行过 try 方法,如果执行过才能执行回滚 rollback 方法,如果没有执行过就不任何操作,Seata 的做法是新增一个 TCC 事务控制表 tcc_fence_log,在 try 阶段执行成功后在 tcc_fence_log 表中插入一条记录,在 rollback 时去查询 tcc_fence_log 表是否有 try 阶段执行成功的记录,如果有,才会执行 rollback,如果不存在记录说明 Try 方法没有执行,则不再执行 rollback 方法。

相关推荐
aloha_78940 分钟前
B站宋红康JAVA基础视频教程(chapter14数据结构与集合源码)
java·数据结构·spring boot·算法·spring cloud·mybatis
周湘zx3 小时前
k8s中的微服务
linux·运维·服务器·微服务·云原生·kubernetes
mingzhi615 小时前
应届生必看 | 毕业第一份工作干销售好不好?
网络·web安全·面试
八了个戒5 小时前
【TypeScript入坑】TypeScript 的复杂类型「Interface 接口、class类、Enum枚举、Generics泛型、类型断言」
开发语言·前端·javascript·面试·typescript
Pandaconda7 小时前
【计算机网络 - 基础问题】每日 3 题(十)
开发语言·经验分享·笔记·后端·计算机网络·面试·职场和发展
你知道“铁甲小宝”吗丶9 小时前
【第33章】Spring Cloud之SkyWalking服务链路追踪
java·spring boot·spring·spring cloud·skywalking
ღ᭄ꦿ࿐Never say never꧂9 小时前
微服务架构中的负载均衡与服务注册中心(Nacos)
java·spring boot·后端·spring cloud·微服务·架构·负载均衡
测试老哥9 小时前
功能测试干了三年,快要废了。。。
自动化测试·软件测试·python·功能测试·面试·职场和发展·压力测试
写bug写bug10 小时前
6 种服务限流的实现方式
java·后端·微服务