业务场景题-设计一个秒杀系统

设计一个秒杀系统

这是前几天面试的时候有个面试官问我的一个问题

他问的是如果让你设计一个支付订单系统,你会考虑什么,其实这件就是一个秒杀系统,因为主要考虑的就是在高并发的场景下的问题。

当时我好像没说几句话,主要是场景题确实是没咋准备。

以下我的文章完全作为一个后端的视角去思考

瞬时高并发

一般在秒杀点的前几分钟,用户并发量才真正突增,达到秒杀时间点时,并发量会达到顶峰。

但是由于这类活动是大量用户抢少量商品的场景,必定会出现狼多肉少的情况,所以绝大部分的用户会秒杀失败,只有极少部分用户能成功。

正常情况下,大部分用户会受到商品已经抢完的提醒,受到该提醒后,他们大概率不会在那个活动页面停留了,如此一来,用户并发量又会急剧下降,所以这个峰值持续的时间其实是非常短的,这样就会出现瞬时高并发的情况。

那么可以从以下几点思考

  • 缓存
  • mq异步处理
  • 限流
  • 分布式锁

读多写少

在秒杀过程中,系统一般会先检查一下库存是否足够,如果足够才允许下单,写数据库。如果不够,则直接返回该商品已经抢完。

由于大量用户抢少量商品,只有极少部分用户能抢成功,所以绝大部分用户在秒杀时,库存其实是不足的,系统会直接返回该商品已经抢完。

这是非常典型的读多写少的场景。

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

缓存问题

通常情况下,我们需要在redis中保存商品信息,同时数据库中也要有相关信息,毕竟缓存并不完全可靠。

用户在垫底秒杀按钮,请求秒杀接口的过程中,需要传入商品参数,然后服务端需要校验该商品是否合法。

根据商品id,先从缓存中查询商品,如果商品存在,则参与秒杀,如果不在,则需要从数据库中查询商品,如果存在,则将商品信息放入缓存,然后参与秒杀,如果商品不存在,则直接提示失败,

但这里需要注意几个问题:

  • 缓存击穿
  • 缓存穿透
  • 缓存雪崩

具体请看缓存雪崩、击穿、穿透 - 夏尾草 - 博客园

库存问题

对于库存问题看似简单,实则还是有点可圈可点的

真正的秒杀商品的场景,不是说扣完库存就完事 了,如果用户在一段时间内未支付,导致订单超时过期了,库存还是要加回去的

此外还需要注意库存不足和库存超卖的问题。

数据库扣减库存

最简单的做法就是直接扣,但这不能保证在并发的场景下库存小于零。

那么可能会有人说,在更新前检查下不就好了吗,但这又不符合原子性。还是会超卖。

有人说不是加把锁就好了吗,这样确实可以解决,但是性能不好。

还有更优雅的解决方案,就是基于数据库的乐观锁,这样会少一次数据查询,而且能够天然的保证数据操作的原子性。

乐观锁:数据库乐观锁是一种并发控制策略,核心思想是 假设并发冲突概率低,操作时不加锁,仅在提交(更新)数据时检查是否有冲突。适用于读多写少,最终一致性可接受(允许数据在短时间内存在不一致,但经过一段时间后自动同步为一致的业务场景)

redis扣减库存

redis的incr方法是原子性的,可以用该方法扣减库存

代码流程如下:

  1. 先判断该用户有没有秒杀过该商品,如果已经秒杀过,则直接返回-1。

  2. 查询库存,如果库存小于等于0,则直接返回0,表示库存不足。

  3. 如果库存充足,则扣减库存,然后将本次秒杀记录保存起来。然后返回1,表示成功。

    但如果在高并发下,有多个请求同时查询库存,当时都大于0。由于查询库存和更新库存非原则操作,则会出现库存为负数的情况,即库存超卖。

这里可以使用lua脚本扣减库存解决

我们都知道lua脚本,是能保证原子性的,它跟redis一起配合使用,能完美解决上面的问题。

  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,是直接返回,表示库存不足。

分布式锁

在秒杀的时候,需要先从缓存中查是否存在,如果不存在,则会从数据库中查商品。如果在数据库放入缓存中,然后返回。如果数据库中没有,则直接返回失败。

