gitbook/手把手带你搭建秒杀系统/docs/427445.md
2022-09-03 22:05:03 +08:00

173 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 10|不差毫厘:秒杀的库存与限购
你好,我是志东,欢迎和我一起从零打造秒杀系统。
你应该还记得,在介绍秒杀系统所面临的挑战时,我们就有提到库存超卖的问题,它是秒杀系统面临的几大挑战之一。而库存系统一般是商城平台的公共基础模块,负责所有商品可售卖数量的管理,对于库存系统来说,如果我只卖100件商品,那理想状态下,我希望外部系统就放过来100个下单请求就好了(以每单购买1件来说),因为再多的请求过来,库存不足,也会返回失败。
并且对于像秒杀这种大流量、高并发的业务场景,更不适合直接将全部流量打到库存系统,所以这个时候就需要有个系统能够承接大流量,并且只放和商品库存相匹配的请求量到库存系统,而限购就承担这样的角色。**限购之于库存,就像秒杀之于下单,前者都是后者的过滤网和保护伞。**
所以在有了限购系统之后,库存扣减的难题其实就转移到限购了。当然从纯技术的角度来说,不管是哪个系统来做库存的限制,高并发下库存扣减都是绕不开的难题。所以在今天这节课里,首先我们会了解限购的能力,然后会详细地讲解如何从技术角度解决库存超卖的问题。这样只要你学会了这类问题的解决方案和思路,不管是否做活动库存与真实库存的区分,都能从容应对。
## **限购**
顾名思义,限购的主要功能就是做商品的限制性购买。因为参加秒杀活动的商品都是爆品、稀缺品,所以为了让更多的用户参与进来,并让有限的投放量惠及到更多的人,所以往往会对商品的售卖做限制,一般限制的维度主要包括两方面。
**商品维度限制:**最基本的限制就是商品活动库存的限制,即每次参加秒杀活动的商品投放量。如果再细分,还可以支持针对不同地区做投放的场景,比如我只想在北京、上海、广州、深圳这些一线城市投放,那么就只有收货地址是这些城市的用户才能参与抢购,而且各地区库存量是隔离的,互不影响。
**个人维度限制:**就是以个人维度来做限制,这里不单单指同一用户ID,还会从同一手机号、同一收货地址、同一设备IP等维度来做限制。比如限制同一手机号每天只能下1单,每单只能购买1件,并且一个月内只能购买2件等。个人维度的限购,体现了秒杀的公平性。
有了这些功能支持之后,再做一个热门秒杀活动时,首先会在限购系统中配置活动库存以及各种个人维度的限购策略;然后在用户提单时,走下限购系统,通过限购的请求,再去做真实库存的扣减,这个时候到库存系统的量已经是非常小了。
该限购流程如下图所示:
![](https://static001.geekbang.org/resource/image/a7/b1/a755edd995f37468850dc23338bd53b1.jpg?wh=1234x732)
那么在介绍完限购之后,下面我再来详细说一下上图中活动库存扣减的实现方案。
## **活动库存扣减方案**
我们都知道,用户成功购买一个商品,对应的库存就要完成相应的扣减。而库存的扣减主要涉及到两个核心操作,一个是查询商品库存,另一个是在活动库存充足的情况下,做对应数量的扣减。两个操作拆分开来,都是非常简单的操作,但是在高并发场景下,不好的事情就发生了。
举个简单的例子,比如现在活动商品有2件库存,此时有两个并发请求过来,其中请求A要抢购1件,请求B要抢购2件,然后大家都去调用活动查询接口,发现库存都够,紧接着就都去调用对应的库存扣减接口,这个时候,两个都会扣减成功,但库存却变成了-1,也就是超卖了。
整个过程如下图所示:
![](https://static001.geekbang.org/resource/image/34/65/3432cfe3769def280f570ab27a550365.jpg?wh=1178x622)
从图中我们可以看到,库存超卖的问题主要是由两个原因引起的,一个是查询和扣减不是原子操作,另一个是并发引起的请求无序。
所以要解决这个问题,我们就得**做到库存扣减的原子性和有序性**。理想过程应该如下图所示:
![](https://static001.geekbang.org/resource/image/1e/31/1e42d6e7e2bb5efc95d789b71278c331.jpg?wh=1508x1060)
当然理想很美好,那我们该怎么去实现它呢?
你首先可能会想到利用数据库的行锁机制。这种方式的优点是简单安全,但是其性能比较差,无法适用于我们秒杀业务场景,在请求量比较小的业务场景下,是可以考虑的。
既然数据库不行,那能使用分布式锁吗?即通过Redis或者ZooKeeper来实现一个分布式锁,以商品维度来加锁,在获取到锁的线程中,按顺序去执行商品库存的查询和扣减,这样就同时实现了顺序性和原子性。
其实这个思路是可以的,只是不管通过哪种方式实现的分布式锁,都是有弊端的。以Redis的实现来说,仅仅在设置锁的有效期问题上,就让人头大。如果时间太短,那么业务程序还没有执行完,锁就自动释放了,这就失去了锁的作用;而如果时间偏长,一旦在释放锁的过程中出现异常,没能及时地释放,那么所有的业务线程都得阻塞等待直到锁自动失效,这与我们要实现高性能的秒杀系统是相悖的。所以**通过分布式锁的方式可以实现,但不建议使用。**
那还有其他方式吗?有!我们都知道Redis本身就是单线程的,天生就可以支持操作的顺序性,如果我们能在一次Redis的执行中,同时包含查询和扣减两个命令不就好了吗?庆幸的是,Redis确实能够支持。
Redis有个功能,是可以执行Lua脚本的(我们Nginx服务也有用到Lua语言,看来Lua语言的适用场景还真不少),并且可以保证脚本中的所有逻辑会在一次执行中按顺序完成。而在Lua脚本中,又可以调用Redis的原生API,这样就能同时满足顺序性和原子性的要求了。
当然这里的原子性说法可能不是很准确,因为Lua脚本并不会自动帮你完成回滚操作,所以如果你的脚本逻辑中包含两步写操作,需要自己去做回滚。好在我们库存扣减的逻辑针对Redis的命令就两种,一个读一个写,并且写命令在最后,这样就不存在需要回滚的问题了。
这里能帮我们实现Redis执行Lua脚本的命令有两个,一个是EVAL,另一个是EVALSHA。
原生EVAL方法的使用语法如下:
```plain
EVAL script numkeys key [key ...] arg [arg ...]
```
其中EVAL是命令,script是我们Lua脚本的字符串形式,numkeys是我们要传入的参数数量,key 是我们的入参,可以传入多个,arg 是额外的入参。
但这种方式需要每次都传入Lua脚本字符串,不仅浪费网络开销,同时Redis需要每次重新编译Lua脚本,对于我们追求性能极限的系统来说,不是很完美。
所以这里就要说到另一个命令EVALSHA了,原生语法如下:
```plain
EVALSHA sha1 numkeys key [key ...] arg [arg ...]
```
可以看到其语法与EVAL类似,不同的是这里传入的不是脚本字符串,而是一个加密串sha1。这个sha1是从哪来的呢?它是通过另一个命令SCRIPT LOAD 返回的,该命令是预加载脚本用的,语法为:
```plain
SCRIPT LOAD script
```
这样的话,我们通过预加载命令,将Lua脚本先存储在Redis中,并返回一个sha1,下次要执行对应脚本时,只需要传入sha1即可执行对应的脚本。这完美地解决了EVAL命令存在的弊端,所以我们这里也是基于EVALSHA方式来实现的。
既然有了思路,也有了方案,那我们开始用代码实现它吧。
首先我们根据以上介绍的库存扣减核心操作,完成核心Lua脚本的编写。其主要实现的功能就是查询库存并判断库存是否充足,如果充足,则做相应的扣减操作,脚本内容如下:
```plain
-- 调用Redis的get指令,查询活动库存,其中KEYS[1]为传入的参数1,即库存key
local c_s = redis.call('get', KEYS[1])
-- 判断活动库存是否充足,其中KEYS[2]为传入的参数2,即当前抢购数量
if not c_s or tonumber(c_s) < tonumber(KEYS[2]) then
return 0
end
-- 如果活动库存充足,则进行扣减操作。其中KEYS[2]为传入的参数2,即当前抢购数量
redis.call('decrby',KEYS[1], KEYS[2])
```
然后我们将Lua脚本转成字符串,并添加脚本预加载机制。
预加载可以有多种实现方式,一个是外部预加载好,生成了sha1然后配置到配置中心,这样Java代码从配置中心拉取最新sha1即可。另一种方式是在服务启动时,来完成脚本的预加载,并生成单机全局变量sha1。我们这里先采取第二种方式,代码结构如下图所示:
![图片](https://static001.geekbang.org/resource/image/47/7d/47c9b70d3ee184f6f822cf93a7dd997d.png?wh=849x624)
以上是将Lua脚本转成字符串形式,并通过@PostConstruct完成脚本的预加载。然后新增EVALSHA方法,如下图所示:
![图片](https://static001.geekbang.org/resource/image/1a/62/1a1371b717a91yy229c6bfe09afbe762.png?wh=891x522)
方法入参为活动商品库存key以及单次抢购数量,并在内部调用Lua脚本执行库存扣减操作。看起来是不是很简单?在写完底层核心方法之后,我们只需要在下单之前,调用该方法即可,具体如下图所示:
![图片](https://static001.geekbang.org/resource/image/20/76/20a3cd59468619a6b2f55732458f2276.png?wh=879x309)
一切完成后,接下来就让我们来验证一下,是否会出现超卖的情况吧。
## 模拟场景
我们模拟的场景是这样的:
* 首先,通过前文中提到的活动创建接口,完成活动的创建;
* 然后,调用活动开始接口,并将商品活动信息同步到Redis里,包括商品活动库存;
* 接着,我们通过并发测试工具,直接模拟请求下单操作;
* 最后,请求在经过限购(代码中直接调用EVALSHA核心方法模拟)时,判断是否通过,如果通过就继续下单,并完成数据库中库存的扣减,如果售空,则返回失败。
我们按照模拟思路,先创建一个活动,数据库库存为4,然后调用活动开始接口,活动信息如下图所示:
![图片](https://static001.geekbang.org/resource/image/3b/df/3b0072f152aacb4d9096df46d6f93bdf.png?wh=832x314)
再查看一下该活动对应的Redis活动库存,也是4件,如下图所示:
![图片](https://static001.geekbang.org/resource/image/57/65/57239bdc7c8ed19c9759094f432aa965.png?wh=832x134)
然后我们开始模拟1秒内发出多个并发请求,每个请求抢购2件商品。我这里使用的是wrk工具做的测试,测试命令如下:
```plain
wrk -t3 -c3 -d1s http://localhost:8080//settlement/submitData?productId=20002001
```
以上命令的大概意思是使用3个线程来做压测,持续时间1秒。执行后的测试结果如下:
![图片](https://static001.geekbang.org/resource/image/c4/d0/c4a65e2b027d025af53152c5ec13cfd0.png?wh=832x206)
从上图可以看到,在1秒内发出了15个请求。现在我们看下限购的结果,1代表通过,0代表不通过,具体如下图所示:
![图片](https://static001.geekbang.org/resource/image/fc/62/fc2835653c7412c22dc5b73b043f9862.png?wh=845x193)
确实只有2个请求通过限购,其他的全部被拦截了。这个时候,我们再分别查看下Redis活动库存和数据库库存,如下图所示:![图片](https://static001.geekbang.org/resource/image/4c/4d/4c86e82e0a4dba7880b8d3ca3abb9d4d.png?wh=758x202)
![图片](https://static001.geekbang.org/resource/image/49/d7/49dd3bbd73be597622432859da5a72d7.png?wh=851x334)
库存数量都变成了0,没有出现超卖的情况!一切都完美符合我们的预期。
## **总结**
这节课我们分析了库存系统的业务边界,由于是电商平台的基础系统,并且基于秒杀业务隔离的原则,使得库存系统不太适合直接承接秒杀的高并发流量,需要有个过滤层。而限购系统刚好可以胜任这样的角色,限购可以从商品和个人的维度来做商品的限制性购买,从而可以帮库存系统抵挡住无效的流量,只放过和商品库存相匹配的请求数量。
当然不管是哪个系统来做库存的控制,都要面临的问题就是库存的精确控制,所以我们从纯技术的角度分析了库存超卖发生的两个原因。一个是库存扣减涉及到的两个核心操作,查询和扣减不是原子操作;另一个是高并发引起的请求无序。
所以我们的应对方案是利用Redis的单线程原理,以及提供的原生EVALSHA和SCRIPT LOAD 命令来实现库存扣减的原子性和顺序性,并且经过实测也确实能达到我们的预期,且性能良好,从而有效地解决了秒杀系统所面临的库存超卖挑战。以后再遇到类似的问题,你也可以用同样的解决思路来应对。
## 思考题
请你思考一下,根据我们校验前置的原则,是否可以仅仅将库存的校验前置到demo-nginx或demo-web中,像下图所示:
![](https://static001.geekbang.org/resource/image/4c/c6/4c6ef532c64c3ddc2a54a6e05deee4c6.jpg?wh=1814x858)
如果可以,该如何具体实现它?
期待你的思考和方案,也欢迎你在留言区中与我交流,我们下节课再见!