Seata分布式事务解决方案详解

分布式事务现存方案

什么是分布式事务,这里就不做解释了,介绍一下下面的常用分布式事务解决方案

Seata分布式事务框架:阿里巴巴2019年开源的分布式事务解决方案。

AT,TCC,SAGA,XA。本文会详细分析AT和TCC原理以及对比,SAGA和XA暂时不在本文讨论中,后续会补上。提一嘴,Saga不存在并发执行问题,因为Saga本质上是一个责任链模式,在同一个线程上有严格的先后执行驱动顺序。

RocketMQ柔性事务,在国内绝大多数的互联网公司里,一般来说,重要的业务系统,一般都是使用mq柔性事务,大多情况下,一般来说都能确保数据是一致的

社区活跃度

Seata在19年初开源,一经发布,社区活跃度一路走高,因为其低侵入性的特性而大受欢迎。从开源地址来看目前seata在市面上的使用及活跃度是非常高的。

Seata分布式事务解决方案详解_第1张图片

Seata分布式方案实现成本分析

这里只对AT和TCC做分析

AT 模式是一种无侵入的分布式事务解决方案。在 AT 模式下,用户只需关注自己的“业务 SQL”,用户的 “业务 SQL” 作为一阶段,Seata 框架会自动生成事务的二阶段提交和回滚操作,使用成本很低,只需要安装seata server,然后在pom文件引用seata-all的包,在yml写上对应的配置即可,AT模式上游服务方法加上注解即可,适用于不希望对业务进行改造的场景,几乎 0 学习成本

但是其实这类统一的分布式事物的框架都不太稳定,所以基本上大家要么选用TCC方式去实现,要么就消息队列去保证最终一致性。但后两者都额外的增加了回归或实现最终一致性的成本,并且使用TCC方案会引出 TCC 的悬挂,幂等,空回滚等极端问题,并且TCC 对业务的侵入性很强,而且这种模式并不能很好地被复用,会导致开发量激增,这个也是TCC难用的地方。

除特定的数据强一致性场景(如金融),可能会针对事务做很多的工作。大部分项目对分布式事务的要求并不是很高,能不用尽量不用,解决分布式事务最好的方案就是不要导致分布式事务的产生。

AT和TCC模式对比

AT TCC
全局锁 需要 不需要
undo_log回滚日志 需要 不需要
commit/cancel阶段代码实现 不需要 需要
是否需要开发者解决悬挂和空回滚问题 不需要 需要
性能 低(高并发时的全局锁) 高(无锁)

Seata AT模式

Seata AT模式分布式事务原理

在每个服务对应的库里,都建好undo_log表,比如我们执行一个插入语句,seata会对应的去生成一个逆向操作的回滚日志。

我们的增删改操作和seata回滚日志的生成,会绑定在一个本地事务里提交,要么一起成功,要么一起失败。

上游服务开启一个全局事务,调用下游服务的时候,rpc调用会传递xid給下游服务,下游服务也会去注册分支事务branch_id,下游本地事务提交成功以后,会通知seata server分支事务成功

如果下游事务失败,会通知seata server分支事务失败,然后seata server会通知每个服务的seata客户端进行回滚,根据MySql里的undo log表进行逆向操作。

Seata分布式事务解决方案详解_第2张图片

Seata AT模式分布式事务读写隔离原理

没有全局锁就会出现数据错乱,分布式事务1回滚补偿,但是补偿的数据已经被分布式事务2篡改了。

读隔离:在全局层面,是读未提交,本地事务提交了,但是全局事务还没提交,所以是一个未提交的状态,这时候另一个分布式事务2过来了,可以查询到没有提交的分布式事务1的已经提交的本地事务更新的数据。

Seata分布式事务解决方案详解_第3张图片

Seata本地锁与全局锁的死锁问题以及超时机制

分布式事务1根据之前的undo log 执行逆向操作sql进行补偿,但是需要去获取11001的本地锁,但是此时本地锁被分布式事务2持有,并且它在等待获取全局锁提交事务释放本地锁,但是全局锁又被分布式事务1持有着,典型的死锁。

Seata为了解决死锁,设置了超时机制,超过一定时间还没获取到全局锁,就任务本次分布式事务失败,本地事务回滚。

全局锁的存在,导致AT模式在高并发情况下,会出现吞吐量降低的情况,比如举个例子

生单链路中,用户发起一个生单请求,上游订单服务调用下游库存服务,但是一个sku在库存表里对应一条数据,如果大量的用户同时对同一个sku发起生单请求,那么就会出现某一个库存sku id的全局锁竞争的情况了,导致吞吐量大大降低,这也是AT模式的不足之处。

