背景:
在项目中,我们难免遇到同一时刻需要不同的方法对同一个数据库的数据进行修改。因此就可能会出现脏写的情况。对于mqsql而言,明明有排他锁来管理update操作,为什么会出现数据脏写情况呢?终于在我测试下发现,@Transactional很是无辜的背到了这个黑锅。
问题复现:
场景一:
/**
* 测试方法一
* @param condition
* @return
*/
@PostMapping(value = "/testOnee")
@Transactional(rollbackFor = Exception.class)
@ApiOperation(value = "测试方法一")
public IResponse testOne(@ModelAttribute ChannelWitnessCondition condition) throws InterruptedException {
CaseContractInfo caseContractInfo = caseContractInfoService.getContractByContractNo("BYD3000028-001");
//线程等待100秒,我先看看风景去【执行update前等待】
Thread.currentThread().sleep(100000);
caseContractInfo.setApplyStatus(ApplyStatusEnum.LOAN_WAIT_APPROVE.getState());
caseContractInfoService.updateById(caseContractInfo);
return IResponse.success(true);
}
/**
* 测试方法二
* @param condition
* @return
*/
@PostMapping(value = "/testTwo")
@Transactional(rollbackFor = Exception.class)
@ApiOperation(value = "测试方法二")
public IResponse testTwo(@ModelAttribute ChannelWitnessCondition condition) {
CaseContractInfo caseContractInfo = caseContractInfoService.getContractByContractNo("BYD3000028-001");
caseContractInfo.setIsLock(WhetherEnum.YES.getCode());
caseContractInfoService.updateById(caseContractInfo);
return IResponse.success(true);
}
在上面代码中我分别都使用到了 @Transactionalda 来到达到事务在整个方法的一致性。不难发现,我分别在两个方法里,对CaseContractInfo这个对象的applyStatus属性和isLock属性进行了修改操作。但是在一个方法中,我并没有直接update而是花费了100s去看了看窗外的风景。于是我用postMan先后调用了“测试方法一”和“测试方法二”。如图:
虽然我先调用的方法一,不难发现,方法二很快,啊!仅用了771ms就返回成功了。而我的方法一,在看100s的风景后,终于在1m41.40s后告诉我他回来了。
通过对数据库的查询发现:
在方法二返回时,is_lock属性正确的被修改成了yes。但是当方法一也放回时,is_lock属性却变成了no,明明方法一没有对is_lock属性进行操作,为什么会更改它的值呢?
当我再次将数据还原测试时,发现对方法一设置断点查看时,发现,原来方法一原始查询的is_lock属性就是no。
终于真相大白了。原来方法一像龟兔赛跑的兔子一样, 看到了奖杯(方法一caseContractInfo实例对象)就做了自己的标记(is_lock = "no")认定了奖杯一定是自己的。就忙着干其他事情去了。这个时候方法二乌龟虽然起步慢,但很快在方法一兔子看风景的时候把奖杯(方法二caseContractInfo实例对象)拿走了,还把兔子的标记换成了自己的(is_lock = "yes")。等方法一兔子回来了以为奖杯(方法一caseContractInfo对象)还在,就不承认乌龟的奖杯(方法二caseContractInfo实例对象)是真的。又把自己的标记(is_lock = "no")给标记了回去。【根本原因:mybatisplus包中的service.updateById是整个对象体的属性覆盖】
场景二:
突然疑惑了起来,明明@Transactional有整个方法事务的一致性,而Mqsal对于update也有排他锁,为什么两次修改都成功了呢?难道排他锁根本没有生效?
于是再次测试:
/**
* 测试方法一
* @param condition
* @return
*/
@PostMapping(value = "/testOnee")
@Transactional(rollbackFor = Exception.class)
@ApiOperation(value = "测试方法一")
public IResponse testOne(@ModelAttribute ChannelWitnessCondition condition) throws InterruptedException {
CaseContractInfo caseContractInfo = caseContractInfoService.getContractByContractNo("3000028-001");
caseContractInfo.setApplyStatus(ApplyStatusEnum.LOAN_WAIT_APPROVE.getState());
caseContractInfoService.updateById(caseContractInfo);
//线程等待100秒,我先看看风景去【执行update后等待】
Thread.currentThread().sleep(100000);
return IResponse.success(true);
}
/**
* 测试方法二
* @param condition
* @return
*/
@PostMapping(value = "/testTwo")
@Transactional(rollbackFor = Exception.class)
@ApiOperation(value = "测试方法二")
public IResponse testTwo(@ModelAttribute ChannelWitnessCondition condition) {
CaseContractInfo caseContractInfo = caseContractInfoService.getContractByContractNo("3000028-001");
caseContractInfo.setIsLock(WhetherEnum.YES.getCode());
caseContractInfoService.updateById(caseContractInfo);
return IResponse.success(true);
}
当我把方法一的等待100s放在updateById后时发现,Mysql的排他锁终于告诉我说,她还在,给了我安全感。(原来在整个方法中只有执行了updateById方法后,才拿到了排他锁,而@Transactional注解中,对于整个事务的commit,必须等到整个方法执行完毕。才能释放每个排他锁)
这次,经历过上次的教训,兔子依然没有忘记看风景,为了防止乌龟追上自己,于是这次他就对奖杯(caseContractInfo实例对象)上了一把锁(Mqsql排他锁)让其他人动不了它。兔子拿着钥匙看着风景慢慢的向奖杯走着。方法二乌龟这个时候来到了终点,一看又把锁,那就只能等兔子带着钥匙了呗。结果方法一兔子一只不送钥匙过来。这个时候乌龟妈妈叫乌龟回家吃饭(postMan Http post请求的响应时间默认时60s)乌龟只能说不等了我得回家了。这奖杯要不成了。(就报了Lock wait timeout获取锁超时的报错)
问题解决:
/**
* 测试方法一
* @param condition
* @return
*/
@PostMapping(value = "/testOnee")
@Transactional(rollbackFor = Exception.class)
@ApiOperation(value = "测试方法一")
public IResponse testOne(@ModelAttribute ChannelWitnessCondition condition) throws InterruptedException {
CaseContractInfo caseContractInfo = caseContractInfoService.getContractByContractNo("3000028-001");
//线程等待100秒,我先看看风景去【执行update前等待】
Thread.currentThread().sleep(100000);
// caseContractInfo.setApplyStatus(ApplyStatusEnum.LOAN_WAIT_APPROVE.getState());
//【执行update前时,只更新要更新的属性】
caseContractInfoService.update(Wrappers.<CaseContractInfo>lambdaUpdate()
.eq(CaseContractInfo::getContractNo,"3000028-001")
.set(CaseContractInfo::getApplyStatus, ApplyStatusEnum.LOAN_WAIT_APPROVE.getState()));
return IResponse.success(true);
}
/**
* 测试方法二
* @param condition
* @return
*/
@PostMapping(value = "/testTwo")
@Transactional(rollbackFor = Exception.class)
@ApiOperation(value = "测试方法二")
public IResponse testTwo(@ModelAttribute ChannelWitnessCondition condition) {
CaseContractInfo caseContractInfo = caseContractInfoService.getContractByContractNo("3000028-001");
caseContractInfo.setIsLock(WhetherEnum.YES.getCode());
caseContractInfoService.updateById(caseContractInfo);
return IResponse.success(true);
}
为了能让乌龟和兔子双赢。我们就不能让乌龟一直等,也不能让兔子丢了面子。于是我们设置了成了一个签名板(方法二caseContractInfo实例对象)。当方法二乌龟到终点时就让他签名(is_lock = ''yes")。等兔子到了。告诉他让他把签名签上(更新方法一caseContractInfo实例对象),不要擦掉乌龟的签名。签自己的就好(applyStatus = "waitApprove")。这样两个人就都满足了 。
Mysql 脏写终于解决了。总结为在整个代码逻辑中,为了能快速释放排他锁 ,尽量将所有update方法放在逻辑最后。而且update前一定要重新查询对象。获取最新属性,或者只修改需要修改的属性,避免脏写。