Redis高并发分布式锁实战(Setnx、Redisson及底层源码、RedLock):库存扣减中理解分布式锁的含义【干货满满】

一、分布式锁场景

1、互联网秒杀

2、抢优惠券

3、接口幂等性校验

二、扣减库存实战

1、不加锁版本

依赖:

	<parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.0.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <modelVersion>4.0.0</modelVersion>


    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>

        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson</artifactId>
            <version>3.6.5</version>
        </dependency>
    </dependencies>

配置文件:

server:
  port: 8080

spring:
  redis:
    database: 0
    timeout: 3000
    host: 192.168.131.171
    port: 6380

SpringBoot启动:

package com.jihu.redis;

import org.redisson.Redisson;
import org.redisson.config.Config;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
public class RedisApplication {
     
    public static void main(String[] args) {
     
        SpringApplication.run(RedisApplication.class, args);
    }
	
	// 注意要注入redisson 否则会出错
    @Bean
    public Redisson redisson() {
     
        Config config = new Config();
        config.useSingleServer().setAddress("redis://192.168.131.171:6380").setDatabase(0);
        return (Redisson) Redisson.create(config);
    }
}

直接上代码,我们模拟一个扣减库存的简单场景。

【初始版本】

package com.jihu.redis.lock;

import org.redisson.Redisson;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;

@RestController
public class StockController {
     

    @Autowired
    private Redisson redisson;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;


    /**
     * 模拟减库存
     * @return ""
     */
    @RequestMapping("/deduct_stock")
    public String deductStock() {
     
		// 从redis获取库存数量
        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
        // 如果库存数量大于0
        if (stock > 0) {
     
            int realStock = stock -1;

            stringRedisTemplate.opsForValue().set("stock", realStock + "");
            System.out.println("扣减成功,剩余库存:" + realStock + "");
        } else {
      // 如果库存数量小于0
            System.out.println("扣减失败,库存不足!");
        }

        return "end";
    }
}

这样扣减库存的代码,如果我们直接部署到生产环境,会不会出问题?

我们来分析一下,假如现在是高并发的场景,用户需要秒杀抢一些商品。

问题分析

当大量请求同时去执行从Redis获取数据这一行代码的时候:

/ 从redis获取库存数量,此时值设置为50
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));

此时由于我们没有任何的同步措施,这些请求都会直接从Redis中获取数据,查询到的结果自然就都是50了。

然后判断49大于0,然后都减1,库存剩余都是49.

然后都调用下面的代码将库存设置到Redis中,并且设置的值都是49.

stringRedisTemplate.opsForValue().set("stock", realStock + "");

但是这样显然不对呀!如果我是5个请求同时进来,都执行了减库存,库存应该是45呀,现在竟然还是49了!这样就会产生超卖的问题!

2、改进:加JVM锁

我们从上面的代码中分析得知,之所以会发生超卖,是因为我们读取Redis库存、扣减库存和将扣减后库存设置到Redis中的这些操作不是原子的,所以导致了超卖问题的产生。

【改进方案一:加JVM锁】
我们可以给以上操作库存的的操作加锁,从而实现原子性操作,这样应该可以避免超卖问题吧!于是乎,我们使用Synchronized来加锁:

package com.jihu.redis.lock;

import org.redisson.Redisson;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;

@RestController
public class StockController {
     

    @Autowired
    private Redisson redisson;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;


    /**
     * 模拟减库存
     * @return ""
     */
    @RequestMapping("/deduct_stock")
    public String deductStock() {
     
    	// 将操作库存的操作放到同步块中
        synchronized (this) {
     
            // 从redis获取库存数量
            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
            // 如果库存数量大于0
            if (stock > 0) {
     
                int realStock = stock -1;

                stringRedisTemplate.opsForValue().set("stock", realStock + "");
                System.out.println("扣减成功,剩余库存:" + realStock + "");
            } else {
      // 如果库存数量小于0
                System.out.println("扣减失败,库存不足!");
            }

        }
                
        return "end";
    }
}

分析,这样当多个请求执行进来的时候,线程会原子性的执行同步块中的扣减库存操作,可以防止超卖问题的产生。

问题分析

这样的加锁处理,在单机环境下,确实没什么问题,可以防止超卖情况的发生。

但是,现在的互联网公司基本都是集群架构,后端会存在多个服务实例。如果我们还是这样来使用JVM级别的锁,还能防止超卖情况的发生吗?

显然是不能的。因为JVM锁只能锁住同一个JVM进程中的线程,分布式集群架构下,请求往往是跨多个JVM进程的

如下图,如果有两个tomcat同时去Redis查库存,Synchronized同步块只能锁住自己JVM中的并发请求,那另一台JVM中的请求是无法锁住的。这样依然会产生,多个请求同时去Redis查库存的情况。查询后发现都是50,都减去一个库存,然后都把49写入到Redis中,这样就产生了超卖的问题。
Redis高并发分布式锁实战(Setnx、Redisson及底层源码、RedLock):库存扣减中理解分布式锁的含义【干货满满】_第1张图片

那在这样分布式的场景下,我们又该如何的解决超卖问题的?

此时我们需要一把分布式锁

何为分布式锁,就是可以锁住不同JVM进程的锁,可以使用Redis、zookeeper等实现。原理就是,多个JVM都可以或都需要访问这些第三方中间件,这样Redis或zooKeeper就相当于一个管理者,可以对不同的JVM请求进行管理。这样就达到了管理多个JVM中请求的功能了。

超卖测试

