前言
Hi 你好,我是东东拿铁,95后奶爸程序员。
提起秒杀的扣库存,大家先回答下几个问题
-
扣库存的方式有哪几种,在用户做什么操作的时候去扣库存,各有什么优劣?
-
如何保证库存不超卖?不同方案有什么区别
-
当扣库存遇到性能瓶颈,如何处理
如果你能够有着很清晰的答案,那么大佬请收下我的膝盖,文中错误与不足请不吝指教,如果你还有着些许疑问,希望本文能够给你带来帮助。
本文将讲述一下,如何来做秒杀中的"减库存"。
架构设计
扣库存的几种方式
比较常见的有下面两种方式:
下单减库存
优点
买家下单后,直接把库存扣除,这样商品的库存一定是最准确的。
缺点
如果存在商家之间恶意竞争,在秒杀开始时进行抢购,但实际并不付款。这样的话,商家的秒杀活动就完全没有效果。
付款减库存
优点
下单后不减库存,用户完成付款后才真正减库存,如果不付款, 库存会保留给其他买家。
缺点
下单成功数,可能会远远多于库存数。也就导致了很多用户下完单后,在支付时,可能因为库存不足而支付失败
库存预热
为什么要库存预热
假设库存的数量全部放在DB中,那么商品的秒杀状态、扣库存操作全部都要去db中查询,对于秒杀场景来说,这是不能接受的。
怎么做
比如我们有一个茅台商品,那么我们可以把该商品的库存,进行分段,尽可能减少Redis的瓶颈,假设我们有100库存,那么我们可以分成5个key。
key1=maotai-01,value=20;
key2=maotai-02,value=20;
key3=maotai-03,value=20;
key4=maotai-04,value=20;
key5=maotai-05,value=20;
ruby
127.0.0.1:6379> incrby maotai-01 -1 #卖出扣减一个,返回剩余0,下单成功
(integer) 0
如何减库存
解决方案
-
通过事务解决,保证减完后不能为负数,否则回滚
-
设置数据库库存字段为无符号数,为负数直接数据库报错
-
使用库存判断语句
ini
update t_goods set inventory = inventory - #{num} where id = #{xxxx} AND (inventory - #{num}) >= 0
以上三种方式我们能够发现,无论如何,都需要有db来去抗最后一步,减库存的操作的。
但如果商品过热,在减库存时,我们都会用到MySQL的行锁来保证一致性。当大量线程来抢行锁的时候,并发度越高,等待的线程机会越多(排队),TPS会下降的很明显,吞吐量会收到比较大的影响。
极端情况下,单个过热的商品,可能会影响到整个数据库的性能。
行锁简介
MySQL 的行锁是在引擎层由各个引擎自己实现的。但并不是所有的引擎都支持行锁,比如MyISAM 引擎就不支持行锁。不支持行锁意味着并发控制只能使用表锁,对于这种引擎的表,同一张表上任何时刻只能有一个更新在执行,这就会影响到业务并发度,但InnoDB 是支持行锁的。
行锁就是针对数据表中行记录的锁。这很好理解,比如事务 A 更新了一行,而这时候事务 B 也要更新同一行,则必须等事务 A 的操作完成后才能进行更新。如上图
两阶段锁
假设我们有一个事务
sql
begin;
1.select * from t_goods where id = xxx;
2.update t_goods set inventory = inventory - #{num} where id = #{xxxx} AND (inventory - #{num}) >= 0
3.select * from t_goods where id = xxx;
commit;
上面有三个sql,我用序号给大家标注了,大家可以猜一下,行锁事什么时候加上的。
答案是2,虽然我们update语句之前还有一个sql语句,但实际上,InnoDB是在真正更新操作的时候,才会去加行锁。
但锁释放的时候,是在事务提交的时候,才会真正的释放。
所以你可以发现,我们要尽量减少加锁的时间,所以更新操作,尽量要把更新操作放在事务的最后,保证更新完就提交事务,及时的释放行锁。
死锁检测
还是以这个sql来举例
ini
update t_goods set inventory = inventory - #{num} where id = #{xxxx}
AND (inventory - #{num}) >= 0
在秒杀场景下,如果有大量线程更新库存,那么所有的线程都会排队,等待这一行行锁的释放。
目前InnoDB有两种策略:
- 一种策略是,直接进入等待,直到超时。这个超时时间可以通过参数
innodb_lock_wait_timeout 来设置。
- 另一种策略是,发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数innodb_deadlock_detect 设置为 on,表示开启这个逻辑。
innodb_lock_wait_timeout这个参数默认是50s,也就是说,一个线程要等待超过50s,这个事务才会被杀死,对于线上服务来说,这个等待是不可接受的。但是这个值我们很难去调整,比如设置成1s,那么可能就是单纯的等待,没有死锁,就会误杀。
所以我们一般会使用第二个方案,死锁检测。
当秒杀进行过程中,每个新来的被堵住的线程,都要判断会不会由于自己的加入导致了死锁,这是一个时间复杂度是 O(n) 的操作。假设有 1000 个并发线程要同时更新同一行,那么死锁检测操作就是
1000*1000 = 100 万这个量级的。虽然最终检测的结果是没有死锁,但是这期间要消耗大量的 CPU 资源。因此,数据库的CPU会飙升,但实际事务提交的速度会很慢。
那么对于秒杀场景来说,单行的并发无可避免,那么我们的解决思路就是尽可能的降低并发度,以下两种方案可以参考
-
考虑一行变为多行,其实类似于上面redis拆分key的思路,一个是为了避免热点导致redis达到网络瓶颈,而mysql拆分行可以极大的减少并发度。
-
并发度控制,比如削峰,让扣库存的并发控制在10左右,这样就不会有什么问题了,你可以参考我之前的文章 秒杀之削峰解耦
总结一下
这篇文章主要介绍了秒杀场景下扣库存的架构设计,稍带着普及一下MySQL行锁的相关知识。
文章只介绍了相关的架构设计方面,具体的细节其实还有很多需要思考的地方,比如拆行、拆key的时候,如果发生取消订单,那么对应库存需要加上,拆分行后,那就需要代码中有一些特殊处理。
Redis、MySQL的拆分设计,其实和分库分表等相关的思路一致,都是通过拆分的方式,解决可能会遇到的瓶颈,架构设计都是想通的,大家在后面的工作中,尽量去用这样的思路去思考,一通百通。
最后,如果本文对你有帮助,欢迎点赞评论,每一个评论我都会认真回答。也欢迎加我的wx:Ldhrlhy10,加我进群,一起进步,成为更好的自己。