在高并发的编程场景中,锁机制是保证数据一致性和线程安全的重要手段。在 Redis 中,锁的应用也非常广泛,本文将从悲观锁和乐观锁的基本概念出发,深入探讨分布式锁,重点介绍基于 Redis 的分布式锁框架 Redisson 的底层原理和代码实现。

目录

一、悲观锁与乐观锁

悲观锁

乐观锁

二、分布式锁

分布式锁的核心概念

常见的分布式锁实现方式

三、Redisson 分布式锁

Redisson 简介

Redisson 入门

Redisson 分布式锁原理

Redisson 分布式锁的特点总结

1. 获取锁的底层逻辑

2. 释放锁的底层逻辑

3. 可重入的实现本质


一、悲观锁与乐观锁

悲观锁

悲观锁认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。在 Java 中,常见的 Synchronized 关键字以及 Lock 接口的实现类(如 ReentrantLock)都属于悲观锁。这种锁机制在竞争激烈的情况下能够有效防止数据冲突,但由于它会阻塞其他线程,可能会导致性能下降。

乐观锁

乐观锁则认为线程安全问题不一定会发生,因此在操作数据时不加锁,而是在更新数据时去判断有没有其它线程对数据做了修改。如果没有修改则认为是安全的,自己才更新数据;如果已经被其它线程修改说明发生了安全问题,此时可以重试或抛出异常。乐观锁通常通过版本号或者时间戳等机制来实现。

二、分布式锁

分布式锁的核心概念

在分布式系统中,多个进程可能会同时访问共享资源,为了保证共享资源在同一时间只能被一个进程访问,需要使用分布式锁。分布式锁的核心是实现多进程之间的互斥。

常见的分布式锁实现方式

常见的分布式锁实现方式有基于 MySQL、Redis 和 Zookeeper 三种:

  • MySQL:利用 MySQL 本身的互斥锁机制来实现分布式锁。例如,可以通过 SELECT... FOR UPDATE 语句来实现排它锁。这种方式高可用性较好,但性能一般,因为每次获取和释放锁都需要与数据库进行交互。
  • Redis:利用 Redis 的 setnx(SET if Not eXists)这样的互斥命令来实现分布式锁。setnx 命令只有在键不存在时才会设置键值对,利用这个特性可以实现简单的锁机制。Redis 的性能较好,并且可以通过设置过期时间来保证锁的安全性。
  • Zookeeper:利用 Zookeeper 节点的唯一性和有序性实现互斥。客户端可以创建临时有序节点,通过判断节点序号来获取锁。Zookeeper 的高可用性和一致性都比较好,但性能相对一般。

三、Redisson 分布式锁

Redisson 简介

Redisson 是一个在 Redis 的基础上实现的 Java 驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的 Java 常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现,例如可重入锁(Reentrant Lock)、公平锁(Fair Lock)、联锁(MultiLock)、红锁(RedLock)等。

Redisson 入门

  1. 引入依赖:
    在 Maven 项目中,需要在 pom.xml 文件中添加以下依赖:
    <dependency>
        <groupId>org.redisson</groupId>
        <artifactId>redisson</artifactId>
        <version>3.13.6</version>
    </dependency>
  2. 配置 Redisson 客户端:
    通过以下配置类来配置 Redisson 客户端,示例中配置了单点 Redis 地址:
    @Configuration
    public class RedisConfig {
        @Bean
        public RedissonClient redissonClient() {
            Config config = new Config();
            config.useSingleServer().setAddress("redis://192.168.150.101:6379").setPassword("123321");
            return Redisson.create(config);
        }
    }

Redisson 分布式锁原理

  1. 获取锁流程:
    尝试获取锁后,先判断锁的剩余时间(ttl)是否为 null,如果为 null 说明锁不存在,再判断 leaseTime 是否为 -1,如果是则开启 watchDog(看门狗机制),它会每隔一段时间(releaseTime / 3)重置锁的过期时间,以防止业务执行时间过长导致锁提前释放。如果 ttl 不为 null,则判断剩余等待时间是否大于 0,如果大于 0 则订阅并等待释放锁的信号,当等待时间超时则返回获取锁失败。
  2. 释放锁流程:
    尝试释放锁,判断是否释放成功,如果成功则发送释放锁消息并取消 watchDog;如果失败则记录异常并结束。