TCC

TCC是业务层面的分布式事务,最终一致性,不会一直持有资源的全局锁。

try(预留一些资源,实际的业务动作并没有执行)、commit(实际的业务动作执行)、cancel(把预留的资源做一个逆向补偿,取消资源的预留)

Seata AT模式在高并发的情况下,由于全局锁的概念,可能导致吞吐量降低(超时机制保证了不会出现死锁问题)

AT模式有点像自动挡,加个注解,加一张undo_log表就不需要管了,刚性事务,要么全都成功,要么全都失败。

用不要有全局锁的分布式事务方案,TCC,手动挡,不依赖于底层数据资源的事务支持。

Seata分布式事务解决方案详解_第4张图片

TCC模式可能会存在的问题

三者都只是有可能会产生,不是必然事件,但是我们代码中,需要去做防御性编程处理这三种情况,才能使我们的TCC代码足够的健壮

简单来说就是,try可能会出现一些问题,导致卡住了没好好去执行,seata server可能认为这个try没有好好去执行,让你先跑一个cancel回滚操作,这个可能性也是存在的,但是此时你这个try并没有执行,所以这个回滚,是个空回滚,然后等你空回滚完了,try又执行了,然后你的资源就被try悬挂起来了。

还有一种场景,比方说,先跑了一个try,seata server认为try已经成功了,但是其实try并没有成功,然后第二个分支事务失败了,会通知第一个分支事务回滚,这时候就会在没有try成功的情况下出现一个空回滚

悬挂

1.4.2版本之后增加了TCC防悬挂措施,需要数据源支持。

其实就是有一些资源被悬挂起来后续无法处理了

  1. 发起方通过RPC调用参与者一阶段Try,但是发生网络阻塞导致RPC超时
  2. RPC超时后,TC会回滚分布式事务(可能是发起方主动通知TC回滚或者是TC发现事务超时后回滚),调用已注册的各个参与方的二阶段Cancel
  3. 参与方空回滚后,发起方对参与者的一阶段Try才开始执行,进行资源预留从而形成悬挂

空回滚

当没有调用参与方Try方法的情况下,就调用了二阶段的Cancel方法,

Cancel方法需要有办法识别出此时Try有没有执行。如果Try还没执行,

表示这个Cancel操作是无效的,即本次Cancel属于空回滚。

如果Try已经执行,那么执行的是正常的回滚逻辑。

二阶段重试幂等

try成功了以后,seata server会通知分支事务执行commit,但是如果commit失败了,它会不断的进行重试,cancel同理。

只要有重试,那么就要保证commit,cancel幂等。

Seata AT&&TCC实现方案

AT模式

每个服务对应的库里,都要加入一张表undo_log,以提供给seata执行逆向操作

  CREATE TABLE `undo_log` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `branch_id` bigint NOT NULL COMMENT '分支事务id',
  `xid` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '全局事务id',
  `context` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '上下文',
  `rollback_info` longblob NOT NULL COMMENT '回滚日志',
  `log_status` int NOT NULL COMMENT '日志状态',
  `log_created` datetime NOT NULL COMMENT '创建时间',
  `log_modified` datetime NOT NULL COMMENT '更新时间',
  `ext` varchar(100) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;

上游服务方法加注解 @GlobalTransactional(rollbackFor = Exception.class)

下游被rpc调用的方法,加上本地spring事务注解 @Transactional(rollbackFor = Exception.class)

上游服务会去跟seata server注册全局事务xid,然后rpc调用的时候将xid传递给下游服务,下游服务获取本地锁,执行本地事务,插入undo log,向seata server申请分支事务branchId,然后申请全局锁,提交本地事务释放本地锁,通知seata server分支事务成功/失败。

下游服务分支事务全都执行成功后,上游服务向seata server提交全局事务,全局事务提交成功后,各个分支事务删除xid+branch id的undo logs。

但是假设一个请求 基于seata对一条数据加了全局锁,并进行了本地分支事务提交。此时全局事务没有提交。另一个请求过来(这个请求就是简单的更新这条数据,不是分布式事务)自然就不需要获取全局锁。也就是可以直接更新。那全局事务再进行回滚时就有问题了,这也正是我们开发时需要考虑的地方,当有可能操作同一条数据引起并发问题的时候,给他加上全局事务,去获取全局锁。

分布式事务成功截图

Seata分布式事务解决方案详解_第5张图片 分布式事务回滚截图

Seata分布式事务解决方案详解_第6张图片

