目录
1.扣库存场景
2.乐观锁实现典型误区
3.典型误区剖析
4.解决方案
每次对inventoryId的库存量字段(inventory_amount)进行操作,要求并发时不会出现超卖情况。
乐观锁思想:先根据库存ID查询库存量,扣除库存时根据当前数据库库存量和查询时库存量是否相等(使用数据库的当前库存量作为版本号进行比较),再执行数据库更新操作,如果更新不成功,再循环查询库存量,再判断更新,直到超过一定次数结束。相关说明:spring-boot框架(1.5.4.RELEASE),默认事务隔离级别和传播行为,数据库为:mysql 5.6.33 事务默认设置。
典型实现代码:
@Override
@Transactional
public String updateInventoryAmount(String inventoryId, Integer varNum) throws Exception
{
int flag = 1;
int i =0;
while (flag > 0 && flag < 100)
{
// 查询库存量
ItemInventory inventory = queryInventoryById(inventoryId);
logger.debug("第"+(++i)+"次查询,库存数量为:"+inventory.getInventoryAmount());
if (null == inventory || null == inventory.getInventoryAmount())
{
return HttpUtils.showFail("item_query_error_busi","库存查询失败");
}
// 是否足够
if (0 > varNum + inventory.getInventoryAmount())
{
logger.error("errorCode: item_inventory_insuf_busi errorMessage: {}规格的库存不足", inventory.getProductStandard());
return HttpUtils.showFail("item_inventory_insuf_busi", inventory.getProductStandard() + "规格的库存不足");
}
//尝试更新库存
ItemInventoryExample example = new ItemInventoryExample();
ItemInventoryExample.Criteria crit = example.createCriteria();
crit.andInventoryIdEqualTo(inventoryId);
crit.andInventoryAmountEqualTo(inventory.getInventoryAmount());
// 修改库存量
inventory.setInventoryAmount(varNum + inventory.getInventoryAmount());
int updateNum = vMapper.updateByExampleSelective(inventory, example);
if(updateNum == 0){
Thread.sleep(10);
logger.error("库存修改失败!");
}else{
logger.debug("库存修改成功!");
}
flag = updateNum == 1 ? -1 : flag + 1;
if (flag >= 100)
{
logger.error("item_inventory_toobusi_busi:太幸运了,你和10多亿人进行了竞争");
return HttpUtils.showFail("item_inventory_toobusi_busi","太幸运了,你和10多亿人进行了竞争");
}
}
return null;
}
jmeter测试情况
jmeter10个并发,循环10次。
测试结果:执行总请求次数100次,错误率达97%(失败率高的离谱),这明显不是我们想要的结果,。
下面为并发扣库存部分日志,观察下图中红线部分,是不是很有特点。
图中,线程http-nio-8701-exec-4执行的第42次、43次、44次查询,每次查询到的库存量都是9695,然后每次都修改失败;然后观察后续的全部日志,发现到最后第99次,也全部是这样;这时我们可以基本猜测这应该跟事务有关系。
此时,再深入一下,为什么每次查询,查询到的库存量都是一样的(9695,实际数据库已经改变,不是这个值了),这时如果了解事务隔离级别的话,是不是会联想到Repeatable Read(可重复读),不清楚没关系,解释一下:它确保同一事务的多个实例在并发读取数据时,会看到同样的数据行。这时再看看spring boot的事务注释(@Transactional),它没指定具体级别,使用的是默认值(Use the default isolation level of the underlying datastore,springboot默认值使用的是底层数据库的默认值,本场景即是mysql的Repeatable Read),所以我们可以基本确定当前事务隔离级别为可重复读,所以导致了每次查询到的库存量都一样,即使有并发事务改变过。
失败原因另一方面分析:前面之所以失败,是因为前后库存量不一致导致的,可重复读隔离级别下,前面查询获取到的库存量其实是正确的,后面更新使用了当前数据库的最新库存量(其它事务改变了),而不是前面的库存量,这是由mysql决定的,具体原因还在查找,可参考下其他人碰到的这种情况:https://www.cnblogs.com/Allen-win/p/8283102.html。
第一种解决方案
解决思路:解决并发事务时每次查询看到最新已提交的库存量。
解决方法:修改事务的隔离级别,严格程度降一级别,即READ_COMMITTED(读取提交内容);这个级别与可重复读的区别就是并发事务时是否看到相同的已提交数据,不影响其它,同时执行效率更高。使用jmeter测试,失败率为0%,结果符合预期。
隐患:暂未发现,推荐。
第二种解决方案
解决思路:查询库存量时不使用事务,只有执行更新代码逻辑时添加事务。
实现代码:
@Override
public String updateInventoryAmount(String inventoryId, Integer varNum) throws Exception
{
int flag = 1;
while (flag > 0 && flag < SysStaticConstData.UPDATE_TRY_TIMES_MAX)
{
// 查询库存量
ItemInventory inventory = queryInventoryById(inventoryId);
if (null == inventory || null == inventory.getInventoryAmount())
{
return HttpUtils.showFail("item_query_error_busi","库存查询失败");
}
logger.debug("更新前库存数量:{}",inventory.getInventoryAmount());
// 开启事务
DefaultTransactionDefinition defaultTransactionDefinition = new DefaultTransactionDefinition();
defaultTransactionDefinition.setPropagationBehavior(TransactionDefinition.PROPAGATION_NESTED); // 设置传播行为
defaultTransactionDefinition.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ);
TransactionStatus transactionStatus = dataSourceTransactionManager.getTransaction(defaultTransactionDefinition);
try{
// 是否足够
if (0 > varNum + inventory.getInventoryAmount())
{
logger.error("errorCode: item_inventory_insuf_busi errorMessage: {}规格的库存不足", inventory.getProductStandard());
return HttpUtils.showFail("item_inventory_insuf_busi", inventory.getProductStandard() + "规格的库存不足");
}
//尝试更新库存
ItemInventoryExample example = new ItemInventoryExample();
ItemInventoryExample.Criteria crit = example.createCriteria();
crit.andInventoryIdEqualTo(inventoryId);
crit.andInventoryAmountEqualTo(inventory.getInventoryAmount());
// 修改库存量
inventory.setInventoryAmount(varNum + inventory.getInventoryAmount());
int updateNum = vMapper.updateByExampleSelective(inventory, example);
flag = updateNum == 1 ? -1 : flag + 1;
if (flag >= SysStaticConstData.UPDATE_TRY_TIMES_MAX)
{
dataSourceTransactionManager.commit(transactionStatus); // 提交事务
return HttpUtils.showFail("item_inventory_toobusi_busi","太幸运了,你和10多亿人进行了竞争");
}
dataSourceTransactionManager.commit(transactionStatus); // 提交事务
logger.debug("更新后库存数量:{}",inventory.getInventoryAmount());
}catch (Exception e){
logger.error("库存更新失败!", e);
dataSourceTransactionManager.rollback(transactionStatus); // 事务回退
}
}
return null;
}
隐患:由于查询库存量时未使用事务,会导致可能查询到其它事务未提交的数据,其它事务若发生回滚,会造成最终更新时库存量不一致,加大了失败的风险。不推荐。