如果在高并发下,有大量请求都去查一个缓存中不存在的商品,这鞋 请求都会直接打到数据库,就会造成缓存穿透,然后全打到数据库上,数据库由于承受不住这样的压力会直接挂掉。

那么这时候就需要使用redis分布式锁了

setNx

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

用该命令其实可以加锁,但和后面的设置超时时间是分开的,并非原子操作。

假如加锁成功了,但是设置超时时间失败了,该lockKey就变成永不失效的了。在高并发场景中,该问题会导致非常严重的后果。

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

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:过期时间

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

    释放锁

    在加锁时,既然已经有了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

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

自旋锁

上面的加锁方法看起来好像都没有问题,但是如果仔细想想,如果并发量特别高,有百万级别的去竞争那把锁,那么就会只有一个请求成功,其余的请求都失败

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

那这变成一种线性的秒杀了,就不是高并发的秒杀了,这和我们预期的不一样。

那么如何解决

使用自旋锁

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;

在规定的时间,比如500ms内,自旋不断尝试加锁,如果成功则直接返回,如果失败则休眠一段时间,再发起新一轮的尝试,如果到了超过时间,还未加锁成功,则直接返回失败,

mq异步处理

在真实的秒杀场景中,有三个核心流程:

秒杀->下单->支付

而这三个核心流程中,真正并发量大的是秒杀功能,下单和支付功能实际并发量都很小,因为商品的量有限,所以我们在设计秒杀系统时,有必要把下单和支付功能从秒杀的住流程拆分出来,特别是下单功能能要做出mq异步处理的。而支付功能,是义务场景本身保证的异步。

于是,秒杀后下单的流程变成了image-20250630160244472

消息丢失问题

秒杀成功了,往mq发送下单消息的时候,有时候可能会失败。原因有很多,比如网络问题,broker挂了,mq服务端磁盘问题等,这些情况都可能会造成消息丢失。

那么有哪些防止消息丢失的手段呢。

加一张表记录状态,秒杀发送前先将消息记录在表里,等下单成功了再回来更改表里订单的状态。

但这又不符合了原子性,如果生产者把消息写入消息发送表之后,再发送mq消息到mq服务端的过程中失败了,造成了消息丢失。

这时候,要如何处理呢?

那就得使用重试机制,

每隔一段时间就去查询表里待处理的数据并发送给mq。

重复消费问题

本来消费者消费消息时,在ack应答的时候,如果网络超时,本身就可能会消费重复的消息。但由于消息发送者增加了重试机制,会导致消费者重复消息的概率增大。

还是加一张表解决

垃圾回收问题

这套方案还有个问题,就是如果因为某些原因,消费者一直下单失败,一直不能回调状态变更接口,这样就会不停地重试发消息,最会就会产生大量的垃圾消息。

就给消息发送次数设置一个阈值,如果达到了阈值则直接返回并且报错,如果没有就次数加1,然后发送消息

这样能保证就算出现了异常也只会产生少量的垃圾消息,不会影响到正常的业务。

延迟消费问题

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

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

使用rocketmq的延迟队列功能,这简直就是天生为超时订单设计的

下单的时候生产者往mq的延迟队列里发送一个消息通常是订单id,此时订单状态为待支付,达到了延迟时间,消息消费者会更新订单状态为取消,如果不是待支付状态而是已支付状态,就会直接返回,因为当支付成功后会直接修改订单状态为已支付。

如果限流

针对单一用户(防脚本)

给单一用户设置阈值,限制一个用户在规定时间内请求接口的次数

针对同一ip

有的人有多台设备,也有黄牛帮忙抢票什么的,所以还得针对同一ip去设置阈值

对接口限流

有的专业抢票黄牛可能会更换ip,然后把网站搞炸让正常用户使用不了。这时候可以限制请求接口的总次数,在高并发的场景下,这种限制对系统的稳定性非常必要,至少可以保证一部分用户的正常使用。

面对脚本最好的方法就是加上人机验证和验证码

同样能限制用户的访问次数,但这种情况不会出现误杀,也能有效防止黄牛。

posted @ 2025-06-30 16:48  夏尾草  阅读(76)  评论(0)    收藏  举报