TCC模式

TCC不需要undo log表,但是针对空回滚问题,可以搞一张空回滚记录表,下文会仔细讲解。

调用方 方法可以打上全局事务注解,如果是AT+TCC混合模式,TCC这里不要打全局事务注解,在AT上游打上就行了,理解成二者为一个分布式事务。

  @GlobalTransactional(rollbackFor = Exception.class)

很多时候,预留资源的try并不好做,try可以直接做成实际业务操作,比如直接锁库存。

在 TCC interface接口层,写一个注解

  @LocalTCC

代表这是本地TCC接口

搞三个接口,try(预留一些资源,实际的业务动作并没有执行)、commit(实际的业务动作执行)、cancel(把预留的资源做一个逆向补偿,取消资源的预留)。

  @TwoPhaseBusinessAction(name = "tccInterfaceService", commitMethod = "commitMethodName", rollbackMethod = "rollbackMethodName")

try方法,上面打这个注解,name写你接口交给spring容器管理的bean名,commitMethod写上第二个commit方法名字,rollbackMethod写上执行cancel回滚方法的名字。

极端问题解决方案

上面已经知道,TCC方案会有极端情况出现(悬挂、空回滚、二阶段重试幂等),如何解决呢,我这边引用了TccResultHolder,存储TCC第一阶段执行结果,用于解决这些问题。

我们定义一个类 TccResultHolder,存储TCC第一阶段执行结果,用于解决TCC幂等,空回滚,悬挂问题。

类里面搞俩常量,标识TCC try阶段开始执行的标识以及标识TCC try阶段执行成功的标识,再定义一个ConcurrentHashMap用来保存TCC事务执行过程的状态。

      /**
     * 标识TCC try阶段开始执行的标识
     */
    private static final String TRY_START = "TRY_START";

    /**
     * 标识TCC try阶段执行成功的标识
     */
    private static final String TRY_SUCCESS = "TRY_SUCCESS";

    /**
     * 保存TCC事务执行过程的状态
     */
    private static Map, Map> map =
            new ConcurrentHashMap, Map>();

tagTryStart方法和tagTrySuccess方法用于标记try阶段开始执行和try阶段执行成功,把执行tcc的实现类,业务唯一标识(如sku),全局事务xid(通过BusinessActionContext获取xid)传进来即可。

  /**
 * 标记try阶段开始执行
 *
 * @param tccClass 执行tcc的实现类
 * @param bizKey   业务唯一标识
 * @param xid      全局事务xid
 */
public static void tagTryStart(Class tccClass, String bizKey, String xid) {
    setResult(tccClass, bizKey, xid, TRY_START);
}
/**
 * 标记try阶段执行成功
 *
 * @param tccClass 执行tcc的实现类
 * @param xid      全局事务xid
 */
public static void tagTrySuccess(Class tccClass, String bizKey, String xid) {
    setResult(tccClass, bizKey, xid, TRY_SUCCESS);
}
 /**
     * 判断标识是否为空
     *
     * @param tccClass
     * @param xid
     * @return
     */
    public static boolean isTagNull(Class tccClass, String bizKey, String xid) {
        String v = getResult(tccClass, bizKey, xid);
        if (StringUtils.isBlank(v)) {
            return true;
        }
        return false;
    }
    /**
     * 判断try阶段是否执行成功
     *
     * @param tccClass
     * @param xid
     * @return
     */
    public static boolean isTrySuccess(Class tccClass, String bizKey, String xid) {
        String v = getResult(tccClass, bizKey, xid);
        if (StringUtils.isNotBlank(v) && TRY_SUCCESS.equals(v)) {
            return true;
        }
        return false;
    }

public static void setResult(Class tccClass, String bizKey, String xid, String v) {
        Map results = map.get(tccClass);

        if (results == null) {
            synchronized (map) {
                if (results == null) {
                    results = new ConcurrentHashMap<>();
                    map.put(tccClass, results);
                }
            }
        }
        //保存当前分布式事务id
        results.put(getTccExecution(xid, bizKey), v);
    }

    public static String getResult(Class tccClass, String bizKey, String xid) {
        Map results = map.get(tccClass);
        if (results != null) {
            return results.get(getTccExecution(xid, bizKey));
        }

        return null;
    }


    public static void removeResult(Class tccClass, String bizKey, String xid) {
        Map results = map.get(tccClass);
        if (results != null) {
            results.remove(getTccExecution(xid, bizKey));
        }
    }
    private static String getTccExecution(String xid, String bizKey) {
        return xid + "::" + bizKey;
    }

try

