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

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

  • Seata简介
    • AT 模式
      • 前提
      • 整体机制
      • 写隔离
      • 读隔离
      • 工作机制
      • 一阶段
      • 二阶段-回滚
      • 二阶段-提交
  • 分布式事务方案比较
    • Lcn
    • Seata
    • 消息中间件方案
  • Demo
    • 环境准备
    • 启动seata-server
    • 微服务配置

Seata简介

随着项目体量越来越大,分布式系统越来越收到大家的欢迎,可分布式系统也会带来两大问题,一是分布式锁,二是分布式事务。分布式锁可以使用redis解决,可分布式事务没有这么简单。Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。接下来本文以AT事务模式为例,详细介绍Seata。

AT 模式

前提

  • 基于支持本地 ACID 事务的关系型数据库。
  • Java 应用,通过 JDBC 访问数据库。

整体机制

两阶段提交协议的演变:

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

二阶段:

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

写隔离

  • 一阶段本地事务提交前,需要确保先拿到 全局锁
  • 拿不到 全局锁 ,不能提交本地事务。
  • 全局锁 的尝试被限制在一定范围内,超出范围将放弃,并回滚本地事务,释放本地锁。

以一个示例来说明:

两个全局事务 tx1 和 tx2,分别对 a 表的 m 字段进行更新操作,m 的初始值 1000。

tx1 先开始,开启本地事务,拿到本地锁,更新操作 m = 1000 - 100 = 900。本地事务提交前,先拿到该记录的 全局锁 ,本地提交释放本地锁。 tx2 后开始,开启本地事务,拿到本地锁,更新操作 m = 900 - 100 = 800。本地事务提交前,尝试拿该记录的 全局锁 ,tx1 全局提交前,该记录的全局锁被 tx1 持有,tx2 需要重试等待 全局锁
分布式事务解决方案——Seata_第1张图片
tx1 二阶段全局提交,释放 全局锁 。tx2 拿到 全局锁 提交本地事务。
分布式事务解决方案——Seata_第2张图片
如果 tx1 的二阶段全局回滚,则 tx1 需要重新获取该数据的本地锁,进行反向补偿的更新操作,实现分支的回滚。

此时,如果 tx2 仍在等待该数据的 全局锁,同时持有本地锁,则 tx1 的分支回滚会失败。分支的回滚会一直重试,直到 tx2 的 全局锁 等锁超时,放弃 全局锁 并回滚本地事务释放本地锁,tx1 的分支回滚最终成功。

因为整个过程 全局锁 在 tx1 结束前一直是被 tx1 持有的,所以不会发生 脏写 的问题。

读隔离

在数据库本地事务隔离级别 读已提交(Read Committed) 或以上的基础上,Seata(AT 模式)的默认全局隔离级别是 读未提交(Read Uncommitted)

如果应用在特定场景下,必需要求全局的 读已提交 ,目前 Seata 的方式是通过 SELECT FOR UPDATE 语句的代理。
分布式事务解决方案——Seata_第3张图片
SELECT FOR UPDATE 语句的执行会申请 全局锁 ,如果 全局锁 被其他事务持有,则释放本地锁(回滚 SELECT FOR UPDATE 语句的本地执行)并重试。这个过程中,查询是被 block 住的,直到 全局锁 拿到,即读取的相关数据是 已提交 的,才返回。

出于总体性能上的考虑,Seata 目前的方案并没有对所有 SELECT 语句都进行代理,仅针对 FOR UPDATE 的 SELECT 语句。

工作机制

以一个示例来说明整个 AT 分支的工作过程。

业务表:product

Field Type Key
id bigint(20) PRI
name varchar(100)
since varchar(100)

AT 分支事务的业务逻辑:

update product set name = 'GTS' where name = 'TXC';

一阶段

过程:

  1. 解析 SQL:得到 SQL 的类型(UPDATE),表(product),条件(where name = ‘TXC’)等相关的信息。
  2. 查询前镜像:根据解析得到的条件信息,生成查询语句,定位数据。
select id, name, since from product where name = 'TXC';

得到前镜像:

id name since
1 TXC 2014
  1. 执行业务 SQL:更新这条记录的 name 为 ‘GTS’。
  2. 查询后镜像:根据前镜像的结果,通过 主键 定位数据。
select id, name, since from product where id = 1;

得到后镜像:

