Jade Dungeon

电商秒杀方案

限制前端请求

限制前端发来的请求量

譬如定在了周二10点开启抢购,那么在之前的一周时间内,都会有预约通知, 或者普通的用户浏览。通过预约量、浏览量等数据分析, 大概能预估到在周二会参与“点击抢购按钮”的人数。譬如有500万。

此时,我们是知道实际商品数量的,譬如20万。

那么我是没有必要让这500万个请求都到后台的,我最多最多放200万个请求到后台。 其他的300万直接就在前端网页看单机动画就好了。

这一步做起来很简单,20万个商品,我提前生成200万个token,在用户点击预约、 或者浏览该商品时,就按规则发放出去。(规则可以是譬如公平模式, 某个用户id已经预约多次了,还没抢到,那么给他token。也可以就是随机发放, 5天的预热时间,每天发4万个就好)

前端接收到是否能参与秒杀的反馈后,就保存在浏览器本地就好,当秒杀开始时, 没得到token的用户,就只好在本地看单机动画,过几秒告诉他商品不足就好了。

那些幸运的得到了token的用户,就有了给后台发请求参加秒杀的机会了, 此时还需要前端(APP客户端)来对请求进行控制,因为用户喜欢反复点击、 反复刷新页面等手段来参加抢购,这时就不能再放重复请求进后台了, 哪怕是他重复点击了,也要保证请求不反复发送。

对于大部分吃瓜群众来说,只会操作页面的就通过这种方式控制, 但对于程序员们就不行了,即便是你在抢购开始前,没有暴露抢购的接口, 但在抢购开始的一瞬间,他们依旧能搞到你的下单接口地址, 并开始用程序频繁提交下单请求。

网关限制请求

用程序下单对程序员们都懂,拼接好请求的各个参数,开启并发提交到服务器。

到了这一步,已经不归前端管了,请求会直达负载均衡器,然后到后台网关。

在网关里要控制好这部分请求, 要以最快的速度判断出来的每一个请求是否放行到后面的服务。

网关的实现方案有很多,kong(nginx+lua),Gateway,zuul等。 在网关里可以简单的实现限流机制,我们主要限制的有如下几种:

  • 黑名单(ip、用户id等),可以直接放内存里
  • 过多的重复请求(可以采用redis集群计数,对同一个ip、id发起的重复请求给予拒绝) ,考虑到redis的带宽、性能瓶颈,可以考虑做分片,或者做二级缓存, 直接在jvm内存里统计计数
  • 没有token的请求,就是之前放出去那批token

限制了非常规请求后,我们假如还有100万个请求在2秒内打到了服务端, 这依旧是非常恐怖的数字,即便你有10台服务器,还是有大概率被打满CPU, 后面的请求就有面临5秒超时的风险。

此时,我们要做的就是尽快处理完前面的请求,把商品赶紧卖光。100万个请求, 20万个商品,那肯定是不能让那80万请求去触碰下单的服务的, 我们要在网关处就终结掉这80万个请求,给他们交代你来晚了。

此时你需要令牌桶,如guava的rateLimer就可以,简单好用。 譬如我有20个zuul网关服务在运行,单个服务要承担5万个请求, 单个tomcat在不做复杂计算、不做数据库操作,做到1-2千的QPS还是可以的。

我每一个zuul服务里譬如开辟1.5万个令牌桶,在1-3秒内放完, 得不到令牌桶的就直接返回失败就行了。在这一步失败的耗时会很短, 因为在网关层就失败了,不会进入到后面的下单流程。

请注意,这一步是没有用消息队列的,因为大部分请求是要被拒绝的, 需要尽快的返回拒绝信息,进队列再慢慢消费就慢了。

令牌桶签发完毕,剩下的请求都是幸运儿,就可以进入到后面的下单流程了。

防超卖

从上面的流程看,我们通过令牌桶放出去的令牌数是大于商品数量的, 那么就面临超卖问题。

超卖在分布式环境下,方案就是分布式锁,譬如redisson的分布式锁, 可以针对商品id加分布式锁。

问题又出来了,如果商品数量很少,几百几千个,通过分布式锁也能很快的处理完。 实测,redis加锁、释放锁耗时约1ms,再加上客户端逻辑处理时间,按下一单要5-10ms (非常急速了),那么一秒在对同一个分布式锁的操作上,也就百单而已。

可以发现,通过对同一个商品id加分布式锁,商品数量巨多时就麻烦了, 因为是对商品id加锁,那么上锁解锁这个动作被执行几十万次,时间耗费巨大。

那么这个分布式锁就会有严重的性能问题,就要再次对商品数量进行分片, 譬如一个用来分布式锁的redis key,只放500个商品数量,耗完结束, 对应这个key的请求就算全部卖光。譬如商品id是10,那么我们就用goodsId-10-1, goodsId-10-2,goodsId-10-3这样,建立count/500个key,当请求来时, 按照hash将分布式锁加到不同的key上。这样也能大幅提高分布式锁的性能。

当然这样会造成redis压力巨大,再将redis做个集群也行。总的来说这个方案貌似很复杂, 而且很难控制,譬如比较难以控制不同key的余量消耗。

那么太复杂的方案就还是抛弃。我们直接在服务实例里写商品数量, 这样直接在内存里判断商品剩余量,谁也不通信了,性能达到极致。

譬如我们部署了20个订单实例,20万个商品,我们将服务接入配置中心(Apollo, disconf,nacos之类的),通过配置中心来下发每个实例的商品数量,而且可以动态控制。 可以在抢购开始前,通过配置中心下发到每个服务1万个商品数量, 当这个实例将内存里的商品数量消耗完毕,就算售罄。当然,由于服务的处理速度, 和请求的不均匀,可能导致某个实例早早售罄,别的实例还有大量剩余。 也就是在页面上比你晚的人,来了还能买到,而你早早售罄了。那就不要怪程序员了。

要是中途个别实例挂掉了怎么办?挂掉了我们就不管它了。不要为它再设置什么复杂逻辑了 。大不了少卖一些而已。既然是售罄,卖20万个,和卖了19万3千个,也没什么区别。 可以等其他实例全卖光后,统计一下redis的订单数量,譬如卖了19万个3千, 再把它启动起来,设置个7000的剩余量,这样也行。但这不重要了。 可以将这部分放到30分钟后,没付款的被丢回库存池里再卖也一样。