Lost Update问题:一次分布式锁与数据库事务的纠缠与解决
Lost Update 问题
A4的访问模式r1[x], w2[x], w2[commit], w1[x], w1[commit]
这种访问模式下,w2的更新可能会丢失。因为w1可能基于一个比较old-x来做更新x的操作。
RR 隔离级别下的事务表现
快照读
测试数据:
事务 1:
start transaction; update test set A=B where ID = '11'; commit;
事务2:
start transaction; select * from test where ID = '11'; commit;
执行顺序:
2(start) , 1(start) , 1(update) , 1(commit) , 2(select)
执行发现,虽然 A 已经更新了数据,将 A 更新为了 2 ,但由于 2 事务的 start 早于 A 事务,2 事务读取到的仍然是 1 事务提交前的数据。
也就是 1 事务读取到的数据,是1事务开始时,该条数据的快照,即读取为快照读而非当前读。
快照读导致的 Lost update
测试数据
事务1
start transaction; select * from test; select @varB:=B from test where ID = '11'; update test set A=@varB where ID = '11'; commit;
事务2
start transaction; update test set B=5 where ID = '11'; commit;
执行顺序:
1(start) , 1(select) , 2(start) , 2(update) ,2(commit) ,1(select) , 1(update) , 1(commit)
按照预期,事务2先将 B 改为 5 并提交,然后事务 1 将 A 赋值为 B ,A , B 的值都应该是 5 。
但测试结果为,A,B 的值为 1,,5
这是因为在事务2开启之前,事务1已经开启了,事务1内读到的数据为事务1开启前的快照读。
实际问题
有一个需要保证并发安全性的方法,考虑到锁的粒度与分布式要求,选择了基于 Redis 的分布式锁。
需要保证并发安全性的原因是该方法会并发操作数据库某表中的数据。大概过程是这样的:
@Transactional(isolation = Isolation.REPEATABLE_READ)
public class TestService {
public void domain() throws Exception {
String lockKey = "xxxxxx";
redisLockUtils.tryLock(lockKey, 3);
try {
// 查询数据库中的一条数据
// 修改数据回写
} catch (Exception e) {
e.printStackTrace();
} finally {
redisLockUtils.unlock(lockKey);
}
}
}
乍一看没什么问题,操作共享资源(数据库中的数据)的代码段通过分布式锁实现互斥,保证了共享资源的线程安全。
但是经过测试,却出现了并发问题,多个线程并发操作同一条数据时,总有一些线程的操作会丢失。
但经过分析日志,每条线程都竞争到了锁,并正常执行了加锁部分的代码段。
代码的互斥通过锁机制保证了,再三确认自认为没什么漏洞,只能将思路放到数据库。
该方法在 service 中,service 类上有 @Transactional(isolation = Isolation.REPEATABLE_READ) 注解,该类中的方法都被嵌套了声明式事务,且事务隔离级别为可重复读。
回顾一下事务的隔离级别:
隔离级别 | 脏读 | 不可重复读 | 幻象读 |
---|---|---|---|
READ UNCOMMITTED | 允许 | 允许 | 允许 |
READ COMMITED | 不允许 | 允许 | 允许 |
REPEATABLE READ | 不允许 | 不允许 | 允许 |
SERIALIZABLE | 不允许 | 不允许 | 不允许 |
REPEATABLE READ 为可重复读(也是 mysql 的默认隔离级别),即在一个事务范围内相同的查询会返回相同的数据。
即在事务刚刚开始时,数据库就对事务中已涉及的数据进行了快照,在此期间,其它事务对这些数据的修改不会影响该事务。因为在该事务内读的都是快照。
那么问题就找到了:
多个线程等待在竞争锁的 redisLockUtils.tryLock(lockKey, 3) 这行代码时,每个线程其实都早已开启了自己的事务。因此当持有锁的线程对数据进行了修改并释放锁,等待的线程竞争到锁后,竞争到锁的线程并看不到上一个持有锁的线程对数据库做的修改。
因为该线程所在的事务在进入方法时就早已开启,不管别的线程如何改动数据,该线程都是读取的事务开启时的快照。
那么解决方法就很明确了:
1、关闭事务
2、修改事务隔离级别为读已提交
3、在加锁代码段中开启和提交事务,不要先开启事务再去竞争锁
综合看方法 3 最适合当前场景,因为:
1、对数据库的操作有多条,事务是肯定要开启的。
2、修改隔离级别治标不治本,逻辑结构上还是有缺陷。先开启事务、再竞争锁,会有很多事务等待在竞争锁的代码上,给数据库增加很多没有必要的压力。
那么实现下方案3。方案 3 的实现有两种方式:
1、继续使用声明式事务,将事务相关的代码抽取出来封装为一个单独的方法
2、在加锁代码中手动开启提交事务
我选择使用更灵活的方案 2 ,手动操作事务的方法封装如下:
@Component public class TransactionUtils { @Resource private DataSourceTransactionManager transactionManager; //开启事务,传入隔离级别 public TransactionStatus begin(int isolationLevel) { DefaultTransactionDefinition def = new DefaultTransactionDefinition(); // 事物隔离级别,开启新事务 TransactionDefinition.ISOLATION_READ_COMMITTED def.setIsolationLevel(isolationLevel); // 事务传播行为 def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); //默认事务 TransactionStatus transaction = transactionManager.getTransaction(def); //将拿到的事务返回进去,才能提交。 return transaction; } //提交事务 public void commit(TransactionStatus transaction) { //提交事务 transactionManager.commit(transaction); } //回滚事务 public void rollback(TransactionStatus transaction) { transactionManager.rollback(transaction); } }
service 代码改为:
public class TestService { public void domain() throws Exception { String lockKey = "xxxxxx"; redisLockUtils.tryLock(lockKey, 3); try { // 查询数据库中的一条数据 // 手动开启事务 TransactionStatus transaction = transactionUtils.begin(TransactionDefinition.ISOLATION_READ_COMMITTED); try { // 修改数据回写 // 手动提交事务 transactionUtils.commit(transaction); } catch (Exception e) { // 手动回滚事务 transactionUtils.rollback(transaction); } } catch (Exception e) { e.printStackTrace(); } finally { redisLockUtils.unlock(lockKey); } } }
在加锁的代码段内,需要事务的最小范围上开启事务。
至此问题解决。
但手动 开启/提交/回滚 事务时也需注意,因为大部分项目主流是使用声明式事务,使用手动事务会显得很突兀,也不利于代码风格的统一。
此外,手工开启的事务与声明式事务的混用会打乱事务的传播行为(待证实,目前测试是这样的)。比如,内层的声明式事务是无法加入到外层的手工开启的事务的,这就会造成事务的加入变成了事务的嵌套,在不经意间就会造成数据库的死锁。
但是,声明式事务的最小单位是方法,粒度很粗,单纯的为一个事务去封装一个方法,在语义上有时会比较别扭。
因此在遇到类似情形时,还是具体问题具体分析,根据项目中事务嵌套情况的复杂度和个人对代码风格的要求来选择。