在本篇文章开始之前,我们回顾一下seata在AT模式下提交回滚的二个阶段(摘录官网)
一阶段
Seata通过其JDBC数据源代理对业务SQL进行解析,然后把业务数据在更新前后的数据镜像组织成回滚日志,利用本地事务的ACID特性,将业务数据的更新和回滚日志的写入在同一个本地事务中提交
1、解析SQL
2、查询前镜像
3、执行业务SQL
4、查询后镜像
5、生成undolog并插入表undo_log
注:第3步和第5步在同一个本地事务中
二阶段提交
收到TC协调器的提交请求,异步清理回滚日志
二阶段回滚
收到TC协调器的分支回滚请求
1、开启本地事务
2、通过XID和Branch ID查询undo_log表记录
3、解析rollback_info字段信息,比较前镜像、后镜像和当前记录值来确定是否回滚
4、生成回滚SQL并执行
5、提交本地事务
在高并发场景下,第一阶段是如何保证前后镜像的数据不会被其它线程修改呢?如图:
说明:
1、A线程所在的服务接入seata,执行的业务SQL如下
update product set name = 'GTS' where name = 'TXC'
2、B线程所在的服务未接入seata,只是一个普通的业务操作
update product set version = '2015' where name = 'TXC'
梳理一下执行过程:
A线程业务SQL开始执行之前,首先通过解析的SQL查询前镜像
//前镜像为:1 | TXC | 2014
select id,name,version where name = 'TXC'
在高并发场景下,A线程在执行业务SQL前,这时B线程修改了同一行记录的version字段
//修改后的值:1 | TXC | 2015
update product set version = '2015' where name = 'TXC'
A线程继续执行业务SQL
//修改后的值:1 | GTS | 2015
update product set name = 'GTS' where name = 'TXC'
A线程根据ID查询后镜像
//后镜像为:1 | GTS | 2015
select id,name,version from product where id = 1
整个过程执行完毕!这里假设A线程的下游服务业务异常,则最终的决议是全局回滚,那么A线程执行回滚的数据版本是前镜像,也就是1 TXC 2014
,这将导致B线程执行的数据1 TXC 2015
被“忽略”了?
这种情况会出现吗?答案是否,而且B线程会被阻塞,直到A线程的本地事务提交后才开始执行,那么seata是怎么处理的?
如何保证UndoLog和业务SQL在同一个事务中完成
父类AbstractDMLBaseExecuot主要提供了的事务开启、提交及回滚操作,内部使用了模板方法模式,暴露beforeImage()和afterImage()两个方法,由具体的子类完成实现
protected T executeAutoCommitTrue(Object[] args) throws Throwable {
T result = null;
AbstractConnectionProxy connectionProxy = statementProxy.getConnectionProxy();
LockRetryController lockRetryController = new LockRetryController();
try {
//开启事务
connectionProxy.setAutoCommit(false);
while (true) {
try {
//
result = executeAutoCommitFalse(args);
//提交事务
connectionProxy.commit();
break;
} catch (LockConflictException lockConflict) {
//目标Connection回滚
connectionProxy.getTargetConnection().rollback();
lockRetryController.sleep(lockConflict);
}
}
} catch (Exception e) {
LOGGER.error("exception occur", e);
throw e;
} finally {
//还原事务提交状态
connectionProxy.setAutoCommit(true);
}
return result;
}
其中executeAutoCommitFalse方法主要完成前后镜像的获取、执行业务SQL以及生成undo_log
protected T executeAutoCommitFalse(Object[] args) throws Throwable {
TableRecords beforeImage = beforeImage();
T result = statementCallback.execute(statementProxy.getTargetStatement(), args);
TableRecords afterImage = afterImage(beforeImage);
prepareUndoLog(beforeImage, afterImage);
return result;
}
最终事务的提交是由ConnectionProxy代理来完成的,在执行目标connection的commit之前完成对undo_log的插入操作
if (context.hasUndoLog()) {
//插入undolog日志数据
UndoLogManager.flushUndoLogs(this);
}
//本地事务提交
targetConnection.commit();
A线程查询某行的前镜像并且未释放本地锁之前,其它线程必须等待
seata提供了3种基于DML操作的执行器策略,分别是DeleteExecutor、UpdateExecutor、InsertExecutor
上面我们已经讲过beforeImage()和afterImage()方法由子类完成,这里以UpdateExecutor为例展开讨论
前镜像是根据业务SQL解析出查询条件,再通过Select语句查询出前镜像,代码中我们可以看到在SQL语句最后加上了"for update"关键字,也就是说在查询前镜像时已经对表数据加上了排他锁(表锁或行锁取决于条件是否是索引字段)
protected TableRecords beforeImage() throws SQLException {
...
StringBuffer selectSQLAppender = new StringBuffer("SELECT ");
if (!tmeta.containsPK(updateColumns)) {
selectSQLAppender.append(this.getColumnNameInSQL(tmeta.getPkName()) + ", ");
}
...
selectSQLAppender.append(" FROM " + this.getFromTableInSQL());
if (StringUtils.isNotBlank(whereCondition)) {
selectSQLAppender.append(" WHERE " + whereCondition);
}
selectSQLAppender.append(" FOR UPDATE");
String selectSQL = selectSQLAppender.toString();
...
return beforeImage;
}
后镜像的查询逻辑比较好理解,seata也充分考虑到性能问题,因为前后镜像的主键不会被修改,所以seata使用前镜像的主键来查询后镜像数据
selectSQLAppender.append(" FROM " + this.getFromTableInSQL() + " WHERE " + this.buildWhereConditionByPKs(pkRows));
对于本篇开始提出的疑问,看到这里已经有了答案。但是我们必须注意另外一个问题,如果查询条件未使用索引字段,那么for update会锁住整张表,对系统性能有一定的影响,所以我们在设计表索引字段时,应该要考虑到这一点