1、我们先启动端口为8080的服务;
2、修改端口为8090,然后修改IDEA,在Allow parallel run打钩,这样基于可以启动多个服务实例了,只是运行在不同端口。我们启动8080和8090的服务;
在这里插入图片描述
3、配置nginx,实现分发,将请求分发到8080和8090这两个服务实例上:
如下是nginx配置:

# 配置Redis分发(我使用的是windows本机的nginx)
    upstream redislock {
     
	server 127.0.0.1:8080 weight=1;
	server 127.0.0.1:8090 weight=1;
    }

    server {
     
	listen       80;
        server_name  localhost;

	 location / {
     
		root html;
		index index.html index.htm;
		proxy_pass http://redislock;
        }
    }

4、配置好之后,确保执行http://localhost/deduct_stock之后,8080和8090都可以接收到转发的请求:
Redis高并发分布式锁实战(Setnx、Redisson及底层源码、RedLock):库存扣减中理解分布式锁的含义【干货满满】_第2张图片
Redis高并发分布式锁实战(Setnx、Redisson及底层源码、RedLock):库存扣减中理解分布式锁的含义【干货满满】_第3张图片
5、随后我们使用jmeter进行压测准备:

我们配置200个请求,为了模拟高并发,在0s内发完;并且持续4轮;即总共会压800个请求。
Redis高并发分布式锁实战(Setnx、Redisson及底层源码、RedLock):库存扣减中理解分布式锁的含义【干货满满】_第4张图片
压测地址:http://localhost/deduct_stock
Redis高并发分布式锁实战(Setnx、Redisson及底层源码、RedLock):库存扣减中理解分布式锁的含义【干货满满】_第5张图片

6、开始压测
Redis高并发分布式锁实战(Setnx、Redisson及底层源码、RedLock):库存扣减中理解分布式锁的含义【干货满满】_第6张图片
等待压测成功后,我们来分别查看8080和8090两个服务的日志。

我们来思考:如果两个日志打印出的 剩余库存的值有相同的,那么就以为着可能会发生超卖

首先看8080的日志:
Redis高并发分布式锁实战(Setnx、Redisson及底层源码、RedLock):库存扣减中理解分布式锁的含义【干货满满】_第7张图片
然后看8090的日志:
Redis高并发分布式锁实战(Setnx、Redisson及底层源码、RedLock):库存扣减中理解分布式锁的含义【干货满满】_第8张图片
很明显的看到,两个日志中的剩余库存出现了大量相同的数字,这意味着已经发生了超卖的情况!!!

3、改进:加分布式锁

【改进方案二:加分布式锁】

三、Redis分布式锁实现

1、Setnx实现分布式锁

【setnx基础版本】存在问题!

package com.jihu.redis.lock;

import org.redisson.Redisson;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class StockController {
     

    @Autowired
    private Redisson redisson;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;


    /**
     * 模拟减库存
     * @return ""
     */
    @RequestMapping("/deduct_stock")
    public String deductStock() {
     
        String lockKey = "lockKey";

        // setIfAbsent 相当于jedis.setnx(key, value)操作
        /**
         * setnx(lockKey, value):
         *
         *      如果这个lockKey在Redis中是已经存在的,则这条命令不做任何操作,返回false
         *      如果这个lockey不存在于Redis中,则设置成功,并返回true
         *
         *  此时如果多个请求来执行这个命令,会在Redis中排队,此时只会有一个请求成功设置了lockKey并返回true.
         *  生育的请求查询发现这个lockKey已经存在了,会直接返回false,不进行任何操作
         */
        Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "xiaoyan");

        // ========= setnx 竞争锁失败 ==========
        if (!result) {
     
            return "{code: 10001, message:请重新再试!}";
        }

        // ========== setnx 竞争锁成功 =======

        // 从redis获取库存数量
        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
        // 如果库存数量大于0
        if (stock > 0) {
     
            int realStock = stock -1;
            // 相当于jedis.set(key, value)
            stringRedisTemplate.opsForValue().set("stock", realStock + "");
            System.out.println("扣减成功,剩余库存:" + realStock + "");
        } else {
      // 如果库存数量小于0
            System.out.println("扣减失败,库存不足!");
        }

        // 成功扣减库存后的请求,需要释放这个lockKey
        stringRedisTemplate.delete(lockKey);

        return "end";
    }
}

我们这样使用setnx命令来实现Redis分布式锁,现在只会有一个请求使用setnx设置lockKey成功,然后执行扣减库存操作,然后删除lockKey. 剩余的请求竞争失败的会直接返回false。

那现在这样还存在其他问题吗?大家思考一下?如果执行扣减库存的时候抛出异常了呢?这样lockKey的锁岂不是永远都不会被释放了?

如果服务宕机了?该怎么删除lockKey呢 ?

所以说对于上面的问题,我们必须优化一下:
1、使用finally块来删除lockKey;
2、给setnx设置的lockKey添加过期时间。

【setnx升级版本】依然存在问题!

package com.jihu.redis.lock;

