王富贵

Stay hungry,Stay foolish

若你自认理性且正确,那更应该心平气和,言之有据地与人交流,即使不能立刻说服对方,但也至少埋下了一颗种子,冷嘲热讽或居高临下的态度,总会加大人与人之间的隔阂,最终只是在自己的小圈子中制造回声。愿我们最终都能成为乐观且包容的人,愿意耐心听取他人的苦衷和心声。
  menu
69 文章
0 浏览
0 当前访客
ღゝ◡╹)ノ❤️

秒杀场景简单思考

1、秒杀场景

秒杀的特点就是这样时间极短瞬间用户量大。也就是说,我们需要在最短的时间内完成最多的任务。

实际上秒杀主要的问题就是一致性优化和库存方案。

1.1、库存系统设计

1.1.1、redis库存

我们知道库存方案通常苦于mysql的性能瓶颈,因此,我们可以使用redis在做库存系统,实现更高的吞吐量。

当请求进来,我们开启redis的事务,主要操作就是库存扣减,set集合记录消费用户。

但存在超卖的问题,实际上就是原子性问题,所以我们在库存上添加一个版本号,采用乐观锁解决问题。

但采用乐观锁有一个问题,就是高并发的情况下轮询操作效率很低,也就是乐观锁不适合高并发的情况。极端情况下,采用这种方案还会有库存遗留问题,原因是多数线程都抢不到乐观锁而失败,最后秒杀时间过去,反而库存还有遗留。

最后我们只能采用lua脚本(本质上就是悲观锁)来保证redis的一致性。

那么总结一下redis的库存系统:采用redis事务保证消费顺序性,采用redis连接池优化连接操作过程,采用lua脚本以悲观锁的方式实现redis的原子操作。

1.1.1.1、简单实现

/**
 * 使用lua脚本解决库存遗留问题
 */
public class SecKill_redisByScript {
   
   private static final  org.slf4j.Logger logger =LoggerFactory.getLogger(SecKill_redisByScript.class) ;

   public static void main(String[] args) {
      JedisPool jedispool =  JedisPoolUtil.getJedisPoolInstance();
 
      Jedis jedis=jedispool.getResource();
      System.out.println(jedis.ping());
  
      Set<HostAndPort> set=new HashSet<HostAndPort>();

   // doSecKill("201","sk:0101");
   }
   
   static String secKillScript ="local userid=KEYS[1];\r\n" + 
         "local prodid=KEYS[2];\r\n" + 
         "local qtkey='sk:'..prodid..\":qt\";\r\n" + 
         "local usersKey='sk:'..prodid..\":usr\";\r\n" + 
         "local userExists=redis.call(\"sismember\",usersKey,userid);\r\n" + 
         "if tonumber(userExists)==1 then \r\n" + 
         "   return 2;\r\n" + 
         "end\r\n" + 
         "local num= redis.call(\"get\" ,qtkey);\r\n" + 
         "if tonumber(num)<=0 then \r\n" + 
         "   return 0;\r\n" + 
         "else \r\n" + 
         "   redis.call(\"decr\",qtkey);\r\n" + 
         "   redis.call(\"sadd\",usersKey,userid);\r\n" + 
         "end\r\n" + 
         "return 1" ;
    
   static String secKillScript2 = 
         "local userExists=redis.call(\"sismember\",\"{sk}:0101:usr\",userid);\r\n" +
         " return 1";

   public static boolean doSecKill(String uid,String prodid) throws IOException {

      JedisPool jedispool =  JedisPoolUtil.getJedisPoolInstance();
      Jedis jedis=jedispool.getResource();

       //String sha1=  .secKillScript;
      String sha1=  jedis.scriptLoad(secKillScript);
      Object result= jedis.evalsha(sha1, 2, uid,prodid);

        String reString=String.valueOf(result);
      if ("0".equals( reString )  ) {
         System.err.println("已抢空!!");
      }else if("1".equals( reString )  )  {
         System.out.println("抢购成功!!!!");
      }else if("2".equals( reString )  )  {
         System.err.println("该用户已抢过!!");
      }else{
         System.err.println("抢购异常!!");
      }
      jedis.close();
      return true;
   }
}

