关于后端:面试必考秒杀系统要如何设计

1次阅读

共计 10378 个字符,预计需要花费 26 分钟才能阅读完成。

前言

高并发下如何设计秒杀零碎?这是一个高频面试题。这个问题看似简略,然而外面的水很深,它考查的是高并发场景下,从前端到后端多方面的常识。

秒杀个别呈现在商城的 促销流动 中,指定了肯定数量(比方:10 个)的商品(比方:手机),以极低的价格(比方:0.1 元),让大量用户参加流动,但只有极少数用户可能购买胜利。这类流动商家绝大部分是不赚钱的,说白了是找个噱头宣传本人。

虽说秒杀只是一个促销流动,但对技术要求不低。上面给大家总结一下设计秒杀零碎须要留神的 9 个细节。

1 刹时高并发

个别在 秒杀工夫点(比方:12 点)前几分钟,用户并发量才真正突增,达到秒杀工夫点时,并发量会达到高峰。

但因为这类流动是大量用户抢大量商品的场景,必定会呈现 狼多肉少 的状况,所以其实绝大部分用户秒杀会失败,只有极少局部用户可能胜利。

失常状况下,大部分用户会收到商品曾经抢完的揭示,收到该揭示后,他们大概率不会在那个流动页面停留了,如此一来,用户并发量又会急剧下降。所以这个峰值继续的工夫其实是十分短的,这样就会呈现刹时高并发的状况,上面用一张图直观的感受一下流量的变动:

像这种刹时高并发的场景,传统的零碎很难应答,咱们须要设计一套全新的零碎。能够从以下几个方面动手:

  1. 页面动态化
  2. CDN 减速
  3. 缓存
  4. mq 异步解决
  5. 限流
  6. 分布式锁

2. 页面动态化

流动页面是用户流量的第一入口,所以是并发量最大的中央。

如果这些流量都能间接拜访服务端,恐怕服务端会因为承受不住这么大的压力,而间接挂掉。

流动页面绝大多数内容是固定的,比方:商品名称、商品形容、图片等。为了缩小不必要的服务端申请,通常状况下,会对流动页面做 动态化 解决。用户浏览商品等惯例操作,并不会申请到服务端。只有到了秒杀工夫点,并且用户被动点了秒杀按钮才容许拜访服务端。

这样能过滤大部分有效申请。

但只做页面动态化还不够,因为用户散布在全国各地,有些人在北京,有些人在成都,有些人在深圳,地区相差很远,网速各不相同。

如何能力让用户最快拜访到流动页面呢?

这就须要应用 CDN,它的全称是 Content Delivery Network,即内容散发网络。

使用户就近获取所需内容,升高网络拥塞,进步用户拜访响应速度和命中率。

3 秒杀按钮

大部分用户怕错过 秒杀工夫点 ,个别会提前进入流动页面。此时看到的 秒杀按钮 是置灰,不可点击的。只有到了秒杀工夫点那一时刻,秒杀按钮才会主动点亮,变成可点击的。

但此时很多用户曾经急不可待了,通过不停刷新页面,争取在第一工夫看到秒杀按钮的点亮。

从后面得悉,该流动页面是动态的。那么咱们在动态页面中如何管制秒杀按钮,只在秒杀工夫点时才点亮呢?

没错,应用 js 文件管制。

为了性能思考,个别会将 css、js 和图片等动态资源文件提前缓存到 CDN 上,让用户可能就近拜访秒杀页面。

看到这里,有些聪慧的小伙伴,可能会问:CDN 上的 js 文件是如何更新的?

秒杀开始之前,js 标记为 false,还有另外一个随机参数。当秒杀开始的时候零碎会生成一个新的 js 文件,此时标记为 true,并且随机参数生成一个新值,而后同步给 CDN。因为有了这个随机参数,CDN 不会缓存数据,每次都能从 CDN 中获取最新的 js 代码。此外,前端还能够加一个定时器,管制比方:10 秒之内,只容许发动一次申请。如果用户点击了一次秒杀按钮,则在 10 秒之内置灰,不容许再次点击,等到过了工夫限度,又容许从新点击该按钮。

