Redis高并发分布式锁实战

案例:

简单的模拟多线程同时抢购一件商品的场景。

一、环境搭建

在springBoot中引入redis依赖

  <!--SpringBoot的Redis支持-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
            <!--注意这里使用2.1.x版本用于支持redis的分布式锁设置超时时间的功能!-->
            <version>2.1.8.RELEASE</version>
        </dependency>
        <!--SpringBoot缓存支持-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-cache</artifactId>
        </dependency>

 

二、编写controller,用于处理减库存业务

1、版本一:直接获取商品库存,进行if判断,然后设置库存来操作减库存流程

@RestController
@RequestMapping("/myredis")
public class MyRedisController {
    
    @Autowired
    StringRedisTemplate stringRedisTemplate;
​
    @RequestMapping("/stock/{id}")
    public ResponseEntity<String> reduceStock(@PathVariable int id){
        String key = "stock:"+id;
        //获取货物库存
        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get(key));
        //检测库存是否小于等于0,如果是则提示库存不足
        if(stock<=0){
            System.out.println(Thread.currentThread().getName()+"库存不足!"+stringRedisTemplate.opsForValue().get(key));
            return  new ResponseEntity<String>("库存不足!", HttpStatus.INTERNAL_SERVER_ERROR);
        }
        //处理减库存业务
        stringRedisTemplate.opsForValue().set(key,String.valueOf(stock-1));
        return new ResponseEntity<String>("减库存成功!", HttpStatus.OK);
    }
}

浏览器或者请求工具调用接口/myredis/stock/1001 来减指定id(1001) 编号的货物库存。

版本1中对该接口只是简单的获取指定id的库存,然后判断再设置减库存后的值。这种模式在高并发的情况下肯定会出现超卖的现象。假设多个线程同时的拿到最后一个商品,stock值都是一样的,判断stock>0之后就进行减1的操作,再将剩余的结果存放到redis中,这就会引起超卖的现象。

 

2、版本二:采用加锁synchronized

可以在代码块上加synchronized锁。

@RestController
@RequestMapping("/myredis")
public class MyRedisController {
    
    @Autowired
    StringRedisTemplate stringRedisTemplate;
​
    @RequestMapping("/stock/{id}")
    public ResponseEntity<String> reduceStock(@PathVariable int id){
       
        String key = "stock:"+id;
        synchronized(this){
           //获取货物库存
           int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get(key));
           //检测库存是否小于等于0,如果是则提示库存不足
           if(stock<=0){
              System.out.println(Thread.currentThread().getName()+"库存不 
              足!"+stringRedisTemplate.opsForValue().get(key));
            return  new ResponseEntity<String>("库存不足!", HttpStatus.INTERNAL_SERVER_ERROR);
        }
           //处理减库存业务
           stringRedisTemplate.opsForValue().set(key,String.valueOf(stock-1));
        }
        return new ResponseEntity<String>("减库存成功!", HttpStatus.OK);
     
    }
}

说明下问题,加锁的在高并发场景下对性能并不是特别好,因为其互斥性,可能会导致过多的线程排队等候,系统开销也比较大,不建议使用,并且在分布式环境下,使用synchronized关键字就不管用了,会出现分布式问题。

 

3、版本三:采用分布式锁setnx

setnx命令的语法: setnx key value

如果key存在,则不能设置成功,返回0(false),如果key不存在,那么久执行设置key,value。

 @RequestMapping("/stock/{id}")
    public ResponseEntity<String> reduceStock(@PathVariable int id){
            //进来的线程必须要获取是否存在分布式锁
            String lockKey = "stock:"+id+":lockey";
            Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockKey);
            if (!flag) {
                return new ResponseEntity<String>("服务器繁忙,请稍后重试", HttpStatus.NOT_ACCEPTABLE);
            }
        try {
            String key = "stock:" + id;
            //获取货物库存
            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get(key));
            //检测库存是否小于等于0,如果是则提示库存不足
            if (stock <= 0) {
                return new ResponseEntity<String>("库存不足!", HttpStatus.INTERNAL_SERVER_ERROR);
            }
            //处理减库存业务
            stringRedisTemplate.opsForValue().set(key, String.valueOf(stock - 1));
            return new ResponseEntity<String>("减库存成功!", HttpStatus.OK);
        }finally {
                //最后必须释放分布式锁,让下个线程获取
                stringRedisTemplate.delete(lockKey);
            }
        }
    }

