乐观锁(扣库存场景)应用剖析。

目录

1.扣库存场景

2.乐观锁实现误区

3.误区剖析

4.解决方案

5.理论深度拓展


1.扣库存场景

     每次对inventoryId的库存量字段(inventory_amount)进行操作,要求并发时不会出现超卖情况。

2.乐观锁实现误区

     乐观锁思想:cas+自旋,先根据库存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%(失败率高的离谱),这明显不是我们想要的结果。

3.误区剖析

    下面为并发扣库存部分日志,观察下图中红线部分,是不是很有特点。

    图中,线程http-nio-8701-exec-4执行的第42次、43次、44次查询,每次查询到的库存量都是9695,然后每次都修改失败;观察后续的全部日志,发现到最后第99次,也全部是这样;这时我们可以基本猜测这应该跟事务有关系。

    此时,再深入一下,为什么每次查询,查询到的库存量都是一样的(9695,实际数据库已经改变,不是这个值了),这时如果了解事务隔离级别的话,是不是会联想到Repeatable Read(可重复读,它确保同一事务的多个实例在并发读取数据时,会看到同样的数据行),目前只是猜猜,后面确定下。这时再看方法的事务注释(@Transactional),它没指定具体级别,使用的是默认值(Use the default isolation level of the underlying datastore,springboot默认值使用的是底层数据库的默认值,即mysql的Repeatable Read),至此我们可以确定当前事务隔离级别为可重复读,所以导致了并发时多个线程读取到的库存量都一样。

    失败原因另一方面分析:前面之所以失败,是因为前后库存量不一致导致的,可重复读隔离级别下,前面查询获取到的库存量其实是正确的,后面更新时使用了当前数据库的最新库存量(其它事务改变了),而不是前面的库存量,导致两者不一样,无法更新。大家有空可以了解下可重复读的一个问题:MySQL可重复读采坑记录-对事务B进行更新时,事务A提交的更新会不会影响到事务B - Allen101 - 博客园

4.解决方案

第一种解决方案(推荐)

    解决思路:解决并发事务时每次查询看到最新已提交的库存量。

    解决方法:修改事务的隔离级别,严格程度降一级别,即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;
	}

    隐患:由于查询库存量时未使用事务,会导致可能查询到其它事务未提交的数据,其它事务若发生回滚,会造成最终更新时库存量不一致,加大了失败的风险。不推荐。

5.理论深度拓展

    这个问题其实涉及到了3个方面,并发、事务、乐观锁;并发有三个概念(原子性、可见性、有序性),事务(ACID,Atomicity、Consistency、Isolation(隔离性)、Durability);乐观锁(cas【Compare And Swap】+自旋)。

    问题的产生是由于为了防止高并发时出现超卖,使用了乐观锁的cas+自旋,这种方式其实有潜在要求,每次自旋时都要读取到最新的数据(其它线程修改了的也必须读取到),其实正常情况下是不会有什么问题的。

     但为了多一层保障,出错时数据库可以进行回滚,又在该方式下加上了事务(@Transactional,默认使用mysql的可重复读),可重复读事务的隔离性影响了并发时的可见性。

     理论分析(有点绕):可重复读事务,确保同一事务的多个实例在并发读取数据时,会看到同样的数据行;并发的可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。这两者之间是冲突的,可重复读事务在并发时想要看到同样的数据,就必然要读取一个最初的基准值,该事务下的多个实例以这个值为参考,即使该事务下的某个实例修改了这个值,事务下的其它实例也不能立刻更新这个基准值,否则实例在这个事务下就不能算看到同样的数据(这违反了可重复读的原则,会导致其它问题),多个实例看到同样的值就是前面代码运行时一直读取到相同库存量的情况(不论自旋多久),看到相同的库存量,一个线程已经修改了库存量,并提交了事务,其它线程再拿最初的库存量进行比较并更新就不会成功(cas一直失败)。

  • 5
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

kenick

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值