性能文章>Redisson分布式锁RedissonLock的原理>

Redisson分布式锁RedissonLock的原理原创

https://a.perfma.net/img/2382850
1年前
464167

1 分布式锁诞生的背景

由于现在的服务器基本上都是分布式部署的,也就是同样的代码会部署到多台服务器上面。用户在访问接口时,由nginx通过负载均衡将用户的请求转发到多台服务器中的其中一台。每台服务器上面都有一个JVM实例去运行我们编写的代码,这种情况下你用java的synchronized去给一段代码加锁,只能锁住一台服务器。但是,不同的用户在访问同一个接口时,nginx会将多个用户的多个请求平均的发给每个服务器。假如我们总共有三条服务器,最坏的情况会出现,三个不同的用户分别锁住一台服务器,那么此时就相当于一个数据被三个人同时访问了,但是我们的业务要求是同一时间这个数据,只能被一个用户访问。此时就需要分布式锁了。

分布式锁的原理如下:

分布式锁用的不是Java的synchronized锁,而是用的数据库的锁。虽然,我们的代码被部署到多台服务器上面了,但是这多台服务器连接的数据库都是同一个,所以我们可以在数据库上面加锁。比如,让三个用户都往同一个表里面插入一条数据,然后在表中增加一个唯一索引,谁插入成功了,就算谁抢到锁了,可以访问数据。没抢到的用户不能访问数据。只不过,分布式锁一般都选择使用Redis来代替数据库。利用Redis执行命令的单线程特性,并且Redis也提供了setnx和set key value nx 这样的类似数据库的唯一索引命令。nx的意思就是not exists不存在才设置,如果key已经存在了就设置失败了,哪个用户设置成功,哪个用户就抢到锁了。Redis分布式锁,现在最流行的就是Redisson这个Redis客户端了。

2 Redisson分布式锁代码如下

  • pom.xml文件
<?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.study.demo</groupId>
    <artifactId>redisson-rate-limit</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <name>redisson-rate-limit</name>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>1.8</maven.compiler.target>
        <spring-boot.version>2.2.8.RELEASE</spring-boot.version>
        <project.name>RateLimit</project.name>
    </properties>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.11.RELEASE</version>
        <relativePath/>
    </parent>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson-spring-boot-starter</artifactId>
            <version>3.10.7</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>
</project>
  • application.yml
server:
  port: 8080

spring:
  redis:
    host: 192.168.212.142
    port: 6379
    password: 123321
  application:
    name: RedissonRateLimit
  • Redisson配置类
package com.study.demo.config;

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.redisson.config.SentinelServersConfig;
import org.redisson.config.SingleServerConfig;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RedissonConfig {
    @Value("${spring.application.name}")
    private String serverName;

    @Bean
    public RedissonClient redissonClient(RedisProperties redisProperties) {
        Config config = new Config();
        SingleServerConfig singleServerConfig = config.useSingleServer();
        singleServerConfig.setAddress("redis://" + redisProperties.getHost() + ":" + redisProperties.getPort());
        singleServerConfig.setPassword(redisProperties.getPassword());
        singleServerConfig.setKeepAlive(true);
        singleServerConfig.setDatabase(redisProperties.getDatabase());
        singleServerConfig.setClientName(serverName);
        return Redisson.create(config);
    }
}
  • Service类
package com.study.demo.service;

import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.concurrent.TimeUnit;

@Service
public class WatchDogService {

    @Autowired
    RedissonClient redissonClient;