4 读多写少

在秒杀的过程中,零碎个别会先查一下库存是否足够,如果足够才容许下单,写数据库。如果不够,则间接返回该商品曾经抢完。

因为大量用户抢大量商品,只有极少局部用户可能抢胜利,所以绝大部分用户在秒杀时,库存其实是有余的,零碎会间接返回该商品曾经抢完。

这是十分典型的:读多写少 的场景。

如果有数十万的申请过去,同时通过数据库查缓存是否足够,此时数据库可能会挂掉。因为数据库的连贯资源十分无限,比方:mysql,无奈同时反对这么多的连贯。

而应该改用缓存,比方:redis。

即使用了 redis,也须要部署多个节点。

5 缓存问题

通常状况下,咱们须要在 redis 中保留商品信息,外面蕴含:商品 id、商品名称、规格属性、库存等信息,同时数据库中也要有相干信息,毕竟缓存并不齐全牢靠。

用户在点击秒杀按钮,申请秒杀接口的过程中,须要传入的商品 id 参数,而后服务端须要校验该商品是否非法。

大抵流程如下图所示:

依据商品 id,先从缓存中查问商品,如果商品存在,则参加秒杀。如果不存在,则须要从数据库中查问商品,如果存在,则将商品信息放入缓存,而后参加秒杀。如果商品不存在,则间接提醒失败。

这个过程外表上看起来是 OK 的,然而如果深入分析一下会发现一些问题。

5.1 缓存击穿

比方商品 A 第一次秒杀时,缓存中是没有数据的,但数据库中有。虽说下面有如果从数据库中查到数据,则放入缓存的逻辑。

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

如何解决这个问题呢?

这就须要加锁,最好应用分布式锁。

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

是不是下面加锁这一步能够不须要了?

外表上看起来,的确能够不须要。但如果缓存中设置的过期工夫不对,缓存提前过期了,或者缓存被不小心删除了,如果不减速同样可能呈现缓存击穿。

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

5.2 缓存穿透

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

因为后面曾经加了锁,所以即便这里的并发量很大,也不会导致数据库间接挂掉。

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

这时能够想到 布隆过滤器

零碎依据商品 id,先从布隆过滤器中查问该 id 是否存在,如果存在则容许从缓存中查问数据,如果不存在,则间接返回失败。

虽说该计划能够解决缓存穿透问题,然而又会引出另外一个问题:布隆过滤器中的数据如何更缓存中的数据保持一致?

这就要求,如果缓存中数据有更新,则要及时同步到布隆过滤器中。如果数据同步失败了,还须要减少重试机制,而且跨数据源,能保证数据的实时一致性吗?

显然是不行的。

所以布隆过滤器绝大部分应用在缓存数据更新很少的场景中。

如果缓存数据更新十分频繁,又该如何解决呢?

这时,就须要把不存在的商品 id 也缓存起来。

下次,再有该商品 id 的申请过去,则也能从缓存中查到数据,只不过该数据比拟非凡,示意商品不存在。须要特地留神的是,这种非凡缓存设置的超时工夫应该尽量短一点。

6 库存问题

对于库存问题看似简略,实则外面还是有些货色。

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

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

扣减库存中除了下面说到的 预扣库存 回退库存 之外,还须要特地留神的是库存有余和库存超卖问题。

6.1 数据库扣减库存

应用数据库扣减库存,是最简略的实现计划了,假如扣减库存的 sql 如下:

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

这种写法对于扣减库存是没有问题的,但如何管制库存有余的状况下,不让用户操作呢?

这就须要在 update 之前,先查一下库存是否足够了。

伪代码如下:

int stock = mapper.getStockById(123);
if(stock > 0) {int count = mapper.updateStock(123);
  if(count > 0) {addOrder(123);
  }
}

大家有没有发现这段代码的问题?