import org.redisson.Redisson;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class StockController {
     

    @Autowired
    private Redisson redisson;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;


    /**
     * 模拟减库存
     * @return ""
     */
    @RequestMapping("/deduct_stock")
    public String deductStock() {
     
        String lockKey = "lockKey";

        try {
     
            // setIfAbsent 相当于jedis.setnx(key, value)操作
            /**
             * setnx(lockKey, value):
             *
             *      如果这个lockKey在Redis中是已经存在的,则这条命令不做任何操作,返回false
             *      如果这个lockey不存在于Redis中,则设置成功,并返回true
             *
             *  此时如果多个请求来执行这个命令,会在Redis中排队,此时只会有一个请求成功设置了lockKey并返回true.
             *  生育的请求查询发现这个lockKey已经存在了,会直接返回false,不进行任何操作
             */
            // Redis内部会原子性的保证设置value和设置过期时间同时成功。
            Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "xiaoyan", 10, TimeUnit.SECONDS);
            // 添加过期时间,防止服务宕机后无法删除

            // 这是错误的设置过期的方式!!!如果服务还没执行到这行就宕机了,依然会无法释放锁!Redis过期时间一定要在set数据的时候就设置!!!
            // stringRedisTemplate.expire(lockKey, 10, TimeUnit.SECONDS);

            // ========= setnx 竞争锁失败 ==========
            if (!result) {
     
                return "{code: 10001, message:请重新再试!}";
            }

            // ========== setnx 竞争锁成功 =======

            // 从redis获取库存数量
            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
            // 如果库存数量大于0
            if (stock > 0) {
     
                int realStock = stock -1;
                // 相当于jedis.set(key, value)
                stringRedisTemplate.opsForValue().set("stock", realStock + "");
                System.out.println("扣减成功,剩余库存:" + realStock + "");
            } else {
      // 如果库存数量小于0
                System.out.println("扣减失败,库存不足!");
            }
        } finally {
      // 防止异常导致锁无法释放!!!
            // 成功扣减库存后的请求,需要释放这个lockKey
            stringRedisTemplate.delete(lockKey);
        }

        return "end";
    }
}

注意,设置过期时间的时候,一定要和set值一起设置,让Redis原子性的去执行这个两个命令

注意这个自动过期的时间设置,我们现在设置的是10s,即如果超过10s没有释放,Redis会自动释放。

我们这样设置完,好像各种情况都考虑到了!

那么,大家仔细再思考,是否还存在其他问题呢?

在一般的软件公司下,这样确实可以应对并发减库存的问题。


那么问题来了,在大型互联网公司中,如果此时的并发量非常大,大家想有没有可能存在这样的问题。我的一个请求成功竞争到了lockKey, 但是在执行扣减库存的业务的时候由于某些原因,导致执行的时间超过了10s.

这时候,Redis会自动释放这个lockKey的锁,但是我的这个请求并没有执行完扣减库存的业务呀。这时候其他请求也进入到扣减库存的业务代码中来。此时第一个请求还没来得及将扣减后的库存写回到Redis中,新的请求却体检查询了Redis数据库,这样一来,直接导致库存数量不准确,一定会产生超卖!!!

并且,如果第二个请求执行的过程中,第一个请求恰好执行15s结束,执行finally块中的删除lockKey,这样就会把第二个请求加的lockKey给删除掉。这样就会出现一个问题,某个请求的锁好像加成功了,一会又失败了。这样已经存在了很多问题…

所以,我们还需要进一步进行优化!

我们来分析此时的问题,因为第一个请求扣减库存的时间太长,导致加的锁过期,从而新的请求可以加锁成功,而新的请求执行扣减库存的过程中,第一个请求恰好执行完,然后再finally块中释放了锁。但事实上,这个锁是第二个请求加的,不再是第一个请求的锁了。因为第一个请求的锁已经因为过期被Redis自己释放了。

总结来说:现在的问题是,不同的请求都可以操作同一个lockKey。所以,我们要思考,是否可以给每一个请求一个不同的lockkey,从而使得每一个请求只能自己加锁、解锁

我们只是只是用了一个lockKey,那么我们现在可以针对每一个请求都生成一个lockKey:

【setnx最终版本】可用

 @RequestMapping("/deduct_stock")
    public String deductStock() {
     
        String lockKey = "lockKey";
        String clientId = UUID.randomUUID().toString();
        try {
     
            // setIfAbsent 相当于jedis.setnx(key, value)操作
            /**
             * setnx(lockKey, value):
             *
             *      如果这个lockKey在Redis中是已经存在的,则这条命令不做任何操作,返回false
             *      如果这个lockey不存在于Redis中,则设置成功,并返回true
             *
             *  此时如果多个请求来执行这个命令,会在Redis中排队,此时只会有一个请求成功设置了lockKey并返回true.
             *  生育的请求查询发现这个lockKey已经存在了,会直接返回false,不进行任何操作
             */
            // Redis内部会原子性的保证设置value和设置过期时间同时成功。
            // clientId: 每一个请求都生成一个clientId, 这样只有自己才能释放锁 
            Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, clientId, 30, TimeUnit.SECONDS);
            // 添加过期时间,防止服务宕机后无法删除

            // 这是错误的设置过期的方式!!!如果服务还没执行到这行就宕机了,依然会无法释放锁!Redis过期时间一定要在set数据的时候就设置!!!
            // stringRedisTemplate.expire(lockKey, 30, TimeUnit.SECONDS);

            // ========= setnx 竞争锁失败 ==========
            if (!result) {
     
                return "{code: 10001, message:请重新再试!}";
            }

            // ========== setnx 竞争锁成功 =======

            // 从redis获取库存数量
            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
            // 如果库存数量大于0
            if (stock > 0) {
     
                int realStock = stock -1;
                // 相当于jedis.set(key, value)
                stringRedisTemplate.opsForValue().set("stock", realStock + "");
                System.out.println("扣减成功,剩余库存:" + realStock + "");
            } else {
      // 如果库存数量小于0
                System.out.println("扣减失败,库存不足!");
            }
        } finally {
      // 防止异常导致锁无法释放!!!
            // 成功扣减库存后的请求,需要释放这个lockKey
            // 如果lockKey对应的value是本次请求设置的value,才允许释放锁!
            if (clientId.equals(stringRedisTemplate.opsForValue().get(lockKey))) {
     
                stringRedisTemplate.delete(lockKey);
            }
        }

        return "end";
    }

