秒杀

一、 秒杀业务难做的点

秒杀系统,库存只有一份,所有人会在集中的时间读和写这些数据,多个人读一个数据会导致超卖的发生。并且读写冲突,锁非常严重,这是秒杀业务难的地方。那我们怎么优化秒杀业务的架构呢?

二、秒杀优化

​ (1)我们要将请求尽量拦截在系统上游(不要让锁冲突落到数据库上去)。传统秒杀系统之所以挂,请求都压倒了后端数据层,数据读写锁冲突严重,并发高响应慢,几乎所有请求都超时,流量虽大,下单成功的有效流量甚小。

​ (2)充分利用缓存中间件

三、优化细节

后端优化:将请求尽量拦截在系统上游
  • 削峰:秒杀请求在时间上高度集中于某一个时间点,瞬时流量容易压垮系统,因此需要对流量进行削峰处理,缓冲瞬时流量,尽量让服务器对资源进行平缓处理
  • 异步:将同步请求转换为异步请求,来提高并发量,本质也是削峰处理
  • 利用缓存:为了减少请求数据库交互,因此可以将商品信息放在缓存中,减少数据库查询。可以在秒杀开始前就预热将商品信息放入redis中。
  • 负载均衡:利用 Nginx 等使用多个服务器并发处理请求,减少单个服务器压力
前端优化
  • 限流:使用图形验证码,分散请求
  • 禁止重复提交:限定每个用户发起一次秒杀后,需等待才可以发起另一次请求,从而减少用户的重复请求
  • 本地标记:用户成功秒杀到商品后,将提交按钮置灰,禁止用户再次提交请求
  • 动静分离:将前端静态数据直接缓存到离用户最近的地方,比如用户浏览器、CDN 或者服务端的缓存中

四、分布式锁解决库存超卖

  1. 首先因为当很多用户来抢购商品的时候,同时修改数据库的库存,这个时候,肯定会出现超卖的现象。那么如何解决呢,很多人会想到synchronized同步锁,他们会想到锁住修改商品库存的代码,来实现悲观锁,如下
Synchronized(this){

修改库存的代码

}

但是这样子是不可取的,因为现在的项目都不再是部署在单机,单点上,至少都是两台机器,当我开启两个端口(使用Nginx 负载均衡分发),我用jmeter测试的时候会发现,还是会出现超卖。

  1. 那么怎么办呢,这个时候就要调用第三方来帮助,使用redis来完成分布式锁 在redis中有一个命令叫做setnx(key,value),这个指令是当redis中没有这个key时,再添加value。

  2. 在下面的代码中,在操作库存的上面加一个锁,也就是setnx一个值,当setnx还在里面的时候,其他线程无法进入,当本线程走完了,最后将setnx的值释放,这个时候别的线程才会进入,也就实现了一个基本的锁,但是锁最怕的就是死锁,为了防止死锁,所以要用try,finally来让finally中释放setnx的值。但是当线程还没走到释放这一行代码,就被kill,就又进入了死锁,所以还需要给锁加一个生存时间。

  3. 这样在普通并发下其实已经没问题了,但是在超高并发下,还是有问题,那就是这个生存时间的设定问题,比如你生存时间设定的10秒,线程1进入,上锁到结束需要15秒,那么当线程1走到10秒的时候,锁已经开了,这时候线程2进入,然后线程2上锁,然后线程1走最后5秒解锁了,他解锁的是线程2的锁。所以为了解决这个问题,要保证每个线程解锁的是自己的锁,可以用uuid或者snowflake随机生成一个value,把他作为锁的value,在最后释放的时候,比对一下value值,这样就可以保证每个线程解锁的是自己的锁。

  4. 最后还是有个问题,这个定时生命的问题,设定什么其实都不好,总有可能会出现两个线程同时进入操作,那咋办呢,就要让这个锁每次生命时间要到了,看下他还在不在执行,如果在 那就再次延长生命时间,也就是给锁续命。

    @ResponseBody
        @RequestMapping("seckill")
        public String deductStock(){
            String lockKey = "lockKey";
            String clientId = UUID.randomUUID().toString();
            try{
                //setIfAbsent等同于 setnx
                boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey,"peiyuxiang",10, TimeUnit.SECONDS);
                //防止线程被kill,从而走不到finally造成死锁,所以设定生存时间。但是万一走到这就被kill,还没设定时间。。所以在上面直接set同时设定生存时间
                /*stringRedisTemplate.expire(lockKey,100, TimeUnit.SECONDS);*/
                if(!result){
                    return "error_code";
                }
                int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
                if(stock > 0){
                    int realStock = stock - 1;
                    stringRedisTemplate.opsForValue().set("stock",realStock + "");
                    System.out.println("库存扣减成功,剩余库存" + realStock);
                }else {
                    System.out.println("扣减失败,库存不足");
                }
            }finally {
                if(clientId.equals(stringRedisTemplate.opsForValue().get(lockKey)))
                    stringRedisTemplate.delete(lockKey);
            }
    
            return "end";
        }

    6.或者使用Redisson就可以完成上述的分布式锁。并且可重入。

@ResponseBody
    @RequestMapping("seckill")
    public String deductStock(){
        String lockKey = "lockKey";
        String clientId = UUID.randomUUID().toString();
        //获取锁对象
        RLock redissonLock = redisson.getLock(lockKey);
        try{
            //setIfAbsent等同于 setnx
            //防止线程被kill,从而走不到finally造成死锁,所以设定生存时间。但是万一走到这就被kill,还没设定时间。。所以在上面直接set同时设定生存时间
            /*stringRedisTemplate.expire(lockKey,100, TimeUnit.SECONDS);*/
            /*boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey,"peiyuxiang",10,TimeUnit.SECONDS);

            if(!result){
                return "error_code";
            }*/
            //加锁
            redissonLock.lock();//当作setIfAbsent(lockKey,clientId,30,TimeUnit.SECONDS)
            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
            if(stock > 0){
                int realStock = stock - 1;
                stringRedisTemplate.opsForValue().set("stock",realStock + "");
                System.out.println("库存扣减成功,剩余库存" + realStock);
            }else {
                System.out.println("扣减失败,库存不足");
            }
        }finally {
            redissonLock.unlock();
            /*if(clientId.equals(stringRedisTemplate.opsForValue().get(lockKey)))
            stringRedisTemplate.delete(lockKey);*/
        }
        return "end";
    }

五、测试

使用Jmeter测试 这里使用nginx反向代理开启多个端口,(具体配置nginx可以看我博客主页Nginx。)测试发现,库存并未发生超卖。

六、关于图形验证码验证、用户不可多次秒杀都是利用session完成的。还有kafka中间件的使用来缓冲瞬时流量会在后续整理。