一、seata全局锁
所有文章
https://www.cnblogs.com/lay2017/p/12485081.html
正文
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的管理里面就可以基本做到维持数据一致性。