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

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次,感觉没问题啊,如果这是一个真实活动 这不是演习, 这个任务交到了刚来公司不久的你,要是做得好可能有奖励,要是超卖严重,可能就要卷铺盖走人了,不行得考虑所有的可能出想的问题。

倒杯卡布奇诺冷静一下

仔细检查逻辑,考虑多种情境,发现:万一业务代码执行时间长,第一个线程还没释放锁,结果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";
}

觉得哪里不对劲

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分布式锁问题&踩坑&解决方案)