使用Redis的setnx来实现分布式锁的话,这样的解决方案是可行的,一些公司也有这样使用分布式锁的!

存在的的问题:这样虽然解决了不同请求释放锁的问题,但是依然可能导致bug. 如果第一个请求要执行15s, 而超过了10s的过期时间,导致锁被自动释放,其他的请求就会进入扣减库存的代码。如果第一个请求恰好扣减完库存,还没来得及将结果写入到Redis中的时候,新的请求查询了库存。这样可能出现库存拆卖的问题。所以,这个过期时间设置为多少,需要我们自己权衡好。

那么,还有没有更好的解决方案呢?

当然有!我们继续往下分析!!!


现在存在的问题的原因是我们不太能确定下起来,这个过期时间到底设置为多少比较合适!

设置的太长,如果服务宕机没有在finally块中释放锁的话,这样就需要等待很久的时间才能释放锁。

设置的太短,可能会因为第一个请求时间异常导致锁被提前释放,而扣减库存的业务还没执行完,从而导致超卖问题!

归根结底,是这超时时间的设置问题!

在当前认知中,似乎已经没有更好的解决方案了。我们需要打破认知,引入新的概念了:font color=“red”> 锁续命/font>。


锁续命(也称看门狗)

Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, clientId, 30, TimeUnit.SECONDS);

假如我们现在设置锁的过期时间是30s,我们可以再后台启动一个子线程去执行一个定时器,每过1 / 3的过期时间后(30 / 3 = 10 s)就去检查一下当前的锁状态:

  • 如果锁还没有被释放(说明依然在执行扣减库存的业务代码),那么我们就重新给这个请求设置锁的过期时间为30s。从而让这个请求可以持续的持有锁。

  • 如果发现锁已经被释放了,那么就直接结束这个子线程。

这个锁续命的想法非常之巧妙,但是在高并发的场景下让我们自己来实现,势必不是那么容易。

这个一个简单的分布式锁,我们就踩了很多坑,如果没有高并发经验的人来写,那后果更不可想象。

所以,市面上现在已经存在一些技术来帮助我们解决Redis分布式锁的问题了。

我们用的比较多的是Redisson. 下面我们来学习一下Redeisson.

2、Redeisson实现分布式锁

网址:https://redisson.org/

Redisson介绍

Redisson也是一个Redis Java Client,即Redis Java客户端,类似于Jedis.

Redisson对于RedisAPI的支持没有Jedis那么全面,但是对于高并发的支持要优于Jedis。比如Redisson支持布隆过滤器、分布式锁、分布式对象等等
Redis高并发分布式锁实战(Setnx、Redisson及底层源码、RedLock):库存扣减中理解分布式锁的含义【干货满满】_第9张图片

使用Redisson实现分布式锁

依赖:

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

Redis可以支持单机版、哨兵、cluster集群等多种模式,我们可以自己去配置,配置好之后将其注入到Srping容器中,在要使用的使用@Autowired注入使用即可。
Redis高并发分布式锁实战(Setnx、Redisson及底层源码、RedLock):库存扣减中理解分布式锁的含义【干货满满】_第10张图片
Redis高并发分布式锁实战(Setnx、Redisson及底层源码、RedLock):库存扣减中理解分布式锁的含义【干货满满】_第11张图片
准备工作做好之后,我们就来开始使用Redisson实现分布式锁吧。

package com.jihu.redis.lock;

import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class StockForRedissonController {
     

    @Autowired
    private Redisson redisson;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;


    /**
     * 模拟减库存
     * @return ""
     */
    @RequestMapping("/deduct_stock")
    public String deductStock() {
     
        String lockKey = "lockKey";

        // 获取到redisson锁对象
        RLock redissonLock = redisson.getLock(lockKey);
        try {
     
            // ========= 添加redisson锁并实现锁续命功能 =============
            /**
             *  主要执行一下几个操作
             *
             *  1、将localKey设置到Redis服务器上,默认过期时间是30s
             *  2、每10s触发一次锁续命功能
             */
            redissonLock.lock();

            // ======== 扣减库存业务员开始 ============
            // 从redis获取库存数量
            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
            // 如果库存数量大于0
            if (stock > 0) {
     
                int realStock = stock -1;
                // 相当于jedis.set(key, value)
                stringRedisTemplate.opsForValue().set("stock", realStock + "");
                System.out.println("扣减成功,剩余库存:" + realStock + "");
            } else {
      // 如果库存数量小于0
                System.out.println("扣减失败,库存不足!");
            }
            // ======== 扣减库存业务员结束 ============
        } finally {
      // 防止异常导致锁无法释放!!!
            // ============= 释放redisson锁 ==========
            redissonLock.unlock();
        }

        return "end";
    }
}

就这!!!!!????

就这么几行代码就搞定了分布式锁?

不得不说,这个Redisson有点东西呀。我们自己及使用setnx的时候要考虑各种情况,而且稍不注意,就会出错。

而Redisson底层都帮我们考虑到了之前的问题,并且进行了完美的解决。那我们先来测试一下功能是否好用,然后再来看一下Redisson底层实现。

Redisson分布式锁测试

