seata事务【回滚】原理及源码分析

Seata数据回滚原理及源码分析

背景

Seata在年初开源,一经发布,社区活跃度一路走高,因为其低侵入性的特性而大受欢迎。近期自己也特意学习了一下Seata的源码,借此分享一下自己对Seata的理解

和很多网友一样,本地的demo启动后各种问题随之出现,比如说启动找不到file.conf,代码异常后数据却不回滚等等,因为Seata开源时间并不久,所以遇到问题也只能通过调试源码去解决

前言

初始化

所有接入seata的应用程序都必须配置GlobalTransactionScanner这个bean,它负责初始化Transaction Manager(TM)以及Resource Manager(RM)

<bean class="io.seata.spring.annotation.GlobalTransactionScanner">
	<constructor-arg value="${spring.application.name}"/>
	<constructor-arg value="my_test_tx_group"/>
</bean>

GlobalTransactionScanner.java的源码如下:

private void initClient() {
       ...
        //init TM
        TMClient.init(applicationId, txServiceGroup);
        //init RM
        RMClient.init(applicationId, txServiceGroup);
    }

这里重点关注一下RM客户端初始化,通过配置客户端消息监听器RmMessageListener,用来接收TC(事务协调器)发起的提交或回滚的请求

public class RMClient {
    public static void init(String applicationId, String transactionServiceGroup) {
        RmRpcClient rmRpcClient = RmRpcClient.getInstance(applicationId, transactionServiceGroup);
        rmRpcClient.setResourceManager(DefaultResourceManager.get());
        //注册消息监听器
        rmRpcClient.setClientMessageListener(new RmMessageListener(DefaultRMHandler.get()));
        rmRpcClient.init();
    }
}

继续跟踪一下RmMessageListener的onMessage方法,看看它做了哪些事情

@Override
    public void onMessage(long msgId, String serverAddress, Object msg, ClientMessageSender sender) {
        if (LOGGER.isInfoEnabled()) {
            LOGGER.info("onMessage:" + msg);
        }
        //处理提交请求
        if (msg instanceof BranchCommitRequest) {
            handleBranchCommit(msgId, serverAddress, (BranchCommitRequest)msg, sender);
        } 
        //处理回滚请求
        else if (msg instanceof BranchRollbackRequest) {
            handleBranchRollback(msgId, serverAddress, (BranchRollbackRequest)msg, sender);
        }
    }

可以看到,这个监听器其实就是commit和rollback请求回调的入口,后面会详细介绍

开启TC事务协调器服务

官网下载seata-server安装包并解压

启动TC服务

[root@localhost ~]# cd seata-server-0.6.1/bin/
[root@localhost ~]# nohup ./seata-server.sh >/dev/null 2>&1 &

场景演示

服务调用

BusinessServiceImpl.java
下单操作入口

@GlobalTransactional
public void purchase(BusinessDTO businessDto) {
	LOGGER.info(">>>>>>全局事务XID:", RootContext.getXID());
	String userId = businessDto.getUserId();
	String productCode = businessDto.getProductCode();
	int orderCount = businessDto.getCount();
	// 创建订单
	orderService.create(userId, productCode, orderCount);
	// 减扣库存
	storageService.deduct(productCode, orderCount);
	LOGGER.info("下单成功!");
}

OrderServiceImpl.java
创建订单 && 账户扣款

public void create(String userId, String productCode, int orderCount) {
	LOGGER.info(">>>>>>OrderService begin,XID为" + RootContext.getXID());
	int money = calculate(orderCount);
	OrderDTO order = mockOrder(userId, productCode, orderCount, money);
	// 创建订单
	orderDao.insert(order);
	// 账户扣款
	accountService.debit(userId, money);
	LOGGER.info(">>>>>>OrderService end.");
}

AccountServiceImpl.java
修改账户金额

public void debit(String userId, int amount) {
	LOGGER.info(">>>>>>AccountService begin,XID为" + RootContext.getXID());
	accountDao.update(userId, amount);
	LOGGER.info(">>>>>>AccountService end.");
}

StorageServiceImpl.java
库存减扣,在这个方法内模拟抛出异常

@Override
public void deduct(String productCode, int count) {
	LOGGER.info(">>>>>>StorageService begin,XID为" + RootContext.getXID());
	storageDao.update(productCode, count);
	LOGGER.info(">>>>>>StorageService end.");
	throw new RuntimeException("模拟异常!");
}

