Skip to content

秒杀系统设计

1. 秒杀背景

秒杀一般出现在商城的促销活动中, 指定了一定数量的商品,以极低的价格让大量用户参与活动,但只有极少数用户能够购买成功,常见于商家搞促销活动中。

2. 秒杀特点

一般在秒杀时间点前几分钟,用户并发量才真正突增,达到秒杀时间点时,并发量会达到顶峰。但由于这类活动是大量用户抢少量商品的场景,所以绝大部分用户秒杀会失败,只有极少部分用户能够成功。大部分用户会收到商品已经抢完的提醒。正常情况下,收到该提醒后他们大概率不会在那个活动页面停留了。如此一来,用户并发量又会急剧下降,所以这个峰值持续的时间其实是非常短的,这样就会出现瞬时高并发的情况。 面对高并发场景,可以使用的技术:

  1. 页面静态化
  2. CDN加速
  3. 缓存
  4. MQ异步处理
  5. 限流
  6. 分布式锁

2. 页面静态化

Alt text 活动页面是用户流量的第一入口, 所以是并发量最大的地方,如果这些流量都能直接访问服务端,恐怕服务端会因为承受不住这么大的压力而直接挂掉。而我们知道活动页面绝大多数内容是固定的,为了减少不必要的服务端请求,通常情况下,会对活动页面做静态化处理。用户浏览商品等常规操作,并不会请求到服务端。只有到了秒杀时间点,并且用户主动点了秒杀按钮才允许访问服务端。这样能过滤大部分无效请求。

3. 使用CDN加速

但只做页面静态化还不够,因为用户分布在全国各地,地域相差很远,网速各不相同,如何才能让用户最快访问到活动页面呢?这就需要使用CDN,使用户就近获取所需内容,降低网络拥塞,提高用户访问响应速度和命中率

3. 秒杀按钮

大部分用户怕错过秒杀时间点,一般会提前进入活动页面,此时看到的秒杀按钮是置灰,不可点击的,只有到了秒杀时间点那一时刻。秒杀按钮才会自动点亮,变成可点击的,但此时很多用户已经迫不及待了,通过不停刷新页面,争取在第一时间看到秒杀按钮的点亮。从前面得知,该活动页面是静态的,那么我们在静态页面中如何控制秒杀按钮,只在秒杀时间点时才点亮呢,使用js文件控制。当秒杀开始的时候系统会生成一个新的is文件,此时标志按钮状态为true,并且随机参数生成一个新值,然后同步给CDN,由于有了这个随机参数,CDN不会缓存数据。页面每次都能从CDN中获取最新的is代码,此外前端还可以加一个定时器,控制10秒之内只允许发起一次请求,如果用户点击了一次秒杀按钮,则在10秒之内置灰。不允许再次点击,等到过了时间限制,又允许重新点击该按钮

4. 读多写少

在秒杀的过程中,系统一般会先查一下库存是否足够,如果足够才允许下单,写数据库。如果不够,则直接返回该商品已经抢完。由于大量用户抢少量商品,所以库存肯定是不足的,这是非常典型的读多写少的场景。如果有数十万的请求过来,同时通过数据库查缓存是否足够,此时数据库可能会挂掉。因为数据库的连接资源非常有限,无法同时支持这么多的连接,而应该改用缓存,比如:redis,而且也需要部署多个节点

5. 缓存问题

我们需要在redis中保存商品信息, 里面包含商品Id,商品名称,商品价格,规格属性,库存数量等信息,同时数据库中也要有相关信息,毕竟缓存并不完全可靠。请求秒杀接口调用过程中,需要传入的商品id等参数,然后服务端需要校验该商品是否合法,大致流程如下图所示 Alt text 根据商品id先从缓存中查询商品,如果商品存在,则参与秒杀,如果不存在,则需要从数据库中查询商品,如果存在,则将商品信息放入缓存,然后参与秒杀,如果商品不存在,则直接提示失败。这个过程表面上看起来是OK的,深入分析还是有一些问题。

6. 缓存击穿

比如商品A第一次秒杀时,缓存中是没有数据的,但数据库中有,虽说上面有如果从数据库中查到数据,则放入缓存的逻辑。但是这些请求同时去查缓存中没有数据,然后又同时访问数据库,数据库可能扛不住压力,直接挂掉,如何解决这个问题呢?
Alt text 这就需要加锁,最好使用分布式锁, 当然针对这种情况最好在项目启动之前,先把缓存进行预热即事先把所有的商品同步到缓存中。这样商品基本都能直接从缓存中获取到,就不会出现缓存击穿的问题了,是不是上面加锁这一步可以不需要了,表面上看起来确实可以不需要,但如果缓存中设置的过期时间不对,缓存提前过期了,或者缓存被不小心删除了。如果不加锁同样可能出现缓存击穿。其实这里加锁,相当于买了一份保险。

7. 缓存穿透