上面采用了setnx命令,假设线程1、线程2、线程3同时访问这个扣减库存接口,那么同时想要去执行setIfAbsent方法(实际上就是setnx命令)。因为redis是单线程模型,所以无论要执行多少条命令,都是按照请求到达的先后顺序进行排队的,redis会把所有待执行的命令按先后顺序排在队列中:

 

故此,不管有多少个线程同时执行,对应的分布式锁有且仅有一个成功上锁! 所以上面的方法成功的解决了超卖的问题。并且注意到上面对成功加锁的线程才处理业务,如果处理业务期间抛出异常,最终都会执行finally块来释放分布式锁,这很重要!

如果不释放分布式锁,那么之后的线程就会一直访问不了!除非手动的去redis服务器中删除分布式锁,故此为了避免执行过程抛异常,要在finally块释放锁!

 

 

3.2 分布式锁设置过期时间

上面的方法仍然会出现一些严重的bug。有下面一场景:

假设你处于高并发场景下,线程1刚刚好加上分布式锁,执行减库存业务逻辑,此时刚刚获取完库存即将进行减库存的时刻,突然redis服务器宕机了,那么就会导致一个问题,刚刚加上的分布式锁就会永久的残留在redis服务器中!

因为没有对分布式锁做超时过期处理 ,故此我们要将锁加上超时时间来修复这个bug,这样即使redis服务器宕机了,也不会说这个分布式锁永远残留在redis服务器中的现象。

 @RequestMapping("/stock/{id}")
    public ResponseEntity<String> reduceStock(@PathVariable int id){
            //进来的线程必须要获取是否存在分布式锁
            String lockKey = "stock:"+id+":lockey";
            Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockKey);
            //为分布式锁设置过期时间
            stringRedisTemplate.expire(lockKey,10,TimeUnit.SECONDS);
            if (!flag) {
                return new ResponseEntity<String>("服务器繁忙,请稍后重试", HttpStatus.NOT_ACCEPTABLE);
            }
        try {
            String key = "stock:" + id;
            //获取货物库存
            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get(key));
            //检测库存是否小于等于0,如果是则提示库存不足
            if (stock <= 0) {
                return new ResponseEntity<String>("库存不足!", HttpStatus.INTERNAL_SERVER_ERROR);
            }
            //处理减库存业务
            stringRedisTemplate.opsForValue().set(key, String.valueOf(stock - 1));
            return new ResponseEntity<String>("减库存成功!", HttpStatus.OK);
        }finally {
                stringRedisTemplate.delete(lockKey);
        }
    }

如果细心一点去想一下,其实上面设置超时时间的做法是不完美的,那万一你在执行完setIfAbsent,也就是刚刚设置完分布式锁,还没来得及设置过期时间的时候,突然redis宕机了怎么办呢?

针对上面的情况,在stringRedisTemplate中,我们可以直接使用下面的命令

 stringRedisTemplate.opsForValue().setIfAbsent(lockKey,lockKeyValue,10,TimeUnit.SECONDS);

这条命令在spring-boot-starter-data-redis 2.1.x版本才存在喔!

这条命令就将设置分布式锁和超时时间合成了一个原子操作,也就是一条执行命令,保证设置上过期时间。

 

更进一步思考

即使你为分布式锁加上了过期时间,但仍然会出现bug。假设有三个线程

  • 线程a 首先加上了分布式锁 ,按照上面的代码逻辑分布时锁key和value都是一样的。 然后线程a由于网络原因,其任务要执行15s。但分布式锁只是设置了10秒的过期时间,所以当线程a执行完10s后,线程b在那个时刻刚好访问接口

  • 线程b检测发现没有分布式锁,所以线程b进行加锁。然后执行b的逻辑。 假设线程b执行8s ,在线程a执行完后面的5s后,线程a紧接着要去执行finally块,去删除刚刚加的分布式锁(但是已经过期了!),线程a误以为这把锁时刚刚自己加的,原因是分布式锁按照上面的逻辑,不管是哪个线程,都是加到同样的key和value,此时删的就是线程b加的分布式锁,而线程b还在执行业务逻辑!

  • 这个时候,恰巧线程c也访问到了这个接口,线程c发现没有分布式锁(实际上被a删了,但b还在执行!),故此c线程加锁,然后执行逻辑。然后线程b执行完了之后,如果c线程没执行完,那么b线程删的就是c线程加的分布式锁!

经过上面的分析,发现如果处于高并发,且网络有延时的情况,就可能会导致分布式锁失效问题,而且可能是永久失效!

3.3 解决分布式锁失效问题