id name since
1 GTS 2014
  1. 插入回滚日志:把前后镜像数据以及业务 SQL 相关的信息组成一条回滚日志记录,插入到 UNDO_LOG 表中。
{
	"branchId": 641789253,
	"undoItems": [{
		"afterImage": {
			"rows": [{
				"fields": [{
					"name": "id",
					"type": 4,
					"value": 1
				}, {
					"name": "name",
					"type": 12,
					"value": "GTS"
				}, {
					"name": "since",
					"type": 12,
					"value": "2014"
				}]
			}],
			"tableName": "product"
		},
		"beforeImage": {
			"rows": [{
				"fields": [{
					"name": "id",
					"type": 4,
					"value": 1
				}, {
					"name": "name",
					"type": 12,
					"value": "TXC"
				}, {
					"name": "since",
					"type": 12,
					"value": "2014"
				}]
			}],
			"tableName": "product"
		},
		"sqlType": "UPDATE"
	}],
	"xid": "xid:xxx"
}
  1. 提交前,向 TC 注册分支:申请 product 表中,主键值等于 1 的记录的 全局锁 。
  2. 本地事务提交:业务数据的更新和前面步骤中生成的 UNDO LOG 一并提交。
  3. 将本地事务提交的结果上报给 TC。

二阶段-回滚

  1. 收到 TC 的分支回滚请求,开启一个本地事务,执行如下操作。
  2. 通过 XID 和 Branch ID 查找到相应的 UNDO LOG 记录。
  3. 数据校验:拿 UNDO LOG 中的后镜与当前数据进行比较,如果有不同,说明数据被当前全局事务之外的动作做了修改。这种情况,需要根据配置策略来做处理,详细的说明在另外的文档中介绍。
  4. 根据 UNDO LOG 中的前镜像和业务 SQL 的相关信息生成并执行回滚的语句:
update product set name = 'TXC' where id = 1;
  1. 提交本地事务。并把本地事务的执行结果(即分支事务回滚的结果)上报给 TC。

二阶段-提交

  1. 收到 TC 的分支提交请求,把请求放入一个异步任务的队列中,马上返回提交成功的结果给 TC。
  2. 异步任务阶段的分支提交请求将异步和批量地删除相应 UNDO LOG 记录。

分布式事务方案比较

Lcn

分布式事务解决方案——Seata_第4张图片
核心步骤

创建事务组

  • 是指在事务发起方开始执行业务代码之前先调用TxManager创建事务组对象,然后拿到事务标示GroupId的过程。

加入事务组

  • 添加事务组是指参与方在执行完业务方法以后,将该模块的事务信息通知给TxManager的操作。

通知事务组

  • 是指在发起方执行完业务代码以后,将发起方执行结果状态通知给TxManager,TxManager将根据事务最终状态和事务组的信息来通知相应的参与模块提交或回滚事务,并返回结果给事务发起方。

优点

  • 保证数据的强一致性

缺点

  • 可能会造成死锁的现象,比如,订单服务调用派单服务成功以后,订单服务还没执行完毕就宕机,此时,TxManage并没有收到通知,派单服务的事务也不能顺利进行,导致死锁。
  • lcn的性能不是特别强大

Seata

详细介绍见上文

优点

  • seata的性能比lcn要好
  • seata不会造成死锁的情况
  • 业务改造成本低

缺点

  • SQL性能会有损耗
  • 部分SQL场景不支持

seata和lcn比较,有什么不一致?

  • seata和lcn大致的实现思路是一致的,但是回滚的机制不一样。
  • lcn是采取代理数据源的模式,再根据发起方执行本地事务的结果进行回滚或者提交
  • seata采取的是根据undo_log日志表,进行逆向生成sql语句,来解决回滚
  • lcn能够保证强一致性,但可能发生死锁的现象
  • seata能保证最终一致性

消息中间件方案

分布式事务解决方案——Seata_第5张图片

  • 在系统A处理任务A前,首先向消息中间件发送一条消息
  • 消息中间件收到后将该条消息持久化,但并不投递。此时下游系统B仍然不知道该条消息的存在
  • 消息中间件持久化成功后,便向系统A返回一个确认应答
  • 系统A收到确认应答后,则可以开始处理任务A
  • 任务A处理完成后,向消息中间件发送Commit请求。该请求发送完成后,对系统A而言,该事务的处理过程就结束了,此时它可以处理别的任务了
  • 消息中间件收到Commit指令后,便向系统B投递该消息,从而触发任务B的执行
  • 当任务B执行完成后,系统B向消息中间件返回一个确认应答,此时,这个分布式事务完成
  • 若系统A在处理任务A时失败,那么就会向消息中间件发送Rollback请求。系统A发完之后便可以认为回滚已经完成,它便可以去做其他的事情,不会发送消息
  • 若系统B处理任务失败,则重新投递消息,若一直失败,则需要人工干预