下面是8080的log输出:
Redis高并发分布式锁实战(Setnx、Redisson及底层源码、RedLock):库存扣减中理解分布式锁的含义【干货满满】_第12张图片
下面是8090的log输出:
Redis高并发分布式锁实战(Setnx、Redisson及底层源码、RedLock):库存扣减中理解分布式锁的含义【干货满满】_第13张图片
可有看到,此时已经完美的结局了分布式扣减库存的问题。

四、Resisson分布式底层原理剖析


我们此时来理一下思路:

假如有多个线程同时来执行扣减库存操作,Redisson底层也是使用setnx来设置一个lockKey。

1、此时有且仅有一个线程能够设置成功,如果某个线程加锁成功,Redisson会启动一个后台守护线程,每隔10s检查当前线程是否持有锁,如果持有锁,则延长持有锁的时间为30s。

2、其他竞争锁失败的线程会一直自旋,尝试加锁。

3、当第一个线程执行完业务代码,释放锁后,其他的线程再去竞争锁。

注意:
Redisson底层设置了过期时间为30s, 这样可以防止服务宕机后锁无法释放的问题,所以我们不用担心。


我们是实现一些业务的时候,往往希望它们是原子操作,尤其是涉及到交互Redis的时候。但是Redis对原子的支持并不是很友好,所以我们经常会使用lua脚本来保证原子性。

而Redisson底层使用了大量的lua脚本来保证原子性。

所以在了解Redisson底层原理之前,我们先来了解一下lua脚本。

Lua脚本

https://blog.csdn.net/qq_43631716/article/details/118333267?spm=1001.2014.3001.5501

Redis在2.6推出了脚本功能,允许开发者使用Lua语言编写脚本传到Redis中执行。使用脚本的好处如下:

1、减少网络开销:本来5次网络请求的操作,可以用一个请求完成,原先5次请求的逻辑放在redis服务器上完成。使用脚本,减少了网络往返时延。这点跟管道类似。

2、原子操作:Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。管道不是原子的,不过redis的批量操作命令(类似mset)是原子的。

3、替代redis的事务功能:redis自带的事务功能很鸡肋,而redis的lua脚本几乎实现了常规的事务功能,官方推荐如果要使用redis的事务功能可以用redis lua替代。

官网文档上有这样一段话:

A Redis script is transactional by definition, so everything you can do with a Redis transaction, 
you can also do with a script,and usually the script will be both simpler and faster.

从Redis2.6.0版本开始,通过内置的Lua解释器,可以使用EVAL命令对Lua脚本进行求值。EVAL命令的格式如下:

EVAL script numkeys key [key ...] arg [arg ...]

script参数是一段Lua脚本程序,它会被运行在Redis服务器上下文中,这段脚本不必(也不应该)定义为一个Lua函数。numkeys参数用于指定键名参数的个数。键名参数 key [key …] 从EVAL的第三个参数开始算起,表示在脚本中所用到的那些Redis键(key),这些键名参数可以在 Lua中通过全局变量KEYS数组,用1为基址的形式访问( KEYS[1] , KEYS[2] ,以此类推)。

在命令的最后,那些不是键名参数的附加参数 arg [arg …] ,可以在Lua中通过全局变量ARGV数组访问,访问的形式和KEYS变量类似( ARGV[1] 、 ARGV[2] ,诸如此类)。例如:

127.0.0.1:6379> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second
1) "key1"
2) "key2"
3) "first"
4) "second"

eval 使用C语言来解析的。2 值得是key的数量。

其中 “return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}” 是被求值的Lua脚本,数字2指定了键名参数的数量, key1和key2是键名参数,分别使用 KEYS[1] 和 KEYS[2] 访问,而最后的 first 和 second 则是附加参数,可以通过 ARGV[1] 和 ARGV[2] 访问它们。在 Lua 脚本中,可以使用redis.call()函数来执行Redis命令。

// ********** lua脚本示例 *************
            // lua脚本模拟一个商品减库存的原子操作(模拟扣减库存的操作,从15减去10)
            // lua脚本命令执行方式:redis‐cli ‐‐eval /tmp/test.lua , 10
            jedis.set("product_count_10016", "15"); //初始化商品10016的库存
            String script = " local count = redis.call('get', KEYS[1]) " +
                    " local a = tonumber(count) " +
                    " local b = tonumber(ARGV[1]) " +
                    " if a >= b then " +
                    "   redis.call('set', KEYS[1], a-b) " +
                    "   return 1 " +
                    " end " +
                    " return 0 ";
//            String script= "local count = redis.call('get', KEYS[1])  local a = tonumber(count) local b = tonumber(ARGV[1])  if a >= b then  redis.call('set', KEYS[1], a‐b)  return 1  end  return 0 ";
            System.out.println("script: " + script);
            Object obj = jedis.eval(script, Arrays.asList("product_count_10016"),
                    Arrays.asList("10"));
            System.out.println(obj);
script:  local count = redis.call('get', KEYS[1])  local a = tonumber(count)  local b = tonumber(ARGV[1])  if a >= b then    redis.call('set', KEYS[1], a-b)    return 1  end  return 0 
1

Process finished with exit code 0

如果出现“ERR Error compiling script (new function): user_script:1: ‘)’ expected near ‘¬’” 错误,可能是复制的lua脚本中空格不对。