没错,查问操作和更新操作不是原子性的,会导致在并发的场景下,呈现库存超卖的状况。

有人可能会说,这样好办,加把锁,不就搞定了,比方应用 synchronized 关键字。

的确,能够,然而性能不够好。

还有更优雅的解决计划,即基于数据库的乐观锁,这样会少一次数据库查问,而且可能人造的保证数据操作的原子性。

只需将下面的 sql 略微调整一下:

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

在 sql 最初加上:stock > 0,就能保障不会呈现超卖的状况。

但须要频繁拜访数据库,咱们都晓得数据库连贯是十分低廉的资源。在高并发的场景下,可能会造成零碎雪崩。而且,容易呈现多个申请,同时竞争行锁的状况,造成互相期待,从而呈现死锁的问题。

6.2 redis 扣减库存

redis 的 incr 办法是原子性的,能够用该办法扣减库存。伪代码如下:

 boolean exist = redisClient.query(productId,userId);
  if(exist) {return -1;}
  int stock = redisClient.queryStock(productId);
  if(stock <=0) {return 0;}
  redisClient.incrby(productId, -1);
  redisClient.add(productId,userId);
return 1;

代码流程如下:

  1. 先判断该用户有没有秒杀过该商品,如果曾经秒杀过,则间接返回 -1。
  2. 查问库存,如果库存小于等于 0,则间接返回 0,示意库存有余。
  3. 如果库存短缺,则扣减库存,而后将本次秒杀记录保存起来。而后返回 1,示意胜利。

预计很多小伙伴,一开始都会按这样的思路写代码。但如果认真想想会发现,这段代码有问题。

有什么问题呢?

如果在高并发下,有多个申请同时查问库存,过后都大于 0。因为查问库存和更新库存非准则操作,则会呈现库存为正数的状况,即 库存超卖

当然有人可能会说,加个 synchronized 不就解决问题?

调整后代码如下:

   boolean exist = redisClient.query(productId,userId);
   if(exist) {return -1;}
   synchronized(this) {int stock = redisClient.queryStock(productId);
       if(stock <=0) {return 0;}
       redisClient.incrby(productId, -1);
       redisClient.add(productId,userId);
   }

return 1;

synchronized 的确能解决库存为正数问题,然而这样会导致接口性能急剧下降,每次查问都须要竞争同一把锁,显然不太正当。

为了解决下面的问题,代码优化如下:

boolean exist = redisClient.query(productId,userId);
if(exist) {return -1;}
if(redisClient.incrby(productId, -1)<0) {return 0;}
redisClient.add(productId,userId);
return 1;

该代码次要流程如下:

  1. 先判断该用户有没有秒杀过该商品,如果曾经秒杀过,则间接返回 -1。
  2. 扣减库存,判断返回值是否小于 0,如果小于 0,则间接返回 0,示意库存有余。
  3. 如果扣减库存后,返回值大于或等于 0,则将本次秒杀记录保存起来。而后返回 1,示意胜利。

该计划咋一看,如同没问题。

但如果在高并发场景中,有多个申请同时扣减库存,大多数申请的 incrby 操作之后,后果都会小于 0。

虽说,库存呈现正数,不会呈现 超卖的问题。但因为这里是预减库存,如果负数值负的太多的话,前面万一要回退库存时,就会导致库存不准。

那么,有没有更好的计划呢?

6.3 lua 脚本扣减库存

咱们都晓得 lua 脚本,是可能保障原子性的,它跟 redis 一起配合应用,可能完满解决下面的问题。

lua 脚本有段十分经典的代码:

  StringBuilder lua = new StringBuilder();
  lua.append("if (redis.call('exists', KEYS[1]) == 1) then");
  lua.append("local stock = tonumber(redis.call('get', KEYS[1]));");
  lua.append("if (stock == -1) then");
  lua.append("return 1;");
  lua.append("end;");
  lua.append("if (stock > 0) then");
  lua.append("redis.call('incrby', KEYS[1], -1);");
  lua.append("return stock;");
  lua.append("end;");
  lua.append("return 0;");
  lua.append("end;");
  lua.append("return -1;");

