所有文章


 

正文

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的管理里面就可以基本做到维持数据一致性。