优点

  • 拓展性强

缺点

  • 通用性差
  • 消息处理存在延时

Demo

环境准备

SpringCloud
Consul
Pgsql
Seata

启动seata-server

从seata.io官网下载最新版seata-1.4.2压缩包并解压
修改registry.conf注册中心配置

  consul {
    cluster = "seata-server" //注册中心注册的服务名
    serverAddr = "127.0.0.1:8500"
    aclToken = ""
  }

修改file.conf配置

  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 = "postgresql"
    driverClassName = "org.postgresql.Driver"
    ## if using mysql to store the data, recommend add rewriteBatchedStatements=true in jdbc connection param
    url = "jdbc:postgresql://127.0.0.1:5433/seata"
    user = "XXX"
    password = "XXX"
    minConn = 5
    maxConn = 100
    globalTable = "global_table"
    branchTable = "branch_table"
    lockTable = "lock_table"
    queryLimit = 100
    maxWait = 5000
  }

执行SQL,创建seata-server所需要的表,SQL脚本可在官方GitHub中找到
启动服务

微服务配置

创建undo_log表,用于记录事务和回滚日志

CREATE TABLE IF NOT EXISTS public.undo_log
(
    id            SERIAL       NOT NULL,
    branch_id     BIGINT       NOT NULL,
    xid           VARCHAR(128) NOT NULL,
    context       VARCHAR(128) NOT NULL,
    rollback_info BYTEA        NOT NULL,
    log_status    INT          NOT NULL,
    log_created   TIMESTAMP(0) NOT NULL,
    log_modified  TIMESTAMP(0) NOT NULL,
    CONSTRAINT pk_undo_log PRIMARY KEY (id),
    CONSTRAINT ux_undo_log UNIQUE (xid, branch_id)
);

CREATE SEQUENCE IF NOT EXISTS undo_log_id_seq INCREMENT BY 1 MINVALUE 1 ;

seata对数据源做了代理的和接管,在每个参与到分布式事务的服务中,都需要做如下配置

@Configuration
public class DataSourceConfiguration {
 
    @Bean
    @ConfigurationProperties(prefix = "spring.datasource")
    public DataSource druidDataSource(){
        DruidDataSource druidDataSource = new DruidDataSource();
        return druidDataSource;
    }
 
    @Primary
    @Bean("dataSource")
    public DataSourceProxy dataSource(DataSource druidDataSource){
        return new DataSourceProxy(druidDataSource);
    }
 
    @Bean
    public SqlSessionFactory sqlSessionFactory(DataSourceProxy dataSourceProxy)throws Exception{
        SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
        sqlSessionFactoryBean.setDataSource(dataSourceProxy);
        sqlSessionFactoryBean.setMapperLocations(new PathMatchingResourcePatternResolver()
                .getResources("classpath*:/mapper/*.xml"));
        sqlSessionFactoryBean.setTransactionFactory(new SpringManagedTransactionFactory());
        return sqlSessionFactoryBean.getObject();
    }
}

在项目resources中加入两个配置文件 file.conf和registry.conf
file.conf修改

service {
  #transaction service group mapping
  vgroupMapping.my_test_tx_group = "seata-server" //这个 seata-server 是seata服务端在注册中心的名称,my_test_tx_group 是自己自定义的,需和服务application.yml配置一致
  #only support when registry.type=file, please don't set multiple addresses
  default.grouplist = "127.0.0.1:8091" //这个是seata的服务地址
  #degrade, current not support
  enableDegrade = false
  #disable seata
  disableGlobalTransaction = false
}

registry.conf更改consul注册中心

  consul {
    cluster = "seata-server" //注册中心注册的服务名
    serverAddr = "127.0.0.1:8500"
    aclToken = ""
  }

