当前位置:   article > 正文

Java岗大厂面试百日冲刺【Day50】— 秒杀系统2 (日积月累,每日三题)_情景就是类似与库存增减

情景就是类似与库存增减

  大家好,我是陈哈哈,北漂五年。相信大家和我一样,都有一个大厂梦,作为一名资深Java选手,深知面试重要性,接下来我准备用100天时间,基于Java岗面试中的高频面试题,以每日3题的形式,带你过一遍热门面试题及恰如其分的解答。

  一路走来,随着问题加深,发现不会的也愈来愈多。但底气着实足了不少,相信不少朋友和我一样,日积月累才是最有效的学习方式!想起高三时一个同学的座右铭:只有沉下去,才能浮上来。共勉(juan)。

在这里插入图片描述

小蛮腰

作者:爪哇小白2021



  本栏目Java开发岗高频面试题主要出自以下各技术栈:Java基础知识集合容器并发编程JVMSpring全家桶MyBatis等ORMapping框架MySQL数据库Redis缓存RabbitMQ消息队列Linux操作技巧等。

面试题1:在秒杀业务流程中是怎么控制减库存的?

  说到扣减库存,秒杀系统和普通大型电商系统中的减库存还不尽相同,对于大型电商系统来说,我们常见的是购买下单后一般都有个有效付款时间,这种是减库存的常用三种方式之一:预扣库存,而另外两种方式分别有下单减库存付款减库存

  在电商平台秒杀购物场景中,用户的实际购买过程一般分为两步:下单和付款。你想买一台 13香(iphone13) 手机,在商品页面点了立即购买按钮,核对信息之后点击提交订单,这一步称为下单操作。下单之后,你只有真正完成付款操作才能算真正购买吧。

  那如果你是架构师,你会在哪个环节完成减库存的操作呢?

在这里插入图片描述

  总结来说,减库存操作一般有如下几个方式:

  • 下单减库存:即当买家下单后,在商品的总库存中减去买家购买数量。下单减库存是最简单的减库存方式,也是控制最精确的一种,下单时直接通过数据库的事务机制控制商品库存,这样一定不会出现超卖的情况。但是,不排除有些人下完单可能并不会付款~

  • 付款减库存:即买家下单后,并不立即减库存,而是等到有用户付款后才真正减库存,否则库存一直保留给其他买家。但因为付款时才减库存,如果并发比较高,有可能出现买家下单后付不了款的情况,因为可能商品已经被其他先一步支付的人买走了。

  • 预扣库存:这种方式相对复杂一些,买家下单后,库存为其保留一定的时间(如10分钟),超过这个时间,库存将会自动释放,释放后其他买家就可以继续购买。在买家付款前,系统会校验该订单的库存是否还有保留:如果没有保留,则再次尝试预扣;如果库存不足(也就是预扣失败)则不允许继续付款;如果预扣成功,则完成付款并实际地减去库存。

  由于购物过程中存在两步或者多步的操作,因此在不同的操作步骤中减库存,就会存在一些可能被恶意买家利用的漏洞,例如发生恶意下单的情况。

  假如我们采用下单减库存的方式,即用户下单后就减去库存,正常情况下,买家下单后付款的概率会很高,所以不会有太大问题,再加上卖家对秒杀商品的库存有严格限制,所以秒杀商品采用下单减库存更加合理。另外,理论上由于下单减库存预扣库存以及涉及第三方支付的付款减库存在逻辑上更为简单,所以性能上更占优势。

  而对于创建订单和减库存的事物问题,可以把两者放一起,分两步来做,先创建订单但是先不生效,然后减库存,如果减库存成功后再生效订单,否则订单不生效。

  但是有一种场景例外,就是当卖家参加某个活动时,此时活动的有效时间是商品的黄金售卖时间,如果有竞争对手通过恶意下单的方式将该卖家的商品全部下单,让这款商品的库存减为零,那么这款商品就不能正常售卖了。要知道,这些恶意下单的人是不会真正付款的,这正是下单减库存方式的不足之处。

  但在实际秒杀业务系统中,还是使用下单减库存形式,保证系统高性能,针对恶意刷单情况,会有具体的策略去应对。实在不行还会有planB兜底。


在这里插入图片描述

课间休息,来看看出差在外的小梅同学的临时搬砖工地,坐标:西安


面试题2:你们是怎么解决超卖问题的?

  首先,我们要明确超卖的原因:

  假设某个秒杀场景中,我们一共只有100个商品,在最后一刻,我们已经消耗了99个商品,仅剩最后一个。这个时候,系统发来多个并发请求,这批请求读取到的商品余量都是99个,然后都通过了这一个余量判断,最终导致超卖。让多个人抢购到了最后一个商品,这种在高并发的情况下很容易出现。

  解决线程安全的思路有很多种,常见的我们都应该知道的三种方式:

1、使用悲观锁(行锁)

  当查询某条记录时,即让数据库为该记录加锁,锁住记录后别人无法操作,使用类似如下语法:

select * from t where id=xx for update;

SKU.objects.select_for_update().get(id=xx)
  • 1
  • 2
  • 3

  悲观锁类似于我们在多线程资源竞争时添加的互斥锁,会有很多请求需要等待锁释放,某些线程可能永远都没有机会抢到这个锁,出现死锁现象,采用不多。

2、使用乐观锁(标记查询)

  乐观锁并不是真实存在的锁,而是在更新的时候判断此时的库存是否是之前查询出的库存,如果相同,表示没人修改,可以更新库存,否则表示别人抢过资源,不再执行库存更新。类似如下操作:

update t set stock=2 where id=xx and stock=3;

SKU.objects.filter(id=xx, stock=3).update(stock=2)
  • 1
  • 2
  • 3

  使用乐观锁需修改数据库的事务隔离级别:使用乐观锁的时候,如果一个事务修改了库存并提交了事务,那其他的事务应该可以读取到修改后的数据值,所以不能使用可重复读RR的隔离级别,应该修改为读取已提交(Read committed)。

3、加任务队列

  将下单的逻辑放到任务队列中(如celery),将并行转为串行,所有人排队下单。比如开启只有一个进程的Celery,一个订单一个订单的处理。


  后端的数据库在高并发和超卖下主要会有如下3个问题:(主要讨论写的问题,读的问题通过增加cache可以很容易的解决)

  1. 首先MySQL自身对于高并发的处理性能就会出现问题,一般来说,MySQL的处理性能会随着并发thread上升而上升,但是到了一定的并发度之后会出现明显的拐点(6个并发性能最优),之后一路下降,最终甚至会比单thread的性能还要差。

在这里插入图片描述

  1. 其次,超卖的根结在于减库存操作是一个事务操作,需要先select,然后insert,最后update -1。最后这个-1操作是不能出现负数的,但是当多用户在有库存的情况下并发操作,出现负数这是无法避免的。

  2. 最后,当减库存和高并发碰到一起的时候,由于操作的库存数目在同一行,就会出现争抢InnoDB行锁的问题,导致出现互相等待甚至死锁,从而大大降低MySQL的处理性能,最终导致前端页面出现超时异常。

  针对上述问题,如何解决呢? 我们先看眼淘宝的高大上解决方案:

  • 关闭死锁检测,提高并发处理性能。
  • 修改源代码,将排队提到进入引擎层前,降低引擎层面的并发度。
  • 组提交,降低server和引擎的交互次数,降低IO消耗。

  在林晓斌分享的《秒杀场景下MySQL的低效》一文中所有优化都使用后,TPS在高并发下,从原始的150飙升到8.5w,提升近566倍!来看下林晓斌老师团队的演示文稿:

在这里插入图片描述
  如上,这是通常数据库并发修改InnoDB的场景,当并发线程到6个以上时出现明显拐点,而且在秒杀中6个并发线程是远远满足不了需求的,那么该怎么办呢?

在这里插入图片描述
  如上,组提交,把三个A线程的提交内容整合到一起提交给MySQL,N倍量级的降低数据库交互次数,也提高N倍的并发能力。