我们想要只允许当前线程去释放自己加的分布式锁,不允许其他线程随意的删除其他线程加的锁,那么就要给锁加一个标志,比如我们给分布式锁使用uuid生成一个当前线程对应的序列号,并设置到分布式锁对应的value中,那么当要释放锁的时候,就要先去检测一下分布式锁的key对应的value是否是当前线程枷锁前生成的序列号值,如果,是才允许释放锁。

 @RequestMapping("/stock/{id}")
    public ResponseEntity<String> reduceStock(@PathVariable int id){
        //进来的线程必须要获取是否存在分布式锁
            String lockKey = "stock:"+id+":lockey";
            String lockKeyValue = UUID.randomUUID().toString();
​
            Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockKeyValue,10,TimeUnit.SECONDS);
            //为分布式锁设置过期时间
            if (!flag) {
                //设置失败,返回
                System.out.println(Thread.currentThread().getName() + "服务器繁忙,请稍后重试");
                return new ResponseEntity<String>("服务器繁忙,请稍后重试", HttpStatus.NOT_ACCEPTABLE);
            }
        try {
            //首先用分布式锁,查看是否存在锁
            String key = "stock:" + id;
            //获取货物库存
            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get(key));
            //检测库存是否小于等于0,如果是则提示库存不足
            if (stock <= 0) {
                System.out.println(Thread.currentThread().getName() + "库存不足!" + stringRedisTemplate.opsForValue().get(key));
                return new ResponseEntity<String>("库存不足!", HttpStatus.INTERNAL_SERVER_ERROR);
            }
            //处理减库存业务
            stringRedisTemplate.opsForValue().set(key, String.valueOf(stock - 1));
            return new ResponseEntity<String>("减库存成功!", HttpStatus.OK);
        }finally {
            if(lockKeyValue.equals(stringRedisTemplate.opsForValue().get(lockKey))){
                stringRedisTemplate.delete(lockKey);
            }
        }
    }

上面的代码还是会存在一个问题。如果一个线程执行时间有超过了锁过期时间,别的线程依旧能够加锁。这种解决办法通常是设置一个定时器进行锁的续期

假设线程a,加了把分布式锁,设置过期10s,但是它却要执行30s,当他执行到10s,锁就没了,其他线程可以随意加锁,放到抢购环境下,就会出现超卖问题! 那么解决这种现象,我们为每一个加锁的线程,设置一个定时器,定期的去轮询当前线程是否执行完任务,判断是否执行完成的标准是查看是否存在分布式锁,如果检测发现存在分布式锁,那就将锁的过期时间延长,如果没有发现该线程对应的分布式锁,说明任务已经完成了,结束定时任务。这样来就能保证线程执行完成,分布式锁依然存在。

实际上市面上有很多成熟的框架用以支持分布式锁,比如我们的redission,这套框架可以很简单的实现上面我们加分布式锁的流程。

 

4、使用redisson实现分布式锁

       <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson</artifactId>
            <version>3.11.3</version>
        </dependency>

在配置类中往spring容器加入redis对象,下面是使用单台redis服务器的,当然redisson可以支持哨兵模式,redis集群等等api。

  @Bean
    public Redisson redisson(){
        Config config = new Config();
        //设置redis服务器连接地址,并设置服务器的redis数据库
        config.useSingleServer().setAddress("redis://localhost:6379").setDatabase(0);
        //调用Redisson静态构造方法创建redisson对象
        return (Redisson)Redisson.create(config);
    }

修改controller方法如下:

    @RequestMapping("/stock/{id}")
    public ResponseEntity<String> reduceStock(@PathVariable int id) throws InterruptedException {
        //进来的线程必须要获取是否存在分布式锁
        String lockKey = "stock:"+id+":lockey";
        //redission加锁,注意这里只用传一个lockKey,对应的value会在redisson后台自动加上唯一标志。
        RLock lock = redisson.getLock(lockKey);
        lock.lock();
        try {
            //首先用分布式锁,查看是否存在锁
            String key = "stock:" + id;
            //获取货物库存
            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get(key));
            //检测库存是否小于等于0,如果是则提示库存不足
            if (stock <= 0) {
                System.out.println(Thread.currentThread().getName() + "库存不足!" + stringRedisTemplate.opsForValue().get(key));
                return new ResponseEntity<String>("库存不足!", HttpStatus.INTERNAL_SERVER_ERROR);
            }
            //处理减库存业务
            stringRedisTemplate.opsForValue().set(key, String.valueOf(stock - 1));
            return new ResponseEntity<String>("减库存成功!", HttpStatus.OK);
        }finally {
            lock.unlock();
        }
    }