该代码的次要流程如下:

  1. 先判断商品 id 是否存在,如果不存在则间接返回。
  2. 获取该商品 id 的库存,判断库存如果是 -1,则间接返回,示意不限度库存。
  3. 如果库存大于 0,则扣减库存。
  4. 如果库存等于 0,是间接返回,示意库存有余。

7 分布式锁

之前我提到过,在秒杀的时候,须要先从缓存中查商品是否存在,如果不存在,则会从数据库中查商品。如果数据库中,则将该商品放入缓存中,而后返回。如果数据库中没有,则间接返回失败。

大家试想一下,如果在高并发下,有大量的申请都去查一个缓存中不存在的商品,这些申请都会间接打到数据库。数据库因为承受不住压力,而间接挂掉。

那么如何解决这个问题呢?

这就须要用 redis 分布式锁了。

7.1 setNx 加锁

应用 redis 的分布式锁,首先想到的是 setNx 命令。

if (jedis.setnx(lockKey, val) == 1) {jedis.expire(lockKey, timeout);
}

用该命令其实能够加锁,但和前面的设置超时工夫是离开的,并非原子操作。

如果加锁胜利了,然而设置超时工夫失败了,该 lockKey 就变成永不生效的了。在高并发场景中,该问题会导致十分重大的结果。

那么,有没有保障原子性的加锁命令呢?

7.2 set 加锁

应用 redis 的 set 命令,它能够指定多个参数。

String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {return true;}
return false;

其中:

  • lockKey:锁的标识
  • requestId:申请 id
  • NX:只在键不存在时,才对键进行设置操作。
  • PX:设置键的过期工夫为 millisecond 毫秒。
  • expireTime:过期工夫

因为该命令只有一步,所以它是原子操作。

7.3 开释锁

接下来,有些敌人可能会问:在加锁时,既然曾经有了 lockKey 锁标识,为什么要须要记录 requestId 呢?

答:requestId 是在开释锁的时候用的。

if (jedis.get(lockKey).equals(requestId)) {jedis.del(lockKey);
    return true;
}
return false;

在开释锁的时候,只能开释本人加的锁,不容许开释他人加的锁。

这里为什么要用 requestId,用 userId 不行吗?

答:如果用 userId 的话,假如本次申请流程走完了,筹备删除锁。此时,偶合锁到了过期工夫生效了。而另外一个申请,偶合应用的雷同 userId 加锁,会胜利。而本次申请删除锁的时候,删除的其实是他人的锁了。

当然应用 lua 脚本也能防止该问题:

if redis.call('get', KEYS[1]) == ARGV[1] then 
 return redis.call('del', KEYS[1]) 
else 
  return 0 
end

它能保障查问锁是否存在和删除锁是原子操作。

7.4 自旋锁

下面的加锁办法看起来如同没有问题,但如果你认真想想,如果有 1 万的申请同时去竞争那把锁,可能只有一个申请是胜利的,其余的 9999 个申请都会失败。

在秒杀场景下,会有什么问题?

答:每 1 万个申请,有 1 个胜利。再 1 万个申请,有 1 个胜利。如此上来,直到库存有余。这就变成均匀分布的秒杀了,跟咱们设想中的不一样。

如何解决这个问题呢?

答:应用自旋锁。

try {Long start = System.currentTimeMillis();
  while(true) {String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
     if ("OK".equals(result)) {return true;}
     
     long time = System.currentTimeMillis() - start;
      if (time>=timeout) {return false;}
      try {Thread.sleep(50);
      } catch (InterruptedException e) {e.printStackTrace();
      }
  }
 
} finally{unlock(lockKey,requestId);
}  
return false;

在规定的工夫,比方 500 毫秒内,自旋一直尝试加锁,如果胜利则间接返回。如果失败,则休眠 50 毫秒,再发动新一轮的尝试。如果到了超时工夫,还未加锁胜利,则间接返回失败。