在这里插入图片描述
从原始的150飙升到8.5w,提升近566倍!牛!

  这里是林晓斌老师的《秒杀场景下MySQL的低效.pdf》下载路径,大家可以拿去学习。
提取码:0915


  不过结合我们的实际,改源码这种高大上的解决方案显然有那么一点不切实际。于是我们需要讨论出一种适合我们实际情况的解决方案。

  首先设定一个前提,为了防止超卖现象,所有减库存操作都需要进行一次减后检查,保证减完不能等于负数。(由于MySQL事务的特性,这种方法只能降低超卖的数量,但是不可能完全避免超卖)

update number set x=x-1 where (x -1 ) >= 0;
  • 1

针对以上问题提出了四个解决方案,我们可以参考一下:


  • 解决方案1:

  将存库从MySQL前移到Redis中,所有的写操作放到内存中,由于Redis中不存在锁故不会出现互相等待,并且由于Redis的写性能和读性能都远高于MySQL,这就解决了高并发下的性能问题。然后通过队列等异步手段,将变化的数据异步写入到DB中。

  至于为什么不把库存的写和读都放弃redis里面,是因为redis的持久化没有mysql的好,所有操作都依赖Redis会带来一定的风险。

  • 优点:解决性能问题

  • 缺点:没有解决超卖问题,同时由于异步写入DB,存在某一时刻DB和Redis中数据不一致的风险。


  • 解决方案2:

  引入队列,然后将所有写DB操作在单队列中排队,完全串行处理。当达到库存阀值的时候就不在消费队列,并关闭购买功能。这就解决了超卖问题。

  • 优点:解决超卖问题,略微提升性能。

  • 缺点:性能受限于队列处理机处理性能和DB的写入性能中最短的那个,另外多商品同时抢购的时候需要准备多条队列。


  • 解决方案3:

  将写操作前移到MC中,同时利用MC的轻量级的锁机制CAS来实现减库存操作。

  • 优点:读写在内存中,操作性能快,引入轻量级锁之后可以保证同一时刻只有一个写入成功,解决减库存问题。

  • 缺点:没有实测,基于CAS的特性不知道高并发下是否会出现大量更新失败?不过加锁之后肯定对并发性能会有影响。


  • 解决方案4:

  将提交操作变成两段式,先申请后确认。然后利用Redis的原子自增操作(相比较MySQL的自增来说没有空洞),同时利用Redis的事务特性来发号,保证拿到小于等于库存阀值的号的人都可以成功提交订单。然后数据异步更新到DB中。

  • 优点:解决超卖问题,库存读写都在内存中,故同时解决性能问题。

  • 缺点:由于异步写入DB,可能存在数据不一致。另可能存在少买,也就是如果拿到号的人不真正下订单,可能库存减为0,但是订单数并没有达到库存阀值。


在这里插入图片描述

课间休息,好久没看我们家小哈了,这货从6月份的2.6斤涨到了现在的7斤。白嫖功夫着实厉害。


面试题3:缓存和数据库双写一致性是怎么做的?

  这是个特别经典的话题,也是业界讨论的焦点问题之一。经过翻阅了许多资料发现,大部分观点认为,做缓存不应该是去更新缓存,而是应该删除缓存,然后由下个请求去去缓存,发现不存在后再读取数据库,写入缓存。下面我们通过分析大佬们的观点来学习这个问题。


《分布式之数据库和缓存双写一致性方案解析》孤独烟老师:

  • 原因一:线程安全角度

同时有请求A和请求B进行更新操作,那么会出现

(1)线程A更新了数据库

(2)线程B更新了数据库

(3)线程B更新了缓存

(4)线程A更新了缓存

这就出现请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A更早更新了缓存。这就导致了脏数据,因此不考虑。

  • 原因二:业务场景角度

有如下两点:

(1)如果你是一个写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能。

(2)如果你写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。显然,删除缓存更为适合。


  其实如果业务非常简单,只是去数据库拿一个值,写入缓存,那么更新缓存也是可以的。但是,淘汰缓存操作简单,并且带来的副作用只是增加了一次cache miss,建议作为通用的处理方式。

那么问题就来了,我们是先删除缓存,然后再更新数据库,还是先更新数据库,再删缓存呢?


《【58沈剑架构系列】缓存架构设计细节二三事》58沈剑:

  对于一个不能保证事务性的操作,一定涉及“哪个任务先做,哪个任务后做”的问题,解决这个问题的方向是:如果出现不一致谁先做对业务的影响较小,就谁先执行

  假设先淘汰缓存,再写数据库:第一步淘汰缓存成功,第二步写数据库失败,则只会引发一次Cache miss

  假设先写数据库,再淘汰缓存:第一步写数据库操作成功,第二步淘汰缓存失败,则会出现DB中是新数据,Cache中是旧数据,数据不一致。

  沈剑老师说的没有问题,不过没完全考虑好并发请求时的数据脏读问题,让我们再来看看孤独烟老师《分布式之数据库和缓存双写一致性方案解析》:

先删缓存,再更新数据库。该方案会导致请求数据不一致

同时有一个请求A进行更新操作,另一个请求B进行查询操作。那么会出现如下情形:

(1)请求A进行写操作,删除缓存
(2)请求B查询发现缓存不存在
(3)请求B去数据库查询得到旧值
(4)请求B将旧值写入缓存
(5)请求A将新值写入数据库

  上述情况就会导致不一致的情形出现。而且,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。

  所以先删缓存,再更新数据库并不是一劳永逸的解决方案,再看看先更新数据库,再删缓存

  先更新数据库,再删缓存这种情况不存在并发问题么?

  不是的。假设这会有两个请求,一个请求A做查询操作,一个请求B做更新操作,那么会有如下情形产生。

(1)缓存刚好失效
(2)请求A查询数据库,得一个旧值
(3)请求B将新值写入数据库
(4)请求B删除缓存
(5)请求A将查到的旧值写入缓存

  如果发生上述情况,确实是会发生脏数据。
  然而,发生这种情况的概率又有多少呢?
  发生上述情况有一个先天性条件,就是步骤(3)的写数据库操作比步骤(2)的读数据库操作耗时更短,才有可能使得步骤(4)先于步骤(5)。可是,大家想想,数据库的读操作的速度远快于写操作的(不然做读写分离干嘛,做读写分离的意义就是因为读操作比较快,耗资源少),因此步骤(3)耗时比步骤(2)更短,这一情形很难出现

  先更新数据库,再删缓存依然会有问题,不过,问题出现的可能性会因为上面说的原因,变得比较低!

  所以,如果你想实现基础的缓存数据库双写一致的逻辑,那么在大多数情况下,在不想做过多设计,增加太大工作量的情况下,请先更新数据库,再删缓存!

那么,如果业务必须保证绝对一致性怎么办?

  没有办法做到绝对的一致性,这是由CAP理论决定的,缓存系统适用的场景就是非强一致性的场景,所以它属于CAP中的AP

  所以,只能委曲求全,做到BASE理论中说的最终一致性

  最终一致性强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。

  大佬们给出了到达最终一致性的解决思路,主要是针对上面两种双写策略(先删缓存,再更新数据库/先更新数据库,再删缓存)导致的脏数据问题,进行相应的处理,来保证最终一致性。

每日小结

  正所谓十个面试九个秒杀。虽然是开玩笑,但通过我和粉丝们交流中发现,秒杀这个事儿在面试中真的越来越常见了。嗨,多的不说了,今天的内容你做到心中有数了么?对了,如果你的朋友也在准备面试,请将这个系列扔给他,如果他认真对待,肯定会感谢你的!!好了,今天就到这里,学废了的同学,记得在评论区留言:打卡。,给同学们以激励。

参考资料

https://www.pianshen.com/article/63261266030/
https://www.cnblogs.com/billyxp/p/3701124.html
https://www.cnblogs.com/rude3knife/p/13429885.html

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/小小林熬夜学编程/article/detail/606318
推荐阅读
相关标签
  

闽ICP备14008679号