    /**
     * Redisson分布式锁
     *
     */
    public void watchDogService() {
        // 分布式锁key
        String lockKey = "lockKey";
        RLock lock = redissonClient.getLock(lockKey);
        boolean isLock = false;
        try {
            // lock.tryLock() 尝试获取分布式锁,没有等待时间。获取失败就直接失败了。
            // 如果获取锁成功,有看门狗。锁的默认有效时间为30秒,看门狗每10秒钟会给锁的有效时间重置为30秒。
            // 如果你的代码有问题,比如陷入死循环了,看门狗将会一直续期,别的线程永远也拿到不到这个锁。
            // 这不是看门狗的问题,是你自己代码写死循环的问题。
             isLock = lock.tryLock();

            // lock.tryLock(10, TimeUnit.SECONDS) 尝试获取分布式锁,如果在10秒钟内获取不到锁,返回失败。
            // 如果获取不到锁,会死循环一直重试获取锁,直到超过10秒钟。
            // 如果10秒钟内,获取锁成功,则有看门狗。锁的默认有效时间为30秒,看门狗每10秒钟会给锁的有效时间重置为30秒
            // 注意这里的10秒钟,是等待获取锁的时间。不是锁本身的时间。
           // isLock = lock.tryLock(10, TimeUnit.SECONDS);

            // lock.tryLock(10, 15, TimeUnit.SECONDS) 尝试获取分布式锁,如果在10秒钟内获取不到锁,返回失败。
            // 如果获取不到锁,会死循环一直重试获取锁,直到超过10秒钟。
            // 如果10秒钟内,获取锁成功,则没有看门狗,注意没有看门狗。锁的有效时间为你设置的15秒。
            // 如果15秒内,你的业务代码没有执行完成。别的线程是可以再次拿到这个锁的,就意味着你的分布式锁有问题,没锁住,相当于有俩个线程同时执行了。
            // isLock = lock.tryLock(10, 15, TimeUnit.SECONDS);
            if (isLock) {
                // Thread.sleep(100000)模拟处理业务,耗时100秒钟
                System.out.println("获取分布式锁成功,开始处理业务逻辑");
                Thread.sleep(100000);
            } else {
                throw new RuntimeException("获取锁失败,请勿重复操作");
            }
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            if (isLock) {
                lock.unlock();
            }
        }
    }

    /**
     * Redisson的分布式锁是可重入的
     *
     * @param retry 重入次数
     */
    public void watchDogRetryService(int retry) {
        // 分布式锁key
        String lockKey = "lockKeyRetry";
        RLock lock = redissonClient.getLock(lockKey);
        boolean isLock = false;
        try {
            // lock.tryLock() 尝试获取分布式锁,没有等待时间。获取失败就直接失败了。
            // 如果获取锁成功,有看门狗。锁的默认有效时间为30秒,看门狗每10秒钟会给锁的有效时间重置为30秒。
            // 如果你的代码有问题,比如陷入死循环了,看门狗将会一直续期,别的线程永远也拿到不到这个锁。
            // 这不是看门狗的问题,是你自己代码写死循环的问题。
            isLock = lock.tryLock();

            // lock.tryLock(10, TimeUnit.SECONDS) 尝试获取分布式锁,如果在10秒钟内获取不到锁,返回失败。
            // 如果获取不到锁,会死循环一直重试获取锁,直到超过10秒钟。
            // 如果10秒钟内,获取锁成功,则有看门狗。锁的默认有效时间为30秒,看门狗每10秒钟会给锁的有效时间重置为30秒
            // 注意这里的10秒钟,是等待获取锁的时间。不是锁本身的时间。
            // isLock = lock.tryLock(10, TimeUnit.SECONDS);

            // lock.tryLock(10, 15, TimeUnit.SECONDS) 尝试获取分布式锁,如果在10秒钟内获取不到锁,返回失败。
            // 如果获取不到锁,会死循环一直重试获取锁,直到超过10秒钟。
            // 如果10秒钟内,获取锁成功,则没有看门狗,注意没有看门狗。锁的有效时间为你设置的15秒。
            // 如果15秒内,你的业务代码没有执行完成。别的线程是可以再次拿到这个锁的,就意味着你的分布式锁有问题,没锁住,相当于有俩个线程同时执行了。
            // isLock = lock.tryLock(10, 15, TimeUnit.SECONDS);
            if (isLock) {
                Thread.sleep(500);
                // Redisson分布式锁可以重入
                System.out.println("Redisson分布式锁可以重入,添加锁成功:" + retry + ",锁key:" + lockKey);
                if (retry < 0) {
                    // 终止递归
                    return;
                }
                watchDogRetryService(lockKey, --retry);
            } else {
                throw new RuntimeException("获取锁失败,请勿重复操作");
            }
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            if (isLock) {
                System.out.println("Redisson分布式锁可以重入 释放锁:" + retry + ",锁key:" + lockKey);
                lock.unlock();
            }
        }
    }
}
  • Controller类
package com.study.demo.controller;

import com.study.demo.service.WatchDogService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TestController {

    @Autowired
    WatchDogService watchDogService;

    @GetMapping("watchDog")
    public String watchDog() {
        watchDogService.watchDogService();
        return "秒杀成功";
    }

    @GetMapping("watchDogRetry")
    public String watchDogRetry() {
        watchDogService.watchDogRetryService(5);
        return "秒杀成功";
    }
}

  • SpringBoot项目启动类
