背景
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、当前镜像不等于修改后镜像且当前镜像等于修改前镜像,说明数据无变更,无须回滚
举例:
修改前镜像 | 修改后镜像 | 当前镜像 | 是否回滚 |
---|---|---|---|
5000 | 4700 | 5000 | 不回滚 |
5000 | 4700 | 4700 | 回滚 |
5000 | 5000 | *** | 不回滚 |
5000 | 4700 | 4400 | 脏数据&&不回滚 |
了解了回滚的原理之后,我们再回到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