在try阶段方法最开始的时候,执行tagTryStart方法,标识try阶段开始执行,通过map实现记录整个TCC执行的过程状态。

为了解决空回滚,可以在自己的库里再加一张表,tcc Class名称,xid,业务id,当发生空回滚的时候,就往这张表里插入一条记录。

解决空悬挂的思路:即当rollback接口出现空回滚时,需要打一个标识(在数据库中查一条记录),在try这里判断一下

封装一个isEmptyRollback方法,如果查询到有空回滚的记录,就return true。

  if (isEmptyRollback()) {
    // 移除TCC标记
    TccResultHolder.removeResult(getClass(), skuCode, xid);
    return false;
}

在try方法结束的时候,标记一下try成功

  // 标识try阶段执行成功
TccResultHolder.tagTrySuccess(getClass(), skuCode, xid);

commit

在commit方法中,加入如下代码,判断holder中是否记录try成功记录,如果没有就return

  // 当出现网络异常或者TC Server异常时,会出现重复调用commit阶段的情况,所以需要进行幂等操作
if (!TccResultHolder.isTrySuccess(getClass(), skuCode, xid)) {
    return;
}

然后在commit方法结束,调用remove方法,移除TCC标记

  // 移除标识
TccResultHolder.removeResult(getClass(), skuCode, xid);

rollback

在rollback方法中,判断holder中是否有try记录,如果没有的话,说明此时出现了空回滚,往上面提到的空回滚表中加入一条记录,然后再return,不需要rollback了。

  // 空回滚处理
if (TccResultHolder.isTagNull(getClass(), skuCode, xid)) {
    log.info("mysql:出现空回滚");
    insertEmptyRollbackTag();
    return;
}
  // try阶段没有完成的情况下,不必执行回滚,因为try阶段有本地事务,事务失败时已经进行了回滚
// 如果try阶段成功,而其他全局事务参与者失败,这里会执行回滚
if (!TccResultHolder.isTrySuccess(getClass(), skuCode, xid)) {
    // 移除标识
    TccResultHolder.removeResult(getClass(), skuCode, xid);
    log.info("mysql:无需回滚");
    insertEmptyRollbackTag();
    return;
}

然后在rollback方法结束,调用remove方法,移除TCC标记

  // 移除标识
TccResultHolder.removeResult(getClass(), skuCode, xid);

Seata使用踩坑注意事项

假设一个请求基于 seata 对一条数据加了全局锁,并进行了本地分支事务提交。此时全局事务没有提交,另一个请求过来(这个请求就是简单的更新这条数据,不是分布式事务)自然就不需要获取全局锁,也就是可以直接更新,那全局事务再进行回滚时就有问题了,这也正是我们开发时需要考虑的地方,当有可能操作同一条数据引起并发问题的时候,给他加上全局事务,去获取全局锁。

还有听说有公司之前用Seata这个框架,生产上出大事故,导致账单数据经常丢失,而且经常出现死锁。负责开发的兄弟加班了几个月,每天晚上加班修复数据,后面弃用了seata,就没出现过这种事故了。因为公司经历过的这个事,导致那哥们儿对 Seata心存疑虑。

Dubbo+ZK整合Seata处理分布式事务

目前现有的落地方案都是spring cloud alibaba+nacos整合的seata,由于公司的技术栈为dubbo+zookeeper,并且seata官网并未给出zk整合seata的具体实现,所以我这里给出下面的方案重新整合。

Seata分布式事务解决方案详解_第7张图片

1. 本地安装seata server

下载 https://github.com/seata/seata/releases/download/v1.3.0/seata-server-1.3.0.zip

这里跟项目中的seata客户端依赖版本保持一致(Seata 1.4.2版本中数据库中时间字段不能使用datetime类型否则会引起序列化错误)

解压安装包得到如下目录

  .
├──bin
├──conf
└──lib

修改启动脚本⽂件,调整jvm内存。(机器内存⾜够的也可以不⽤改)

编辑seata-server.sh 或 seata-server.bat

修改120⾏处内容

默认堆内存是2048M,建议修改为512M即可,栈内存,永久代的值都可以不⽤改,原配置截图如下:

Seata分布式事务解决方案详解_第8张图片 建议修改的值为

  -Xmx512m -Xms512m -Xmn256m

2. seata与zk整合,以zk为注册中心

这里以zk为例子,nacos网上资源很多,不做过多描述

修改conf文件夹里file.conf和registry.conf

