高并发核心业务-扣减库存的核心解决方案

445 阅读7分钟

这是我参与2022首次更文挑战的第2天,活动详情查看:2022首次更文挑战

需求分析

最近遇到一个问题,就是扣减库存这一功能,在进行jmeter进行压测的时候,发现库存数变成负数。这显然是不太现实的。所以在思考如何优雅的扣减库存这一数据的正确性编写了这一篇文章,有兴趣的同学可以一起讨论讨论。

在这里不谈秒杀设计、不谈使用队列等等让请求串行化这种。秒杀的话有:限流、队列、异步这些方式,这里一概不谈!

高并发下扣减库存的常见解决方案

我们来谈一下怎么使用锁来保证数据的正确性呢,每次领优惠券都只能领一张,那怎么来防止超发导致库存变成负数,你可以想到哪几种方式呢?

图片

方案一:可以通过同步代码块Synchronized,lock

代码如下所示,couponId为用户购买的id,num就是要领取优惠券的数量,从上面的时序图可以看到,在第四步校验是否符合要求时,这时候的库存是>0的,所以满足了领劵的条件。在高并发下同时有很多线程来同时请求扣减优惠券,这时候就会造成了上面所提到的库存变为负数。那使用Synochronized这种方法加锁可以吗?答案是:不行!因为synchronized的作用范围是单个的jvm实例,如果是做了集群分布式的话,那就失效了,并且的话JVM加锁之后就是串行等待了

public synchronized void reduceCouponStock(long couponId ,Integer num) {
//业务逻辑
}

问题:synchronized 作用范围是单个jvm实例, 如果做了集群分布式等,就失效了,且单机JVM加锁后就是串行等待问题

方案二:分布式锁redis,zookeeper

分布式锁能够解决上述的问题吗?答案是:可以的!但是呢分布式锁有个问题就是过于笨重,导致性能下降。(什么?你不懂?那我就画图给你看看),如下图所示,在节点进行访问数据库之前,需要访问redis/zookeeper管理器,等节点拿到锁之后就可以访问到数据库了。在这里节点再拿锁的过程中是要消耗通讯成本,还有请求响应的时间。

图片

方案三:直接数据库更新扣减

第一种下面这种方式可以解决领劵数量为1的情况下,如果领劵的数量大于1这种方式就不行了。比如:库存为1,领劵数量为2,那领劵数量大于了库存的数量,库存还是会变成负数。

update coupon set stock=stock - #{num} where id = #{couponId} and stock>0

第二种方法可以很好的解决库存为负数的问题,最后的(stock - #{num})>=0说明了库存数减去领劵数大于0的话,那么这句sql是可以执行成功的,如果库存数-领劵数是为负数,说明库存已经不足了。该sql就不会执行成功!

修复了库存为负数的问题。

update coupon set stock=stock - #{num} where id = #{couponId} and (stock - #{num})>=0

update coupon set stock=stock - #{num} where id = #{couponId} and stock >= #{num} 

最多扣减1个优惠券的话,使用这一种就可以了

update coupon set stock=stock-1 where id = #{couponId} and stock>0

从上面的可以延伸出来下面这一句,oldStock为旧的库存,这样写会有什么问题吗?肯定有的!比如说扣减库存,如果别人补充了库存的话,那么就会存在了ABA问题,所以要看业务情况是否有这个限制。比如说:线程C查出来的库存数量是10个(还没保存的状态),在线程C还没保存的时间段中,线程A扣减了1个,这时库存数就变成了9个。这时,线程B又更新了10个,就更新成功了。这一时间段库存有没有被修改呢?答案是有的。有点绕可以捋一捋。

update coupon set stock=stock-1 where id = #{couponId} and stock = #{oldStock}

【大厂面试题-p7】高并发库存扣减超卖问题,很多人加了乐观锁版本号去解决,那下面三种有什么区别,分别适合哪些场景使用

下面提供三种方案,在不同的技术层级可能想到的解决方案不一样。

1)update product set stock=stock-1 where id = 1 and stock>0

2)update product set stock=stock-1 where stock=#{原先查询的库存}  and id = 1 and stock>0

3)update product set stock=stock-1,versioin = version+1 where  id = 1 and stock>0 and version=#{原先查询的版本号} 

这个面试题的核心解答就是超卖的问题,就是为了防止库存变成了负数,下面就来对上述的几种方案来做一些阐述。

方案一:  也就是第一条语句,id是主键索引的前提下,如果每次只是减少1个库存的话,可以使用该方式,只做数据安全的校验就可以有效的减库存,并且性能高,可以避免掉大量没有用的sql,只要是有库存的话就可以操作成功了。使用场景就好比如有,高并发场景下的取号器、优惠券发放会扣减库存等等

方案二:  可以使用业务自身的条件做乐观锁,但是会存在ABA问题(该问题上面有说),这种方案的好处就是不用增加version版本字段,如果业务只是扣减库存不用在意ABA问题的话,可以使用这种方式。但是使用这种方式的话业务的性能就会与方案一差了点,因为库存变动之后的sql是无效的。

方案三:  增加了版本号主要是为了解决ABA的这个问题,version只能做递增。使用的场景就比如有:商品的秒杀 、优惠券方法,需要记录对库存操作前后的业务问题。

三种方案都各有利弊,要看业务场景而定。

根据深思熟虑,总结扣减库存所要关注的技术点有

1、当剩余的数量大于当前需要扣减的数量的话,不允许超卖

2、同一个数据的数量存在用户并发进行扣减的问题,需要保证并发的一致性

3、要保证可用性和性能,性能至少是秒级的

4、一次扣减包含多个目标或数量

5、当扣减有多个数量的时候,其中一个没有扣减成功,需要进行回滚

6、必须要有扣有还

7、一次扣减可以有多次的返还

8、返还等幂性

单库场景

对于面试题进行剖析,这一种纯数据库的实现能够满足到扣减业务的各项功能的需求,无非就是依赖了两点。第一点是基于了数据库的乐观锁方式来保证了并发扣减的强一致性,第二点是基于数据库的事务实现了可以批量进行扣减失败的回滚。咱们来画图看看

图片

如果数据量大导致单库压力也很大的话,我们可以做主从分库分表,服务也可以做成集群等等

主从数据库场景

采用读写分离的方式,主从复制直接使用mysql等数据库已经有的功能,在改动上非常的小,只需要在扣减服务里面配置两个数据源。当客户来查询剩余的库存时,扣减服务校验的时候,读取从的数据库就好。真正的数据扣减还是要使用主数据库的。在读写分离之后,根据二八原则,80%为读流量,主库降低了压力80%。但如果采用了读写分离也会导致读取数据不准确的问题,库存的数量也在实时变更的,最终的实际扣减保证数据准确性。

图片

数据库+缓存方案

这里我们可以说说使用数据库+缓存的这种架构来处理扣减库存的业务,当对磁盘进行数据操作时,向文件的末尾追加写入性能的话要远远大于随机修改的性能。数据库同样也是这样的,插入要比更新的性能要好。数据库的更新的话,为了保证对同一条数据并发更新的一致性,通常会加锁,锁是很耗性能的。通过这个理论我们可以推演出来一个扣减流程图

图片