秒杀系统如何做?秒杀架构系列之“减库存”

前言

Hi 你好,我是东东拿铁,95后奶爸程序员。

提起秒杀的扣库存,大家先回答下几个问题

  1. 扣库存的方式有哪几种,在用户做什么操作的时候去扣库存,各有什么优劣?

  2. 如何保证库存不超卖?不同方案有什么区别

  3. 当扣库存遇到性能瓶颈,如何处理

如果你能够有着很清晰的答案,那么大佬请收下我的膝盖,文中错误与不足请不吝指教,如果你还有着些许疑问,希望本文能够给你带来帮助。

本文将讲述一下,如何来做秒杀中的"减库存"。

架构设计

扣库存的几种方式

比较常见的有下面两种方式:

下单减库存

优点

买家下单后,直接把库存扣除,这样商品的库存一定是最准确的。

缺点

如果存在商家之间恶意竞争,在秒杀开始时进行抢购,但实际并不付款。这样的话,商家的秒杀活动就完全没有效果。

付款减库存

优点

下单后不减库存,用户完成付款后才真正减库存,如果不付款, 库存会保留给其他买家。

缺点

下单成功数,可能会远远多于库存数。也就导致了很多用户下完单后,在支付时,可能因为库存不足而支付失败

库存预热

为什么要库存预热

假设库存的数量全部放在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

如何减库存

解决方案

  1. 通过事务解决,保证减完后不能为负数,否则回滚

  2. 设置数据库库存字段为无符号数,为负数直接数据库报错

  3. 使用库存判断语句

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有两种策略:

  1. 一种策略是,直接进入等待,直到超时。这个超时时间可以通过参数

innodb_lock_wait_timeout 来设置。

  1. 另一种策略是,发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数innodb_deadlock_detect 设置为 on,表示开启这个逻辑。

innodb_lock_wait_timeout这个参数默认是50s,也就是说,一个线程要等待超过50s,这个事务才会被杀死,对于线上服务来说,这个等待是不可接受的。但是这个值我们很难去调整,比如设置成1s,那么可能就是单纯的等待,没有死锁,就会误杀。

所以我们一般会使用第二个方案,死锁检测。

当秒杀进行过程中,每个新来的被堵住的线程,都要判断会不会由于自己的加入导致了死锁,这是一个时间复杂度是 O(n) 的操作。假设有 1000 个并发线程要同时更新同一行,那么死锁检测操作就是

1000*1000 = 100 万这个量级的。虽然最终检测的结果是没有死锁,但是这期间要消耗大量的 CPU 资源。因此,数据库的CPU会飙升,但实际事务提交的速度会很慢。

那么对于秒杀场景来说,单行的并发无可避免,那么我们的解决思路就是尽可能的降低并发度,以下两种方案可以参考

  1. 考虑一行变为多行,其实类似于上面redis拆分key的思路,一个是为了避免热点导致redis达到网络瓶颈,而mysql拆分行可以极大的减少并发度。

  2. 并发度控制,比如削峰,让扣库存的并发控制在10左右,这样就不会有什么问题了,你可以参考我之前的文章 秒杀之削峰解耦

总结一下

这篇文章主要介绍了秒杀场景下扣库存的架构设计,稍带着普及一下MySQL行锁的相关知识。

文章只介绍了相关的架构设计方面,具体的细节其实还有很多需要思考的地方,比如拆行、拆key的时候,如果发生取消订单,那么对应库存需要加上,拆分行后,那就需要代码中有一些特殊处理。

Redis、MySQL的拆分设计,其实和分库分表等相关的思路一致,都是通过拆分的方式,解决可能会遇到的瓶颈,架构设计都是想通的,大家在后面的工作中,尽量去用这样的思路去思考,一通百通。

最后,如果本文对你有帮助,欢迎点赞评论,每一个评论我都会认真回答。也欢迎加我的wx:Ldhrlhy10,加我进群,一起进步,成为更好的自己。

相关推荐
小蜗牛慢慢爬行25 分钟前
如何在 Spring Boot 微服务中设置和管理多个数据库
java·数据库·spring boot·后端·微服务·架构·hibernate
wm10431 小时前
java web springboot
java·spring boot·后端
小扳3 小时前
微服务篇-深入了解 MinIO 文件服务器(你还在使用阿里云 0SS 对象存储图片服务?教你使用 MinIO 文件服务器:实现从部署到具体使用)
java·服务器·分布式·微服务·云原生·架构
龙少95433 小时前
【深入理解@EnableCaching】
java·后端·spring
溟洵5 小时前
Linux下学【MySQL】表中插入和查询的进阶操作(配实操图和SQL语句通俗易懂)
linux·运维·数据库·后端·sql·mysql
SomeB1oody7 小时前
【Rust自学】6.1. 定义枚举
开发语言·后端·rust
SomeB1oody7 小时前
【Rust自学】5.3. struct的方法(Method)
开发语言·后端·rust
古木20198 小时前
前端面试宝典
前端·面试·职场和发展
啦啦右一9 小时前
Spring Boot | (一)Spring开发环境构建
spring boot·后端·spring
森屿Serien9 小时前
Spring Boot常用注解
java·spring boot·后端