7.5 redisson

除了下面的问题之外,应用 redis 分布式锁,还有锁竞争问题、续期问题、锁重入问题、多个 redis 实例加锁问题等。

这些问题应用 redisson 能够解决,因为篇幅的起因,在这里先保留一点悬念,有疑难的私聊给我。前面会出一个专题介绍分布式锁,敬请期待。

8 mq 异步解决

咱们都晓得在实在的秒杀场景中,有三个外围流程:而这三个外围流程中,真正并发量大的是秒杀性能,下单和领取性能理论并发量很小。所以,咱们在设计秒杀零碎时,有必要把下单和领取性能从秒杀的主流程中拆分进去,特地是下单功能要做成 mq 异步解决的。而领取性能,比方支付宝领取,是业务场景自身保障的异步。

于是,秒杀后下单的流程变成如下:如果应用 mq,须要关注以下几个问题:

8.1 音讯失落问题

秒杀胜利了,往 mq 发送下单音讯的时候,有可能会失败。起因有很多,比方:网络问题、broker 挂了、mq 服务端磁盘问题等。这些状况,都可能会造成音讯失落。

那么,如何避免音讯失落呢?

答:加一张音讯发送表。

在生产者发送 mq 音讯之前,先把该条音讯写入音讯发送表,初始状态是待处理,而后再发送 mq 音讯。消费者生产音讯时,解决完业务逻辑之后,再回调生产者的一个接口,批改音讯状态为已解决。

如果生产者把音讯写入音讯发送表之后,再发送 mq 音讯到 mq 服务端的过程中失败了,造成了音讯失落。

这时候,要如何解决呢?

答:应用 job,减少重试机制。

用 job 每隔一段时间去查问音讯发送表中状态为待处理的数据,而后从新发送 mq 音讯。

8.2 反复生产问题

原本消费者生产音讯时,在 ack 应答的时候,如果网络超时,自身就可能会生产反复的音讯。但因为音讯发送者减少了重试机制,会导致消费者反复音讯的概率增大。

那么,如何解决反复音讯问题呢?

答:加一张音讯处理表。

消费者读到音讯之后,先判断一下音讯处理表,是否存在该音讯,如果存在,示意是反复生产,则间接返回。如果不存在,则进行下单操作,接着将该音讯写入音讯处理表中,再返回。

有个比拟要害的点是:下单和写音讯处理表,要放在同一个事务中,保障原子操作。

8.3 垃圾音讯问题

这套计划外表上看起来没有问题,但如果呈现了音讯生产失败的状况。比方:因为某些起因,音讯消费者下单始终失败,始终不能回调状态变更接口,这样 job 会不停的重试发消息。最初,会产生大量的垃圾音讯。

那么,如何解决这个问题呢?每次在 job 重试时,须要先判断一下音讯发送表中该音讯的发送次数是否达到最大限度,如果达到了,则间接返回。如果没有达到,则将次数加 1,而后发送音讯。

这样如果出现异常,只会产生大量的垃圾音讯,不会影响到失常的业务。

8.4 提早生产问题

通常状况下,如果用户秒杀胜利了,下单之后,在 15 分钟之内还未实现领取的话,该订单会被主动勾销,回退库存。

那么,在 15 分钟内未实现领取,订单被主动勾销的性能,要如何实现呢?

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

但 job 有个问题,须要每隔一段时间解决一次,实时性不太好。

还有更好的计划?

答:应用提早队列。

咱们都晓得 rocketmq,自带了提早队列的性能。

下单时音讯生产者会学生成订单,此时状态为待领取,而后会向提早队列中发一条音讯。达到了延迟时间,音讯消费者读取音讯之后,会查问该订单的状态是否为待领取。如果是待领取状态,则会更新订单状态为勾销状态。如果不是待领取状态,阐明该订单曾经领取过了,则间接返回。