验证原子性:

			jedis.set("product_count_10016", "15"); //初始化商品10016的库存
            String script = " local count = redis.call('get', KEYS[1]) " +
                    " local a = tonumber(count) " +
                    " local b = tonumber(ARGV[1]) " +
                    " if a >= b then " +
                    "   redis.call('set', KEYS[1], a-b) " +
                    // 模拟语法错误回滚
                    "   bb == 0" +
                    "   return 1 " +
                    " end " +
                    " return 0 ";

            System.out.println("script: " + script);
            Object obj = jedis.eval(script, Arrays.asList("product_count_10016"),
                    Arrays.asList("10"));
            System.out.println(obj);

在这里插入图片描述

我们去查看redis, 发现库存没有被扣减成功,因为事务发生了回滚。

可以用lua脚本来实现分布式锁。

lua脚本缺点注意点

注意,不要在Lua脚本中出现死循环和耗时的运算,否则redis会阻塞,将不接受其他的命令, 所以使用时要注意不能出现死循环、耗时的运算。redis是单进程、单线程执行脚本。管道不会阻塞redis

Redisson之lock()方法底层实现

注意,lock()方法自带30s的看门狗功能!如果使用tryLock(long waitTime, long leaseTime, TimeUnit unit), 看门狗功能会失效

1、lock() 方法

 @Override
    public void lock() {
     
        try {
     
            lockInterruptibly();
        } catch (InterruptedException e) {
     
            Thread.currentThread().interrupt();
        }
    }
@Override
    public void lockInterruptibly() throws InterruptedException {
     
        lockInterruptibly(-1, null);
    }
 @Override
    public void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException {
     
    	// 获取当前线程ID
        long threadId = Thread.currentThread().getId();
        // 返回锁剩余的过期时间
        	// 如果是当前线程加锁成功,返回null
        	// 如果是其他线程加锁失败,返回剩余过期时间
        Long ttl = tryAcquire(leaseTime, unit, threadId);
        // lock acquired
        if (ttl == null) {
     
        	// 如果是当前线程加锁成功
            return;
        }
		
		// 
        RFuture<RedissonLockEntry> future = subscribe(threadId);
        commandExecutor.syncSubscription(future);

		// 锁竞争失败的线程要CAS, 继续去调用tryAcquire获取锁
        try {
     
            while (true) {
     
            	//  pttl:当 key 不存在时,返回 -2 。 当 key 存在但没有设置剩余生存时间时,返回 -1 。 否则,以毫秒为单位,返回 key 的剩余生存时间。 
            	// 在当前逻辑中获取锁成功返回null
             	// 继续去调用tryAcquire尝试获取锁
                ttl = tryAcquire(leaseTime, unit, threadId);
                // 获取成功,跳出循环
                // lock acquired
                if (ttl == null) {
     
                    break;
                }
			
                // waiting for message
                if (ttl >= 0) {
       // 获取到剩余剩余过期时间
                	// 在ttl时间后,再次调用tryAcquire尝试获取锁
                    getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                } else {
       // lockKey不存在或者过期时间为0
                    getEntry(threadId).getLatch().acquire();
                }
            }
        } finally {
     
            unsubscribe(future, threadId);
        }
//        get(lockAsync(leaseTime, unit));
    }

这里,未抢到锁的线程并不是直接再去竞争锁,而是等到锁快到过期时间的时候再去竞争,这样减少了CPU的消耗。


我们这里看一下tryAcquire方法:
private Long tryAcquire(long leaseTime, TimeUnit unit, long threadId) {
     
        return get(tryAcquireAsync(leaseTime, unit, threadId));
    }

再跟一下tryAcquireAsync方法:

private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, final long threadId) {
     
        if (leaseTime != -1) {
     
            return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
        }
		
		// ===== 向redis中添加lockKey =====
        // .getLockWatchdogTimeout() 返回默认的30s
        RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
        // 添加一个监听器
        ttlRemainingFuture.addListener(new FutureListener<Long>() {
     
            @Override
            public void operationComplete(Future<Long> future) throws Exception {
     
                if (!future.isSuccess()) {
     
                    return;
                }

                Long ttlRemaining = future.getNow();
                // lock acquired
                if (ttlRemaining == null) {
     
                	// ===========  设置定时任务,给锁续命【看门狗设置】 =============
                    scheduleExpirationRenewal(threadId);
                }
            }
        });
        return ttlRemainingFuture;
    }


scheduleExpirationRenewal这个方法就是关于锁续命的方法,我们重来看一下

===========  设置定时任务,给锁续命【看门狗设置】 =============
private void scheduleExpirationRenewal(final long threadId) {
     
		// 到期续命的map中是否存在当前key: UUID+threadId,如果存在直接返回
        if (expirationRenewalMap.containsKey(getEntryName())) {
     
            return;
        }
		
		// 新建一个延迟任务(会延迟10s后执行)
        Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
     
            @Override
            public void run(Timeout timeout) throws Exception {
     
                // ======== 异步执行lua脚本 ======== 
                	// lua脚本的意思是:如果UUID+threadId已经存在于hash数据lockKey中,就重新设置设置过期时间为默认的30s;不存在直接返回0
                RFuture<Boolean> future = commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
                        "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                            "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                            "return 1; " +
                        "end; " +
                        "return 0;",
                          Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
                
                future.addListener(new FutureListener<Boolean>() {
     
                    @Override
                    public void operationComplete(Future<Boolean> future) throws Exception {
     
                        // private static final ConcurrentMap expirationRenewalMap = PlatformDependent.newConcurrentHashMap();
                        // expiration Renewal:过期续约
                    	// 从过期续命的map中移除当前的值UUID+threadId
                        expirationRenewalMap.remove(getEntryName());
                        // 如果锁续命失败,抛出异常
                        if (!future.isSuccess()) {
     
                            log.error("Can't update lock " + getName() + " expiration", future.cause());
                            return;
                        }
                        
                        // 如果锁续命成功, 递归调用自己,开始下一轮续命调用,继续判断是否需要续命
                        if (future.getNow()) {
     
                            // reschedule itself
                            scheduleExpirationRenewal(threadId);
                        }
                    }
                });
               // internalLockLeaseTime = private long lockWatchdogTimeout = 30 * 1000; 
            }  // internalLockLeaseTime / 3 = 10s , 延迟10s后执行这个任务,这验证了我们之前所说的时间
        }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
	
		// concurrentHashMap的putIfAbsent方法会判断key是否存在,如不存在(absent缺席的)设置后并返回null;
			// 已经存在,那么不会覆盖已有的值,直接返回已经存在的值
		// 如果过期续命的map中已经了存在当前的key UUID+threadId, 则并取消task,停止后续续命
        if (expirationRenewalMap.putIfAbsent(getEntryName(), task) != null) {
     
            task.cancel();
        }
    }

