所有文章
正文
seata的at模式主要实现逻辑是数据源代理,而数据源代理将基于如MySQL和Oracle等关系事务型数据库实现,基于数据库的隔离级别为read committed。换而言之,本地事务的支持是seata实现at模式的必要条件,这也将限制seata的at模式的使用场景。
官方文档给出了非常好的图来说明at模式下,全局锁与隔离相关的逻辑:https://seata.io/zh-cn/docs/dev/mode/at-mode.html
写隔离
首先,我们理解一下写隔离的流程
分支事务1-开始
|
V 获取 本地锁
|
V 获取 全局锁 分支事务2-开始
| |
V 释放 本地锁 V 获取 本地锁
| |
V 释放 全局锁 V 获取 全局锁
|
V 释放 本地锁
|
V 释放 全局锁
如上所示,一个分布式事务的锁获取流程是这样的
1)先获取到本地锁,这样你已经可以修改本地数据了,只是还不能本地事务提交
2)而后,能否提交就是看能否获得全局锁
3)获得了全局锁,意味着可以修改了,那么提交本地事务,释放本地锁
4)当分布式事务提交,释放全局锁。这样就可以让其它事务获取全局锁,并提交它们对本地数据的修改了。
可以看到,这里有两个关键点
1)本地锁获取之前,不会去争抢全局锁
2)全局锁获取之前,不会提交本地锁
这就意味着,数据的修改将被互斥开来。也就不会造成写入脏数据。全局锁可以让分布式修改中的写数据隔离。
beforeImage校验全局锁
在将StatementProxy的时候我们提到过,在执行业务sql之前。会生成一个前置数据镜像,也就是beforeImage方法。
那么,beforeImage方法中将会生成一个 select [字段] from [表] where [条件] for update 这样的sql来查询镜像数据。seata的数据源代理将会对select for update这样的语句进行代理,代理中将会检验一下全局锁是否冲突,如下所示
while (true) {
try {
// 执行sql
rs = statementCallback.execute(statementProxy.getTargetStatement(), args);
// 构建数据行
TableRecords selectPKRows = buildTableRecords(getTableMeta(), selectPKSQL, paramAppenderList);
// 构建锁KEY
String lockKeys = buildLockKey(selectPKRows);
if (StringUtils.isNullOrEmpty(lockKeys)) {
break;
}
if (RootContext.inGlobalTransaction()) {
// 校验全局锁
statementProxy.getConnectionProxy().checkLock(lockKeys);
} else if (RootContext.requireGlobalLock()) {
statementProxy.getConnectionProxy().appendLockKey(lockKeys);
} else {
throw new RuntimeException("Unknown situation!");
}
break;
} catch (LockConflictException lce) {
if (sp != null) {
conn.rollback(sp);
} else {
conn.rollback();
}
// 锁冲突,重试
lockRetryController.sleep(lce);
}
}
如代码所示,select for update会做一次全局锁校验(checkLock会去调用Server端)。如果出现锁冲突,那么不断进行重试。
这样依赖,select for update所在的本地事务只要等待全局锁释放,由于已经占了本地锁,所以可以顺利获取全局锁。而后,进行入update等业务操作,然后提交顺利提交本地事务,
seata也表明,默认的事务隔离级别是read uncommitted。那么要实现read committed的话,就可以使用select for update来实现,逻辑和这里是一样的,其实就是通过占用本地锁,然后重试等待全局锁来达到读写隔离的目的。
分支事务register占用锁
在看分支事务register的时候,我们只是简单地扫了扫BranchSession的创建,然后添加到GlobalSession中。
这其中忽略了一个要点,就是在branch的register过程,会进行全局锁的获取操作。客户端会讲tablename和数据行的primary key给构造成lock key传输到Server端。而Server端将会根据这个lock key来判断是否能够占用全局锁
我们看看seata关于database方式的实现,跟进LockStoreDataBaseDao的acquireLock(List<LockDO> lockDOs)方法
方法很长,删减以后逻辑其实很简单。就是构造一个checkLock的sql,查查看是否已经有相关的数据。如果没有则进行doAcquireLock占用操作,占用操作也很简单就是进行数据插入。
前面checkLock提到的调用Server端的校验,其实也就是构造并执行一下checkLock看看有没数据而已
@Override
public boolean acquireLock(List<LockDO> lockDOs) {
// ...
try {
// ...
// 获取checkLock的sql语句
String checkLockSQL = LockStoreSqls.getCheckLockableSql(lockTable, sj.toString(), dbType);
ps = conn.prepareStatement(checkLockSQL);
// ...
// 查询是否有占用的数据
rs = ps.executeQuery();
String currentXID = lockDOs.get(0).getXid();
while (rs.next()) {
String dbXID = rs.getString(ServerTableColumnsName.LOCK_TABLE_XID);
if (!StringUtils.equals(dbXID, currentXID)) {
canLock &= false;
break;
}
// ...
}
if (!canLock) {
conn.rollback();
return false;
}
// ...
if (unrepeatedLockDOs.size() == 1) {
LockDO lockDO = unrepeatedLockDOs.get(0);
// 进行占用操作
if (!doAcquireLock(conn, lockDO)) {
// ...
}
} else {
// 进行占用操作
if (!doAcquireLocks(conn, unrepeatedLockDOs)) {
// ...
}
}
conn.commit();
return true;
} catch (SQLException e) {
// ...
} finally {
// ...
}
}
那么checkLockSql和doAcquireLock分开两个步骤是否会有并发问题呢?
理论上不会有的,正如我们前面一直提到的,要先获取本地锁,再来查询获取全局锁。所以,当本地锁还没有获取的时候,不会去获取全局锁。也就不需要考虑并发问题
如果占用全局锁失败怎么办呢?客户端会进行锁冲突的判断,然后进行重试操作。
分支事务释放全局锁
而分支事务在从GlobalSession中remove的时候会去unlock全局锁,如下GlobalSession中的代码
@Override
public void removeBranch(BranchSession branchSession) throws TransactionException {
for (SessionLifecycleListener lifecycleListener : lifecycleListeners) {
lifecycleListener.onRemoveBranch(this, branchSession);
}
branchSession.unlock();
remove(branchSession);
}
从unlock一路跟进LockStoreDataBaseDao的unlock(String xid, Long branchId)会看看
@Override
public boolean unLock(String xid, Long branchId) {
Connection conn = null;
PreparedStatement ps = null;
try {
conn = logStoreDataSource.getConnection();
conn.setAutoCommit(true);
// 批量删除的sql构造并执行
String batchDeleteSQL = LockStoreSqls.getBatchDeleteLockSqlByBranch(lockTable, dbType);
ps = conn.prepareStatement(batchDeleteSQL);
ps.setString(1, xid);
ps.setLong(2, branchId);
ps.executeUpdate();
} catch (SQLException e) {
throw new StoreException(e);
} finally {
IOUtil.close(ps, conn);
}
return true;
}
其实就是去删除之前doAcquireLock方法insert进去的数据,就算解锁了
@GlobalLock
有的方法它可能并不需要@GlobalTransactional的事务管理,但是我们又希望它对数据的修改能够加入到seata机制当中。那么这时候就需要@GlobalLock了。
加上了@GlobalLock,在事务提交的时候就回去checkLock校验一下全局锁。
private void processLocalCommitWithGlobalLocks() throws SQLException {
// 全局锁校验
checkLock(context.buildLockKeys());
try {
// 提交本地事务
targetConnection.commit();
} catch (Throwable ex) {
throw new SQLException(ex);
}
context.reset();
}
可以看到,在本地事务提交之前会调用checkLock校验全局锁,和之前在事务中的写隔离一样的逻辑。也一样的,如果出现锁冲突的话进行重试操作
总结
本文简单看了几个全局锁的场景,可以感觉到只要遵循本地锁、全局锁的获取和释放的逻辑顺序,将数据读写的操作纳入seata的管理里面就可以基本做到维持数据一致性。