registry.conf配置

  registry {
  type = "zk"
​
  zk {
    cluster = "default"
    serverAddr = "127.0.0.1:2181"
    sessionTimeout = 6000
    connectTimeout = 2000
    username = ""
    password = ""
  }
}
​
config {
  Type = "zk"
​
  zk {
    serverAddr = "127.0.0.1:2181"
    sessionTimeout = 6000
    connectTimeout = 2000
    username = ""
    password = ""
  }
  file {
    name = "file.conf"
  }
}

file.conf配置

  ## transaction log store, only used in seata-server
store {
  mode = "db"
​
  ## file store property
  file {
    dir = "sessionStore"
    # branch session size , if exceeded first try compress lockkey, still exceeded throws exceptions
    maxBranchSessionSize = 16384
    # globe session size , if exceeded throws exceptions
    maxGlobalSessionSize = 512
    # file buffer size , if exceeded allocate new buffer
    fileWriteBufferCacheSize = 16384
    # when recover batch read size
    sessionReloadReadSize = 100
    # async, sync
    flushDiskMode = async
  }
​
  ## database store property
  db {
    ## the implement of javax.sql.DataSource, such as DruidDataSource(druid)/BasicDataSource(dbcp)/HikariDataSource(hikari) etc.
    datasource = "druid"
    ## mysql/oracle/postgresql/h2/oceanbase etc.
    dbType = "mysql"
    driverClassName = "com.mysql.jdbc.Driver"
    url = "jdbc:mysql://127.0.0.1:3306/seata"
    user = "root"
    password = "root"
    minConn = 5
    maxConn = 30
    globalTable = "global_table"
    branchTable = "branch_table"
    lockTable = "lock_table"
    queryLimit = 100
    maxWait = 5000
  }
​
}

修改 conf/nacos-config.txt配置为zk-config.properties

  service.vgroupMapping.order-service-seata-service-group=default
service.vgroupMapping.account-service-seata-service-group=default
service.vgroupMapping.storage-service-seata-service-group=default
service.vgroupMapping.business-service-seata-service-group=default
store.mode=db
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
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.queryLimit=100
store.db.lockTable=lock_table

3. 执⾏启动脚本(默认是8091端⼝)

  sudo chmod -R 777 seata/* 
# 执⾏启动脚本⽂件 
sudo bin/seata-server.sh
# 如果启动不了,提示Operation not permitted
sudo xattr -r -d com.apple.quarantine seata/*
# 执⾏启动脚本⽂件 
sudo bin/seata-server.sh

Seata分布式事务解决方案详解_第9张图片

Seata分布式事务解决方案详解_第10张图片

4. Dubbo项目pom.xml配置

${seata.version}版本要与seata server版本一致

  

    io.seata
    seata-spring-boot-starter
    ${seata.version}

5. application.yml配置

  #seata配置 zk版
seata: 
  enable-auto-data-source-proxy: false # 如果引⼊的是seata-spring-boot-starter 请关闭⾃动代理 
  application-id: seata-server # Seata应⽤的名称 
  tx-service-group: default_tx_group # 事务组 
  service: 
    vgroup-mapping: 
      default_tx_group: default
  registry:
    type: zk
    zk:
      server-addr: 127.0.0.1:2181
      connect-timeout: 2000
      session-timeout: 6000
      cluster: default
  config:
    type: zk
    zk:
      server-addr: 127.0.0.1:2181
      connect-timeout: 2000
      session-timeout: 6000
  #seata配置 nacos版
seata: 
  enable-auto-data-source-proxy: false # 如果引⼊的是seata-spring-boot-starter 请关闭⾃动代理 
  application-id: seata-server # Seata应⽤的名称 
  tx-service-group: default_tx_group # 事务组 
  service: 
    vgroup-mapping: 
      default_tx_group: default
  config: 
    type: nacos 
    nacos: 
      server-addr: 127.0.0.1:8848 # Nacos服务地址 
      namespace: seata # Seata的NameSpace ID 
      group: SEATA_GROUP 
      username: nacos 
      password: nacos 
  registry: 
    type: nacos # 基于Nacos实现分布式事务管理 
    nacos: 
      server-addr: 127.0.0.1:8848 # Nacos服务地址 
      namespace: seata # Seata的NameSpace ID 
      group: SEATA_GROUP 
      username: nacos
      password: nacos 
      cluster: default
      application: seata-server

总结

自动挡跑车,好用但少用,容易出事情,抛砖引玉,欢迎大家讨论

上面提到的悬挂空回滚等问题解决方案,是一套可以落地的通用解决方案,如果大家有想法改进的可以提

不要怼我,我很脆弱

你可能感兴趣的:(seata,分布)