我们看到前面调用的时候穿的参数是-1和null, 所以我们直接看leaseTime == -1的逻辑,发现其有调用了一个tryLockInnerAsync方法,我们跟进来看一下这个方法:

<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
     
        internalLockLeaseTime = unit.toMillis(leaseTime);

        return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
                  "if (redis.call('exists', KEYS[1]) == 0) then " +
                      "redis.call('hset', KEYS[1], ARGV[2], 1); " +
                      "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                      "return nil; " +
                  "end; " +
                  "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                      "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                      "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                      "return nil; " +
                  "end; " +
                  "return redis.call('pttl', KEYS[1]);",
                    Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
    }

在整个方法中我们看到了熟悉的lua脚本。我们理一下参数对应:

KEYS[1]: Collections.<Object>singletonList(getName(), 即lockKey
ARGV[1]:internalLockLeaseTime, 锁的过期时间,如果不设置,默认是30s
ARGV[2]: UUID + threadId

===============  ARGV[1]  =============================

private long lockWatchdogTimeout = 30 * 1000;

===============  ARGV[2]  =============================
final UUID id;

String getLockName(long threadId) {
     
        return id + ":" + threadId;
    }

我们来大概看一下这段lua脚本的意思:

1、调用exist命令查看key是否存在,这个key是Collections.singletonList(getName())的值,即我们之前的lockKey。即此段脚本是判断Redis中是否存在了lockKey。如果不存在(== 0)),跳转到2;如果存在(!= 0)直接到end,跳到4;

redis.call('exists', KEYS[1]) == 0

2、此时的场景是lockKey不存在与Reis中:

"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
  • “edis.call(‘hset’, KEYS[1], ARGV[2], 1);”:发现,这个lockKey被存储成一个hash类型的数据结构了,调用这个命令相当于执行以下的Redis命令:

  • "return nil; ":return null;

hset lockKey uuid+threadId 1

这里设置的1其实就是锁的重入次数!!!Redisson也支持可重入锁!

  • "redis.call(‘pexpire’, KEYS[1], ARGV[1]); ":为刚才设置的lockKey添加一个过期时间,ARGV[1]默认是30s. 相当于执行以下的Redis命令:

pexpire lockKey 30000

2中的lua脚本执行完成后继续向下执行"if (redis.call(‘hexists’, KEYS[1], ARGV[2]) == 1) ", 跳转到3继续查看。

3、此时的场景是这个lockKey已经存在与Redis中了(此时会有重入锁):

"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
	"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
	"redis.call('pexpire', KEYS[1], ARGV[1]); " +
	"return nil; " +
  • "if (redis.call(‘hexists’, KEYS[1], ARGV[2]) == 1) then ":判断lockKey中是否存在key: UUID+threadId, 即判断当前这个线程对应的锁是否已经存在:

hexists lockKey UUID+1001

如果==1, 即这个线程对应的key:UUID+threadId已经存在于reids的hash数据lockKey中,则执行下面的lua脚本:

"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +

(1)"redis.call(‘hincrby’, KEYS[1], ARGV[2], 1); ":hincrby是hash数据的加法操作。为当前线程的lockKey的hash值加1,即将锁的重入次数加1!
(2)"redis.call(‘pexpire’, KEYS[1], ARGV[1]); ": 给当前线程的lockKey重新设置过期时间为30s.

4、执行到这里,表明这一次请求的线程不是当前线程,因为threadId不对应。end:之后执行的lua脚本如下:

"return redis.call('pttl', KEYS[1]);"

pttl:当 key 不存在时,返回 -2 。 当 key 存在但没有设置剩余生存时间时,返回 -1 。 否则,以毫秒为单位,返回 key 的剩余生存时间。

即当请求的线程不是当前线程的时候,返回其剩余的过期时间。

这个ttl会返回到方法tryAcquire。


Redisson之tryLock方法

如果现在有这样的需求,我的第一个线程竞争到锁之后,剩余的线程在5s内如果没有竞争到锁,需要直接返回错误信息,告诉用户系统繁忙,请稍后再试!

此时我们可以使用tryLock来实现。但是需要 注意,lock()方法自带30s的看门狗功能!如果使用tryLock(long waitTime, long leaseTime, TimeUnit unit), 看门狗功能会失效

package com.jihu.redis.lock;

import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.concurrent.TimeUnit;

@RestController
public class StockForRedisson2Controller {
     

    @Autowired
    private Redisson redisson;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;


