Seata事务隔离
本文目标:帮助用户明白使用Seata AT模式时,该如何正确实现事务隔离,防止脏读脏写。
希望读者在阅读本文前,已阅读过seata官网中对AT模式的介绍,并且对数据库本地锁有所了解
(例如,两个事务同时在对同一条记录做update时,只有拿到record lock的事务才能更新成功,另一个事务在record lock未释放前只能等待,直到事务超时)
首先请看这样的一段代码,尽管看着“初级”,但持久层框架实际上帮我们做的主要事情也就这样。
@Service
public class StorageService {
@Autowired
private DataSource dataSource;
@GlobalTransactional
public void batchUpdate() throws SQLException {
Connection connection = null;
PreparedStatement preparedStatement = null;
try {
connection = dataSource.getConnection();
connection.setAutoCommit(false);
String sql = "update storage_tbl set count = ?" +
" where id = ? and commodity_code = ?";
preparedStatement = connection.prepareStatement(sql);
preparedStatement.setInt(1, 100);
preparedStatement.setLong(2, 1);
preparedStatement.setString(3, "2001");
preparedStatement.executeUpdate();
connection.commit();
} catch (Exception e) {
throw e;
} finally {
IOutils.close(preparedStatement);
IOutils.close(connection);
}
}
}
从代理数据源说起
使用AT模式,最重要的事情便是代理数据源,那么用DataSourceProxy
代理数据源有什么作用呢?
DataSourceProxy能帮助我们获得几个重要的代理对象
-
通过
DataSourceProxy.getConnection()
获得ConnectionProxy
-
通过
ConnectionProxy.prepareStatement(...)
获得StatementProxy
Seata的如何实现事务隔离,就藏在这2个Proxy中,我先概述下实现逻辑。
StatementProxy.executeXXX()
的处理逻辑
-
当调用
io.seata.rm.datasource.StatementProxy.executeXXX()
会将sql交给io.seata.rm.datasource.exec.ExecuteTemplate.execute(...)
处理。ExecuteTemplate.execute(...)
方法中,Seata根据不同dbType和sql语句类型使用不同的Executer,调用io.seata.rm.datasource.exec.Executer
类的execute(Object... args)
。- 如果选了DML类型Executer,主要做了以下事情:
- 查询前镜像(select for update,因此此时获得本地锁)
- 执行业务sql
- 查询后镜像
- 准备undoLog
- 如果你的sql是select for update则会使用
SelectForUpdateExecutor
(Seata代理了select for update),代理后处理的逻辑是这样的:- 先执行 select for update(获取数据库本地锁)
- 如果处于
@GlobalTransactional
or@GlobalLock
,检查是否有全局锁 - 如果有全局锁,则未开启本地事务下会rollback本地事务,再重新争抢本地锁和全局锁,以此类推,除非拿到全局锁
ConnectionProxy.commit()
的处理逻辑
- 处于全局事务中(即,数据持久化方法带有
@GlobalTransactional
)- 注册分支事务,获取全局锁
- undoLog数据入库
- 让数据库commit本次事务
- 处于
@GlobalLock
中(即,数据持久化方法带有@GlobalLock
)- 向tc查询是否有全局锁存在,如存在,则抛出异常
- 让数据库commit本次事务
- 除了以上情况(
else
分支)- 让数据库commit本次事务
@GlobalTransactional的作用
标识一个全局事务
@GlobalLock + select for update的作用
如果像updateA()
方法带有@GlobalLock + select for update
,Seata在处理时,会先获取数据库本地锁,然后查询该记录是否有全局锁存在,若有,则抛出LockConflictException。
先举一个脏写的例子,再来看Seata如何防止脏写
假设你的业务代码是这样的:
updateAll()
用来同时更新A和B表记录,updateA()
updateB()
则分别更新A、B表记录updateAll()
已经加上了@GlobalTransactional
class YourBussinessService {
DbServiceA serviceA;
DbServiceB serviceB;
@GlobalTransactional
public boolean updateAll(DTO dto) {
serviceA.update(dto.getA());
serviceB.update(dto.getB());
}
public boolean updateA(DTO dto) {
serviceA.update(dto.getA());
}
}
class DbServiceA {
@Transactional
public boolean update(A a) {
}
}
|
怎么用Seata防止脏写?
办法一:updateA()
也加上@GlobalTransactional
,此时Seata会如何保证事务隔离?
class DbServiceA {
@GlobalTransactional
@Transactional
public boolean updateA(DTO dto) {
serviceA.update(dto.getA());
}
}
updateAll()
先被调用(未完成),updateA()
后被调用
办法二: @GlobalLock + select for update
class DbServiceA {
@GlobalLock
@Transactional
public boolean updateA(DTO dto) {
serviceA.selectForUpdate(dto.getA());
serviceA.update(dto.getA());
}
}
-
updateAll()
先被调用(未完成),updateA()
后被调用 -
那如果是
updateA()
先被调用(未完成),updateAll()
后被调用呢?
由于2个业务都是要先获得本地锁,因此同样不会发生脏写 -
一定有人会问,“这里为什么要加上select for update? 只用@GlobalLock能不能防止脏写?” 能。但请再回看下上面的图,select for update能带来这么几个好处:
- 锁冲突更“温柔”些。如果只有@GlobalLock,检查到全局锁,则立刻抛出异常,也许再“坚持”那么一下,全局锁就释放了,抛出异常岂不可惜了。
- 在
updateA()
中可以通过select for update获得最新的A,接着再做更新。