package com.study.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.EnableAspectJAutoProxy;

@SpringBootApplication
//@ComponentScan(basePackages = {"com.study.demo", ""})
//@EnableAspectJAutoProxy
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

2.1 把项目启动起来

  1. 在浏览器上面访问这个接口:http://localhost:8080/watchDog
  2. 然后再打开一个浏览器标签页继续访问这个接口:http://localhost:8080/watchDog,就会发现第二次访问时报错了,报错如下截图:
    分布式锁

看到这个报错说明分布式锁起作用了。

  1. 演示一下可重入功能,在浏览器上面访问这个接口:http://localhost:8080/watchDogRetry

分布式锁可重入

2.2 代码讲解

2.2.1 获取分布式锁代码

Controller接口

获取分布式锁

Lua脚本知识:就算你在Lua里写出花,在Redis里面执行也是一个命令(eval/evalsha)去执行的,一条命令没执行完,其他客户端是看不到的。并且Redis执行命令的时候是单线程的,有多个请求同时执行时,也是排队的,我执行完了,你才能执行。所以Redisson的分布式锁这里没有用setnx命令去完成分布式锁的功能。

  • 核心代码就这下面的俩行代码
RLock lock = redissonClient.getLock("lockKey");
boolean isLock = lock.tryLock();
  1. redissonClient.getLock(“lockKey”); 这行代码就是从Redisson的客户端上面获取一个RLock对象,返回的真实对象为:org.redisson.RedissonLock类的实例对象。
  2. lock.tryLock();这个方法里面会执行几条Redis命令,我们来看下tryLock方法的源码:

org.redisson.RedissonLock.tryLock()方法的源码

leaseTime为-1

tryAcquireOnceAsync方法源码

看门狗时间

看门狗时间默认30秒钟

看门狗的时间默认为30秒

看tryLockInnerAsync方法源码

tryLockInnerAsync方法里面的Redis命令

我们来分析一下tryLockInnerAsync方法里面的Redis命令,这个方法里面的Redis命令总共可以分为三个功能:

  1. 第一个功能为:设置分布式锁的key,先使用exists KEYS[1]判断用户指定的key在Redis中是否存在。key如果不存在exists命令会返回0,存在会返回1。如果key不存在会执行:hset key ARGV[2] 1这个命令,ARGV[2]就是getLockName(threadId)方法的返回值。这个命令的意思就是将用户的key当做Redis的Hash数据机构的名字,然后往这个hash里面存入一个key-value的数据,key的值是线程的ID,value的值为1,代表该线程获取锁1次。接下来执行pexpire KEYS[1] ARGV[1]这个命令,意思是给用户指定的key设置过期时间,过期时间为ARGV[1]就是internalLockLeaseTime(看门狗的时间为30秒)。

  2. 第二个功能为:增加分布式锁的可重入功能。先使用hexists KEYS[1] ARGV2 意思就是判断一下分布式锁是否存在,其实这里主要判断是用户的key有没有过期,因为如果不存在直接就走第一个if判断并直接return了。返回值等于1说明没有过期,执行hincrby KEYS[1] ARGV2 1命令,给线程ID的值加1,代表当前这个线程重入了(又获取一次锁)。接下来执行pexpire KEYS[1] ARGV[1]这个命令,意思是给用户指定的key设置过期时间,过期时间为ARGV[1]就是internalLockLeaseTime(看门狗的时间为30秒)。

  3. 第三个功能:这个是一个兜底功能,代码走到这里就说明获取key过期了,重入时获取分布式锁失败了。我们想一下,什么情况下代码能走到这里呢:return redis.call(‘pttl’, KEYS[1]);只有一种情况,在第一个if里面判断key是否存在的时候,key还存在。然后走到第二个if的时候,key过期了,此时hexists命名返回的是0。然后才走到最后这行return redis.call(‘pttl’, KEYS[1]);代码。所以,代码走到这里说明锁重入时获取锁失败。如果有看门狗自动续期的情况下,这行代码永远都走不到这里。

  4. 看门狗自动续期代码如下:

自动续期

自动续期

自动续期定时任务10秒钟执行一次