我们来分析一下哪些操作会执行成功
在这里插入图片描述

  • 红框
    StorageService(减扣库存+新增undolog)成功后,因为decute方法抛出了异常,所以本地事务回滚,t_storage表的num不变、undo_log表数据为空!
    在这里插入图片描述
    在这里插入图片描述
  • 绿框
    OrderService(创建订单+新增undolog)成功
    在这里插入图片描述
    在这里插入图片描述
    rpc调用AccountService(账户扣款+undolog)成功
    在这里插入图片描述
    在这里插入图片描述
    根据Seata的分支事务提交及回滚原理,我们知道最终的决议是全局回滚(因为StorageService执行失败了),所以每个RM都会收到协调器发来的回滚请求。
    可是上例中为什么订单表和对应的undo_log表数据没有回滚成功呢?通过TC服务端日志可以看到,TC协调器不断的在重试回滚OrderService的业务
    在这里插入图片描述

细节分析

首先来看一下官网给出的回滚流程图解
在这里插入图片描述
第1步执行成功了,那么问题很明显第2步验证不通过。。。

文章开始我们提到过一个监听器(RmMessageListener),它是接收commit和rollback请求的入口

@Override
public void onMessage(long msgId, String serverAddress, Object msg, ClientMessageSender sender) {
    if (LOGGER.isInfoEnabled()) {
        LOGGER.info("onMessage:" + msg);
    }
    if (msg instanceof BranchCommitRequest) {
        handleBranchCommit(msgId, serverAddress, (BranchCommitRequest)msg, sender);
    } else if (msg instanceof BranchRollbackRequest) {
        handleBranchRollback(msgId, serverAddress, (BranchRollbackRequest)msg, sender);
    }
}

这里我们主要分析一下回滚的细节:
RMHandlerAT从request中获取分支类型(branchType)、分支(branchId)、事务ID(xid),将这此信息传递给DataSourceManager->branchRollback方法

@Override
public BranchStatus branchRollback(BranchType branchType, String xid, long branchId, String resourceId, String applicationData) throws TransactionException {
    DataSourceProxy dataSourceProxy = get(resourceId);
    if (dataSourceProxy == null) {
        throw new ShouldNeverHappenException();
    }
    try {
    	//根据Xid和branchId找到undo_log信息,解析rollback_info信息
        UndoLogManager.undo(dataSourceProxy, xid, branchId);
    } catch (TransactionException te) {
        if (te.getCode() == TransactionExceptionCode.BranchRollbackFailed_Unretriable) {
            return BranchStatus.PhaseTwo_RollbackFailed_Unretryable;
        } else {
            return BranchStatus.PhaseTwo_RollbackFailed_Retryable;
        }
    }
    return BranchStatus.PhaseTwo_Rollbacked;
}

UndoLogManager做了哪些事情呢?
1、通过 XID 和 Branch ID 查找到相应的 UNDO LOG 记录

 //SELECT * FROM undo_log WHERE  branch_id = ? AND xid = ? FOR UPDATE
 selectPST = conn.prepareStatement(SELECT_UNDO_LOG_SQL);
 selectPST.setLong(1, branchId);
 selectPST.setString(2, xid);
 rs = selectPST.executeQuery();

2、解析rollback_info字段

Blob b = rs.getBlob("rollback_info");
String rollbackInfo = BlobUtils.blob2string(b);
BranchUndoLog branchUndoLog = UndoLogParserFactory.getInstance().decode(rollbackInfo);

rollback_info数据非常重要,它记录了数据的前后镜像及操作类型(crud),其封装的类结构如下:

public class SQLUndoLog {
	private SQLType sqlType;// 操作类型(insert、update、delete)
	private String tableName;// 表名
	private TableRecords beforeImage;// 数据修改改前的记录
	private TableRecords afterImage;// 数据修改后的记录
}

解析完成后,是不是就可以直接将后镜像的数据进行逆操作了呢?显然是不可取的,因为在回滚操作前,数据有可能又被其它请求修改,所有需要进行一次数据检验。怎么检验呢?只需要三个值即可:beforeRecords、afterRecords、currentRecords

当然检验之前还需要根据操作类型及数据库类型获取undolog执行器,通过调用工厂类UndoExecutorFactory获取匹配的undo日志执行器(目前只支持MySql,后续版本会扩展支持更多的数据库类型)。这些日志执行器的核心功能是构建undoSql,即逆向操作的Sql语句