如果有大量的请求传入的商品id,在缓存中和数据库中都不存在,这些请求不就每次都会穿透过缓存而直接访问数据库了。由于前面已经加了锁,所以即使这里的并发量很大,也不会导致数据库直接挂掉。但很显然这些请求的处理性能并不好,这的可以想到布隆过滤器。先从布隆过滤器中查询该id是否存在 Alt text 如果存在则允许从缓存中查询数据,如果不存在,则直接返回失败。虽说该方案可以解决缓存穿透问题,但是又会引出另外一个问题,布隆过滤器中的数据如何更缓存中的数据保持一致。这就要求如果缓存中数据有更新则要及时同步到布隆过滤器中,如果数据同步失败了还需要增加重试机制,而且跨数据源能保证数据的实时一致性吗?所以布隆过滤器绝大部分使用在缓存数据更新很少的场景中,如果缓存数据更新非常频繁,又该如何处理呢?这时就需要把不存在的商品id也缓存起来,下次再有该商品id的请求过来,则也能从缓存中查到数据。只不过该数据比较特殊,表示商品不存在。这种特殊缓存设置的超时时间应该尽量短一点。

8. 库存问题

对于库存问题看似简单,如果用户在一段时间内, 还没完成支付,扣减的库存是要加回去的。所以在这里引出了一个预扣库存的概念。 Alt text 扣减库存中除了上面说到的预扣库存和回退库存之外,还需要特别注意的是库存不足和库存超卖问题。我们可以使用数据库做扣减库存,

sql
update product set stock=stock-1 where id=123;

但如何控制库存不足的情况下,不让用户操作呢?可以用如下伪代码:
Alt text 但他有个问题:查询操作和更新操作不是原子性的,会导致在并发的场景下,出现库存超卖的情况,那加把锁比如Synchronized不就可以,但是不够优雅。即基于数据库的乐观锁,这样会少一次数据库查询,而且能够天然的保证数据操作的原子性,只需将上面的sql稍微调整一下:

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

但是我们都知道数据库连接是非常昂贵的资源,在高并发的场景下可能会造成系统雪崩,而且容易出现多个请求同时竞争行锁的情况。造成相互等待,从而出现死锁的问题。

8.1 redis扣减库存

redis的incr方法是原子性的,如果小于0,则直接返回0,表示库存不足。如果库存充足,则扣减库存。然后将本次秒杀记录保存起来,然后返回1,表示成功。伪代码如下:
Alt text 在高并发情况下,有多个请求同时查询库存,当时都大于0,由于查询库存和更新库存非原则操作。则会出现库存为负数的情况,即库存超卖。调整后的代码如下:
Alt text 但是这样会导致接口性能急剧下降,每次查询都需要竞争同一把锁。为了解决上面的问题,代码如下所示:
Alt text 但如果在高并发场景中,有多个请求同时扣减库存,大多数请求的incrby操作之后,结果都会小于0。库存出现负数,不会出现超卖的问题,但由于这里是预减库存,如果负数值负的太多的话,后面万一要回退库存时,就会导致库存不准。

8.2 lua脚本扣减库存

lua脚本可以保证原子性,它和redis配合使用,能够完美解决上面的问题,脚本内容如下:
Alt text

9. 分布式锁

在秒杀的时候,需要先从缓存中查商品是否存在,如果不存在,则会从数据库中查商品,如果在高并发下,有大量的请求都去查一个缓存中不存在的商品。这些请求都会直接打到数据库,数据库由于承受不住压力,而直接挂掉。那么如何解决这个问题呢?这就需要用redis分布式锁了
首先想到的是setNx命令,用该命令其实可以加锁:
Alt text 但和后面的设置超时时间是分开的,并非原子操作。假如加锁成功了,但是设置超时时间失败了。该lock Key就变成永不失效的了。 在高并发场景中,该问题会导致非常严重的后果。

9.1 set加锁

Alt text 使用redis里面的set命令,它可以指定多个参数,1lock Key:表示锁的标识,2 Request ld表示请求id, 3 NX 表示只在键不存在时,才对键进行设置操作,4 PX 表示设置键的过期时间为millisecond, 5 expireTime 表示过期时间

9.2 释放锁

加锁的时候,既然已经有了lock Key锁标识,为什么要需要记录requestld呢?request ld是在释放锁的时候用的,在释放锁的时候,只能释放自己加的锁,不允许释放别人加的锁。为何不用userId呢?此时,巧合锁到了过期时间失效了,另外一个请求巧合使用的相同userld加锁会成功,而本次请求删除锁的时候,删除的其实是别人的锁了。当然使用lua脚本也能避免该问题。
Alt text 它能保证查询锁是否存在和删除锁是原子操作

9.3 自旋锁

Alt text 上面的加锁方法看起来好像没有问题,但如果你仔细想想,如果有1万的请求同时去竞争那把锁,可能只有一个请求是成功的,其余的9999个请求都会失败,在秒杀场景下,就会造成每1万个请求,有1个成功,再1万个请求,有1个成功,如此下去,直到库存不足,这就变成均匀分布的秒杀了。如何解决这个问题呢,使用自旋锁。
在规定的时间,自旋不断尝试加锁,如果成功则直接返回。如果失败,则休眠50毫秒,再发起新一轮的尝试。如果到了超时时间,还未加锁成功,则直接返回失败。