自动续期Redis命令
我们来看自动续期的Redis命令:

  • 首先还是使用hexists KEYS[1] ARGV[2]判断key是否存在。等于1说明存在。
  • 接下来执行pexpire KEYS[1] ARGV[1],将key的时间重新设置为看门狗默认的时间30秒。
  • 自动续期就完成了。

到这里获取分布式锁的代码也结束了。

2.2.1 释放分布式锁代码

  • 释放分布式锁代码就一行
lock.unlock();
  • 看下unlock()方法的源码

unlock()方法的源码

unlock()方法的源码

unlock()方法源码

我们来看下释放分布式锁Redis命令:

  1. 第一条命令:hexists KEYS[1] ARGV[3] 判断key还是否存在,不存在直接return nil代表释放锁成功。

  2. 第二条命令:hincrby KEYS[1] ARGV[3] -1,对线程的值减1,执行这条命令的时候不用担心key是否过期,如果key过期hincrby命令会返回-1。如果返回值大于0,说明这个分布式锁被重入了,还不能释放。重入多少次,就需要释放多少次。值大于0,此时只是将线程的重入次数减1,代表已经释放1次了。并且执行pexpire KEYS[1] ARGV[2] 重新设置key的失效时间为internalLockLeaseTime的值,默认是30秒。

  3. 如果返回值小于等于0,执行del KEYS[1] 删除这个分布式锁,并执行publish KEYS[2], ARGV[1]命令,发布一个消息,通知别的线程可以来获取这个分布式锁了。

到这里释放锁的代码也结束了。

2.2.2 获取分布式锁的时候设置等待时间代码讲解

 // lock.tryLock(10, TimeUnit.SECONDS) 尝试获取分布式锁,如果在10秒钟内获取不到锁,返回失败。
            // 如果获取不到锁,会死循环一直重试获取锁,直到超过10秒钟。
            // 如果10秒钟内,获取锁成功,则有看门狗。锁的默认有效时间为30秒,看门狗每10秒钟会给锁的有效时间重置为30秒
            // 注意这里的10秒钟,是等待获取锁的时间。不是锁本身的时间。
            isLock = lock.tryLock(10, TimeUnit.SECONDS);
  • lock.tryLock(10, TimeUnit.SECONDS); 这个方法跟lock.tryLock();的整体逻辑是一样的,只不过多了一个等待获取锁时间的代码。来看代码把。

lock.tryLock(10, TimeUnit.SECONDS)方法源码

lock.tryLock(10, TimeUnit.SECONDS)方法源码

死循环获取分布式锁

获取分布式锁的代码是一样的,都是调用tryAcquire(leaseTime, unit, threadId);这个方法去获取分布式锁的。只不过加了一个获取锁的时间判断。

2.2.3 获取分布式锁的时候设置等待时间同时也设置分布式锁的有效期leaseTime代码讲解

// lock.tryLock(10, 15, TimeUnit.SECONDS) 尝试获取分布式锁,如果在10秒钟内获取不到锁,返回失败。
            // 如果获取不到锁,会死循环一直重试获取锁,直到超过10秒钟。
            // 如果10秒钟内,获取锁成功,则没有看门狗,注意没有看门狗。锁的有效时间为你设置的15秒。
            // 如果15秒内,你的业务代码没有执行完成。别的线程是可以再次拿到这个锁的,就意味着你的分布式锁有问题,没锁住,相当于有俩个线程同时执行了。
             isLock = lock.tryLock(10, 15, TimeUnit.SECONDS);
  • lock.tryLock(10, 15, TimeUnit.SECONDS);这个方法跟lock.tryLock(10, TimeUnit.SECONDS);的整体逻辑是一样的,只不过没有自动续期的功能了。来看代码把。

注意leaseTiem是你自己传过来的

没有自动续期功能了

获取分布式锁的代码是一样的,都是调用tryAcquire(leaseTime, unit, threadId);这个方法去获取分布式锁的。只不过加了一个获取锁的时间判断,并且没有自动续期功能了。

到这里就代码就全部分析完毕了。你们最好自己去看看代码,代码还是非常容易看懂的。

点赞收藏
四千岁

请关注俺的微信公众号:四千岁

请先登录,查看6条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步

为你推荐

Redis系列:RDB内存快照提供持久化能力

Redis系列:RDB内存快照提供持久化能力

Redis stream 用做消息队列完美吗?

Redis stream 用做消息队列完美吗?

7
6