在发起全局事务的上游服务中加入@GlobalTransactional注解,即可开启全局事务
接下来,我们启动服务测试
Client 配置完成后启动应用并稍待片刻,出现以下后日志就表示 Seata 服务注册成功

register TM success. client version:1.4.2, server version:1.4.2,channel:[id: 0xa4675e28, L:/127.0.0.1:8238 - R:/127.0.0.1:8091]
register RM success. client version:1.4.2, server version:1.4.2,channel:[id: 0x408192d3, L:/127.0.0.1:8237 - R:/127.0.0.1:8091]
register success, cost 94 ms, version:1.4.2,role:RMROLE,channel:[id: 0x408192d3, L:/127.0.0.1:8237 - R:/127.0.0.1:8091]
register success, cost 94 ms, version:1.4.2,role:TMROLE,channel:[id: 0xa4675e28, L:/127.0.0.1:8238 - R:/127.0.0.1:8091]

我准备了order,account,storage三个服务演示seata
order服务

    @Override
    @GlobalTransactional(name = "create-order",rollbackFor = Exception.class)
    public void create(Order order) {
        String xid = RootContext.getXID();
        log.info("------->交易开始");
        orderDao.create(order);
        //远程方法 扣除库存
        storageApi.decrease(order.getProductId(), order.getCount());

        //远程方法,扣除账户余额
        accountApi.decrease(order.getUserId(), order.getMoney());

        log.info("全局事务 xid:{}", xid);
        log.info("操作结束---->end");
    }

storage服务

    @Override
    public void decrease(Long productId, Integer count) {
        String xid = RootContext.getXID();
        log.info("全局事务 xid: {}", xid);
        log.info("-------->扣减库存开始");
        storageDao.decrease(productId, count);
        log.info("-------->扣减库存结束");
    }

account服务

    @Override
    public void decrease(Long userId, BigDecimal money) throws Exception {

        String xid = RootContext.getXID();
        log.info("全局事务 xid:{}", xid);

        log.info("---->扣除账户开始account中");

        Account account = accountDao.getByUserId(userId);

        if (account.getResidue().compareTo(money) < 0) {
            // 如果余额不足,则抛出异常,进行全局事务回滚操作
            throw new Exception("账户余额不足");
        }

        accountDao.decrease(userId, money);
        log.info("---->扣除账户结束account中");

        //修改订单状态,此时调用会导致调用成环
        log.info("修改订单状态开始 account中");
        orderApi.update(userId, money, 0);
    }

account表初始状态,余额为2

id user_id total used residue
1 1 100 98 2

storage表初始状态

id product_id total used residue
1 1 100 10 90

调用接口
POST http://localhost:8005/order/create
Content-Type: application/json

{
“userId”: 1,
“productId”: 1,
“count”: 1,
“money”: 10
}
account服务抛出异常

java.lang.Exception: 账户余额不足
	at com.beast.account.service.impl.AccountServiceImpl.decrease(AccountServiceImpl.java:39) ~[classes/:na]
	at com.beast.account.controller.AccountController.decrease(AccountController.java:26) ~[classes/:na]
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:1.8.0_151]
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:1.8.0_151]
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_151]
	at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_151]

order,storage服务根据undo_log执行回滚

2022-03-22 16:15:35.713  INFO 19404 --- [ch_RMROLE_1_1_8] i.s.c.r.p.c.RmBranchRollbackProcessor    : rm handle branch rollback process:xid=10.21.80.24:8091:2522259474975834267,branchId=2522259474975834272,branchType=AT,resourceId=jdbc:postgresql://10.41.173.228:5433/seata-storage,applicationData=null
2022-03-22 16:15:35.715  INFO 19404 --- [ch_RMROLE_1_1_8] io.seata.rm.AbstractRMHandler            : Branch Rollbacking: 10.21.80.24:8091:2522259474975834267 2522259474975834272 jdbc:postgresql://10.41.173.228:5433/seata-storage
2022-03-22 16:15:35.846  INFO 19404 --- [ch_RMROLE_1_1_8] i.s.r.d.undo.AbstractUndoLogManager      : xid 10.21.80.24:8091:2522259474975834267 branch 2522259474975834272, undo_log deleted with GlobalFinished
2022-03-22 16:15:35.847  INFO 19404 --- [ch_RMROLE_1_1_8] io.seata.rm.AbstractRMHandler            : Branch Rollbacked result: PhaseTwo_Rollbacked

你可能感兴趣的:(java)