9.4 使用redisson框架

除了上面的问题之外,还有锁竞争问题、续期问题、锁重入问题,可以使用redisson框架进行解决

10. 异步处理

我们在设计秒杀系统时,有必要把下单和支付功能从秒杀的主流程中拆分出来,特别是下单功能要做成mq异步处理的。而支付功能,比如支付宝支付,是业务场景本身保证的异步。于是秒杀后下单的流程变成如下:
Alt text

10.1 消息丢失问题

秒杀成功了,往mq发送下单消息的时候有可能会失败。那么,如何防止消息丢失呢? 答:加一张消息发送表
Alt text 在生产者发送mq消息之前,先把该条消息写入消息发送表,初始状态是待处理,然后再发送mq消息。而消费者消费消息时, 在处理完业务逻辑后,再调用生产者的一个接口,修改消息状态为已处理。如果生产者把消息写入消息发送表之后,再发送mq消息到mq服务端的过程中失败了,造成了消息丢失。这时候,要如何处理呢? 答:使用job,增加重试机制。用job每隔一段时间去查询消息发送表中状态为待处理的数据。然后重新发送mq消息 Alt text

10.2 重复消费问题

本来消费者消费消息时,在ack应答的时候,如果网络超时。本身就可能会消费重复的消息。但由于消息发送者增加了重试机制,会导致消费者重复消息的概率增大。那么,如何解决重复消息问题呢? 答:加一张消息处理表
Alt text
下单和写消息处理表,要放在同一个事务中,保证原子操作

10.3 垃圾消息问题

如果出现了消息消费失败的情况,比如由于某些原因,消息消费者下单一直失败,一直不能回调状态变更接口。这样job会不停的重试发消息。最后,会产生大量的垃圾消息,那么,如何解决这个问题呢? Alt text 每次在job重试时,需要先判断一下消息发送表中该消息的发送次数是否达到最大限制。如果达到了,则直接返回。如果没有达到,则将次数加1,然后发送消息。这样如果出现异常,只会产生少量的垃圾消息。不会影响到正常的业务。

10.4 延迟消费问题

通常情况下,如果用户秒杀成功了,在15分钟之内还未完成支付的话,该订单会被自动取消,回退库存。我们首先想到的可能是job,但job有个问题,需要每隔一段时间处理一次,实时性不太好。更好的方案:使用延迟队列 Alt text 我们都知道rocketmq里面自带了延迟队列的功能。下单时消息生产者会先生成订单,此时状态是待支付。然后会向延迟队列中发一条消息,达到了延迟时间,消息消费者读取消息之后,会查询该订单的状态是否为待支付,如果是待支付状态,则会更新订单状态为取消状态。如果不是待支付状态,说明该订单已经支付过了,则直接返回。
用户完成支付之后,会修改订单状态为已支付

11. 如何限流

有些高手并不会像我们一样老老实实,他们可能在自己的服务器上模拟正常用户登录系统,跳过秒杀页面,直接调用秒杀接口,如果是我们手动操作。一秒钟只能点击一次秒杀按钮,但如果是服务器,一秒钟可以请求上千次接口,如果不做任何限制,绝大部分商品可能是被机器抢到而非正常的用户,有点不太公平。我们有必要识别这些非法请求,做一些限制。目前有两种常用的限流方式:1 基于nginx限流 2 基于redis限流

11.1 对同一用户限流

为了防止某个用户,请求接口次数过于频繁,可以只针对该用户做限制。有时候只对某个用户限流是不够的。有些高手可以模拟多个用户请求,这种nginx就没法识别了。这时需要加同-ip限流功能。比如每分钟只能请求5次接口。但这种限流方式可能会有误杀的情况。比如同一个公司或网吧的出口iP是相同的。如果里面有多个正常用户同时发起请求,有些用户可能会被限制住。 Alt text

11.2 对接口限流

有些高手甚至可以使用代理,每次都请求都换一个ip地址,这时可以限制请求的接回总次数。这种限制对于系统的稳定性是非常有必要的 Alt text 加验证码的方式可能更精准一些,同样能限制用户的访问频次。但好处是不会存在误杀的情况。用户在请求之前,需要先输入验证码,用户发起请求之后,服务端会去校验该验证码是否正确。验证码一般是一次性的,同一个验证码只允许使用一次,不允许重复使用。由于生成的数字或者图案比较简单。优点是生成速度比较快, 缺点是有安全隐患。类似的可以使用移动滑块,但是多一些感觉。

11.3 提高业务门槛

上面说的加验证码虽然可以限制非法用户请求,但影响了用户的体验,用户点击秒杀按钮前。还要先输入验证码,流程显得有点繁琐。还有就是只允许会员或者可以只有等级到达3级以上的普通用户。

最后更新于: