手写Redis分布式锁的原理和实现

137 阅读18分钟

为什么需要分布式锁

当并发去读写⼀个【共享资源】的时候,我们为了保证数据的正确,需要控制同⼀时刻只有⼀个线程访问。分布式锁就是⽤来控制同⼀时刻,只有⼀个 JVM 进程中的⼀个线程可以访问被保护的资源。

在同一个JVM虚拟机内,可以使用synchronized或者Lock接口,但是在分布式的环境下,有多个不同JVM虚拟机时单机的线程锁机制不再起作用,资源类在不同的服务器之间共享了,这个时候就需要分布式锁了。

分布式锁的特点

  • 独占排他互斥

    • 可以通过 setnx (redis命令:执行多次,只有一次能够成功)
    • set key value ex 3 nx
  • 防死锁发生

    1. 请求获取到锁之后,服务器挂掉了,导致锁无法释放:给lock锁添加过期时间

      • 通过 set key value ex 3 nx
      • 通过redis命令 expire
  • 保证原子性

    1. 获取锁和设置过期时间之间
    2. 判断和删除之间:lua脚本
  • 自动续期

  • 防误删

    • uuid给每个线程的锁添加唯一标识
  • 可重入

    • hash数据结构 + lua脚本

基于redis分布式锁的实现

使用场景:多个服务间保证同一时刻同一时间段内同一用户只能有一个请求(防止关键业务出现并发攻击)

创建redis项目

  1. 创建名称为redis_distributed_lock_1的maven项目

image-20240521145557330

  1. 修改pom文件
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
​
    <groupId>com.distribute.redislock</groupId>
    <artifactId>redis_distributed_lock_1</artifactId>
    <version>1.0-SNAPSHOT</version>
​
​
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.6.12</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
​
​
    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
        <lombok.version>1.16.18</lombok.version>
    </properties>
​
​
​
    <dependencies>
        <!--SpringBoot通用依赖模块-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!--SpringBoot与Redis整合依赖-->
        <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>
        <!--swagger2-->
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger2</artifactId>
            <version>2.9.2</version>
        </dependency>
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger-ui</artifactId>
            <version>2.9.2</version>
        </dependency>
        <!--redisson-->
        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson</artifactId>
            <version>3.13.4</version>
        </dependency>
        <!--通用基础配置boottest/lombok/hutool-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>${lombok.version}</version>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.8.8</version>
        </dependency>
    </dependencies>
​
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
  1. 创建yml文件

    创建名称为application.properties的配置文件

    server.port=8081spring.application.name=redis_distributed_lock2
    # ========================swagger2=====================
    # http://localhost:8081/swagger-ui.html
    swagger2.enabled=true
    spring.mvc.pathmatch.matching-strategy=ant_path_matcher
    ​
    # ========================redis\u5355\u673A=====================
    spring.redis.database=0
    spring.redis.host=192.168.181.8
    spring.redis.port=6379
    spring.redis.password=123456
    spring.redis.lettuce.pool.max-active=8
    spring.redis.lettuce.pool.max-wait=-1ms
    spring.redis.lettuce.pool.max-idle=8
    spring.redis.lettuce.pool.min-idle=0
    
  2. 修改主启动类

    package com.distribute.redislock;
    ​
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    ​
    ​
    @SpringBootApplication
    public class RedisDistributedLockApp8081
    {
        public static void main(String[] args)
        {
            SpringApplication.run(RedisDistributedLockApp8081.class,args);
        }
    }
    
  3. 编写业务类代码

    创建RedisConfig类

    package com.distribute.redislock.config;
    ​
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
    import org.springframework.data.redis.core.RedisTemplate;
    import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
    import org.springframework.data.redis.serializer.StringRedisSerializer;
    ​
    ​
    @Configuration
    public class RedisConfig
    {
        @Bean
        public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory lettuceConnectionFactory)
        {
            RedisTemplate<String,Object> redisTemplate = new RedisTemplate<>();
            redisTemplate.setConnectionFactory(lettuceConnectionFactory);
            //设置key序列化方式string
            redisTemplate.setKeySerializer(new StringRedisSerializer());
            //设置value的序列化方式json
            redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
    ​
            redisTemplate.setHashKeySerializer(new StringRedisSerializer());
            redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
    ​
            redisTemplate.afterPropertiesSet();
    ​
            return redisTemplate;
        }
    }
    ​
    

    创建Swagger2Config类

    package com.distribute.redislock.config;
    ​
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import springfox.documentation.builders.ApiInfoBuilder;
    import springfox.documentation.builders.PathSelectors;
    import springfox.documentation.builders.RequestHandlerSelectors;
    import springfox.documentation.service.ApiInfo;
    import springfox.documentation.spi.DocumentationType;
    import springfox.documentation.spring.web.plugins.Docket;
    import springfox.documentation.swagger2.annotations.EnableSwagger2;
    ​
    import java.time.LocalDateTime;
    import java.time.format.DateTimeFormatter;
    ​
    @Configuration
    @EnableSwagger2
    public class Swagger2Config
    {
        @Value("${swagger2.enabled}")
        private Boolean enabled;
    ​
        @Bean
        public Docket createRestApi() {
            return new Docket(DocumentationType.SWAGGER_2)
                    .apiInfo(apiInfo())
                    .enable(enabled)
                    .select()
                    .apis(RequestHandlerSelectors.basePackage("com.atguigu.redislock")) //你自己的package
                    .paths(PathSelectors.any())
                    .build();
        }
        private ApiInfo apiInfo() {
            return new ApiInfoBuilder()
                    .title("springboot利用swagger2构建api接口文档 "+"\t"+ DateTimeFormatter.ofPattern("yyyy-MM-dd").format(LocalDateTime.now()))
                    .description("springboot+redis整合")
                    .version("1.0")
                    .termsOfServiceUrl("https://www.baidu.com/")
                    .build();
        }
    ​
    }
    

    创建InventoryService类

    package com.distribute.redislock.service;
    ​
    ​
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.data.redis.core.StringRedisTemplate;
    import org.springframework.stereotype.Service;
    ​
    import java.util.concurrent.locks.Lock;
    import java.util.concurrent.locks.ReentrantLock;
    ​
    ​
    @Service
    @Slf4j
    public class InventoryService
    {
        @Autowired
        private StringRedisTemplate stringRedisTemplate;
    ​
        @Value("${server.port}")
        private String port;
    ​
        private Lock lock = new ReentrantLock();
    ​
        public String sale()
        {
            String retMessage = "";
            lock.lock();
            try
            {
                //1 查询库存信息
                String result = stringRedisTemplate.opsForValue().get("inventory001");
                //2 判断库存是否足够
                Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
                //3 扣减库存
                if(inventoryNumber > 0) {
                    stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
                    retMessage = "成功卖出一个商品,库存剩余: "+inventoryNumber;
                    System.out.println(retMessage);
                }else{
                    retMessage = "商品卖完了,o(╥﹏╥)o";
                }
            }finally {
                lock.unlock();
            }
            return retMessage+"\t"+"服务端口号:"+port;
        }
    }
    ​
    

    创建InventoryController类

    package com.distribute.redislock.controller;
    ​
    ​
    ​
    import com.distribute.redislock.service.InventoryService;
    import io.swagger.annotations.Api;
    import io.swagger.annotations.ApiOperation;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RestController;
    ​
    ​
    @RestController
    @Api(tags = "redis分布式锁测试")
    public class InventoryController
    {
        @Autowired
        private InventoryService inventoryService;
    ​
        @ApiOperation("扣减库存,一次卖一个")
        @GetMapping(value = "/inventory/sale")
        public String sale()
        {
            return inventoryService.sale();
        }
    }
    

Redis实现分布式锁初始化

复制项目,将8081的业务逻辑拷贝到8082,这样有两个后台项目,模拟多个微服务的情况。

Nginx分布式服务架构

为了方便直接在PC电脑上安装nginx,也可以在linux系统中安装,安装教程可以百度,出现如下所示,说明安装成功。

image-20240521164620225

在配置中添加负载均衡设置

upstream mynginx {
    server 127.0.0.1:8081 weight=1;
    server 127.0.0.1:8082 weight=2;
}

启动两个服务8081和8082,请求链接http://127.0.0.1/inventory/sale,可以看到可以轮询请求8081和8082服务器。

使用apifox压测请求,如下图所示。

image-20240521171923688

如下图所示,可以看到两个服务,同一个商品卖出了多次。

image-20240521172001099

可以得出结论,在单机环境下,可以使用synchronized或Lock来实现。

但是在分布式系统中,因为竞争的线程可能不在同一个节点上(同一个jvm中),所以需要一个让所有进程都能访问到的锁来实现(比如redis或者zookeeper来构建)不同进程jvm层面的锁就不管用了,那么可以利用第三方的一个组件,来获取锁,未获取到锁,则阻塞当前想要运行的线程。

使用自旋锁重试

public String sale() {
    String resMessgae = "";
    String key = "distributeRedisLock";
    String uuidValue = IdUtil.simpleUUID() + ":" + Thread.currentThread().getId();
​
    //用自旋调用;也不用if,用while代替
    while (!stringRedisTemplate.opsForValue().setIfAbsent(key, uuidValue)) {
        // 线程休眠20毫秒,进行递归重试
        try {TimeUnit.MILLISECONDS.sleep(20);} catch (InterruptedException e) {e.printStackTrace();}
    }
​
    try {
        // 1 抢锁成功,查询库存信息
        String result = stringRedisTemplate.opsForValue().get("inventory01");
        // 2 判断库存书否足够
        Integer inventoryNum = result == null ? 0 : Integer.parseInt(result);
        // 3 扣减库存,每次减少一个库存
        if (inventoryNum > 0) {
            stringRedisTemplate.opsForValue().set("inventory01", String.valueOf(--inventoryNum));
            resMessgae = "成功卖出一个商品,库存剩余:" + inventoryNum + "\t" + ",服务端口号:" + port;
            log.info(resMessgae);
        } else {
            resMessgae = "商品已售罄。" + "\t" + ",服务端口号:" + port;
            log.info(resMessgae);
        }
    } finally {
        stringRedisTemplate.delete(key);
    }
    return resMessgae;
}
​

宕机与过期防止死锁

部署了微服务的Java程序挂了,代码层面根本没有走到finally这块,没办法保证解锁(无过期时间该key一直存在),这个key没有被删除,需要加入一个过期时间限定key。

解决办法
  1. 高并发多线程下的一致性和原子性,设置了key+过期时间分开了,必须合并成一行具备原子性

    while (!stringRedisTemplate.opsForValue().setIfAbsent(key, uuidValue)) {
                // 线程休眠20毫秒,进行递归重试
                try {TimeUnit.MILLISECONDS.sleep(20);} catch (InterruptedException e){e.printStackTrace();}
    }
    ​
    // 设置过期时间
    stringRedisTemplate.expire(key, 30L, TimeUnit.SECONDS);
    
  2. 加锁和过期时间设置必须同一行,保证原子性

