基于Redis+Zookeeper+MySQL实现高并发秒杀系统(二)终极篇【源码开源发布】

1,296 阅读2分钟

基于Redis+Zookeeper+MySQL实现高并发秒杀系统(二)终极篇【源码开源发布】

源码将在文末公布Git地址,感谢您的支持与关注
作者:Lucas

前言:

在1月12日,我们发布了第一篇秒杀系统的简单介绍。今天呢,我们就在第一篇的基础上,继续持续优化与更新。

在第一篇文章中,我们介绍到。通过JVM本地缓存+Redis,在分布式部署的情况下,会存在多台机器或者说多个JVM缓存不一致的问题(具体出现的问题背景介绍可在1月12日的秒杀系统第一篇中查看答案)。今天我们就通过分布式协调框架Zookeeper来解决这个问题。

前篇代码及问题描述:

private static Map<String,Boolean> params = new ConcurrentHashMap<>();
@PostMapping("/secKill/{productId}/{userId}")
    public ResultRtn secKill(@PathVariable("productId") Long productId,@PathVariable("userId") Long userId){
        //JVM的Key
        String jvmKey = "product"+productId;
         try{
        //判断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("此商品已经售完");
       }
       
            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减的库存应该要加回去,也就说库存还是剩一个,但是B机器的JVM缓存已经标识这个商品被抢光了。那么倘若后面所有的请求或者A机器宕机了,所有的请求到B机器,导致所有人全部抢购失败。到最后库存并没有全部卖出去。出现了少卖现象。 大致执行流程顺序是这样的:
1 : A请求进入A机器,抢到了最后一个商品,Redis库存扣减为0
2 :   此时此刻A请求还没有完全结束的时候,B请求进来,发现库存不足。则B机器的JVM缓存该商品标识为True了。
3 : 此时A请求在A机器因为某些原因,抛出异常。则A请求刚刚在Redis减掉的库存要加回去。因为抛出异常了,意味着该商品没有抢成功。
4 : 但B机器的JVM缓存已经标识该商品被抢完了。那么如果后续所有的请求全部到B机器,是不是所有的请求都抢不到这个商品了。或者我们说A机器宕机了,所有请求全部到了B机器,所有抢购这个商品的全部都失败。那么这就造成了少卖现象。

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

使用分布式协调框架(Zookeeper)解决JVM缓存不一致的问题: 解决思路如下:

当B机器请求商品为已售完的时候,会将B机器的此商品JVM缓存标识别True,并利用Zookeeper监听此商品节点。 那么在A机器发生异常需要回滚的时候,会利用Zookeeper通知B机器,B机器需要删除此商品的JVM缓存。
源码如下:

**配置Zookeeper信息 **

@Configuration
public class ZookeeperConfig {


    /**
     * zookeeper IP
     */
    @Value("${zookeeper.ip}")
    private String ip;

    /**
     * Zookeeper 端口
     */
    @Value("${zookeeper.host}")
    private String host;

    /**
     * 当前应用的服务端口,用于打印测试用
     */
    @Value("${server.port}")
    private String serverPort;


    @Bean
    public ZooKeeper initZookeeper() throws Exception {
        // 创建观察者
        ZookeeperWatcher watcher = new ZookeeperWatcher(serverPort);
        // 创建 Zookeeper 客户端
        ZooKeeper zooKeeper = new ZooKeeper(ip+":"+host, 30000, watcher);
        // 将客户端注册给观察者
        watcher.setZooKeeper(zooKeeper);
        // 将配置好的 zookeeper 返回
        return zooKeeper;
    }


}

配置Zookeeper监听:

@Service
@Slf4j
public class ZookeeperWatcher  implements Watcher {
    private ZooKeeper zooKeeper;
    public ZooKeeper getZooKeeper() {
        return zooKeeper;
    }
    public void setZooKeeper(ZooKeeper zooKeeper) {
        this.zooKeeper = zooKeeper;
    }
    private String serverPort;
    public String getServerPort(){
        return this.serverPort;
    }
    public ZookeeperWatcher(){

    }
    public ZookeeperWatcher (String serverPort){
        this.serverPort = serverPort;
    }
    @Override
    public void process(WatchedEvent watchedEvent) {

        System.out.println("************************zookeeper***start*****************");

        if (watchedEvent.getType() == Event.EventType.None && watchedEvent.getPath() == null) {
            log.info("项目启动,初始化zookeeper节点"+getServerPort());
            try {
                // 创建 zookeeper 商品售完信息根节点
                String path = Constants.zoo_product_key_prefix;
                if (zooKeeper != null && zooKeeper.exists(path, false) == null) {
                    zooKeeper.create(path, "".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
                }
            } catch (KeeperException | InterruptedException e) {
                e.printStackTrace();
            }

        } else if (watchedEvent.getType() == Event.EventType.NodeDataChanged) {
            try {
                // 获取节点路径
                String path = watchedEvent.getPath();
                // 获取节点数据
                String soldOut = new String(zooKeeper.getData(path, true, new Stat()));
                // 获取商品 Id
                String productId = path.substring(path.lastIndexOf("/") + 1);
                // 处理当前服务器对应 JVM 缓存
                 if("false".equals(soldOut)){
                    log.info("端口:"+getServerPort()+",zookeeper节点:"+path+"标识为false【商品并未售完】");
                    if(RedisZookeeperController.getParams().containsKey(Constants.local_product_key_prefix+productId)){
                        RedisZookeeperController.getParams().remove(Constants.local_product_key_prefix+productId);
                    }
                }
            } catch (KeeperException | InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

}

秒杀接口修改

 private static ConcurrentHashMap<String,Boolean> params = new ConcurrentHashMap<>();

    @PostMapping("/secKill/{productId}/{userId}")
    public ResultRtn secKill(@PathVariable("productId") Long productId,
                             @PathVariable("userId") Long userId,
                             @RequestParam("isAndEx") String isAndEx
    ) throws KeeperException, InterruptedException {
        //JVM的Key
        String jvmKey = Constants.local_product_key_prefix+productId;
        //判断JVM缓存
        if(params.containsKey(jvmKey)){
            log.info("本地缓存已售完");
            return ResultRtn.ok("此商品已经售完");
        }
        //商品库存的Redis的Key
        String redisKey = Constants.redis_product_key_prefix+productId;
        //Redis库存减一
        Long count =  redisTemplate.opsForValue().decrement(redisKey);
        try{
        /**
         * 【isAndEx】纯属为了测试模拟并发使用,让我们能够看到zookeeper给我们带来的效果
         * 场景如下:
         * 某商品库存为1  启动两个应用  应用1端口:9001   应用2端口:9002
         * A线程在应用1,【isAndEx】传值为1,则A线程在会在此处卡顿10秒,此时Redis库存已为0,10秒倒计时开始
         *【A线程还在卡顿】- B线程在应用2,【isAndEx】传值为为0,则B线程,查询Redis库存为0,返回商品已售完,且应用2的JVM缓存【params】已设置为true
         * A线程在应用1-模拟的10秒结束,抛出异常,减1的库存要加回来。库存变为1,
         * 当因为应用1发生异常,将库存+1的时候,如果没有zookeeper将应用2的JVM缓存【params】删掉,那么后面所有的请求进入到B机器,则全部不会抢到商品。
         * 至此,则会出现少卖现象。
         */
        if("1".equals(isAndEx)){
            Thread.sleep(10000);
            throw new Exception("模拟异常,已延迟5秒");
        }
        if(count<0){
            redisTemplate.opsForValue().increment(redisKey);
            params.put(Constants.local_product_key_prefix+productId,true);
            // zookeeper 中设置售完标记, zookeeper 节点数据格式 product/1 true
            String productPath = Constants.zoo_product_key_prefix + "/" + productId;
            if(zooKeeper.exists(productPath, true)==null){
                zooKeeper.create(productPath, "true".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
            }
            return ResultRtn.ok("此商品已经售完");
        }
            productService.updateStock(productId);
        }catch (Exception e){
            // 通过 zookeeper 回滚其他服务器的 JVM 缓存中的商品售完标记
            String path = Constants.zoo_product_key_prefix + "/" + productId;
            if (zooKeeper.exists(path, true) != null){
                zooKeeper.setData(path, "false".getBytes(), -1);
            }
            params.remove(jvmKey);
            //出现异常Redis减的1,在加回来
            redisTemplate.opsForValue().increment(redisKey);
            return ResultRtn.error("网络拥挤,请稍后重试");
        }
        return ResultRtn.ok("抢购成功");
    }


    public static ConcurrentHashMap<String,Boolean> getParams(){
        return params;
    }

测试过程:
为了测试,我们启动两个应用,应用 1(机器A)端口9001,应用2(机器B)端口9002。 为了测试出并发问题,我们在秒杀接口里面增加一个参数,来让9001线程暂停10秒钟。那么9002不进行卡顿。这样我们先调用9001,让库存减1且线程睡眠10秒。那么此时我们访问9002,应该是商品已售完。在9001接口返回结果前,反复调用9002,控制台应打印本地缓存已售完,接口返回此商品已售完,这就说明了9002的JVM缓存已经设置为True。等到9001接口因为10秒后抛出异常,Redis库存应在加回去,那么需要通知到9002,告诉9002,我这个商品没有抢成功,库存加回去了。你(9002)的缓存就不能是已售完的情况。否则如果后续所有请求到达9002,全部抢不成功,但实际上是有库存啊。此时我们在调用9002的秒杀接口,就应该是抢购成功。这样我们Zookeeper的目的就达到了。

利用Idea启动两个应用,复制一个原来的,将端口设置为9002

设置数据库商品库存为1

调用接口,将数据库库存同步到Redis

调用9001秒杀接口, isAndEx传1,Redis库存减之后,将卡顿10秒,给足其他线程秒杀的时间

http://127.0.0.1:9001/redisZookeeper/secKill/1337041728065032193/1?isAndEx=1

9001接口卡顿期间,去调用9002秒杀接口, isAndEx传0

http://127.0.0.1:9001/redisZookeeper/secKill/1337041728065032193/1?isAndEx=0

接口返回:

{
    "code": 0,
    "msg": "此商品已经售完",
    "data": "此商品已经售完"
}

等待9001接口返回结果之后,在去调用9002秒杀接口, isAndEx传0,则抢购成功

http://127.0.0.1:9001/redisZookeeper/secKill/1337041728065032193/1?isAndEx=0

接口返回:

{
    "code": 0,
    "msg": "抢购成功",
    "data": "抢购成功"
}

源码公布:
地址:gitee.com/stevenlisw/…