还有个关键点,用户实现领取之后,会批改订单状态为已领取。

9 如何限流?

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

但有些高手,并不会像咱们一样老老实实,通过秒杀页面点击秒杀按钮,抢购商品。他们可能在本人的服务器上,模仿失常用户登录零碎,跳过秒杀页面,间接调用秒杀接口。

如果是咱们手动操作,个别状况下,一秒钟只能点击一次秒杀按钮。然而如果是服务器,一秒钟能够申请成上千接口。这种差距切实太显著了,如果不做任何限度,绝大部分商品可能是被机器抢到,而非正常的用户,有点不太偏心。

所以,咱们有必要辨认这些非法申请,做一些限度。那么,咱们该如何当初这些非法申请呢?

目前有两种罕用的限流形式:

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

9.1 对同一用户限流

为了避免某个用户,申请接口次数过于频繁,能够只针对该用户做限度。限度同一个用户 id,比方每分钟只能申请 5 次接口。

9.2 对同一 ip 限流

有时候只对某个用户限流是不够的,有些高手能够模仿多个用户申请,这种 nginx 就没法辨认了。

这时须要加同一 ip 限流性能。限度同一个 ip,比方每分钟只能申请 5 次接口。

但这种限流形式可能会有误杀的状况,比方同一个公司或网吧的进口 ip 是雷同的,如果外面有多个失常用户同时发动申请,有些用户可能会被限制住。

9.3 对接口限流

别以为限度了用户和 ip 就高枕无忧,有些高手甚至能够应用代理,每次都申请都换一个 ip。

这时能够限度申请的接口总次数。在高并发场景下,这种限度对于零碎的稳定性是十分有必要的。但可能因为有些非法申请次数太多,达到了该接口的申请下限,而影响其余的失常用户拜访该接口。看起来有点得失相当。

9.4 加验证码

绝对于下面三种形式,加验证码的形式可能更精准一些,同样能限度用户的拜访频次,但益处是不会存在误杀的状况。

通常状况下,用户在申请之前,须要先输出验证码。用户发动申请之后,服务端会去校验该验证码是否正确。只有正确才容许进行下一步操作,否则间接返回,并且提醒验证码谬误。

此外,验证码个别是一次性的,同一个验证码只容许应用一次,不容许重复使用。

一般验证码,因为生成的数字或者图案比较简单,可能会被破解。长处是生成速度比拟快,毛病是有安全隐患。

还有一个验证码叫做:挪动滑块,它生成速度比较慢,但比拟平安,是目前各大互联网公司的首选。

9.5 进步业务门槛

下面说的加验证码尽管能够限度非法用户申请,然而有些影响用户体验。用户点击秒杀按钮前,还要先输出验证码,流程显得有点繁琐,秒杀性能的流程不是应该越简略越好吗?

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

12306 刚开始的时候,全国人民都在同一时刻抢火车票,因为并发量太大,零碎常常挂。起初,重构优化之后,将购买周期放长了,能够提前 20 天购买火车票,并且能够在 9 点、10、11 点、12 点等整点购买火车票。调整业务之后(当然技术也有很多调整),将之前集中的申请,扩散开了,一下子升高了用户并发量。

回到这里,咱们通过进步业务门槛,比方只有会员能力参加秒杀流动,一般注册用户没有权限。或者,只有等级达到 3 级以上的普通用户,才有资格加入该流动。

这样简略的进步一点门槛,即便是黄牛党也大刀阔斧,他们总不可能为了加入一次秒杀流动,还另外花钱充值会员吧?

最初说一句(求关注,别白嫖我)

如果这篇文章对您有所帮忙,或者有所启发的话,帮忙扫描下发二维码关注一下,您的反对是我保持写作最大的能源。

求一键三连:点赞、转发、在看。

关注公众号:【苏三说技术】,在公众号中回复:面试、代码神器、开发手册、工夫治理有超赞的粉丝福利,另外回复:加群,能够跟很多 BAT 大厂的前辈交换和学习。

正文完
 0