美文网首页
模拟实验 | Redis分布式锁问题&踩坑&解决方案

模拟实验 | Redis分布式锁问题&踩坑&解决方案

作者: 二十三冰芒 | 来源:发表于2020-04-23 11:02 被阅读0次

    1. 模拟场景和环境说明

    • 模拟高并发下卖电影票场景
    • 使用SpringBoot编写卖票的业务,Redis存储热点数据
    • 发布两个卖票服务,使用Nginx做负载均衡
    • JMeter压测工具模拟高并发


      在这里插入图片描述

    2. 环境搭建

    2.1编写卖票服务

    因为篇幅问题这里只展示Controller层代码,这里不做锁操作,只是搭建实验环境。

    @RestController
    public class StockController {
        @Autowired
        StockService stockService;
        @Autowired
        RedisUtil redisUtil ; //自己封装的Redis工具类
        /**
         * 卖票
         * @return
         */
        @GetMapping("/sell_stock")
        public String sellStock(){
                //从redis中取出票信息
                Stock stock = (Stock)redisUtil.get("stock");
                int num = stock.getNum();
                if(num>0){
                    stock.setNum(num-1);
                    redisUtil.set("stock",stock);
                    System.out.println("卖票成功,剩余【 "+(num-1)+" 】张票");
                }else{
                    System.out.println("票售罄!");
                }
           return "end";
        }
     }
    
    2.2发布该服务两次

    由于这里是做模拟,所以服务都发布在一台机器里,做个场景模拟。

    注意:

    • 有些IDEA同时发布两次服务需要在配置中开启,如图所示
    • 两个服务端口分别是8080、8090
    • 发布完一个服务,修改端口之后用maven重新编译,要不然发布的还是没修改端口的那个版本


      在这里插入图片描述
    2.3 Nginx负载均衡
    2.3.1 安装nginx服务(酌情跳过)

    我使用的是Ubuntu的安装包安装,也可以下载源码安装

    # 在ubuntu 中安装nginx
    sudo apt-get install nginx
    # 启动nginx
    sudo /etc/init.d/nginx start
    

    正常情况下可以访问 http://localhost/

    说明: 所有的配置文件都在/etc/nginx下
    程序文件在/usr/sbin/nginx 日志放在了/var/log/nginx中
    并已经在/etc/init.d/下创建了启动脚本nginx 默认的虚拟主机的目录设置在了/var/www/nginx-default
    (有的版本 默认的虚拟主机的目录设置在了/var/www, 请参考/etc/nginx/sites-available里的配置)

    2.3.2 设值负载均衡
    • 在配置过程中只需要改代理服务器的配置就行,其他服务器不用管。
    # 停止nginx服务
    sudo /etc/init.d/nginx stop
    # 配置负载均衡
    sudo vim nginx.conf 
    http {
            ### 省略
            upstream redistest { 
                            # redistest 可以随意取名字 
                            # 192.168.0.102 是我的本机ip,切记不能设置127.0.0.1
                            server 192.168.0.102:8080 weight=1;
                            server 192.168.0.102:8090 weight=1;
                        }
      }
      
    sudo vim /etc/nginx/sites-available/default
    #在文件最低端添加如下
    server{ 
        listen 8030; #监听端口
        location / { 
        proxy_pass http://redistest; # redistest对应上面设值的
        } 
    }
    #最后开启nginx服务
    sudo /etc/init.d/nginx start
    
    2.4 JMeter建立测试计划

    新建线程组,设置线程数,Ramp-Up时间设置0表示一次将所有请求发送过去


    在这里插入图片描述

    添加HTTP请求,路径写上自己的请求路径,在HTTP请求下添加聚合报告查看测试结果。


    在这里插入图片描述

    3. 模拟和问题解决

    3.1 第一次模拟

    现在的代码没做并发处理,肯定会出现超卖现象,两个服务卖同一张票。

    为什么会超卖呢?
    因为Java在读取Redis的时候是两个服务同时去读,访问Redis的时候没有加锁。

    弹幕 : 为什么不在买票业务加上同步代码呢?

    UP : JDK加的同步机制只能作用在当前Tomcat的JVM里面,我们的环境是两个服务发布在不同的Tomcat里加了同步代码也无济于事。

    3.2 第一次尝试方案

    通过上面的分析,我们知道锁应该加在读取Redis的时候,熟悉Redis的小伙伴都知道Redis里有个【setnx】命令,表示如果不存在就se值,如果key存在就不再设置。于是想到了第一个解决办法:

    • 在读取Redis前使用setnx设置一个值,并且设置过期时间,防止JVM宕机之后该值没释放,导致其他服务不能读写产生死锁,设置成功的线程代表拿到了锁,可以读写,读写之后释放锁。下面是代码实现:
        @GetMapping("/sell_stock")
        public String sellStock(){
                   //作为锁
            String lockKey = "movie_001";
            try{
               //设置锁的过期时间,防止jvm宕机之后,锁永远不释放
               //setIfAbsent(lockKey, lockId,10,TimeUnit.SECONDS); 该语句是原子性的
             boolean result = redisUtil.setnx(lockKey, "movie_001",10);
                if(!result){
                    return "error_code";
                }
                Stock stock = (Stock)redisUtil.get("stock");
                int num = stock.getNum();
                if(num>0){
                    stock.setNum(num-1);
                    redisUtil.set("stock",stock);
                    System.out.println("口票成功,剩余【 "+(num-1)+" 】张票");
                }else{
                    System.out.println("口票失败(error)");
                }
            }finally {
                //释放锁,在finally中,防止,中间出现异常,锁没有释放,出现死锁
                redisUtil.del(lockKey);
            }
           return "end";
        }
    

    问题真的解决了吗?

    使用JMeter压测N次,感觉没问题啊,如果这是一个真实活动 <font color=Blue>这不是演习</font>, 这个任务交到了刚来公司不久的你,要是做得好可能有奖励,要是超卖严重,可能就要卷铺盖走人了,不行得考虑所有的可能出想的问题。

    倒杯卡布奇诺冷静一下

    仔细检查逻辑,考虑多种情境,发现:万一业务代码执行时间长,第一个线程还没释放锁,结果lockKey过期了;这个时候第二个线程可以拿到锁了,开始执行业务代码;正当这时第一个线程执行到了释放锁的语句,把第二个线程拿到的锁释放了;假设第二个线程还没执行到释放锁,这个时候第三个线程可以拿到锁,开始执行业务代码;第二个线程执行到了释放锁的语句,把第三个线程拿到的锁释放了... ... 细思极恐,老子加的锁没用了。

    在这里插入图片描述
    在这里插入图片描述
    3.3 第二次尝试方案

    现在的问题是线程二加的锁被线程一释放了,以此类推。如果可以保证我加的锁我自己释放,别人不能动我的锁... ...突然灵光一现,只要实现每个线程设置的lockKey的value不同就可以了,放下手中的卡布奇诺,撸出了下面的代码:

      @GetMapping("/sell_stock")
        public String sellStock(){
                   //作为锁
            String lockKey = "movie_001";
            //uuid 作为锁的值
            String lockId = UUID.randomUUID().toString();
        try{
         //setIfAbsent(lockKey, lockId,10,TimeUnit.SECONDS); 该语句是原子性的
          boolean result = redisUtil.setnx(lockKey, lockId,10);
                if(!result){
                    return "error_code";
                }
                Stock stock = (Stock)redisUtil.get("stock");
                int num = stock.getNum();
                if(num>0){
                    stock.setNum(num-1);
                    redisUtil.set("stock",stock);
                    System.out.println("口票成功,剩余【 "+(num-1)+" 】张票");
                }else{
                    System.out.println("口票失败(error)");
                }
            }finally {
                   if(lockId.equals(redisUtil.get(lockKey))){
                    redisUtil.del(lockKey);
                }    
         }
           return "end";
    }
    

    <font color=Blue>觉得哪里不对劲</font>

    lockKey的过期时间设置,这里是设置固定值,这里设置固定值总觉得不是很爽,于是开始自言自语...

    我:当初为什么设值固定值?

    另一个我:为了防止在没释放锁之前JVM凉了,锁一直不释放造成死锁

    我:要是程序10秒内没运行到解锁代码,锁就过期了业务代码还在执行,第二个线程此时可以拿到锁,开始执行它的业务代码,不妙!

    另一个我:对啊,要是可以在第8,9秒的时候看看第一个线程有没有执行结束,如果没结束给它的锁续命,这样不久可以保证锁是线程一自己释放的了吗?秒啊!

    我:可是这个怎么实现呢?

    怎么给锁续命?

    这个场景很熟悉,就像一个有轮训检查,过一段时间看看现在的线程是否还活着,活着的话就给它的锁续命。是不是可以创建一个线程作为轮训检查的线程呢?貌似可行。接下来的工作量想想都头疼,或许自己写了一堆代码前辈们早就有更好的解决方案,况且作为小白的我就是面向Google编程。

    接着就找到了Redisson,为使用者提供了一系列具有分布式特性的常用工具类 Redisson项目介绍

    在这里插入图片描述
    3.4 第三次尝试方案

    现在就相当于有了大佬的加持,激情澎湃开始整合Redisson,跟着Redisson官方教程整合,这里不多逼逼了。整合结束后代码如下:

       @Autowired
        Redisson redisson;
      @GetMapping("/sell_stock")
        public String sellStock(){
            //作为锁
            String lockKey = "movie_001";
            RLock redissonLock = redisson.getLock(lockKey);
            try{
                redissonLock.lock();
                Stock stock = (Stock)redisUtil.get("stock");
                int num = stock.getNum();
                if(num>0){
                    stock.setNum(num-1);
                    redisUtil.set("stock",stock);
                    System.out.println("口票成功,剩余【 "+(num-1)+" 】张票");
                }else{
                    System.out.println("口票失败(error)");
                }
            }finally {
               redissonLock.unlock();
            }
            return "end";
        }
    

    到这里真的结束了吗?

    作为小白的我经过这次模拟,觉得这才刚刚开始,写这篇博客就是记录一下自己学习过程,大佬都是从小白一步步踩坑过来的,或许有一天我也可以和大佬一起喝卡布奇诺。

    在这里插入图片描述

    推荐文章 | 评论区可以互相分享优质文章|一同进步

    慢谈 Redis 实现分布式锁 以及 Redisson 源码解析

    相关文章

      网友评论

          本文标题:模拟实验 | Redis分布式锁问题&踩坑&解决方案

          本文链接:https://www.haomeiwen.com/subject/eusoihtx.html