javaEE高并发之如何更新库存问题
有三个阶段可更新库存:成功加入购物车;点击去支付,生成订订单;点击支付。
分析:
1、加入购物车并不代表用户一定会购买,如果这个时候开始预占库存,会导致想购买的无法加入购物车。而不想购买的人一直占用库存,这样的情况对商家是不利的,显然这种做法是不可取的。
2、商品加入购物车后,选择下单,这个时候去预占库存。用户选择去支付说明了,用户购买欲望是比方案一要强烈的。订单也有一个时效,例如半个小时。超过半个小时后,系统自动取消订单,回退预占库存。这个方案相较于方案一是更合理的。
3、如果付款成功之后再减库存,那么很可能存在用户下单后过了几秒,进行在线支付,结果付款成功了却发现库存已经不足,导致购物失败的情况,这样会严重影响购物体验。同时,还要考虑扣款的回退造成更多复杂性(除非,允许一定超卖,或者库存数量“不计” ,另外倘若是秒杀场景,则依然是无法应付。)
所以综上三种情况比对,方案二生成订单时就更新库存是最合适的。
如何安全更新库存?
安全问题简述:
以商品P举例,其主键为pid,那么就是在下单时,将历史库存S修改为 S -N。具体到SQL里,原始操作大概是这样(以SQL SERVER 举例):
update PT set qty = (S - N) where id = pid ;
这是以前的最原始的操作方式,单粒度的看,也没什么大碍。然而,放在一个并发环境中,则立马暴露出诸多问题。
假定在同一时刻,有两个用户提交了订单,一样的操作,一样的商品,一样的数量。那么最终商品P的库存数量应该为 S - N - N。而执行上面的SQL,因为并发,导致两次查询到历史库存均是S(应该至少有一次qty为S - N),则更新完毕后,商品数量最终是 S – N(可以通过线程同步解决该问题,但是这样效率过低)。这种致命性的Bug,也属于超卖(虽然不会扣为负数),如果放在线上,简直是一个定时炸弹。
围绕解决这样的问题,考虑到并发安全以及并发性能,产生了各种解决方案。大体基于两种机制:悲观锁和乐观锁。在诸多场景里,基于每种锁,都有配套的辅助手段。
(1)方案一:使用悲观锁。(适用并发量不大的应用)
仅利用数据库在update时造成的排他锁,使真实更新时串行,并增加库存判断,若库存发生变动,则更新无效,超卖问题也不会发生。譬如(以SQL SERVER 举例):
update PT set qty = qty - N where id = pid and qty >= N;(悲观锁)
严格来讲,这依然是一个较粗的粒度,但不得不说,在单机环境下有一定的可行性。同时,需要考虑高并发情况下(例如商户举办活动,同时参与用户过多)存在一定性能瓶颈,数据库IO负载过大。
(2)方案二:使用乐观锁(不适用并发量较大的商城秒杀活动)
遵循乐观锁的理念,则是默许不会有太大的并发问题(聚焦在小粒度的商品P上,则是认为大多数情况下P不会被同时消费),“放任”线程的执行,不做管控。但是会在关键地方进行版本核对,假如失败,则内部重试或抛出失败信号。
数据库层面上,增加显式的版本号字段(version)。
购买商品P,下单这里需要获取到当前时刻对应的库存qty01,当前记录是版本version01,然后在真实更新时,再次查询商品P的库存,以及对应的当前的版本version02,如果 version01 == version02,那么可以更新。否则,当前数据已因并发被修改,无法更新。这更像是数据库的“不可重复读”,而出现这种情况后(高并发情况下,出现概率直线上升),必须附有关联的内部尝试机制(注意保证幂等性)。 这是一种实现并发管控的方案,但只适合存在并发,但并发量不太大的情况,否则,一是违背乐观锁的理念初衷,二是整体性能以及体验会大打折扣。
具体可查看如下乐观锁相关链接:https://blog.csdn.net/zhangdehua678/article/details/79594212
(3)方案三(综合方案:乐观锁+缓存):
如果秒杀活动,因为商品数量有限,但是人数很多,这个肯定不能直接操作数据库的,会挂的。直接读库写库对数据库压力太大,要用缓存。
即可以将要卖出的商品比如10个商品放到缓存中;然后在redis的memcache里设置一个(k,v)计数器来记录请求数,这个请求书你可以以你要秒杀卖出的商品数为基数,比如你想卖出10个商品,只允许100个请求进来。那当计数器达到100的时候,后面进来的就显示秒杀结束,这样可以减轻你的服务器的压力。然后根据这100个请求,先付款的先得后付款的提示商品以秒杀完。
如下图: