电商秒杀方案
限制前端请求
限制前端发来的请求量
譬如定在了周二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分钟后,没付款的被丢回库存池里再卖也一样。