车票
- 面试题1:在秒杀业务流程中是怎么控制减库存的?
- 面试题2:你们是怎么解决超卖问题的?
- 面试题3:缓存和数据库双写一致性是怎么做的?
- 每日小结
本栏目Java开发岗高频面试题主要出自以下各技术栈:Java基础知识
、集合容器
、并发编程
、JVM
、Spring全家桶
、MyBatis等ORMapping框架
、MySQL数据库
、Redis缓存
、RabbitMQ消息队列
、Linux操作技巧
等。
面试题1:在秒杀业务流程中是怎么控制减库存的?
说到扣减库存,秒杀系统和普通大型电商系统中的减库存还不尽相同,对于大型电商系统来说,我们常见的是购买下单后一般都有个有效付款时间
,这种是减库存的常用三种方式之一:预扣库存
,而另外两种方式分别有下单减库存
、付款减库存
。
在电商平台秒杀购物场景中,用户的实际购买过程一般分为两步:下单和付款。你想买一台 13香(iphone13) 手机,在商品页面点了立即购买
按钮,核对信息之后点击提交订单
,这一步称为下单操作。下单之后,你只有真正完成付款操作才能算真正购买吧。
那如果你是架构师,你会在哪个环节完成减库存的操作呢?
总结来说,减库存操作一般有如下几个方式:
-
下单减库存
:即当买家下单后,在商品的总库存中减去买家购买数量。下单减库存是最简单的减库存方式,也是控制最精确的一种,下单时直接通过数据库的事务机制控制商品库存,这样一定不会出现超卖的情况。但是,不排除有些人下完单可能并不会付款~ -
付款减库存
:即买家下单后,并不立即减库存,而是等到有用户付款后才真正减库存,否则库存一直保留给其他买家。但因为付款时才减库存,如果并发比较高,有可能出现买家下单后付不了款的情况,因为可能商品已经被其他先一步支付的人买走了。 -
预扣库存
:这种方式相对复杂一些,买家下单后,库存为其保留一定的时间(如10分钟),超过这个时间,库存将会自动释放,释放后其他买家就可以继续购买。在买家付款前,系统会校验该订单的库存是否还有保留:如果没有保留,则再次尝试预扣;如果库存不足(也就是预扣失败)则不允许继续付款;如果预扣成功,则完成付款并实际地减去库存。
由于购物过程中存在两步或者多步的操作,因此在不同的操作步骤中减库存,就会存在一些可能被恶意买家利用的漏洞,例如发生恶意下单的情况。
假如我们采用下单减库存
的方式,即用户下单后就减去库存,正常情况下,买家下单后付款的概率会很高,所以不会有太大问题,再加上卖家对秒杀商品的库存有严格限制,所以秒杀商品采用下单减库存
更加合理。另外,理论上由于下单减库存
比预扣库存
以及涉及第三方支付的付款减库存
在逻辑上更为简单,所以性能上更占优势。
而对于创建订单和减库存的事物问题,可以把两者放一起,分两步来做,先创建订单但是先不生效,然后减库存,如果减库存成功后再生效订单,否则订单不生效。
但是有一种场景例外,就是当卖家参加某个活动时,此时活动的有效时间是商品的黄金售卖时间,如果有竞争对手通过恶意下单的方式将该卖家的商品全部下单,让这款商品的库存减为零,那么这款商品就不能正常售卖了。要知道,这些恶意下单的人是不会真正付款的,这正是下单减库存
方式的不足之处。
但在实际秒杀业务系统中,还是使用下单减库存形式,保证系统高性能,针对恶意刷单情况,会有具体的策略去应对。实在不行还会有planB兜底。
面试题2:你们是怎么解决超卖问题的?
首先,我们要明确超卖的原因:
假设某个秒杀场景中,我们一共只有100个商品,在最后一刻,我们已经消耗了99个商品,仅剩最后一个
。这个时候,系统发来多个并发请求,这批请求读取到的商品余量都是99个
,然后都通过了这一个余量判断,最终导致超卖
。让多个人抢购到了最后一个商品,这种在高并发的情况下很容易出现。
解决线程安全的思路有很多种,常见的我们都应该知道的三种方式:
1、使用悲观锁
(行锁)
当查询某条记录时,即让数据库为该记录加锁,锁住记录后别人无法操作,使用类似如下语法:
代码语言:javascript复制select * from t where id=xx for update;
SKU.objects.select_for_update().get(id=xx)
悲观锁类似于我们在多线程资源竞争时添加的互斥锁,会有很多请求需要等待锁释放
,某些线程可能永远都没有机会抢到这个锁,出现死锁现象,采用不多。
2、使用乐观锁
(标记查询)
乐观锁并不是真实存在的锁,而是在更新的时候判断此时的库存是否是之前查询出的库存,如果相同,表示没人修改,可以更新库存,否则表示别人抢过资源,不再执行库存更新。类似如下操作:
代码语言:javascript复制update t set stock=2 where id=xx and stock=3;
SKU.objects.filter(id=xx, stock=3).update(stock=2)
使用乐观锁需修改数据库的事务隔离级别:使用乐观锁的时候,如果一个事务修改了库存并提交了事务,那其他的事务应该可以读取到修改后的数据值,所以不能使用可重复读RR
的隔离级别,应该修改为读取已提交(Read committed
)。
3、加任务队列
将下单的逻辑放到任务队列中(如celery),将并行转为串行
,所有人排队下单。比如开启只有一个进程的Celery,一个订单一个订单的处理。
后端的数据库在高并发和超卖下主要会有如下3个问题:(主要讨论写的问题,读的问题通过增加cache可以很容易的解决)
- 首先
MySQL自身对于高并发的处理性能就会出现问题
,一般来说,MySQL的处理性能会随着并发thread上升而上升,但是到了一定的并发度之后会出现明显的拐点(6个并发性能最优
),之后一路下降,最终甚至会比单thread的性能还要差。
- 其次,超卖的根结在于减库存操作是一个事务操作,需要先select,然后insert,最后update -1。
最后这个-1操作是不能出现负数的
,但是当多用户在有库存的情况下并发操作,出现负数这是无法避免的。 - 最后,当减库存和高并发碰到一起的时候,由于操作的库存数目在同一行,就会出现
争抢InnoDB行锁
的问题,导致出现互相等待甚至死锁
,从而大大降低MySQL的处理性能,最终导致前端页面出现超时异常。
针对上述问题,如何解决呢? 我们先看眼淘宝的高大上解决方案:
- 关闭死锁检测,提高并发处理性能。
- 修改源代码,将排队提到进入引擎层前,降低引擎层面的并发度。
- 组提交,降低server和引擎的交互次数,降低IO消耗。
在林晓斌分享的《秒杀场景下MySQL的低效》一文中所有优化都使用后,TPS在高并发下,从原始的150飙升到8.5w,提升近566倍!来看下林晓斌老师团队的演示文稿:
如上,这是通常数据库并发修改InnoDB的场景,当并发线程到6个以上时出现明显拐点,而且在秒杀中6个并发线程是远远满足不了需求的,那么该怎么办呢?
如上,组提交,把三个A线程的提交内容整合到一起提交给MySQL,N倍量级的降低数据库交互次数,也提高N倍的并发能力。
不过结合我们的实际,改源码这种高大上的解决方案显然有那么一点不切实际。于是我们需要讨论出一种适合我们实际情况的解决方案。
首先设定一个前提,为了防止超卖现象,所有减库存操作都需要进行一次减后检查
,保证减完不能等于负数。(由于MySQL事务的特性,这种方法只能降低超卖的数量,但是不可能完全避免超卖)
update number set x=x-1 where (x -1 ) >= 0;
针对以上问题提出了四个解决方案,我们可以参考一下:
- 解决方案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,但是订单数并没有达到库存阀值。
面试题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