1.1.1.2、问题

我们思考还有什么问题?

当极端情况下,上游发起请求的线程超时报错了,但是他并不知道我下游库存是否扣减成功。因此当系统出现超时报错的时候,我们需要取存放消费用户的set集合来搜索是否消费成功。同时,我们也可以请求合并来做对redis库存系统的优化。

极端情况下,我们修改的库存成功后,写入用户set集合,此时redis挂掉了,就算redis用的aof最高安全方案,也会丢失掉set集合中的一名用户。因此当库存扣减完成之后,我们去检查一下用户扣减的库存,与当前库存是否一致,如果不一致,那么继续放秒杀接口,那实际上这是一个补偿的过程。

并且我们需要做redis托底方案,通常来讲这涉及redis集群(让redis主机down了不至于就不可用了),以及对redis资源的隔离(redis资源隔离避免其他线程抢占我的redis资源导致主机挂掉)。

1.1.2、队列维护

前面我们提到了采用mysql有性能瓶颈问题,因此我们能不能直接在server就做掉呢?

是有的,用户点击抢后,就统一进队列, 然后返回,告诉用户结果稍后公布,再队列里的按顺序一个个扣库存,最终把结果再告诉客户。

这种简单实现我们通常会忽略一个问题,那就是rt(响应时间)。如果请求入队,然后再去mysql悲观锁扣减库存,经过了中间一层,其rt就上不去。通常来讲只有用户对你有信任感,才会愿意等待这么长的时间。也就是说,可能只有12306的抢票可以这么玩吧。

1.1.3、合并队列秒杀库存系统(落地方案)

极海Channel:合并队列秒杀库存系统

这个方案是直接操作数据库的,通过请求合并队列,每个请求最多等待200ms,来减少对数据库的压力。

那么为什么不用redis呢?这有待探究,我自身也不是很清楚。但这个方案是可以落地的。

有一点是说在redis做库存的同时,我们需要写一张流水表,记录消费记录。

1.1.3.1、理论

1. 执行sql分析

对于库存表而言,实际上就是一个商品id和商品库存(stock),对于扣减库存而言,如果先查在扣减,这就涉及事务问题。所以通常来讲我们是直接进行扣减,但库存大于,这本质上就是持有行锁

update `stock` set stock_count = stock_count - 1 where item_id =1 and stock_count >0

由于我们持有行锁,底层是串行执行,所以并发就上不去。同时,我们会往流水表去写一条记录。

那么,这两个操作肯定是一个事务中的,我们在update库存的同时,还要insert流水表,如果使用redis做库存,他们的一致性就很难保证。

2.合并队列

上面提到了我们需要对数据库进行update操作,也就锁操作。那我们想要降低rt,总体有两种方案。一种就是降低锁的粒度,一种是降低锁的持有时间。

  • 对于降低锁的粒度而言,实际上就是做分库分表,将库存方到多个库中,实际上也就是降低了粒度(这需要改造数据库为分库分表)
  • 对于降低锁持有时间而言,我们实际上可以让多个用户请求合并一个队列,多个请求批量修改库存。
3.队列细节

前面我们提到多个请求合并队列,统一进行库存修改。那么所有用户进来都会等待200毫秒(调用wait方法最多等待200ms),所有用户都放入队列里面,再起一个异步线程,对200ms里面所有商品库存一并扣减。

这里有几个问题

  1. 一个是在200ms排队期间,用户是不是一直在等待?

是的,用户最多会等待200ms,那这是应该是可以容忍的。

  1. 什么时候生成队列?

我们可以设计一个计数器,当并发线程达到3的时候再生成队列。

  1. 合并过程中,上游超时报错了,但实际扣减成功了?

实际上我们扣减的同时是写了一个流水表的,如果是超时报错,我们可以提供一个接口去查流水表是否有这条记录。

  1. 合并请求之后,库存不够所有扣减?

针对扣减不成功,我们就只能退化成循环,一个一个去扣减库存了。

4.降低持有锁的时间

前面我们提到了通过合并请求队列降低锁的持有时间。那么我们前面提到了一个事务,update库存,insert流水表。

