秒杀系统设计

1. 前后分离

  • 活动页面绝大多数内容是固定的,比如:商品名称、商品描述、图片等。为了减少不必要的服务端请求,通常情况下,会对活动页面做静态化处理。
  • CDN

2. 库存缓存

如果有数十万的请求过来,同时通过数据库查缓存是否足够,此时数据库可能会挂掉。因为数据库的连接资源非常有限,比如:mysql,无法同时支持这么多的连接。而应该改用缓存,比如:redis

2.1 缓存击穿

在高并发下,同一时刻会有大量的请求,都在秒杀同一件商品,这些请求同时去查缓存中没有数据,然后又同时访问数据库。结果悲剧了,数据库可能扛不住压力,直接挂掉。

如何解决这个问题呢?

这就需要加锁,最好使用分布式锁

缓存击穿

当然,针对这种情况,最好在项目启动之前,先把缓存进行预热。即事先把所有的商品,同步到缓存中,这样商品基本都能直接从缓存中获取到,就不会出现缓存击穿的问题了。

是不是上面加锁这一步可以不需要了?

表面上看起来,确实可以不需要。但如果缓存中设置的过期时间不对,缓存提前过期了,或者缓存被不小心删除了,如果不加速同样可能出现缓存击穿。

其实这里加锁,相当于买了一份保险。

2.2 缓存穿透

如果有大量的请求传入的商品id,在缓存中和数据库中都不存在,这些请求不就每次都会穿透过缓存,而直接访问数据库了。

由于前面已经加了锁,所以即使这里的并发量很大,也不会导致数据库直接挂掉。

但很显然这些请求的处理性能并不好,有没有更好的解决方案?

这时可以想到布隆过滤器

布隆过滤器

秒杀开始前,将所有商品id初始化到布隆过滤器,不存在需要更新布隆过滤器的情况,如果前端请求传来不存在的id的,那么请求大概率会被布隆过滤器拦截(被布隆过滤器命中的id不一定是真的存在,但是没命中的id一定不存在)。

3. 库存问题

真正的秒杀商品的场景,不是说扣完库存,就完事了,如果用户在一段时间内,还没完成支付,扣减的库存是要加回去的。

所以,在这里引出了一个预扣库存的概念,预扣库存的主要流程如下:

预扣库存

扣减库存中除了上面说到的预扣库存回退库存之外,还需要特别注意的是库存不足和库存超卖问题。

3.1 预扣库存

虽然前面我们我做了分布式锁、缓存等措施,防止过多查询请求直接去到数据库,但是这些措施都是针对查询请求的,扣减库存的时候仍然可能有大量请求并发去到数据库,例如:同时有10万个请求查询缓存得到当前库存量不等于0,它们接下来全都要去数据库扣减库存。

update product set stock=stock-1 where id=product and stock > 0;

上面的sql有点类似数据库乐观锁的思想,保证不会超卖。

这里我们可以采取限流措施(可以用sentinel等),例如:虽然有10万个请求将要扣减库存,但是我们只分配1000个数据库扣减名额(sentinel令牌桶🪣),甚至都不用分配1000个名额,因为秒杀商品数量肯定是已知的,假设秒杀商品数量为10个,那我们只需要放行10个扣减库存的请求去到数据库即可。

但是,还有一类情况,就是参与秒杀的商品数量确实很多(比如小米手机刚面世的时候经常搞饥饿营销,几百万甚至几千万的请求去抢几十万的手机😔),远大于数据库能承受的并发能力。

这里我们可以采取 限流 + 延迟重试 ,例如:我们的秒杀商品有1万个,100万个请求要去扣减库存,但是数据库只能承受1000个并发,那我们就限流每秒只有1000个请求去到数据库,让剩下的请求延迟50毫秒再重试,如果业务能允许用户一直等待秒杀结果,那就一直循环重试到有资格去数据库扣减库存或者1万个秒杀商品被抢完,抢购时间可能持续几分钟甚至更久,让用户一直等待结果也不合理,这时我们循环重试一定次数后如果还没有资格,那就先响应这个请求,并且把这个请求扔到延迟队列继续重试。

