一、为什么需要分布式锁?

在开始讲分布式锁之前,有必要简单介绍一下,为什么需要分布式锁?

在Java中,关于锁我想大家都很熟悉。在并发编程中,我们通过锁,来避免由于竞争而造成的数据不一致问题。通常,我们以synchronized 、Lock来使用它。但是Java中的锁,只能保证在同一个JVM进程内中执行。如果在分布式集群环境下呢?

与分布式锁相对应的是「单机锁」,我们在写多线程程序时,避免同时操作一个共享变量产生数据问题,通常会使用一把锁来「互斥」,以保证共享变量的正确性,其使用范围是在「同一个进程」中。

如果换做是多个进程,需要同时操作一个共享资源,如何互斥呢?

例如,现在的业务应用通常都是微服务架构,这也意味着一个应用会部署多个进程,那这多个进程如果需要修改 MySQL 中的同一行记录(修改 MySQL 的某一行数据,或者调用一个 API 请求)时,为了避免操作乱序导致数据错误,此时,我们就需要引入「分布式锁」来解决这个问题了

想要实现分布式锁,必须借助一个外部系统所有进程都去这个系统上申请「加锁」

而这个外部系统,必须要实现「互斥」的能力,即两个请求同时进来,只会给一个进程返回成功,另一个返回失败(或等待)。

这个外部系统,可以是 1、MySQL(基于数据库的乐观锁和悲观锁),也可以是2、Redis 或3、Zookeeper。但为了追求更好的性能,我们通常会选择使用 Redis 或 Zookeeper 来做

下面我就以 Redis 为主线,由浅入深,带你深度剖析一下,分布式锁的各种「安全性」问题,帮你彻底理解分布式锁。

二、单节点redis中分布式锁怎么实现?

我们从最简单的开始讲起。

想要实现分布式锁,必须要求 Redis 有「互斥」的能力,我们可以使用 SETNX 命令,这个命令表示SET if Not eXists,即如果 key 不存在,才会设置它的值,否则什么也不做

两个客户端进程可以执行这个命令,达到互斥,就可以实现一个分布式锁。

打开两个redis-cli客户端。

客户端 1 申请加锁,加锁成功:

127.0.0.1:6379> SETNX lock 1
(integer) 1     // 客户端1,加锁成功

客户端 2 申请加锁,因为它后到达,加锁失败:

127.0.0.1:6379> SETNX lock 1
(integer) 0     // 客户端2,加锁失败

此时,加锁成功的客户端,就可以去操作「共享资源」,例如,修改 MySQL 的某一行数据,或者调用一个 API 请求

操作完成后,还要及时释放锁,给后来者让出操作共享资源的机会。如何释放锁呢?

也很简单,直接使用 DEL 命令删除这个 key 即可:

127.0.0.1:6379> DEL lock // 释放锁
(integer) 1

这个逻辑非常简单,整体的路程就是这样:

但是,它存在一个很大的问题,当客户端 1 拿到锁后,如果发生下面的场景,就会造成「死锁」:

1、程序处理业务逻辑异常没及时释放锁

2、进程挂了,没机会释放锁

这时,这个客户端就会一直占用这个锁,而其它客户端就「永远」拿不到这把锁了。

怎么解决这个问题呢?

1、如何避免死锁(一条命令加锁并设置过期时间)

我们很容易想到的方案是,在申请锁时,给这把锁设置一个「租期」。

在 Redis 中实现时,就是给这个 key 设置一个「过期时间」。这里我们假设,操作共享资源的时间不会超过 10s,那么在加锁时,给这个 key 设置 10s 过期即可:

127.0.0.1:6379> SETNX lock 1    // 加锁
(integer) 1
127.0.0.1:6379> EXPIRE lock 10  // 10s后自动过期
(integer) 1

这样一来,无论客户端是否异常,这个锁都可以在 10s 后被「自动释放」,其它客户端依旧可以拿到锁。

但这样真的没问题吗?还是有问题。

现在的操作,加锁、设置过期是 2 条命令,有没有可能只执行了第一条,第二条却「来不及」执行的情况发生呢?例如:

1)、SETNX 执行成功,执行 EXPIRE 时由于网络问题,执行失败

2)、SETNX 执行成功,Redis 异常宕机,EXPIRE 没有机会执行

3)、SETNX 执行成功,客户端异常崩溃,EXPIRE 也没有机会执行

总之,这两条命令不能保证是原子操作(一起成功),就有潜在的风险导致过期时间设置失败,依旧发生「死锁」问题。

怎么办?

在 Redis 2.6.12 版本之前,我们需要想尽办法,保证 SETNX 和 EXPIRE 原子性执行,还要考虑各种异常情况如何处理。

但在 Redis 2.6.12 之后,Redis 扩展了 SET 命令的参数,用这一条命令就可以了:

// 一条命令保证原子性执行
127.0.0.1:6379> SET lock 1 EX 10 NX
OK

这样就解决了死锁问题,也比较简单。

我们再来看分析下,它还有什么问题?

试想这样一种场景:

(1)、客户端 1 加锁成功,开始操作共享资源

(2)、客户端 1 操作共享资源的时间,「超过」了锁的过期时间,锁被「自动释放」

(3)、客户端 2 加锁成功,开始操作共享资源

(4)、客户端 1 操作共享资源完成,释放锁(但释放的是客户端 2 的锁)

看到了么,这里存在两个严重的问题:

(1)、锁过期:客户端 1 操作共享资源耗时太久,导致锁被自动释放,之后被客户端 2 持有。

(2)、释放别人的锁:客户端 1 操作共享资源完成后,却又释放了客户端 2 的锁。

导致这两个问题的原因是什么?我们一个个来看。

第一个问题,可能是我们评估操作共享资源的时间不准确导致的。

例如,操作共享资源的时间「最慢」可能需要 15s,而我们却只设置了 10s 过期,那这就存在锁提前过期的风险。

过期时间太短,那增大冗余时间,例如设置过期时间为 20s,这样总可以了吧?

这样确实可以「缓解」这个问题,降低出问题的概率,但依旧无法「彻底解决」问题。

为什么?

原因在于,客户端在拿到锁之后,在操作共享资源时,遇到的场景有可能是很复杂的,例如,程序内部发生异常、网络请求超时等等

既然是「预估」时间,也只能是大致计算,除非你能预料并覆盖到所有导致耗时变长的场景,但这其实很难

有什么更好的解决方案吗?

别急,关于这个问题,我会在后面详细来讲对应的解决方案。

我们继续来看第二个问题。

第二个问题在于,一个客户端释放了其它客户端持有的锁。

想一下,导致这个问题的关键点在哪?

重点在于,每个客户端在释放锁时,都是「无脑」操作,并没有检查这把锁是否还「归自己持有」,所以就会发生释放别人锁的风险,这样的解锁流程,很不「严谨」

如何解决这个问题呢?

2、锁被别人释放怎么办?(加锁时锁的值设为唯一标识,释放锁时使用Lua脚本)

解决办法是:客户端在加锁时,设置一个只有自己知道的「唯一标识」进去

例如,可以是自己的线程 ID,也可以是一个 UUID(随机且唯一),这里我们以 UUID 举例:

// 锁的VALUE设置为UUID
127.0.0.1:6379> SET lock $uuid EX 20 NX
OK

这里假设 20s 操作共享时间完全足够,先不考虑锁自动过期的问题。

之后,在释放锁时,要先判断这把锁是否还归自己持有,伪代码可以这么写:

// 锁是自己的,才释放
if redis.get("lock") == $uuid:
    redis.del("lock")

这里释放锁使用的是 GET + DEL 两条命令,这时,又会遇到我们前面讲的原子性问题了。

(1)、客户端 1 执行 GET,判断锁是自己的

(2)、客户端 2 执行了 SET 命令,强制获取到锁(虽然发生概率比较低,但我们需要严谨地考虑锁的安全性模型)

(3)、客户端 1 执行 DEL,却释放了客户端 2 的锁

由此可见,这两个命令还是必须要原子执行才行。

怎样原子执行呢?Lua 脚本。

我们可以把这个逻辑,写成 Lua 脚本,让 Redis 来执行。

因为 Redis 处理每一个请求是「单线程」执行的,在执行一个 Lua 脚本时,其它请求必须等待,直到这个 Lua 脚本处理完成,这样一来,GET + DEL 之间就不会插入其它命令了。

Lua是redis 2.6 版本最大的亮点,通过内嵌对Lua 环境的支持,Redis 解决了长久以来不能高效地处理CAS (check-and-set)命令的缺点,并且可以通过组合使用多个命令,轻松实现以前很难实现或者不能高效实现的模式。

安全释放锁的 Lua 脚本如下:

// 判断锁是自己的,才释放
if redis.call("GET",KEYS[1]) == ARGV[1]
then
    return redis.call("DEL",KEYS[1])
else
    return 0
end

其中ARGV[1]表示设置key时指定的随机值。

先判断当前锁的字符串是否与传入的值相等,是的话就删除Key,解锁成功。

实现分布式锁的方法一:

@RunWith(SpringRunner.class)
@SpringBootTest(classes = AuthServerApplication.class)
public class demo1 {
    @Autowired
    private RedisTemplate redisTemplate;