逻辑上来说是先update再insert的,但是事务开启的时候update操作会持有库存的锁,这个时候执行insert就会同时持有update库存行的锁。

因此,我们可以先使用insert再update,当库存不够update的时候再回滚,这样我们也降低了锁的持有时间。

如图所示:

1

5.库存查询

我们知道库存是有缓存的,高并发情况下频繁查询数据库不太方便,因此我们通常会放入redis中。

缓存同步方案我们通常来讲有双删和异步。

如果我们使用双删的话,实际上扣减库存之后还需要同步的进行删除redis键值操作,并且高并发情况下缓存命中率并不高,因此我们可以采用异步的方案。

我们通过监听binlog的方式,当监听到库存发生改变的时候,异步起一个线程去发送同步缓存数据。但这存在一个问题,因为我们是异步发送的,因此如果出现网络延迟的情况,可能前面消息后面才达到,出现消息错乱的问题。因此我们加入一个版本号,判断一下版本来防止消息错乱。

其次,我们队库存为空十分敏感,因为如果库存没有了前端需要按钮置灰来保证用户不再请求。因此我们可以以类似jvm分代回收机制,来做对库存查询的优化。

总结:

  1. 当库存还有的时候,开启异步线程同步缓存,并添加版本号保证消息不错乱。同时我们也可以做定时任务,比如过3秒才同步,防止频繁去覆盖redis的值。
  2. 当库存为0的时候,使用同步双删双写保证缓存尽快到达,前端置灰。
6.存在问题

1.库存系统单独拿出来做资源隔离,经量压榨单机性能。因为这个集群很难进行横向的扩容。如果机器越多,合并队列的效果就会越差。事实上我们是在serve层面在做的,如果我们在mysql和serve中间加一层中间件,通过中间件来做合并队列,就不存在横向扩容问题了。这就是淘宝秒杀的核心链路方案。但涉及的问题就是中间件如果做了。

2.其中有一些调优的过程,比如等待200ms这个等待时间指标,线程池设置大小,并发数量多大才创建队列,队列创建的容量是多少。

1.1.3.2、队列合并demo

我在这个demo中尝试解决多线程通信问题和创建合并队列问题。

开源地址如下:spike(合并队列秒杀库存demo)

1.2、其他设计

在下游库存设计中,我们已经解决了高并发,快响应,防止超卖等问题。下面还有其他一些问题需要解决。

1.2.1、防刷子(限流)

防止机器人,恶意刷子,抢占订单,导致正常用户抢不到商品。

限流一般采用同一用户限流或同一id进行限流,通过对访问次数的限制,减少同一用户过多的并发。

1.2.2、页面资源问题

前端页面资源访问压力大。

  1. 考虑静态化问题
  2. 加cdn
  3. 静态资源缓存与压缩技术

1.2.3、秒杀按钮

秒杀活动可能8点才开始,我们就不能允许用户7:59分进入,但快到点的时候用户肯定会疯狂点击,因此前端可以定义定时器,进行限制,在未到达时间点之前,按钮为灰色。

同时,在库存系统中我们也提到了库存为零提高敏感度,如果一旦库存为零,迅速按钮置灰停止继续访问。

1.2.4、真链隐藏

为了防止秒杀未开始期间有人获取到后端请求链接提前请求,我们需要在到点之前置灰按钮,并且请求链接需要保证无序性,比如使用uuid,雪花算法作为后缀。

每次秒杀的请求地址毫无规律,时间到才能够看见真实的请求地址。

1.2.5、服务降级

遇到紧急情况时,快速降级避免照成更大的损失。

当请求实在过于庞大的时候,我们可以引入降级策略。例如,降级为输入验证码等方法,拖一定的时间给后端进行处理。例如淘宝普通的链接在并发过于庞大的时候,就会采取降级,验证的方案。

1.2.6、降级和限流

我们前面使用到了降级和限流方案,通常他的实现为滑动窗口和令牌桶。无论哪种方案,降级和限流的中间件都不应该成为短板从而限制并发。做好类似sentinel集群是非常必要的。


标题:秒杀场景简单思考
作者:1938857445
地址:https://www.lmlx66.top/articles/2022/08/10/1660137484250.html