这里为什么不直接响应没获取到数据库扣减库存资格的请求呢?为什么不让其稍后再试呢?这主要是考虑到秒杀公平的问题,例如:小明在第1秒发起秒杀请求,但是因为秒杀系统数据库并发能力不够,你让小明稍后重试,但是注意这个时候秒杀的商品是足够的,第2秒小王发起秒杀并成功,第3秒小明再次秒杀发现商品已经被抢光,这对小明来说明显不公平的。

3.2 回退库存

通常情况下,如果用户秒杀成功了,下单之后,在15分钟之内还未完成支付的话,该订单会被自动取消,回退库存。

那么,在15分钟内未完成支付,订单被自动取消的功能,要如何实现呢?

我们首先想到的可能是job,因为它比较简单。

但job有个问题,需要每隔一段时间处理一次,实时性不太好。

还有更好的方案?

答:使用延迟队列

我们都知道rocketmq,自带了延迟队列的功能。

库存回退

下单时消息生产者会先生成订单,此时状态为待支付,然后会向延迟队列中发一条消息。达到了延迟时间,消息消费者读取消息之后,会查询该订单的状态是否为待支付。如果是待支付状态,则会更新订单状态为取消状态。如果不是待支付状态,说明该订单已经支付过了,则直接返回。

当然需要完成上述功能,需要用户完成支付之后,修改订单状态为已支付。具体来说,支付系统完成支付之后,回调秒杀系统这边提供的回调接口去修改订单支付状态。

已支付

4. 接口限流

通过秒杀活动,如果我们运气爆棚,可能会用非常低的价格买到不错的商品(这种概率堪比买福利彩票中大奖)。

但有些高手,并不会像我们一样老老实实,通过秒杀页面点击秒杀按钮,抢购商品。他们可能在自己的服务器上,模拟正常用户登录系统,跳过秒杀页面,直接调用秒杀接口。

如果是我们手动操作,一般情况下,一秒钟只能点击一次秒杀按钮。

手动操作

但是如果是服务器,一秒钟可以请求成上千接口。

非法操作

这种差距实在太明显了,如果不做任何限制,绝大部分商品可能是被机器抢到,而非正常的用户,有点不太公平。

所以,我们有必要识别这些非法请求,做一些限制。那么,我们该如何现在这些非法请求呢?

目前有两种常用的限流方式:

  1. 基于nginx限流
  2. 基于redis限流

4.1 对同一用户限流

限制同一个用户id,比如每分钟只能请求5次接口。

4.2 对同一ip限流

有时候只对某个用户限流是不够的,有些高手可以模拟多个用户请求,这种nginx就没法识别了。

这时需要加同一ip限流功能。限制同一个ip,比如每分钟只能请求5次接口。

但这种限流方式可能会有误杀的情况,比如同一个公司或网吧的出口ip是相同的,如果里面有多个正常用户同时发起请求,有些用户可能会被限制住。

4.3 对接口限流

别以为限制了用户和ip就万事大吉,有些高手甚至可以使用代理,每次都请求都换一个ip。

这时可以限制请求的接口总次数。

在高并发场景下,这种限制对于系统的稳定性是非常有必要的。但可能由于有些非法请求次数太多,达到了该接口的请求上限,而影响其他的正常用户访问该接口。看起来有点得不偿失。

4.4 加验证码

相对于上面三种方式,加验证码的方式可能更精准一些,同样能限制用户的访问频次,但好处是不会存在误杀的情况。

通常情况下,用户在请求之前,需要先输入验证码。用户发起请求之后,服务端会去校验该验证码是否正确。只有正确才允许进行下一步操作,否则直接返回,并且提示验证码错误。

4.5 提高业务门槛

上面说的加验证码虽然可以限制非法用户请求,但是有些影响用户体验。用户点击秒杀按钮前,还要先输入验证码,流程显得有点繁琐,秒杀功能的流程不是应该越简单越好吗?

其实,有时候达到某个目的,不一定非要通过技术手段,通过业务手段也一样。

我们通过提高业务门槛,比如只有会员才能参与秒杀活动,普通注册用户没有权限。或者,只有等级到达3级以上的普通用户,才有资格参加该活动。

参考

高并发下秒杀商品,你必须知道的 9 个细节

版权

评论