要看懂上面的代码,首先了解下redisson加分布式锁后端逻辑

 

假设线程1和线程2同时访问接口,线程1先调用了redission的lock接口,而线程2迟一步调用

  • 此时线程1就开始执行扣减库存的业务操作,并且redisson会为线程1开启一个线程,每个1/3的过期时间(redisson默认的过期时间是30s,1/3过期时间则为10s轮询一次),就会去轮询访问是否该分布式锁还存在,如果存在则进行续期。

  • 那么在线程1执行它的减库存任务时,线程2会一直因为lock而阻塞着,直到线程1执行完毕,线程2才可以访问。(当然你也可以使用lock.tryLock方法直接让加不了锁的线程直接返回)

当然线程1处理完业务逻辑后,就会进行lock.unlock,此时不必去担心,是否别的线程会干掉线程1加的分布式锁,这些redisson已经帮我们解决了。

 

更进一步思考

问题一:redis集群下,主节点宕机,从节点未同步分布式锁,导致超卖

上面代码使用redisson改造后的代码,已经可以实现比较完整的分布式锁了,在单题redis服务的情况下,不会出现超卖的情况。但是如果时在redis集群架构的情况,还是会出现问题。当你保存分布式锁的那台redis服务器挂掉后,如果你部署了哨兵模式,那就会将主redis节点替换成从redis节点,但是从redis节点还没有来的及将锁同步过去,此时如果有线程继续访问接口,那就会检测到没有分布式锁从而操作减库存业务!在分布式场景下,超卖问题依然会存在!

采用RedissonRedlock

当线程要加分布式锁的时候,我们可以同时向多个redis集群中的节点一起加该分布式锁,当超过半数的redis节点加锁成功的时候,才返回加锁成功。然后线程再进行下面的扣减库存操作,最后对加锁成功的节点当线程执行完成后,要释放分布式锁。

 

代码:

@RequestMapping("/stock/{id}")
public ResponseEntity<String> reduceStock(@PathVariable int id) throws InterruptedException {
    //进来的线程必须要获取是否存在分布式锁
    String lockKey = "stock:"+id+":lockey";
    //这里需要自己实例化不同的redis实例的redisson客户端连接。
    RLock lock = redisson.getLock(lockKey);
    RLock lock2 = redisson2.getLock(lockkey);
    //根据多个RLock对象构建RedissonRedLock,最根本的差别在这
    RedissonRedLock redLock = new RedissonRedLock(lock,lock2);
    try {
        //首先用分布式锁,查看是否存在锁
        String key = "stock:" + id;
        //获取货物库存
        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get(key));
        //检测库存是否小于等于0,如果是则提示库存不足
        if (stock <= 0) {
            System.out.println(Thread.currentThread().getName() + "库存不足!" + stringRedisTemplate.opsForValue().get(key));
            return new ResponseEntity<String>("库存不足!", HttpStatus.INTERNAL_SERVER_ERROR);
        }
        //处理减库存业务
        stringRedisTemplate.opsForValue().set(key, String.valueOf(stock - 1));
        return new ResponseEntity<String>("减库存成功!", HttpStatus.OK);
    }finally {
        //最后释放redLock
        redLock.unlock();
    }
}

问题二:在高并发场景下,性能有瓶颈

redis本身时一个单线程模型,他会对请求先后排序执行命令,尽管redis的性能很高,可以达到每秒几万的qps。但是如果在一些大并发量的场景下,这些就不够用了,比方说抢购商品,同时有30w人抢购,那必然会遇到redis的一个性能瓶颈。

redis分布式锁天生就是与高并发相悖的。

采用分段锁思想(ConcurrentHashMap底层也是使用这个去提升性能的)

假设某件商瓶id为1001 库存为 1000 , 如果我们直接的设置一个key = stock:1001 value=1000 去存的话,你在redis集群架构通过对hash取值之后,只会映射到固定的一个redis节点的槽点。最终存放的该库存数都集中于这台redis服务器上。

采用分段锁的思想就是,我们可以将这个商品的key划分成几段,每段存放一定的数量的该商品货物,比如可以将stock:1001划分如下:

  • key = stock:1001:fragement1 value = 200

  • key = stock:1001:fragement2 value = 200

  • key = stock:1001:fragement3 value = 200

  • key = stock:1001:fragement4 value = 200

  • key = stock:1001:fragement5 value = 200

我们将这个商品的key和value平均分成5段,那么加分布式锁的时候,就可以按这个key的段去加,分散开来,这样性能就提升上去了!

  • 4
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值