    /**
     * 模拟减库存
     *
     * @return ""
     */
    @RequestMapping("/deduct_stock_redisson2")
    public String deductStock() {
     
        String lockKey = "lockKey";

        // 获取到redisson锁对象
        RLock redissonLock = redisson.getLock(lockKey);

        try {
     
            // ========= 注意!tryLock没有自动实现看门狗功能
            /**
             * 竞争锁成功,返回true
             * 此时,在5s内,没有竞争到锁资源的请求就会直接返回false
             */
            boolean tryLock = redissonLock.tryLock(5, 10, TimeUnit.SECONDS);

            // 竞争锁成功
            if (tryLock) {
     
                // ======== 扣减库存业务员开始 ============
                // 从redis获取库存数量
                int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
                // 如果库存数量大于0
                if (stock > 0) {
     
                    int realStock = stock - 1;
                    // 相当于jedis.set(key, value)
                    stringRedisTemplate.opsForValue().set("stock", realStock + "");
                    System.out.println("扣减成功,剩余库存:" + realStock + "");
                } else {
      // 如果库存数量小于0
                    System.out.println("扣减失败,库存不足!");
                }
                // ======== 扣减库存业务员结束 ============
            } else {
     
                return "系统繁忙,请稍后再试!";
            }

        } catch (InterruptedException e) {
     
            e.printStackTrace();
        } finally {
     
            // ============= 释放redisson锁 ==========
            redissonLock.unlock();
        }

        return "end";
    }
}

这样的实现不是很好,后面有机会完善一下。

五、Redis集群下的分布式锁主从失效问题(与zookeeper的比较)

我们知道,redis的从节点需要定时去主节点拉取数据进行同步,那如果我们的一个分布式锁总的lockKey在主节点中设置成功后恰好挂了,还没有来得及同步到从节点,此时由于选举某个从节点被选举为了新的主节点,但是此时这个lockKey该如何获取呢?

此时如果旧的请求还在执行获取到分布式锁之后的任务,但是新的请求去这个新的主节点中又获取到了这个分布式锁prod:1001, 这样不是又会出现超卖问题吗?

如果我们的公司能接受这样的bug,一般情况下很少出现,我们依然可以使用Redis实现的分布式锁。

但是如果不能接受的话,此时我们可以考虑使用zookeeper实现分布式锁。因为zookeeper天生是强一致性的,只有每个节点都同步了这个锁,才会将锁加成功的信息返回给客户端

那我们为什么不一开始就使用zookeeper实现分布式锁呢?

因为Redis的分布式锁性能要优于zookeeper. 并且,并不是所有的产品都使用zookeeper, 反而几乎所有产品都使用Redis。 这样就不用引入zookeeper, 给系统带来复杂性了。

当然,如果我们非要使用Redis实现分布式锁,并且不能产生从节点丢失锁的情况,使用Redis也是可以实现的!

Redis中存在一种RedLock锁!

六、RedLock(解决主从失效问题)

Redis高并发分布式锁实战(Setnx、Redisson及底层源码、RedLock):库存扣减中理解分布式锁的含义【干货满满】_第14张图片
使用RedLock就可有解决主从失效问题。

原理:

RedLock规定,只有超过半数Redis节点加锁成功才算加锁成功,底层实现原理和zookeeper有点像。
Redis高并发分布式锁实战(Setnx、Redisson及底层源码、RedLock):库存扣减中理解分布式锁的含义【干货满满】_第15张图片
Redis高并发分布式锁实战(Setnx、Redisson及底层源码、RedLock):库存扣减中理解分布式锁的含义【干货满满】_第16张图片

底层实现:

同时向所有的Redis服务发送setnx命令,超过半数节点返回加锁成功的消息后,才认为加锁成功,才会执行后面的业务逻辑。

【强烈建议

强烈建议不适用RedLock!互联网公司很少使用,并且可能存在一些bug。

并且,RedLock性能存在严重问题。

七、高并发分布式锁如何实现(分段库存锁)

使用Redis的分布式锁已经能满足大部分的场景。但是在大型互联网公司中使用,还存在一些不足。那么,我们还有更多的优化吗?还可以将性能提升10倍吗?

如果非常多的人抢购的是不同的商品,我们可以考虑加服务器,将数据打散一些。这样性能确实会有提高

但是如果现在是非常多的人同时抢购茅台?只需要20元呢?

这时候加机器是没有用的,因为Redis通过计算hash槽位之后,只会将这些请求定位到某一个cluster集群下面!现在怎么办呢?


想想concurrentHashMap的实现,我们可以引入一个 分段库存锁

比如现在要抢购的商品茅台的id在Redis中是prod:1001, 库存是100.

如果我按照传统的存储方法:

set prod:1001 1000

这样存储,意味着这1000个商品prod:1001我只存储到了一个Redis的key中。大量的请求只查询扣减这一个Redis的key,效率自然不是很高。

那我们思考, 是否可以把这1000个prod:1001分成10段,每一段设置100个商品。即:

set prod:1001:1 100
set prod:1001:2 100
set prod:1001:3 100
set prod:1001:4 100
set prod:1001:5 100
set prod:1001:6 100
set prod:1001:7 100
set prod:1001:8 100
set prod:1001:9 100
set prod:1001:10 100

这样,我的同一个商品被分到了10个Redis的key中,我们在存储的Redis的时候,可以使用特殊的hash,来使得这10个key被分片到不同的cluster中去。效率是不是大大提升了?是不是提高了10倍呢?

这是一个非常好的思路,我们值得学习一下。

你可能感兴趣的:(#,redis,分布式锁)