事件伊始
某天,产品经理华哥发现仓库系统里的一个库存记录被分配了两次,而两次分配后的剩余数量居然是一样的,属实震惊,当然,消息很快传到bug修复师的企业微信里,惊慌之余,就是赶紧打开代码、打开日志、打开数据库,进行漫长的排查之路
初识BUG
找到更新库存位置的代码
public Result reduce(InventoryUpdateDTO inventoryUpdateDTO) {
//获取库存唯一码
String uniqueCode = getUniqueCode(inventoryUpdateDTO);
RLock lock = redissonClient.getLock(uniqueCode);
try {
boolean lockStatus = lock.tryLock();
if (!lockStatus) {
return Result.fail();
}
//查库存
InventoryDTO inventoryDTO = inventoryService.get(uniqueCode);
//修改库存记录,对库存数量进行 减一 操作
xxx(inventoryDTO)
//更新库存记录
inventoryService.update(inventoryDTO);
return Result.succeed();
} catch (Exception e) {
//更新库存异常,返回失败信息
return Result.fail();
} finally {
//释放锁
if (lock.isLocked()) {
lock.unlock();
}
}
}
简化一下这个方法
1. 获取锁
2. 查库存
3. 修改库存
4. 更新库存到数据库
5. 释放锁
根据库存记录被分配了两次后的剩余数量一样,我们可以推测出:
- 两次分配分别是在两个线程中执行的
- 两次查库存查到的数据是一样的
多么典型的并发问题!
定位问题
我们先要找出为什么【两次查库存查到的数据是一样的】
一般来说有两个原因:
- 在
查库存
到更新库存到数据库
的操作不是原子性的 - 开启了事务,且
更新库存到数据库
后没有提交事务
原因1
代码里其实是有使用了锁
将查库存
到更新库存到数据库
合并成一个原子操作
并且通过查看系统日志得知,第2次库存分配获取锁是在第1次释放锁后,即两个线程是串行地执行了这个方法
所以原因1,排除
原因2
先说说为什么一个事务更新库存到数据库
后没有提交,其他事务此时获取库存会是旧数据
这是因为innodb的一个特性【一致性非锁定读】
在基于事务隔离级别【repeatable read】下,有同学可能会认为
- select会加S锁
但事实并非如此,innodb处理一般的select,并不会加锁,因为有了mvcc【多版本并发控制】,是去读取事务开始前的一个数据快照,以便增强处理并发的能力
这个方法并没有启用事务,于是我们点击到外层,果然发现问题所在
@Transactional(rollbackFor = Exception.class)
public Result allocateInventory(){
...
reduce(inventoryUpdateDTO)
//其他操作
other(xxx);
...
}
事务A,在调用方法reduce()
后,还要执行其他操作,所以事务A还未提交
事务B此时可以进入reduce()
获得锁,读取事务B开始前的库存数据快照,与事务A读到的是一样的
假设此时最新且已提交的快照版本为001
事务A | 事务B |
---|---|
开始,确定使用快照001 | |
获取锁 | |
读取库存,读快照001的数据 | 开始,确定使用快照001 |
更新库存, | |
释放锁 | |
获取锁 | |
读取库存,读快照001的数据 | |
更新库存 | |
释放锁 | |
提交 | |
提交 |
解决方案
找出了病根,我想应该有三种可行方案
方案1
将reduce()
挪到allocateInventory()
的最下方,保证在释放锁后,事务可以立即提交
缺点:
- 释放锁后,需要方法返回,这在jvm中是一个方法栈出栈的行为,需要时间,虽然很短,但还是存在【这个极短的时间内,另外一个事务去读取库存】的可能
方案2
给reduce()
的查库存显性加上S锁
select * from xxx where xxx=xxx lock in share mode;
这样就不会去读数据快照,而是等待其他有更新库存的事务提交,释放X锁后,再去加S锁并读取数据
缺点:
- 给mysql加锁总是有风险,
- 要理清楚事务里对于各个表更新的顺序,会不会有互逆的情况,如存在【先更新表A,再更新表B】和【先更新表B,再更新表A】,就极易发生死锁
- 预估这里加S锁会导致线程阻塞多久,等待锁超时是会抛出异常的
庆幸的是:
就算发生了死锁、锁超时,mysql自己会检测抛出异常,然后回滚其中的某个事务,可以保证数据的一致性
方案3
在reduce()
的更新库存步骤,使用乐观锁,好好利用CAS机制
这应该是比较好的解决办法
各位同学有更好的方案也欢迎分享出来 (๑•̀ㅂ•́)و✧