Redisson 分布式锁的特点总结

  • 可重入:利用 Hash 结构记录线程 ID 和重入次数,允许同一个线程多次获取同一把锁。
  • 可重试:利用信号量和 PubSub(发布订阅)功能实现等待、唤醒机制,当获取锁失败时可以重试获取。
  • 超时续约:通过 watchDog 机制,每隔一段时间重置锁的超时时间,保证业务在执行过程中锁不会意外释放。

1. 获取锁的底层逻辑

Redisson 获取可重入锁主要通过 RLock 接口的实现类,关键方法是 tryLockInnerAsync ,简化后的核心代码逻辑如下(这里对代码做了适当简化以便理解,实际代码会更复杂包含很多异常处理和参数校验等):

<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
    internalLockLeaseTime = unit.toMillis(leaseTime);
    // 构建 Lua 脚本,用于尝试获取锁
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_LONG,
        "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.singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}
  • 锁不存在时获取锁:if (redis.call('exists', KEYS[1]) == 0) then 这部分代码判断指定的锁键(KEYS[1] 即锁名称)在 Redis 中是否不存在。如果不存在,就通过 hset 命令在该键下以线程 ID(ARGV[2])为字段,值设为 1,表示该线程获取到了锁,同时使用 pexpire 命令设置锁的过期时间(ARGV[1]),最后返回 nil 表示获取锁成功。
  • 锁存在且是当前线程持有:if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then 这部分判断锁键存在且当前线程 ID 对应的字段存在,意味着是可重入的情况,此时通过 hincrby 命令将该线程的锁计数加 1,并重置锁的过期时间,同样返回 nil 表示获取锁成功(可重入成功)。
  • 锁存在且不是当前线程持有:如果前面两个条件都不满足,即锁被其他线程持有,就通过 pttl 命令获取锁剩余的过期时间并返回,意味着获取锁失败。

2. 释放锁的底层逻辑

释放锁的核心方法是 unlockInnerAsync ,简化后的代码如下:

RFuture<Boolean> unlockInnerAsync(long threadId) {
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
        "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
            "return nil;" +
        "end; " +
        "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
        "if (counter > 0) then " +
            "redis.call('pexpire', KEYS[1], ARGV[2]); " +
            "return 0; " +
        "else " +
            "redis.call('del', KEYS[1]); " +
            "redis.call('publish', KEYS[2], ARGV[1]); " +
            "return 1; " +
        "end; " +
        "return nil;",
        Arrays.asList(getName(), getChannelName()), LockPubSub.unlockMessage, internalLockLeaseTime, getLockName(threadId));
}
  • 判断当前线程是否持有锁:if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then 这部分判断当前线程 ID 对应的字段在锁键中是否不存在,如果不存在说明当前线程没有持有该锁,直接返回 nil 表示释放锁失败。
  • 减少锁计数:如果线程持有锁,通过 hincrby 命令将该线程的锁计数减 1。
  • 锁计数大于 0 的情况(可重入未完全释放):如果减完后的锁计数大于 0 ,说明该线程对这把锁还有重入次数未释放完,此时通过 pexpire 命令重置锁的过期时间,并返回 0 表示还持有锁。
  • 锁计数等于 0 的情况(完全释放锁):如果减完后的锁计数为 0 ,则通过 del 命令删除锁键,并且通过 publish 命令发布一个释放锁的消息(用于通知等待获取该锁的其他线程),最后返回 1 表示成功释放锁。

3. 可重入的实现本质

Redisson 可重入锁利用 Redis 的 Hash 数据结构来记录每个线程对锁的持有情况。Hash 的键是锁的名称,字段是线程 ID,值是该线程获取锁的次数(即重入次数)。当线程再次获取已经持有的锁时,增加对应字段的值;释放锁时,减少对应字段的值,只有当值为 0 时才真正从 Redis 中删除锁键,从而实现了可重入的特性。

通过上述对 Redisson 可重入锁底层代码的剖析,我们可以清晰地看到它是如何借助 Redis 实现分布式环境下的可重入锁功能,确保在多线程多进程场景下,对共享资源的访问能够安全、有序地进行 。

通过对 Redis 中锁机制的全面了解,尤其是 Redisson 分布式锁的原理和使用,我们能够更好地在分布式系统中保证数据的一致性和并发操作的安全性,提升系统的稳定性和可靠性。

更多推荐