public class UndoExecutorFactory {
    public static AbstractUndoExecutor getUndoExecutor(String dbType, SQLUndoLog sqlUndoLog) {
       if (!dbType.equals(JdbcConstants.MYSQL)) {
           throw new NotSupportYetException(dbType);
       }
       switch (sqlUndoLog.getSqlType()) {
           case INSERT:
               return new MySQLUndoInsertExecutor(sqlUndoLog);
           case UPDATE:
               return new MySQLUndoUpdateExecutor(sqlUndoLog);
           case DELETE:
               return new MySQLUndoDeleteExecutor(sqlUndoLog);
           default:
               throw new ShouldNeverHappenException();
        }
    }
}

检验开始
AbstractUndoExecutor.java的dataValidationAndGoOn方法

protected boolean dataValidationAndGoOn(Connection conn) throws SQLException {
	//数据修改前镜像
	TableRecords beforeRecords = sqlUndoLog.getBeforeImage();
	//数据修改后镜像
	TableRecords afterRecords = sqlUndoLog.getAfterImage();
	//如果修改前镜像=修改后镜像,说明数据没有变更,无须回滚
	if (DataCompareUtils.isRecordsEquals(beforeRecords, afterRecords)) {
	    return false;
	}
	//查找当前镜像
	TableRecords currentRecords = queryCurrentRecords(conn);
	//如果当前镜像**不等于**修改后镜像,继续对比
	if (!DataCompareUtils.isRecordsEquals(afterRecords, currentRecords)) {
			//如果当前镜像**等于**修改前镜像,说明数据没有变更,无须回滚,否则为脏数据直接抛出异常
            if (DataCompareUtils.isRecordsEquals(beforeRecords, currentRecords)) {

总结3点:
1、修改前镜像等于修改后镜像,说明数据没有变更,无须回滚
2、当前镜像等于修改后镜像,则说明当前数据需要进行回滚
3、当前镜像不等于修改后镜像且当前镜像等于修改前镜像,说明数据无变更,无须回滚

举例:

修改前镜像修改后镜像当前镜像是否回滚
500047005000不回滚
500047004700回滚
50005000***不回滚
500047004400脏数据&&不回滚

了解了回滚的原理之后,我们再回到OrderService回滚失败的问题上面,通过debug发现原来seata在查询当前镜像时会根据字段类型进行自动转型(300->300.00),最终被当做脏数据,回滚失败!
在这里插入图片描述
修改后镜像值(undo_log的rollback_info字段)

[id,42], [order_no,20190814102915], [user_id,1], [product_code,A0001], [count,3], [amount,300]

当前镜像值

[id,42], [order_no,20190814102915], [user_id,1], [product_code,A0001], [count,3], [amount,300.0]

解决

修改t_order表的amount类型(本例只是为了演示)
在这里插入图片描述
改为int
在这里插入图片描述
重新执行下单操作,可以看到订单表进行了回滚(逆操作->删除订单记录)

这里有个小细节,请注意一下,如果不重新执行下单操作,只是修改amount为int类型,回滚还是失败的。因为在镜像数据对比过程中,除了对比字段的值,还会对比该字段的type,这个type值由数据库驱动通过ResultSet来获取

具体代码实现可查看TableMetaCache.java类中的方法resultSetMetaToSchema()

//方法resultSetMetaToSchema
col.setDataType(rs1.getInt("DATA_TYPE"));

再看一下之前生成的t_order表的修改后镜像数据片段

{"keyType":"NULL","name":"amount","type":8,"value":300}

查询的当前镜像数据片段,type变为了4(因为改为了int)

{"keyType":"NULL","name":"amount","type":4,"value":300}

所以在了解了回滚细节之后,如果发现数据不回滚,可以考虑从这几个细节入手去查找

回顾

最后,再次总结一下回滚的流程(摘录官网):
1、收到 TC 的分支回滚请求,开启一个本地事务,执行如下操作
2、通过 XID 和 Branch ID 查找到相应的 UNDO LOG 记录
3、数据校验:拿 UNDO LOG 中的后镜与当前数据进行比较,如果有不同,说明数据被当前全局事务之外的动作做了修改
4、根据 UNDO LOG 中的前镜像和业务 SQL 的相关信息生成并执行回滚的语句(逆向sql)
5、提交本地事务,并把本地事务的执行结果(即分支事务回滚的结果)上报给 TC

  • 6
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 5
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值