public String sale()
    {
        String retMessage = "";
        String key = "distributeRedisLock";
        String uuidValue = IdUtil.simpleUUID()+":"+Thread.currentThread().getId();
​
        while(!stringRedisTemplate.opsForValue().setIfAbsent(key, uuidValue,30L,TimeUnit.SECONDS))
        {
            log.info("没有获取到锁");
            //暂停毫秒
            try {
                TimeUnit.MILLISECONDS.sleep(20);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        try
        {
            //1 查询库存信息
            String result = stringRedisTemplate.opsForValue().get("inventory001");
            //2 判断库存是否足够
            Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
            //3 扣减库存
            if(inventoryNumber > 0) {
                stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
                retMessage = "成功卖出一个商品,库存剩余: "+inventoryNumber;
                System.out.println(retMessage);
            }else{
                retMessage = "商品卖完了,o(╥﹏╥)o";
            }
        }finally {
            stringRedisTemplate.delete(key);
        }
        return retMessage+"\t"+"服务端口号:"+port;
    }

如下图所示,保证了每次请求只有一个服务可以扣减。

image-20240521193926977

防止误删key的问题

  1. 进程1 获取锁成功并设置设置 30 秒超时;
  2. 进程1 因为⼀些原因导致执⾏很慢(⽹络问题、发⽣ FullGC……),过了 30 秒依然没执⾏完,但是锁过期“⾃动释放了”;
  3. 进程2 申请加锁成功;
  4. 进程1 执⾏完成,执⾏ DEL 释放锁指令,这个时候就把线程2 的锁给释放了。
解决办法

在释放锁的时候,客户端将自己的「唯一标识」与锁上的「标识」比较是否相等,匹配上则删除,否则没有权利释放锁,这里的唯一标识就是uuidValue。

public String sale()
    {
        String retMessage = "";
        String key = "distributeRedisLock";
        String uuidValue = IdUtil.simpleUUID()+":"+Thread.currentThread().getId();
​
        while(!stringRedisTemplate.opsForValue().setIfAbsent(key, uuidValue,30L,TimeUnit.SECONDS))
        {
            log.info("没有获取到锁");
            //暂停毫秒
            try {
                TimeUnit.MILLISECONDS.sleep(20);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        try
        {
            //1 查询库存信息
            String result = stringRedisTemplate.opsForValue().get("inventory001");
            //2 判断库存是否足够
            Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
            //3 扣减库存
            if(inventoryNumber > 0) {
                stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
                retMessage = "成功卖出一个商品,库存剩余: "+inventoryNumber;
                System.out.println(retMessage);
            }else{
                retMessage = "商品卖完了,o(╥﹏╥)o";
            }
        }finally {
            // 改进点,判断加锁与解锁是不同客户端,自己只能删除自己的锁,不误删别人的锁
            if (stringRedisTemplate.opsForValue().get(key).equalsIgnoreCase(uuidValue)) {
                stringRedisTemplate.delete(key);
            }
        }
        return retMessage+"\t"+"服务端口号:"+port;
    }

Lua脚本保证原子性

可以看出,finally中的代码块并不是原子性的,所以需要使用lua脚本来保证原子性。

public String sale()
    {
        String retMessage = "";
​
        String key = "distributeRedisLock";
        String uuidValue = IdUtil.simpleUUID()+":"+Thread.currentThread().getId();
​
        while(!stringRedisTemplate.opsForValue().setIfAbsent(key,uuidValue,30L,TimeUnit.SECONDS))
        {
            //暂停20毫秒,进行递归重试.....
            try { TimeUnit.MILLISECONDS.sleep(20); } catch (InterruptedException e) { e.printStackTrace(); }
        }
        //抢锁成功的请求线程,进行正常的业务逻辑操作,扣减库存
        try
        {
            //1 查询库存信息
            String result = stringRedisTemplate.opsForValue().get("inventory001");
            //2 判断库存是否足够
            Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
            //3 扣减库存,每次减少一个
            if(inventoryNumber > 0)
            {
                stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
                retMessage = "成功卖出一个商品,库存剩余:"+inventoryNumber;
                System.out.println(retMessage+"\t"+"服务端口号"+port);
                testReEnter();
            }else{
                retMessage = "商品卖完了,o(╥﹏╥)o";
            }
        }finally {
            //改进点,修改为Lua脚本的redis分布式锁调用,必须保证原子性,参考官网脚本案例
            String luaScript =
                    "if redis.call('get',KEYS[1]) == ARGV[1] then " +
                        "return redis.call('del',KEYS[1]) " +
                    "else " +
                        "return 0 " +
                    "end";
            stringRedisTemplate.execute(new DefaultRedisScript(luaScript,Boolean.class), Arrays.asList(key),uuidValue);
        }
        return retMessage+"\t"+"服务端口号"+port;
    }
​
private void testReEnter()
    {
        String key = "distributeRedisLock";
        String uuidValue = IdUtil.simpleUUID()+":"+Thread.currentThread().getId();
​
        while(!stringRedisTemplate.opsForValue().setIfAbsent(key,uuidValue,30L,TimeUnit.SECONDS))
        {
            //暂停20毫秒,进行递归重试.....
            try { TimeUnit.MILLISECONDS.sleep(20); } catch (InterruptedException e) { e.printStackTrace(); }
        }
//        redislock();
//        //biz......
//        unredislock();
        //改进点,修改为Lua脚本的redis分布式锁调用,必须保证原子性,参考官网脚本案例
        String luaScript =
                "if redis.call('get',KEYS[1]) == ARGV[1] then " +
                        "return redis.call('del',KEYS[1]) " +
                        "else " +
                        "return 0 " +
                        "end";
        stringRedisTemplate.execute(new DefaultRedisScript(luaScript,Boolean.class), Arrays.asList(key),uuidValue);
    }

执行上述代码发现,当自己持有锁的时候,想再次获取所得时候,发现无法获取,只有等到锁释放了,才能重入,显然这样是不可以的,所以需要实现锁的可重入性。

可重入性锁

什么是可重入性锁

指同一个线程可以多次获取同一个锁,而不会导致死锁或其他线程无法获取该锁的情况,可重入锁是一种特殊的锁,它允许一个线程在已经持有该锁的情况下,再次获取(或重入)该锁,而不会产生冲突或死锁,synchronized和ReentrantLock就是可重入性锁。

当线程拥有锁之后,往后再遇到加锁⽅法,直接将加锁次数加 1,然后再执⾏⽅法逻辑。 退出加锁⽅法之后,加锁次数再减 1,当加锁次数为 0 时,锁才被真正的释放。 可以看到可重⼊锁最⼤特性就是计数,计算加锁的次数。 所以当可重⼊锁需要在分布式环境实现时,我们也就需要统计加锁次数。

加锁逻辑

我们可以使⽤ Redis hash 结构实现,key 表示被锁的共享资源, hash 结构的 fieldKey 的 value 则保存加锁 的次数。

if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1 then
    redis.call('hincrby', KEYS[1], ARGV[1], 1)
    redis.call('expire', KEYS[1], ARGV[2])
    return 1
else
    return 0
end

加锁代码⾸先使⽤ Redis exists 命令判断当前 lock 这个锁是否存在。 如果锁不存在的话,直接使⽤ hincrby 创建⼀个键为 lock hash 表,并且为 Hash 表中键为 uuid 初始化为 0, 然后再次加 1,最后再设置过期时间。 如果当前锁存在,则使⽤ hexists 判断当前 lock 对应的 hash 表中是否存在 uuid 这个键,如果存在,再次使 ⽤ hincrby 加 1,最后再次设置过期时间。 最后如果上述两个逻辑都不符合,直接返回。

测试是否可以重入

#在redis客户端中执行命令
#加锁命令
EVAL "if redis.call('exists',KEYS[1]) == 0 or redis.call('hexists',KEYS[1],ARGV[1]) == 1 then redis.call('hincrby',KEYS[1],ARGV[1],1) redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end" 1 distributeRedisLock 0c90d37cb6ec42268861b3d739f8b3a8:1 30
#查询结果,查询到2说明可以累加
> HGET distributeRedisLock 0c90d37cb6ec42268861b3d739f8b3a8:1
2
解锁逻辑
if redis.call('hexists', KEYS[1], ARGV[1]) == 0 then
    return nil
elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 then
    return redis.call('del', KEYS[1])
else 
    return 0
end

⾸先使⽤ hexists 判断 Redis Hash 表是否存给定的域。 如果 lock 对应 Hash 表不存在,或者 Hash 表不存在 uuid 这个 key,直接返回 nil 。 若存在的情况下,代表当前锁被其持有,⾸先使⽤ hincrby 使可重⼊次数减 1 ,然后判断计算之后可重⼊次数, 若⼩于等于 0,则使⽤ del 删除这把锁。

解锁代码执⾏⽅式与加锁类似,只不过解锁的执⾏结果返回类型使⽤ Long 。这⾥之所以没有跟加锁⼀样使⽤ Boolean ,这是因为解锁 lua 脚本中,三个返回值含义如下: 1 代表解锁成功,锁被释放 0 代表可重⼊次数被减 1,null 代表其他线程尝试解锁,解锁失败。

测试是否删除

#执行解锁逻辑,根据解锁情况可以看到解锁是否成功
> eval "if redis.call('hexists', KEYS[1], ARGV[1]) == 0 then return nil elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 then return redis.call('del', KEYS[1]) else return 0 end " 1 distributeRedisLock 0c90d37cb6ec42268861b3d739f8b3a8:1
0
> eval "if redis.call('hexists', KEYS[1], ARGV[1]) == 0 then return nil elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 then return redis.call('del', KEYS[1]) else return 0 end " 1 distributeRedisLock 0c90d37cb6ec42268861b3d739f8b3a8:1
1
> eval "if redis.call('hexists', KEYS[1], ARGV[1]) == 0 then return nil elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 then return redis.call('del', KEYS[1]) else return 0 end " 1 distributeRedisLock 0c90d37cb6ec42268861b3d739f8b3a8:1
null
将lua脚本集成到java代码中
  1. 初始化sale方法
//注释之前的代码,新建方法,初始化为无锁版
public String sale()
    {
        String retMessage = "";
        //1 查询库存信息
        String result = stringRedisTemplate.opsForValue().get("inventory001");
        //2 判断库存是否足够
        Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
        //3 扣减库存
        if(inventoryNumber > 0) {
            stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
            retMessage = "成功卖出一个商品,库存剩余: "+inventoryNumber+"\t";
            System.out.println(retMessage);
        }else{
            retMessage = "商品卖完了,o(╥﹏╥)o";
        }
        return retMessage+"\t"+"服务端口号:"+port;
    }
  1. 新建RedisDistributedLock类并实现JUC里面的Lock接口
package com.distribute.redislock.myLock;
​
import cn.hutool.core.util.IdUtil;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
​
import java.util.Arrays;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
​
​
//@Component 引入DistributedLockFactory工厂模式,从工厂获得而不再从spring拿到
public class RedisDistributedLock implements Lock
{
    private StringRedisTemplate stringRedisTemplate;
​
    private String lockName;//KEYS[1]
    private String uuidValue;//ARGV[1]
    private long   expireTime;//ARGV[2]
    public RedisDistributedLock(StringRedisTemplate stringRedisTemplate, String lockName)
    {
        this.stringRedisTemplate = stringRedisTemplate;
        this.lockName = lockName;
        this.uuidValue = IdUtil.simpleUUID()+":"+Thread.currentThread().getId();//UUID:ThreadID
        this.expireTime = 30L;
    }
    @Override
    public void lock()
    {
        tryLock();
    }
    @Override
    public boolean tryLock()
    {
        try {
            tryLock(-1L,TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return false;
    }
​
    /**
     * 实现加锁功能,实现这一个干活的就OK,全盘通用
     * @param time
     * @param unit
     * @return
     * @throws InterruptedException
     */
    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException{
        if(time != -1L){
            this.expireTime = unit.toSeconds(time);
        }
        String script =
                "if redis.call('exists',KEYS[1]) == 0 or redis.call('hexists',KEYS[1],ARGV[1]) == 1 then " +
                        "redis.call('hincrby',KEYS[1],ARGV[1],1) " +
                        "redis.call('expire',KEYS[1],ARGV[2]) " +
                        "return 1 " +
                        "else " +
                        "return 0 " +
                        "end";
​
        System.out.println("script: "+script);
        System.out.println("lockName: "+lockName);
        System.out.println("uuidValue: "+uuidValue);
        System.out.println("expireTime: "+expireTime);
​
        while (!stringRedisTemplate.execute(new DefaultRedisScript<>(script,Boolean.class), Arrays.asList(lockName),uuidValue,String.valueOf(expireTime))) {
            TimeUnit.MILLISECONDS.sleep(50);
        }
        return true;
    }
​
    /**
     *实现解锁功能
     */
    @Override
    public void unlock()
    {
        String script =
                "if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 0 then " +
                        "   return nil " +
                        "elseif redis.call('HINCRBY',KEYS[1],ARGV[1],-1) == 0 then " +
                        "   return redis.call('del',KEYS[1]) " +
                        "else " +
                        "   return 0 " +
                        "end";
        // nil = false 1 = true 0 = false
        System.out.println("lockName: "+lockName);
        System.out.println("uuidValue: "+uuidValue);
        System.out.println("expireTime: "+expireTime);
        Long flag = stringRedisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(lockName),uuidValue,String.valueOf(expireTime));
        if(flag == null)
        {
            throw new RuntimeException("This lock doesn't EXIST");
        }
​
    }
    @Override
    public void lockInterruptibly() throws InterruptedException
    {
​
    }
    @Override
    public Condition newCondition()
    {
        return null;
    }
}
​
  1. 使用工厂模式实现可扩展接口
package com.distribute.redislock.myLock;
​
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
​
import java.util.concurrent.locks.Lock;
​
​
@Component
public class DistributedLockFactory
{
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    private String lockName;
​
    public Lock getDistributedLock(String lockType)
    {
        if(lockType == null) return null;
​
        if(lockType.equalsIgnoreCase("REDIS")){
            lockName = "distributeRedisLock";
            return new RedisDistributedLock(stringRedisTemplate,lockName);
        } else if(lockType.equalsIgnoreCase("ZOOKEEPER")){
            //TODO zookeeper版本的分布式锁实现
            return null;
        } else if(lockType.equalsIgnoreCase("MYSQL")){
            //TODO mysql版本的分布式锁实现
            return null;
        }
​
        return null;
    }
}
  1. 修改InventoryService类的sale方法业务方法
    @Autowired
    private DistributedLockFactory distributedLockFactory;
​
    public String sale()
    {
        String retMessage = "";
        Lock redisLock = distributedLockFactory.getDistributedLock("redis");
        redisLock.lock();
        try
        {
            //1 查询库存信息
            String result = stringRedisTemplate.opsForValue().get("inventory001");
            //2 判断库存是否足够
            Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
            //3 扣减库存
            if(inventoryNumber > 0)
            {
                inventoryNumber = inventoryNumber - 1;
                stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(inventoryNumber));
                retMessage = "成功卖出一个商品,库存剩余: "+inventoryNumber+"\t服务端口:" +port;
                System.out.println(retMessage);
            }else {
                retMessage = "商品卖完了,o(╥﹏╥)o"+"\t服务端口:" +port;
            }
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            redisLock.unlock();
        }
        return retMessage;
    }
  1. 单机和并发测试通过
  2. 可重入锁问题
public String sale()
    {
​
        String retMessage = "";
        Lock redisLock = distributedLockFactory.getDistributedLock("redis");
        redisLock.lock();
        try
        {
            //1 查询库存信息
            String result = stringRedisTemplate.opsForValue().get("inventory001");
            //2 判断库存是否足够
            Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
            //3 扣减库存
            if(inventoryNumber > 0)
            {
                inventoryNumber = inventoryNumber - 1;
                stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(inventoryNumber));
                retMessage = "成功卖出一个商品,库存剩余: "+inventoryNumber+"\t服务端口:" +port;
                System.out.println(retMessage);
                testReEnter();
            }else {
                retMessage = "商品卖完了,o(╥﹏╥)o"+"\t服务端口:" +port;
            }
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            redisLock.unlock();
        }
        return retMessage;
    }
​
    private void testReEnter()
    {
        Lock redisLock = distributedLockFactory.getDistributedLock("redis");
        redisLock.lock();
        try
        {
            System.out.println("################测试可重入锁#######");
        }finally {
            redisLock.unlock();
        }
    }

启动服务,请求http://127.0.0.1/inventory/sale,发现问题,ThreadId一致,但是UUID不一致。

lockName: distributeRedisLock
uuidValue: fed6f92ac7d7497485b8a14284a501ac:32
expireTime: 30
lockName: distributeRedisLock
uuidValue: badbf6bb94534409b1c5edfaea3d640a:32
expireTime: 30
  1. 解决方案

DistributedLockFactory新增一个无参构造函数。

@Component
public class DistributedLockFactory
{
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    private String lockName;
    private String uuidValue;
    
    //新增无参构造函数
    public DistributedLockFactory()
    {
        this.uuidValue = IdUtil.simpleUUID();//UUID
    }
​
    public Lock getDistributedLock(String lockType)
    {
        if(lockType == null) return null;
​
        if(lockType.equalsIgnoreCase("REDIS")){
            lockName = "distributeRedisLock";
            //uuidValue由工厂方法中传入
            return new RedisDistributedLock(stringRedisTemplate,lockName,uuidValue);
        } else if(lockType.equalsIgnoreCase("ZOOKEEPER")){
            //TODO zookeeper版本的分布式锁实现
            return null;
        } else if(lockType.equalsIgnoreCase("MYSQL")){
            //TODO mysql版本的分布式锁实现
            return null;
        }
​
        return null;
    }
}

RedisDistributedLock修改构造方法,增加了uuidValue参数。

public RedisDistributedLock(StringRedisTemplate stringRedisTemplate, String lockName,String uuidValue)
    {
        this.stringRedisTemplate = stringRedisTemplate;
        this.lockName = lockName;
        this.uuidValue = uuidValue+":"+Thread.currentThread().getId();
        this.expireTime = 30L;
    }
  1. 测试是否可以重入

使用@Autowired创建的工厂类是一个单例的,在Spring进行注入的时候已经初始化好了,所以所有线程产生的UUID都是一样的。

image-20240522160932656

自动续期

如果RedisLock过期时间大于业务执行时间怎么办?

只要客户端一旦加锁成功,就会启动一个后台线程,会每隔10秒检查一下,如果客户端还持有锁key,那么就会不断的延长锁key的生存时间。

默认情况下,加锁的时间是30秒,.如果加锁的业务没有执行完,就会进行一次续期,把锁重置成30秒。

  1. 修改RedisDistributedLock类,添加自动续期方法
**
     * 实现加锁功能,全盘通用
     * @param time
     * @param unit
     * @return
     * @throws InterruptedException
     */
    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        if (-1 == time) {
            String script =
                    "if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1 then " +
                            "redis.call('hincrby', KEYS[1], ARGV[1], 1) " +
                            "redis.call('expire', KEYS[1], ARGV[2]) " +
                            "return 1 " +
                            "else " +
                            "return 0 " +
                            "end";
            System.out.println("lock() lockName:" + lockName + "\t" + "uuidValue:" + uuidValue);
​
            // 加锁失败需要自旋一直获取锁
            while (!stringRedisTemplate.execute(
                    new DefaultRedisScript<>(script, Boolean.class),
                    Arrays.asList(lockName),
                    uuidValue,
                    String.valueOf(expireTime))) {
                // 休眠60毫秒再来重试
                try {
                    TimeUnit.MILLISECONDS.sleep(60);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            // 新建一个后台扫描程序,来检查Key目前的ttl,是否到我们规定的剩余时间来实现锁续期
            resetExpire();
            return true;
        }
        return false;
    }
​
    // 自动续期
    private void resetExpire() {
        String script =
                "if redis.call('hexists', KEYS[1], ARGV[1]) == 1 then " +
                        "return redis.call('expire', KEYS[1], ARGV[2]) " +
                        "else " +
                        "return 0 " +
                        "end";
        new Timer().schedule(new TimerTask() {
            @Override
            public void run() {
                if (stringRedisTemplate.execute(
                        new DefaultRedisScript<>(script, Boolean.class),
                        Arrays.asList(lockName),
                        uuidValue,
                        String.valueOf(expireTime))) {
                    // 续期成功,继续监听
                    System.out.println("resetExpire() lockName:" + lockName + "\t" + "uuidValue:" + uuidValue);
                    resetExpire();
                }
            }
        }, (this.expireTime * 1000 / 3));
    }
  1. 修改InventoryService业务类
    //实现自动续期功能的完善,后台自定义扫描程序,如果规定时间内没有完成业务逻辑,会调用加钟自动续期的脚本
    public String sale()
    {
        String retMessage = "";
​
        Lock redisLock = distributedLockFactory.getDistributedLock("redis");
        redisLock.lock();
        try
        {
            //1 查询库存信息
            String result = stringRedisTemplate.opsForValue().get("inventory001");
            //2 判断库存是否足够
            Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
            //3 扣减库存,每次减少一个
            if(inventoryNumber > 0)
            {
                stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
                retMessage = "成功卖出一个商品,库存剩余:"+inventoryNumber;
                System.out.println(retMessage+"\t"+"服务端口号"+port);
                //暂停120秒钟线程,故意的,演示自动续期的功能。。。。。。
                try { TimeUnit.SECONDS.sleep(120); } catch (InterruptedException e) { e.printStackTrace(); }
            }else{
                retMessage = "商品卖完了,o(╥﹏╥)o";
            }
        }finally {
            redisLock.unlock();
        }
        return retMessage+"\t"+"服务端口号"+port;
    }
  1. 测试是否续期

可以看到续期成功。

小结

加锁关键逻辑
  • 加锁:加锁实际上就是在redis中,给key设置一个值,为了避免死锁,并给一个过期时间
  • 可重入:加锁的LUA脚本,通过redis里面的hash数据类型,加锁和可重入性都要保证
  • 自旋:加锁不成,需要while进行重试并自旋,AQS
  • 续期:在过期时间内,一定时间内业务还未完成,自动给锁续期
解锁关键逻辑
  • 将redis的key删除,但是也不能乱删,不能说客户端1的请求将客户端2的锁给删掉,只能自己删除自己的锁
  • 考虑可重入性的递减,加锁几次就需要删除几次
  • 最后到零了,直接del删掉
上述实现的分布式锁存在的问题

其实上面那种方案最大的问题,就是如果你对某个redis master实例,写入了myLock这种锁key的value,此时会异步复制给对应的master slave实例。但是这个过程中一旦发生redis master宕机,主备切换,redis slave变为了redis master。

这样就会导致客户端2来尝试加锁的时候,在新的redis master上完成了加锁,而客户端1也以为自己成功加了锁。此时就会导致多个客户端对一个分布式锁完成了加锁。系统在业务语义上一定会出现问题,导致各种脏数据的产生

所以这个就是redis cluster,或者是redis master-slave架构的主从异步复制导致的redis分布式锁的最大缺陷:在redis master实例宕机的时候,可能导致多个客户端同时完成加锁。

红锁

Redis作者针对Redis分布式锁的缺点提出了红锁的概念算法如下:

  1. 顺序向五个节点请求加锁
  2. 根据一定的超时时间来推断是不是跳过该节点
  3. 三个节点加锁成功并且花费时间小于锁的有效期
  4. 认定加锁成功

也就是说,假设锁30秒过期,三个节点加锁花了31秒,自然是加锁失败了。这只是举个例子,实际上并不应该等每个节点那么长时间,就像官网所说的那样,假设有效期是10,那么单个redis实例操作超时时间,应该在5到50毫秒(注意时间单位)还是假设我们设置有效期是30秒,图中超时了两个redis节点。那么加锁成功的节点总共花费了3秒,所以锁的实际有效期是小于27秒的。即扣除加锁成功三个实例的3秒,还要扣除等待超时redis实例的总共时间。