    @Test
    public void testLock() {
        redisTemplate.opsForValue().set("num", "1"); // 设置初始值
        //  设置uuId
        String uuid = UUID.randomUUID().toString();
        //  缓存的lock 对应的值 ,应该是index2 的uuid
        Boolean flag = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 10, TimeUnit.SECONDS); // 一条命令加锁(锁值唯一)并设置过期时间,
        //  判断flag index=1
        if (flag) { //  说明上锁成功!
             // 执行业务逻辑
//            String value = (String) redisTemplate.opsForValue().get("num");
            String value = (String) redisTemplate.opsForValue().get("num");
            System.out.println((String) redisTemplate.opsForValue().get("lock"));
            //  判断
            if (StringUtils.isEmpty(value)) {
                return;
            }
            //  进行数据转换
            int num = Integer.parseInt(value);
            //  放入缓存
            redisTemplate.opsForValue().set("num", String.valueOf(++num));

            //  定义一个lua 脚本,使用Lua脚本释放锁
            String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";

            //  准备执行lua 脚本
            DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
            //  将lua脚本放入DefaultRedisScript 对象中
            redisScript.setScriptText(script);
            //  设置DefaultRedisScript 这个对象的泛型
            redisScript.setResultType(Long.class);
            //  执行删除
            redisTemplate.execute(redisScript, Arrays.asList("lock"), uuid);
            System.out.println((String) redisTemplate.opsForValue().get("lock"));

        } else {
            //  没有获取到锁!
            try {
                Thread.sleep(1000);
                //  睡醒了之后,重试
                testLock();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

控制台打印结果如下:

ff8b0fb9-b59a-4ff6-8663-87bed555a6d4
null

实现分布式锁的方法二:Jedis 来模拟实现一下

(1)、首先,我们在pom文件中,引入Jedis。

在这里,笔者用的是最新版本,注意由于版本的不同,API可能有所差异。

<dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
            <version>3.0.1</version>
        </dependency>

(2)、加锁和解锁的方法

加锁的过程很简单,就是通过SET指令来设置值,成功则返回;否则就循环等待,在timeout时间内仍未获取到锁,则获取失败。解锁我们通过jedis.eval来执行一段LUA就可以。将锁的Key键和生成的字符串当做参数传进来。

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.params.SetParams;

import java.util.Collections;


@Service
public class RedisLock {

    Logger logger = LoggerFactory.getLogger(this.getClass());

    private String lock_key = "redis_lock"; //锁键

    protected long internalLockLeaseTime = 30000;//锁过期时间

    private long timeout = 999999; //获取锁的超时时间


    //SET命令的参数
    SetParams params = SetParams.setParams().nx().px(internalLockLeaseTime);

    @Autowired
    JedisPool jedisPool;


    /**
     * 加锁
     * @param id
     * @return
     */
    public boolean lock(String id){
        Jedis jedis = jedisPool.getResource();
        Long start = System.currentTimeMillis();
        try{
            for(;;){
                //SET命令返回OK ,则证明获取锁成功
                String lock = jedis.set(lock_key, id, params);
                if("OK".equals(lock)){
                    return true;
                }
                //否则循环等待,在timeout时间内仍未获取到锁,则获取失败
                long l = System.currentTimeMillis() - start;
                if (l>=timeout) {
                    return false;
                }
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }finally {
            jedis.close();
        }
    }
    /**
     * 解锁
     * @param id
     * @return
     */
    public boolean unlock(String id){
        Jedis jedis = jedisPool.getResource();
        String script =
                "if redis.call('get',KEYS[1]) == ARGV[1] then" +
                        "   return redis.call('del',KEYS[1]) " +
                        "else" +
                        "   return 0 " +
                        "end";
        try {
            Object result = jedis.eval(script, Collections.singletonList(lock_key),
                    Collections.singletonList(id));
            if("1".equals(result.toString())){
                return true;
            }
            return false;
        }finally {
            jedis.close();
        }
    }
}

(3)、jedis连接池配置

@Configuration
public class RedisConfig extends CachingConfigurerSupport {

    Logger logger = LogManager.getLogger(RedisConfig.class);

    @Value("${spring.redis.host}")
    private String host;

    @Value("${spring.redis.port}")
    private int port;

    @Value("${spring.redis.password}")
    private String password;

    @Value("${spring.redis.timeout}")
    private int timeout;

    @Value("${spring.redis.jedis.pool.max-idle}")
    private int maxIdle;

    @Value("${spring.redis.jedis.pool.max-wait}")
    private long maxWaitMillis;

    @Bean
    public JedisPool jedisPool() {
        logger.info("JedisPool注入成功!!");
        logger.info("redis地址:" + host + ":" + port);

        JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
        jedisPoolConfig.setMaxIdle(maxIdle);
        jedisPoolConfig.setMaxWaitMillis(maxWaitMillis);
        return new JedisPool(jedisPoolConfig, host, port);
//        return new JedisPool(jedisPoolConfig, host, port, timeout, password);
    }
}

(4)、SnowflakeManager工具类

public class SnowflakeManager {
    private static final long EPOCH_STAMP = 1262275200000L;
    private static final long SEQUENCE_BIT = 12L;
    private static final long MACHINE_BIT = 5L;
    private static final long DATA_CENTER_BIT = 5L;
    private static final long MAX_SEQUENCE_NUM = -1L ^ (-1L << SEQUENCE_BIT);
    private static final long MACHINE_LEFT = SEQUENCE_BIT;
    private static final long DATA_CENTER_LEFT = SEQUENCE_BIT + MACHINE_BIT;
    private static final long TIMESTAMP_LEFT = SEQUENCE_BIT + MACHINE_BIT + DATA_CENTER_BIT;
    private static final long MACHINE_ID = 1l;
    private static final long DATACENTER_ID = 1l;
    private static long sequence = 0L;
    private static long lastTimestamp = -1L;

    //异步获取下一个值
    private static synchronized long getNextValue(Long machineId, long dataCenterId) {
        try {
            String os = System.getProperty("os.name");
            SecureRandom secureRandom;
            if (os.toLowerCase().startsWith("win")) {
                // windows机器用
                secureRandom = SecureRandom.getInstanceStrong();
            } else {
                // linux机器用
                secureRandom = SecureRandom.getInstance("NativePRNGNonBlocking");
            }
            long currentTimeMillis = currentTimeMillis();
            //获取当前时间戳,如果当前时间戳小于上次时间戳,则时间戳获取出现异常
            if (currentTimeMillis < lastTimestamp) {
                throw new RuntimeException(String.format("Clock moved backwards. Refusing to generate id for %d milliseconds", (lastTimestamp - currentTimeMillis)));
            }
            //如果等于上次时间戳(同一毫秒内),则在序列号加一;否则序列号赋值为0,从0开始
            if (currentTimeMillis == lastTimestamp) {
                sequence = (sequence + 1) & MAX_SEQUENCE_NUM;
                if (sequence == 0) {
                    sequence = secureRandom.nextInt(Long.valueOf(SEQUENCE_BIT).intValue());
                    currentTimeMillis = tilNextMillis(lastTimestamp);
                }
            } else {
                sequence = secureRandom.nextInt(Long.valueOf(SEQUENCE_BIT).intValue());
            }
            lastTimestamp = currentTimeMillis;
            long nextId = ((currentTimeMillis - EPOCH_STAMP) << TIMESTAMP_LEFT)
                    | (dataCenterId << DATA_CENTER_LEFT)
                    | (machineId << MACHINE_LEFT)
                    | sequence;

            return nextId;
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
        }
        return -1;
    }

    //获取时间戳,并与上次时间戳比较
    private static long tilNextMillis(long lastTimestamp) {
        long currentTimeMillis = currentTimeMillis();
        while (currentTimeMillis <= lastTimestamp) {
            currentTimeMillis = currentTimeMillis();
        }
        return currentTimeMillis;
    }

    //获取系统时间戳
    private static long currentTimeMillis() {
        return System.currentTimeMillis();
    }

    public static synchronized long nextValue() {
        try {
            return getNextValue(MACHINE_ID, DATACENTER_ID);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return 0l;
    }

    public static synchronized long nextValue(long machineId) {
        return getNextValue(machineId, DATACENTER_ID);
    }

    public static synchronized long nextValue(long machineId, long dataCenterId) {
        return getNextValue(machineId, dataCenterId);
    }

    public static synchronized long get() {
        return nextValue();
    }
}
View Code

(5)、配置文件

server.port=8888
#redis数据库索引,默认为0
spring.redis.database=0
spring.redis.host=localhost
spring.redis.port=6379
spring.redis.password=
#连接池最大连接数(使用负值表示没有限制)
spring.redis.jedis.pool.max-active=200
#连接池最大阻塞等待时间(使用负值表示没有限制)
spring.redis.jedis.pool.max-wait=-1
# 连接池中的最大空闲连接
spring.redis.jedis.pool.max-idle=10
# 连接池中的最小空闲连接
spring.redis.jedis.pool.min-idle=0
# 连接超时时间(毫秒)
spring.redis.timeout=1000
spring.redis.cluster.max-redirects=

(6)、测试

最后,我们可以在多线程环境下测试一下。我们开启1000个线程,对count进行累加。调用的时候,关键是唯一字符串的生成。这里,笔者使用的是Snowflake算法。

@Controller
public class IndexController {
    Logger logger = LoggerFactory.getLogger(this.getClass());
    @Autowired
    RedisLock redisLock;

    int count = 0;

    @RequestMapping("/index")
    @ResponseBody
    public String index() throws InterruptedException {

        int clientcount =20;
        CountDownLatch countDownLatch = new CountDownLatch(clientcount);

        ExecutorService executorService = Executors.newFixedThreadPool(clientcount);
        long start = System.currentTimeMillis();
        for (int i = 0;i<clientcount;i++){
            executorService.execute(() -> {
                //通过Snowflake算法获取唯一的ID字符串
                String id = String.valueOf(SnowflakeManager.nextValue());
                try {
                    boolean lock = redisLock.lock(id);
                    if (lock){
                        count++;
                    }
                }finally {
                    redisLock.unlock(id);
                }
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        long end = System.currentTimeMillis();
        logger.info("执行线程数:{},总耗时:{},count数为:{}",clientcount,end-start,count);
        return "Hello";
    }
}

启动项目,浏览器访问:http://localhost:8888/index

控制台打印:

执行线程数:20,总耗时:30896,count数为:20
执行线程数:20,总耗时:30878,count数为:40

好了,这样一路优化,整个的加锁、解锁的流程就更「严谨」了。

这里我们先小结一下,基于 Redis 实现的分布式锁,一个严谨的的流程如下:

(1)、加锁:SET lock_key $unique_id EX $expire_time NX

(2)、操作共享资源

(3)、释放锁:Lua 脚本,先 GET 判断锁是否归属自己,再 DEL 释放锁

好,有了这个完整的锁模型,让我们重新回到前面提到的第一个问题。

锁过期时间不好评估怎么办?

3、锁过期时间不好评估怎么办?(自动续期)

前面我们提到,锁的过期时间如果评估不好,这个锁就会有「提前」过期的风险。

当时给的妥协方案是,尽量「冗余」过期时间,降低锁提前过期的概率。

这个方案其实也不能完美解决问题,那怎么办呢?

是否可以设计这样的方案:加锁时,先设置一个过期时间,然后我们开启一个「守护线程」,定时去检测这个锁的失效时间,如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行「续期」,重新设置过期时间

这确实一种比较好的方案。

如果你是 Java 技术栈,幸运的是,已经有一个库把这些工作都封装好了:Redisson

Redisson 是一个 Java 语言实现的 Redis SDK 客户端,在使用分布式锁时,它就采用了「自动续期」的方案来避免锁过期,这个守护线程我们一般也把它叫做「看门狗」线程

除此之外,这个 SDK 还封装了很多易用的功能:

  • 可重入锁(Reentrant Lock)
  • 乐观锁
  • 公平锁
  • 读写锁
  • 红锁

这个 SDK 提供的 API 非常友好,它可以像操作本地锁的方式,操作分布式锁。如果你是 Java 技术栈,可以直接把它用起来。

redisson原理图如下:

1、可重入锁(Reentrant Lock)

案例1:秒杀抢单数据一致性方案

(1)、添加依赖:

<!--redisson-->
        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson-spring-boot-starter</artifactId>
            <version>3.11.0</version>
        </dependency>

(2)、锁操作方法实现

要想用到分布式锁,我们就必须要实现获取锁和释放锁,获取锁和释放锁可以编写一个DistributedLocker接口,代码如下:

public interface DistributedLocker {

    /****
     * 加锁,会一直循环加锁,直到拿到锁
     */
    RLock lock(String lockkey);

    /****
     * 加锁,指定超时时间
     */
    RLock lock(String lockkey,long timeout);

    /****
     * 加锁,指定超时时间和超时单位
     */
    RLock lock(String lockkey, long timeout, TimeUnit unit);


    /****
     * 加锁,指定超时时间和锁的释放时间
     */
    boolean tryLock(String lockkey,long timeout,long leasetime,TimeUnit unit);

    boolean tryLock(String lockKey, TimeUnit unit, long waitTime);

    /****
     * 解锁
     */
    void unLock(String lockkey);

    /***
     * 解锁
     */
    void unLocke(RLock lock);
}

实现类RedissonDistributedLocker

实现上面接口中对应的锁管理方法编写一个锁管理类RedissonDistributedLocker,代码如下:

@Slf4j
@Component
public class RedissonDistributedLocker implements DistributedLocker {

    @Autowired
    private RedissonClient redissonClient;

    /***
     * 加锁,会一直循环加锁,直到拿到锁
     * @param lockkey
     * @return
     */
    @Override
    public RLock lock(String lockkey) {
        RLock lock = redissonClient.getLock(lockkey);
        lock.lock();
        return lock;
    }

    /***
     * 加锁,在指定时间内拿不到锁就会放弃
     * @param lockkey
     * @return
     */
    @Override
    public RLock lock(String lockkey, long timeout) {
        RLock lock = redissonClient.getLock(lockkey);
        lock.lock(timeout,TimeUnit.SECONDS);
        return lock;
    }

    /***
     * 加锁,在指定时间内拿不到锁就会放弃
     * @param lockkey
     * @return
     */
    @Override
    public RLock lock(String lockkey, long timeout, TimeUnit unit) {
        RLock lock = redissonClient.getLock(lockkey);
        lock.lock(timeout, unit);
        return lock;
    }

    /***
     * 加锁,在指定时间内拿不到锁就会放弃,如果拿到锁,锁最终有效时间为leasetime
     * @param lockkey
     * @return
     */
    @Override
    public boolean tryLock(String lockkey, long timeout, long leasetime, TimeUnit unit) {
        RLock lock = redissonClient.getLock(lockkey);
        try {
            return lock.tryLock(timeout, leasetime, unit);
        } catch (InterruptedException e) {
            log.warn("获取锁出问题={}", e);
            return false;
        }
    }
    /**
     * 建议使用
     *
     * @param lockKey 常量
     * @param unit 时间单位
     * @param waitTime 等待锁时间,如果超过时间则放弃
     * @return
     */

    @Override
    public boolean tryLock(String lockKey, TimeUnit unit, long waitTime) {
        RLock lock = redissonClient.getLock(lockKey);
        try {
            return lock.tryLock(waitTime, unit);
        } catch (InterruptedException e) {
            log.warn("获取锁出问题={}", e);
            return false;
        }
    }

    /****
     * 解锁
     * @param lockkey
     */
    @Override
    public void unLock(String lockkey) {
        RLock lock = redissonClient.getLock(lockkey);
        lock.unlock();
    }

    /***
     * 解锁
     * @param lock
     */
    @Override
    public void unLocke(RLock lock) {
        lock.unlock();
    }
}

(3)、redis配置文件

clusterServersConfig:
  # 连接空闲超时,单位:毫秒 默认10000
  idleConnectionTimeout: 10000
  pingTimeout: 1000
  # 同任何节点建立连接时的等待超时。时间单位是毫秒 默认10000
  connectTimeout: 10000
  # 等待节点回复命令的时间。该时间从命令发送成功时开始计时。默认3000
  timeout: 3000
  # 命令失败重试次数
  retryAttempts: 3
  # 命令重试发送时间间隔,单位:毫秒
  retryInterval: 1500
  # 重新连接时间间隔,单位:毫秒
  reconnectionTimeout: 3000
  # 执行失败最大次数
  failedAttempts: 3
  # 密码
  #password: test1234
  # 单个连接最大订阅数量
  subscriptionsPerConnection: 5
  clientName: null
  # loadBalancer 负载均衡算法类的选择
  loadBalancer: !<org.redisson.connection.balancer.RoundRobinLoadBalancer> {}
  #从节点发布和订阅连接的最小空闲连接数
  slaveSubscriptionConnectionMinimumIdleSize: 1
  #从节点发布和订阅连接池大小 默认值50
  slaveSubscriptionConnectionPoolSize: 50
  # 从节点最小空闲连接数 默认值32
  slaveConnectionMinimumIdleSize: 32
  # 从节点连接池大小 默认64
  slaveConnectionPoolSize: 64
  # 主节点最小空闲连接数 默认32
  masterConnectionMinimumIdleSize: 32
  # 主节点连接池大小 默认64
  masterConnectionPoolSize: 64
  # 订阅操作的负载均衡模式
  subscriptionMode: SLAVE
  # 只在从服务器读取
  readMode: SLAVE
  # 集群地址
  nodeAddresses:
    - "redis://127.0.0.1:7001"
    - "redis://127.0.0.1:7002"
    - "redis://127.0.0.1:7003"
    - "redis://127.0.0.1:7004"
    - "redis://127.0.0.1:7005"
    - "redis://127.0.0.1:7006"
  # 对Redis集群节点状态扫描的时间间隔。单位是毫秒。默认1000
  scanInterval: 1000
  #这个线程池数量被所有RTopic对象监听器,RRemoteService调用者和RExecutorService任务共同共享。默认2
threads: 0
#这个线程池数量是在一个Redisson实例内,被其创建的所有分布式数据类型和服务,以及底层客户端所一同共享的线程池里保存的线程数量。默认2
nettyThreads: 0
# 编码方式 默认org.redisson.codec.JsonJacksonCodec
codec: !<org.redisson.codec.JsonJacksonCodec> {}
#传输模式
transportMode: NIO
# 分布式锁自动过期时间,防止死锁,默认30000
lockWatchdogTimeout: 30000
# 通过该参数来修改是否按订阅发布消息的接收顺序出来消息,如果选否将对消息实行并行处理,该参数只适用于订阅发布消息的情况, 默认true
keepPubSubOrder: true
# 用来指定高性能引擎的行为。由于该变量值的选用与使用场景息息相关(NORMAL除外)我们建议对每个参数值都进行尝试。
#
#该参数仅限于Redisson PRO版本。
#performanceMode: HIGHER_THROUGHPUT

(4)、配置RedisClient和RedissonConnectionFactory

@Configuration
public class RedisConfig {

    /****
     * RedissonClient
     */
    @Bean
    public RedissonClient redisClient() throws IOException {
        //1.加载配置文件
        ClassPathResource resource = new ClassPathResource("redisson.yml");
        //2.解析配置文件
        Config config = Config.fromYAML(resource.getInputStream());
        //3.创建RedissonClient
        return Redisson.create(config);
    }


    /*****
     * RedissonConnectionFactory
     */
    @Bean
    public RedissonConnectionFactory redissonConnectionFactory(RedissonClient redisClient){
        return new RedissonConnectionFactory(redisClient);
    }


    /***
     * 模板操作对象序列化设置
     * @param redissonConnectionFactory
     * @return
     */
    @Bean("redisTemplate")
    public RedisTemplate getRedisTemplate(RedisConnectionFactory redissonConnectionFactory) {
        RedisTemplate<Object, Object> redisTemplate = new RedisTemplate();
        redisTemplate.setConnectionFactory(redissonConnectionFactory);
        redisTemplate.setValueSerializer(valueSerializer());
        redisTemplate.setKeySerializer(keySerializer());
        redisTemplate.setHashKeySerializer(keySerializer());
        redisTemplate.setHashValueSerializer(valueSerializer());
        return redisTemplate;
    }

    /****
     * 序列化设置
     * @return
     */
    @Bean
    public StringRedisSerializer keySerializer() {
        return new StringRedisSerializer();
    }

    /****
     * 序列化设置
     * @return
     */
    @Bean
    public RedisSerializer valueSerializer() {
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
        return jackson2JsonRedisSerializer;
    }
}

(5)、测试

@RestController
@RequestMapping(value = "/redisson")
public class RedissonController {

    @Autowired
    private RedissonDistributedLocker redissonDistributedLocker;

    /***
     * 多个用户实现加锁操作,只允许有一个用户可以获取到对应锁
     */
    @GetMapping(value = "/lock/{time}")
    public String lock(@PathVariable(value = "time")Long time) throws InterruptedException {
        System.out.println("当前休眠标识时间:"+time);

        //获取锁UUUUU
        RLock rlock = redissonDistributedLocker.lock("UUUUU");
        System.out.println("执行休眠:"+time);

        Thread.sleep(time);

        System.out.println("休眠完成,准备释放锁:"+time);
        //释放锁
        redissonDistributedLocker.unLocke(rlock);
        return "OK";
    }
}

 浏览器访问:http://localhost:8085/redisson/lock/15000,再开一个浏览器访问:http://localhost:8085/redisson/lock/1500

控制台打印结果如下:

当前休眠标识时间: 15000
执行休眠: 15000
当前休眠标识时间: 1500
休眠完成,准备释放锁: 15000
执行休眠: 1500
休眠完成,准备释放锁: 1500

(6)、分布式锁解决超卖问题

@Service
public class OrderServiceImpl implements OrderService {

    @Autowired
    private OrderMapper orderMapper;

    @Autowired
    private SkuFeign skuFeign;

    @Autowired
    private IdWorker idWorker;

    @Autowired
    private RedisTemplate redisTemplate;

    @Autowired
    private RedissonDistributedLocker redissonDistributedLocker;

    @Autowired
    private MessageFeign messageFeign;

    /****
     * 热点商品下单
     * @param orderMap
     * @return
     */
    @Override
    public void hotAdd(Map<String, String> orderMap) throws IOException {
        //消息封装对象
        Map<String,Object> messageMap = new HashMap<String,Object>();

        String username = orderMap.get("username");
        String id = orderMap.get("id");

        //Redis中对应的key
        String key="SKU_"+id;
        String lockkey="LOCKSKU_"+id;
        String userKey="USER"+username+"ID"+id;

        //如果key在redis缓存,则表示商品信息在Redis中进行操作
        boolean bo = redissonDistributedLocker.tryLock(lockkey, 10, 10, TimeUnit.MINUTES);
        if(bo){
            if(redisTemplate.hasKey(key)){
                //获取商品数量
                Integer num = Integer.parseInt(redisTemplate.boundHashOps(key).get("num").toString());

                if(num<=0){
                    //商品售罄通知
                    messageMap.put("code",20001);
                    messageMap.put("message","商品已售罄");
                    messageFeign.send(username,JSON.toJSONString(messageMap));
                    return;
                }
                Result<Sku> skuResult =skuFeign.findById(id);
                Sku sku = skuResult.getData();

                //1.创建Order
                Order order = new Order();
                order.setTotalNum(1);
                order.setCreateTime(new Date());
                order.setUpdateTime(order.getCreateTime());
                order.setId("No"+idWorker.nextId());
                order.setOrderStatus("0");
                order.setPayStatus("0");
                order.setConsignStatus("0");
                order.setSkuId(id);
                order.setName(sku.getName());
                order.setPrice(sku.getSeckillPrice()*order.getTotalNum());
                orderMapper.insertSelective(order);

                //2.Redis中对应的num递减
                num--;
                if(num==0){
                    skuFeign.zero(id);
                }

                //2.清理用户排队信息
                Map<String,Object> allMap = new HashMap<String,Object>();
                allMap.put(userKey,0);
                allMap.put("num",num);
                redisTemplate.boundHashOps(key).putAll(allMap);
                //3.记录用户购买过该商品,24小时后过期
                redisTemplate.boundValueOps(userKey).set("");
                redisTemplate.expire(userKey,1,TimeUnit.MINUTES);

                //抢单成功通知
                messageMap.put("code",200);
                messageMap.put("message","抢单成功!");
                messageFeign.send(username,JSON.toJSONString(messageMap));
            }

            //释放锁
            redissonDistributedLocker.unLock(lockkey);
            return;
        }

        //抢单失败通知
        messageMap.put("code",20001);
        messageMap.put("message","抢单失败!");
        messageFeign.send(username,JSON.toJSONString(messageMap));
    }
}

 案例2:

上面我们自己实现的Redis分布式锁,其实不具有可重入性。那么下面我们先来看看Redisson中如何调用可重入锁。

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

首先,通过配置获取RedissonClient客户端的实例,然后getLock获取锁的实例,进行操作即可。

public class demo {
    public static void main(String[] args) throws InterruptedException {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://127.0.0.1:6379");
//        config.useSingleServer().setPassword("redis1234");

        final RedissonClient client = Redisson.create(config);
        // 获取锁的实例
        RLock lock = client.getLock("lock1");
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                lock.lock();
                try {
                    System.out.println(Thread.currentThread() + "-" + System.currentTimeMillis() + "-" + "获取了锁");
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    System.out.println(Thread.currentThread() + "-" + System.currentTimeMillis() + "-" + "释放了锁");
                    lock.unlock();
                }
            }).start();
        }
        //等待执行完成,不设置等待可能出现还未执行完成客户端就关闭的情况
        Thread.sleep(5000);

        //====================关闭客户端====================
        client.shutdown();
    }
}

结果:

Thread[Thread-2,5,main]-1672744988022-获取了锁
Thread[Thread-2,5,main]-1672744988534-释放了锁
Thread[Thread-5,5,main]-1672744988538-获取了锁
Thread[Thread-5,5,main]-1672744989039-释放了锁
Thread[Thread-3,5,main]-1672744989041-获取了锁
Thread[Thread-3,5,main]-1672744989548-释放了锁
Thread[Thread-1,5,main]-1672744989551-获取了锁
Thread[Thread-1,5,main]-1672744990053-释放了锁
Thread[Thread-4,5,main]-1672744990062-获取了锁
Thread[Thread-4,5,main]-1672744990577-释放了锁

2、代码详解

(1)、获取锁实例

 我们先来看RLock lock = client.getLock("lock"); 这句代码就是为了获取锁的实例,然后我们可以看到它返回的是一个RedissonLock对象。

public RLock getLock(String name) {
        return new RedissonLock(this.connectionManager.getCommandExecutor(), name);
    }

子类RedissonLock的构造方法调用时,一定先调用父类的构造方法,

RedissonLock构造方法中,主要初始化一些属性。

public RedissonLock(CommandAsyncExecutor commandExecutor, String name) {
        super(commandExecutor, name); 
    
this.commandExecutor = commandExecutor;
     // 内部锁过期时间
this.internalLockLeaseTime = commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(); this.pubSub = commandExecutor.getConnectionManager().getSubscribeService().getLockPubSub(); }

super(commandExecutor, name)表示继承父类的构造方法,父类RedissonBaseLock的构造方法:

public RedissonBaseLock(CommandAsyncExecutor commandExecutor, String name) {
        super(commandExecutor, name);
        this.commandExecutor = commandExecutor;
        this.id = commandExecutor.getConnectionManager().getId();
        this.internalLockLeaseTime = commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout();
        this.entryName = this.id + ":" + name;
    }

internalLockLeaseTime的值为lockWatchdogTimeout(默认30s)

getCfg()方法获取Config,getLockWatchdoyTimeout()方法获取Config类的lockWatchdogTimeout值,该值默认为30000毫秒。

public Config() {
        this.transportMode = TransportMode.NIO;
        this.lockWatchdogTimeout = 30000L;
        this.reliableTopicWatchdogTimeout = TimeUnit.MINUTES.toMillis(10L);
        this.keepPubSubOrder = true;
        this.useScriptCache = false;
        this.minCleanUpDelay = 5;
        this.maxCleanUpDelay = 1800;
        this.cleanUpKeysAmount = 100;
        this.nettyHook = new DefaultNettyHook();
        this.useThreadClassLoader = true;
        this.addressResolverGroupFactory = new DnsAddressResolverGroupFactory();
    }

(2)、加锁

当我们调用lock方法

public void lock() {
        try {
            this.lock(-1L, (TimeUnit)null, false);
        } catch (InterruptedException var2) {
            throw new IllegalStateException();
        }
    }

点击lock方法,在这里,完成了加锁的逻辑。

private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
        long threadId = Thread.currentThread().getId(); // 当前线程ID
        Long ttl = this.tryAcquire(-1L, leaseTime, unit, threadId); // 尝试获取锁
        if (ttl != null) { // 如果ttl不为空,则表示获取锁失败(即锁已存在但并非本线程)
       // 如果获取锁失败,则订阅到对应这个锁的channel RFuture
<RedissonLockEntry> future = this.subscribe(threadId); if (interruptibly) { this.commandExecutor.syncSubscriptionInterrupted(future); } else { this.commandExecutor.syncSubscription(future); } try { while(true) { // 如果获取锁失败,则进入自旋,不停的尝试获取锁
            // 再次尝试获取锁 ttl
= this.tryAcquire(-1L, leaseTime, unit, threadId); if (ttl == null) { // ttl为空表示成功获取锁,返回 return; }             // 如果ttl大于等于0,则等待ttl时间后继续尝试获取 if (ttl >= 0L) { try { ((RedissonLockEntry)future.getNow()).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS); } catch (InterruptedException var13) { if (interruptibly) { throw var13; } ((RedissonLockEntry)future.getNow()).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS); } } else if (interruptibly) { ((RedissonLockEntry)future.getNow()).getLatch().acquire(); } else { ((RedissonLockEntry)future.getNow()).getLatch().acquireUninterruptibly(); } } } finally { this.unsubscribe(future, threadId); // 取消对channel的订阅 } } }

如上代码,就是加锁的全过程。先调用tryAcquire来获取锁,如果返回值ttl为空,则证明加锁成功,返回;如果不为空,则证明加锁失败。这时候,它会订阅这个锁的Channel,等待锁释放的消息,然后重新尝试获取锁。

获取锁的逻辑

private Long tryAcquire(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
        return (Long)this.get(this.tryAcquireAsync(waitTime, leaseTime, unit, threadId));
    }

tryAcquireAsync方法

获取锁的过程是怎样的呢?接下来就要看tryAcquire方法。在这里,它有两种处理方式,一种是带有过期时间的锁,一种是不带过期时间的锁。

private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
        RFuture ttlRemainingFuture; // 剩余有效期
        if (leaseTime != -1L) { // 如果带有过期时间,则按照普通方式获取锁
            ttlRemainingFuture = this.tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
        } else { // 如果不带过期时间,先按照30s的过期时间来执行获取锁的方法,
            ttlRemainingFuture = this.tryLockInnerAsync(waitTime, this.internalLockLeaseTime, TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
        }
     // 获取锁成功后,设置一个定时任务
        ttlRemainingFuture.onComplete((ttlRemaining, e) -> { // ttlRemaining表示剩余有效期,e表示异常
            if (e == null) {
                if (ttlRemaining == null) { // 剩余时间等于null,说明获取锁成功了
                    if (leaseTime != -1L) { // 如果带有过期时间
                        this.internalLockLeaseTime = unit.toMillis(leaseTime); // 过期时间
                    } else { // 如果不带过期时间
                        this.scheduleExpirationRenewal(threadId); // 调用循环续命的方法续约锁
                    }
                }
            }
        });
        return ttlRemainingFuture;
    }

接着往下看,tryLockInnerAsync方法是真正执行获取锁的逻辑,它是一段LUA脚本代码。在这里,它使用的是hash数据结构。、

<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
        return this.evalWriteAsync(this.getRawName(), LongCodec.INSTANCE, command, 
      "if (redis.call('exists', KEYS[1]) == 0) then // 如果锁(即KEYS[1])不存在,则通过hincrby给数值递增1,也就是1,KEYS[1]表示锁
         redis.call('hincrby', 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 // 如果锁已存在,并且锁的是当前线程,则通过hincrby给数值递增1,
         redis.call('hincrby', KEYS[1], ARGV[2], 1); // 设置过期时间
         redis.call('pexpire', KEYS[1], ARGV[1]);
         return nil;
       end;
       return redis.call('pttl', KEYS[1]);
", // 如果锁已存在,但并非本线程,则返回过期时间ttl
          Collections.singletonList(this.getRawName()),
          new Object[]{unit.toMillis(leaseTime), this.getLockName(threadId)}); }

关于hincrby:

  Redis Hincrby 命令用于为哈希表中的字段值加上指定增量值。增量也可以为负数,相当于对指定字段进行减法操作。如果哈希表的 key 不存在,一个新的哈希表被创建并执行 HINCRBY 命令。如果指定的字段不存在,那么在执行命令前,字段的值被初始化为 0 。

可以看见他并没有使用我们的sexNx来进行操作,而是使用的hash结构。我们的每一个需要锁定的资源都可以看做是一个HashMap,锁定资源的节点信息是Key,锁定次数是value。通过这种方式可以很好的实现可重入的效果,只需要对value进行加1操作,就能进行可重入锁。

这段LUA代码看起来并不复杂,有三个判断:

 • 通过exists判断,如果锁不存在,则设置值和过期时间,加锁成功
 • 通过hexists判断,如果锁已存在,并且锁的是当前线程,则证明是重入锁,加锁成功
 • 如果锁已存在,但锁的不是当前线程,则证明有其他线程持有锁。返回当前锁的过期时间,加锁失败

 

scheduleExpirationRenewal方法

protected void scheduleExpirationRenewal(long threadId) {
        RedissonBaseLock.ExpirationEntry entry = new RedissonBaseLock.ExpirationEntry();
        RedissonBaseLock.ExpirationEntry oldEntry = (RedissonBaseLock.ExpirationEntry)EXPIRATION_RENEWAL_MAP.putIfAbsent(this.getEntryName(), entry);
        if (oldEntry != null) {
            oldEntry.addThreadId(threadId);
        } else {
            entry.addThreadId(threadId);
            this.renewExpiration();
        }

    }

getEntryName()方法:

protected String getEntryName() {
        return this.entryName;
    }

其中entryName的值为

this.entryName = this.id + ":" + name;

renewExpiration方法

private void renewExpiration() {
        RedissonBaseLock.ExpirationEntry ee = (RedissonBaseLock.ExpirationEntry)EXPIRATION_RENEWAL_MAP.get(this.getEntryName());
        if (ee != null) {
       // 这里设置一个定时任务 Timeout task
= this.commandExecutor.getConnectionManager().newTimeout(new TimerTask() { public void run(Timeout timeout) throws Exception { RedissonBaseLock.ExpirationEntry ent = (RedissonBaseLock.ExpirationEntry)RedissonBaseLock.EXPIRATION_RENEWAL_MAP.get(RedissonBaseLock.this.getEntryName()); if (ent != null) { Long threadId = ent.getFirstThreadId(); if (threadId != null) { RFuture<Boolean> future = RedissonBaseLock.this.renewExpirationAsync(threadId); // 调用Lua脚本进行续期 future.onComplete((res, e) -> { if (e != null) { // 报异常就移除key RedissonBaseLock.log.error("Can't update lock " + RedissonBaseLock.this.getRawName() + " expiration", e); RedissonBaseLock.EXPIRATION_RENEWAL_MAP.remove(RedissonBaseLock.this.getEntryName()); } else { if (res) { // 续期成功的话就自己调用自己,为下一轮续期做准备 RedissonBaseLock.this.renewExpiration(); } else { // 续期失败的话就取消续期,移除key等操作 RedissonBaseLock.this.cancelExpirationRenewal((Long)null); } } }); } } } }, this.internalLockLeaseTime / 3L, TimeUnit.MILLISECONDS); // 每次超过设置时间的1/3时,就会再次执行定时任务。每隔10s看下,如果还持有锁,延长生存时间 ee.setTimeout(task); } }

续期

调用Lua脚本进行续期, renewExpirationAsync方法:

protected RFuture<Boolean> renewExpirationAsync(long threadId) {
        return this.evalWriteAsync(this.getRawName(), 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.singletonList(this.getRawName()), this.internalLockLeaseTime, this.getLockName(threadId)); }

续期核心lua脚本在renewExpirationAsync里

很简单,就是看当前线程有没有加锁​​hexists, KEYS[1], ARGV[2]) == 1​​,有加锁的话就代表业务线程还没执行完,就给他的锁重新续期​​pexpire', KEYS[1], ARGV[1]​​,然后返回1,也就是true,没加锁的话返回0,也就是false。

(3)、解锁

 我们通过调用unlock方法来解锁。

public void unlock() {
        try {
            this.get(this.unlockAsync(Thread.currentThread().getId()));
        } catch (RedisException var2) {
            if (var2.getCause() instanceof IllegalMonitorStateException) {
                throw (IllegalMonitorStateException)var2.getCause();
            } else {
                throw var2;
            }
        }
    }

点击unlockAsync方法

public RFuture<Void> unlockAsync(long threadId) {
        RPromise<Void> result = new RedissonPromise();
        RFuture<Boolean> future = this.unlockInnerAsync(threadId); // 解锁方法
        future.onComplete((opStatus, e) -> {
            this.cancelExpirationRenewal(threadId);
            if (e != null) {
                result.tryFailure(e);
            } else if (opStatus == null) {
                IllegalMonitorStateException cause = new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: " + this.id + " thread-id: " + threadId);
                result.tryFailure(cause);
            } else {
                result.trySuccess((Object)null);
            }
        });
        return result;
    }

然后我们再看unlockInnerAsync方法。这里也是一段LUA脚本代码。

protected RFuture<Boolean> unlockInnerAsync(long threadId) {
        return this.evalWriteAsync(this.getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN, 
     "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then // 如果释放锁的线程和已存在锁的线程不是同一个线程,返回null
        return nil;
      end;
      local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); // 通过hincrby递减1的 方式,释放一次锁
      if (counter > 0) then // 若剩余次数大于0,则刷新过期时间
        redis.call('pexpire', KEYS[1], ARGV[2]);
         return 0;
     else // 否则证明锁已经释放,删除key并发布锁释放的消息
        redis.call('del', KEYS[1]);
        redis.call('publish', KEYS[2], ARGV[1]);
        return 1;
     end;
     return nil;
",
      Arrays.asList(this.getRawName(), this.getChannelName()),
      new Object[]{LockPubSub.UNLOCK_MESSAGE, this.internalLockLeaseTime, this.getLockName(threadId)}); }

如上代码,就是释放锁的逻辑。同样的,它也是有三个判断:

 • 如果锁已经不存在,通过publish发布锁释放的消息,解锁成功

 • 如果解锁的线程和当前锁的线程不是同一个,解锁失败,抛出异常

 • 通过hincrby递减1,先释放一次锁。若剩余次数还大于0,则证明当前锁是重入锁,刷新过期时间;若剩余次数小于0,删除key并发布锁释放的消息,解锁成功

至此,Redisson中的可重入锁的逻辑,就分析完了。但值得注意的是,上面的两种实现方式都是针对单机Redis实例而进行的。如果我们有多个Redis实例,请参阅Redlock算法。

这里不重点介绍 Redisson 的使用,大家可以看官方 Github 学习如何使用,比较简单。

到这里我们再小结一下,基于 Redis 的实现分布式锁,前面遇到的问题,以及对应的解决方案:

  • 死锁设置过期时间
  • 过期时间评估不好,锁提前过期守护线程,自动续期
  • 锁被别人释放锁写入唯一标识,释放锁先检查标识,再释放

还有哪些问题场景,会危害 Redis 锁的安全性呢?

三、多节点Redis中如何实现分布式锁

之前分析的场景都是,锁在「单个」Redis 实例中可能产生的问题

而我们在使用 Redis 时,一般会采用主从集群 + 哨兵的模式部署,这样做的好处在于,当主库异常宕机时,哨兵可以实现「故障自动切换」,把从库提升为主库,继续提供服务,以此保证可用性。

那当「主从发生切换」时,这个分布锁会依旧安全吗?

试想这样的场景:

(1)、客户端 1 在主库上执行 SET 命令,加锁成功

(2)、此时,主库异常宕机,SET 命令还未同步到从库上(主从复制是异步的)

(3)、从库被哨兵提升为新主库,这个锁在新的主库上,丢失了!

可见,当引入 Redis 副本后,分布锁还是可能会受到影响。

怎么解决这个问题?

为此,Redis 的作者提出一种解决方案,就是我们经常听到的 Redlock(红锁)

它真的可以解决上面这个问题吗?

1、Redlock 真的安全吗?

好,终于到了这篇文章的重头戏。啊?上面讲的那么多问题,难道只是基础?

是的,那些只是开胃菜,真正的硬菜,从这里刚刚开始。

如果上面讲的内容,你还没有理解,我建议你重新阅读一遍,先理清整个加锁、解锁的基本流程。

如果你已经对 Redlock 有所了解,这里可以跟着我再复习一遍,如果你不了解 Redlock,没关系,我会带你重新认识它。

值得提醒你的是,后面我不仅仅是讲 Redlock 的原理,还会引出有关「分布式系统」中的很多问题,你最好跟紧我的思路,在脑中一起分析问题的答案。

现在我们来看,Redis 作者提出的 Redlock 方案,是如何解决主从切换后,锁失效问题的。

Redlock 的方案基于 2 个前提:

(1)、不再需要部署从库哨兵实例,只部署主库。

(2)、但主库要部署多个,官方推荐至少 5 个实例

也就是说,想用使用 Redlock,你至少要部署 5 个 Redis 实例,而且都是主库,它们之间没有任何关系,都是一个个孤立的实例

注意:不是部署 Redis Cluster,就是部署 5 个简单的 Redis 实例

Redlock 具体如何使用呢?

public class RedLockDemo {
    public static void main(String[] args) {
        Config config = new Config();
        config.useClusterServers().addNodeAddress("redis://127.0.0.1:6380")
                .addNodeAddress("redis://127.0.0.1:6381")
                .addNodeAddress("redis://127.0.0.1:6382")
                .addNodeAddress("redis://127.0.0.1:6383")
                .addNodeAddress("redis://127.0.0.1:6384");
        RedissonClient redisson = Redisson.create(config);
        RLock lock1 = redisson.getLock("lock1");
        RLock lock2 = redisson.getLock("lock2");
        RLock lock3 = redisson.getLock("lock3");
        RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
        // 同时加锁 lock1 lock2 lock3
        // 红锁在大部分节点上加锁成功就算成功
        lock.lock();
        lock.unlock();
        redisson.shutdown();
    }
}

整体的流程是这样的,一共分为 5 步:

(1)、客户端先获取「当前时间戳T1」

(2)、客户端依次向这 5 个 Redis 实例发起加锁请求(用前面讲到的 SET 命令),且每个请求会设置超时时间(毫秒级,要远小于锁的有效时间),如果某一个实例加锁失败(包括网络超时、锁被其它人持有等各种异常情况),就立即向下一个 Redis 实例申请加锁

(3)、如果客户端从 >=3 个(大多数)以上 Redis 实例加锁成功,则再次获取「当前时间戳T2」,如果 T2 - T1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败

(4)、加锁成功,去操作共享资源(例如修改 MySQL 某一行,或发起一个 API 请求)

(5)、加锁失败,向「全部节点」发起释放锁请求(前面讲到的 Lua 脚本释放锁)

我简单帮你总结一下,有 4 个重点:

(1)、客户端在多个 Redis 实例上申请加锁(只有当客户端成功在 N/2+1 个实例中成功加锁成功,才算成功持有分布式锁)

(2)、必须保证大多数节点加锁成功

(3)、大多数节点加锁的总耗时,要小于锁设置的过期时间

(4)、释放锁,要向全部节点发起释放锁请求

第一次看可能不太容易理解,建议你把上面的文字多看几遍,加深记忆。

然后,记住这 5 步,非常重要,下面会根据这个流程,剖析各种可能导致锁失效的问题假设。

好,明白了 Redlock 的流程,我们来看 Redlock 为什么要这么做。

1) 为什么要在多个实例上加锁?

本质上是为了「容错」,部分实例异常宕机,剩余的实例加锁成功,整个锁服务依旧可用。

2) 为什么大多数加锁成功,才算成功?

多个 Redis 实例一起来用,其实就组成了一个「分布式系统」。

在分布式系统中,总会出现「异常节点」,所以,在谈论分布式系统问题时,需要考虑异常节点达到多少个,也依旧不会影响整个系统的「正确性」。

这是一个分布式系统「容错」问题,这个问题的结论是:如果只存在「故障」节点,只要大多数节点正常,那么整个系统依旧是可以提供正确服务的

这个问题的模型,就是我们经常听到的「拜占庭将军」问题,感兴趣可以去看算法的推演过程。

3) 为什么步骤 3 加锁成功后,还要计算加锁的累计耗时?

因为操作的是多个节点,所以耗时肯定会比操作单个实例耗时更久,而且,因为是网络请求,网络情况是复杂的,有可能存在延迟、丢包、超时等情况发生,网络请求越多,异常发生的概率就越大。

所以,即使大多数节点加锁成功,但如果加锁的累计耗时已经「超过」了锁的过期时间,那此时有些实例上的锁可能已经失效了,这个锁就没有意义了。

4) 为什么释放锁,要操作所有节点?

在某一个 Redis 节点加锁时,可能因为「网络原因」导致加锁失败。

例如,客户端在一个 Redis 实例上加锁成功,但在读取响应结果时,网络问题导致读取失败,那这把锁其实已经在 Redis 上加锁成功了。

所以,释放锁时,不管之前有没有加锁成功,需要释放「所有节点」的锁,以保证清理节点上「残留」的锁

好了,明白了 Redlock 的流程和相关问题,看似 Redlock 确实解决了 Redis 节点异常宕机锁失效的问题,保证了锁的「安全性」。

但事实真的如此吗?

 

posted on 2023-03-20 18:08  周文豪  阅读(1047)  评论(0编辑  收藏  举报