基于Redis+Zookeeper+MySQL实现高并发秒杀系统(一)

1,692 阅读4分钟

基于Redis+Zookeeper+MySQL实现高并发秒杀系统(一)

第一篇

为什么使用Redis : MySQL并发操作,单机最多支撑1000个,了不起了。无论是从性能还是安全来说,Redis的集成都大大解决了系统的并发问题。利用Redis的原子性操作。 为什么使用Zookeeper : 虽然Redis性能非常之高,但是少不了就是应用服务于Redis之间的通信,每一次的通信至少是需要时间的。所以我们应该在应用程序增加本地缓存,但是本地缓存会存在一个问题,在分布式部署下,多台服务器的多个应用程序,缓存不一致,一样会导致秒杀系统Bug(后续会做出介绍)。

单MySQL版本: 一般来说,如果并发性没那么高的话,我们通过以下语句也能做到安全。利用MySQL InnoDB的行级锁。更改库存的时候,使用以下SQL进行更新库存,就可以了。没什么大的问题。但性能非常低。所有的压力全部堆到数据库上。

update set stock = stock - 1 where stock > 0 and id = #{id}

Redis+MySQL: 用Redis做第一层拦截,防止很多无效的请求操作数据库。减少MySQL的压力。记住,Redis能支撑的并发数比MySQL多的多了。 使用Redis的SetNx,自增,自减,Redisson,Lua脚本(我们这里使用自增自减法)。 请求---Redis---MySQL

@PostMapping("/secKill/{productId}")
    public ResultRtn secKill(@PathVariable("productId") Long productId){
        //商品库存的Redis的Key
        String redisKey = "product:"+productId+":stock";
        //Redis库存减一
       Long count =  redisTemplate.opsForValue().decrement(redisKey);
       if(count<0){
           redisTemplate.opsForValue().increment(redisKey);
           return ResultRtn.error("此商品已经售完");
       }
        try{
            productService.updateStock(productId);
        }catch (Exception e){
            //出现异常Redis减的1,在加回来
            redisTemplate.opsForValue().increment(redisKey);
        }
        return ResultRtn.ok("抢购成功");
    }
贴心小课堂:
	大家看代码,可以看到,我们在catch里面加了redis的缓存增加操作。
  这里是为了避免,redis缓存减了之后,后续代码如果出现异常,其实库存应该是不减的。所以在异常的时候,我们需要把缓存减掉的库存在加回来。

JVM缓存+Redis+MySQL: 虽然Redis性能非常之高,但是少不了就是应用服务于Redis之间的通信,每一次的通信至少是需要时间的。并且在我们实际的秒杀场景当中,其实我们的很多请求都算是无效的。 比方说我们某个商品库存只有100个,现在有1000个用户来抢,每人只能抢一个。那其实900个用户都是抢不到的。那么这900个用户的请求其实也没有必要再去与Redis进行请求。 所以我们应该在应用程序增加本地缓存。所谓的本地缓存就是我们的JVM缓存。我们可以在ConcurrentHashMap中存储,标示着我们的商品是否还有库存。如果没有库存,直接返回请求结果“已经被抢完”。 请求---JVM缓存---Redis---数据库。 代码如下:

private static Map<String,Boolean> jvmCache = new ConcurrentHashMap<>();
@PostMapping("/secKill/{productId}/{userId}")
    public ResultRtn secKill(@PathVariable("productId") Long productId,@PathVariable("userId") Long userId){
        //JVM的Key
        String jvmKey = "product"+productId;
        //判断JVM缓存
        if(params.containsKey(jvmKey)){
            return ResultRtn.error("此商品已经售完");
        }
        //商品库存的Redis的Key
        String redisKey = "product:"+productId+":stock";
        //Redis库存减一
       Long count =  redisTemplate.opsForValue().decrement(redisKey);
       if(count<0){
           redisTemplate.opsForValue().increment(redisKey);
           params.put("product"+productId,true);
           return ResultRtn.error("此商品已经售完");
       }
        try{
            productService.updateStock(productId);
        }catch (Exception e){
            params.remove(jvmKey);
            //出现异常Redis减的1,在加回来
            redisTemplate.opsForValue().increment(redisKey);
        }
        return ResultRtn.ok("抢购成功");
    }
贴心小课堂:
	这里也是一样,在catch里面。把两个缓存(JVM与Redis)减去的库存,在加回去。

以上代码就是利用JVM与Redis双重缓存实现秒杀的典型案例。在单服务器部署下,以上代码可以说很安全,几乎应该没有什么大问题了。但是如果我们的应用是在分布式部署的情况下。那JVM缓存多台机器不一致。这个问题就非常严重。场景如下******:****** 比方说,现在有个商品iphone12,库存仅剩最后一个。A线程在A机器执行到代码14行的时候,此时B线程在B机器请求进来,发现库存不足,会将B机器的JVM缓存,商品的Key对应的Value设置为true。接着A机器在执行到代码20行的时候,报错了。那么Redis减的库存应该要加回去,A机器设置的JVM缓存也要删掉。也就说库存还是剩一个,但是B机器的JVM缓存已经标识这个商品被抢光了。那么倘若后面所有的请求或者A机器宕机了,所有的请求到B机器,导致所有人全部抢购失败。到最后库存并没有全部卖出去。出现了少卖现象。 大致执行流程顺序是这样的: 1 : A请求进入A机器,抢到了最后一个商品,那么A机器的JVM缓存标识为True,Redis库存扣减为0 2 : 此时此刻A请求还没有完全结束的时候,B请求进来,发现库存不足。则B机器的JVM缓存该商品标识为True了。 3 : 此时A请求在A机器因为某些原因,抛出异常。则A请求刚刚在Redis减掉的库存,要加回去且A机器的JVM缓存,该商品的Key要删掉。因为抛出异常了,意味着该商品没有抢成功。 4 : 但B机器的JVM缓存已经标识该商品被抢完了。那么如果后续所有的请求全部到B机器,是不是所有的请求都抢不到这个商品了。或者我们说A机器宕机了,所有请求全部到了B机器,所有抢购这个商品的全部都失败。那么这就造成了少卖现象。

解决上述问题,怎么解决呢?就涉及到我们的JVM缓存之间的同步问题。就是当A机器的JVM缓存变动了,B机器或者分布式下的其他机器对应这个缓存,也应该同步刷新。

未完待续... 后续增加Zookeeper控制

完整代码地址,将在后续更新Zookeeper以后公布Git地址

JVM缓存+Zookeeper+Redis+MySQL: