一.基于setnx实现的分布式锁存在的问题

基于setnx实现的分布式锁存在下面的问题:

01 不可重入
同一个线程无法多次获取同一把锁

02 不可重试
获取锁只尝试一次就返回 false,没有重试机制

03 超时释放
锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患

04 主从一致性
如果Redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从并同步主中的锁数据,则会出现锁实现

那么我们如何解决这些问题呢?

这里我们直接使用Redission开源框架即可

二.初识Redission

Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。

官网地址:https://redisson.org
GitHub地址:https://github.com/redisson/redisson

三.Redission快速入门

1.引入依赖

<!--添加Redission的依赖项-->
        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson</artifactId>
            <version>3.17.5</version>
        </dependency>

2.配置Redisson客户端

@Configuration
public class RedissonConfig {
    @Bean
    public RedissonClient redissonClient(){
        Config config = new Config();
        config.useSingleServer().setAddress("redis://192.168.56.10:6379");
        return Redisson.create(config);
    }
}

3.使用Redis分布式锁

@Resource
private RedissonClient redissonClient;

@Test
void testRedisson() throws InterruptedException {
    // 获取锁(可重入),指定锁的名称
    RLock lock = redissonClient.getLock("anyLock");
    // 尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
    boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
    // 判断释放获取成功
    if(isLock){
        try {
            System.out.println("执行业务");
        } finally {
            // 释放锁
            lock.unlock();
        }
    }
}

4.源代码优化

@Override
    public Result seckillVoucher(Long voucherId) {
        //1.查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        //2.判断秒杀是否开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀尚未开始");
        }
        //3.判断秒杀是否结束
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀已经结束");
        }
        //4.判断库存是否充足
        if (voucher.getStock() < 1) {
            return Result.fail("库存不足");
        }
        //5.1获取用户id
        Long UserId = UserHolder.getUser().getId();
        //创建锁对象
//        SimpleRedisLock simpleRedisLock = new SimpleRedisLock("order:" + UserId, stringRedisTemplate);
        RLock lock = redissonClient.getLock("lock:order:" + UserId);

        //获取锁
        boolean isLock = lock.tryLock();
        //判断获取锁成功
        if (!isLock) {
            return Result.fail("不允许重复下单");
        }
        try {
            //获取代理对象
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId, voucher);
        } finally {
            //释放锁
            lock.unlock();
        }
    }

四.Redis可重入锁底层原理

1.应用场景

当同一个线程中的一个方法获取锁之后调用另一个方法,另一个方法如果需要获取锁,那么获取锁肯定失败,方法一无法结束,锁一直不能释放,就会陷入思死锁。

2.解决方法

Redisson 可重入锁是怎么工作的?

其核心原理是用了Redis数据库中的哈希类型存储值

简单说:它让同一个线程能多次拿同一把锁,不会死锁;别人想拿?得等你彻底放完。

流程图拆解(对照你给的图):

  1. 开始 → 先看锁是否存在

    • 如果不存在 → 当前线程拿到锁,记下“我是主人”,设个过期时间(比如30秒),然后执行业务。
    • 如果存在 → 看看锁的主人是不是自己?
      • 是自己 → 锁计数 +1(表示我又进了一层),继续执行业务。
      • 不是自己 → 获取失败,要么等待,要么放弃。
  2. 执行业务过程中 → Redisson 后台有个“看门狗”

    • 每隔10秒检查一下:这个线程还在跑吗?
    • 如果在 → 自动给锁续期(重置有效期),防止业务没做完锁就掉了。
  3. 业务结束 → 准备释放锁

    • 先确认:这把锁还是我的吗?(防误删)
      • 不是我的 → 报错,不让你乱动别人的锁。
      • 是我的 → 锁计数 -1。
        • 如果计数归零 → 真正释放锁(删除 Redis 里的 key)。
        • 如果计数还没到零 → 只减计数,锁还留着(因为可能还有嵌套调用没退出)。

核心优势:

  • 可重入:自己调自己,不怕死锁。
  • 自动续期:业务再长也不怕锁提前失效。
  • 安全释放:只允许主人解锁,避免误伤他人。

3.获取锁的Lua脚本

local key = KEYS[1]              -- 锁的 key
local threadId = ARGV[1]         -- 当前线程唯一标识
local releaseTime = ARGV[2]      -- 锁的自动释放时间(秒)

-- 判断锁是否存在
if redis.call('exists', key) == 0 then
    -- 不存在,当前线程获取锁
    redis.call('hset', key, threadId, '1')
    -- 设置过期时间
    redis.call('expire', key, releaseTime)
    return 1   -- 成功获取锁
end

-- 锁已存在,判断是否是当前线程持有
if redis.call('hexists', key, threadId) == 1 then
    -- 是当前线程,重入次数 +1
    redis.call('hincrby', key, threadId, '1')
    -- 重置过期时间(续期)
    redis.call('expire', key, releaseTime)
    return 1   -- 成功重入
end

-- 既不是新锁,也不是自己持有的锁 → 获取失败
return 0

脚本说明

  • 返回值 1:表示成功获取锁(首次获取或重入)
  • 返回值 0:表示获取失败(锁被其他线程占用)
  • 使用 Hash 结构存储锁信息,key 是锁名,field 是线程 ID,value 是重入计数
  • 每次获取或重入都会刷新过期时间,防止业务执行中锁意外失效

4.释放锁的Lua脚本

local key = KEYS[1]              -- 锁的 key
local threadId = ARGV[1]         -- 当前线程唯一标识
local releaseTime = ARGV[2]      -- 锁的自动释放时间(秒)

-- 判断当前锁是否还是由自己持有
if redis.call('HEXISTS', key, threadId) == 0 then
    return nil   -- 不是自己的锁,直接返回,不操作
end

-- 是自己的锁,重入次数减 1
local count = redis.call('HINCRBY', key, threadId, -1)

-- 判断重入次数是否已经为 0
if count > 0 then
    -- 大于 0,说明还有嵌套调用未退出,不能真正释放锁
    -- 重置过期时间,防止后续业务执行中锁失效
    redis.call('EXPIRE', key, releaseTime)
    return nil
else
    -- 等于 0,说明所有嵌套都已退出,可以彻底释放锁
    redis.call('DEL', key)
    return nil
end

脚本说明

  • 返回值始终为 nil:因为释放操作不需要返回成功/失败状态给 Java 层(Java 层通过异常处理“非本人锁”的情况)
  • 核心逻辑
    1. 先确认锁属于当前线程 → 防止误删他人锁
    2. 重入计数减 1 → 支持可重入特性
    3. 若计数仍 > 0 → 只续期,不删除(还在嵌套调用中)
    4. 若计数 == 0 → 删除整个锁 key(完全释放)
  • 安全机制:即使业务超时或异常,只要不是当前线程持有,就不会被错误释放

5.Redis的锁重试和WatchDog机制

<1>、整体流程概览

Redisson 的锁机制主要分为两个部分:

  1. 左侧流程:获取锁(包含重试逻辑和 WatchDog 启动)
  2. 右侧流程:释放锁(包含通知等待者和取消 WatchDog)

这两个流程通过 Redis 的发布订阅(Pub/Sub)机制紧密连接,形成一个完整的闭环。

<2>、获取锁流程详解(左侧)

当我们调用 redisson.getLock("myLock").lock() 时,系统会执行以下步骤:

第一步:尝试获取锁
系统首先会尝试向 Redis 设置一个 Key。这里底层的加锁操作是通过 Lua 脚本保证原子性的。

第二步:判断锁是否存在(TTL 检查)
系统会检查这个 Key 是否存在以及它的剩余存活时间(TTL)。

  • 如果 TTL 为 null:说明锁不存在,或者已经过期。此时当前线程有机会获取锁。
  • 如果 TTL 有值:说明锁正被其他线程持有。当前线程无法直接获取,进入等待逻辑。

第三步:决定是否需要 WatchDog
如果成功获取到了锁,系统会检查用户是否指定了锁的持有时间(leaseTime)。

  • 情况 A:用户指定了时间(例如 tryLock(5, 10, SECONDS))。
    此时 leaseTime 不等于 -1。锁会在 10 秒后自动过期,不需要后台维护。直接返回 true,业务开始执行。
  • 情况 B:用户未指定时间(例如直接调用 lock())。
    此时 leaseTime 等于 -1。为了防止业务执行时间超过锁的默认有效期(通常是 30 秒)导致锁意外释放,系统会立即启动 WatchDog 机制。

第四步:什么是 WatchDog(看门狗)?
WatchDog 是 Redisson 的一个后台线程。

  • 工作原理:它每隔 10 秒(默认值)检查一次,发现当前线程还持有这把锁,就会把锁的过期时间重新重置为 30 秒。
  • 作用:只要业务线程不结束且不调用 unlock(),锁就永远不会过期。这解决了“业务执行时间不确定”的难题。
  • 停止条件:当业务执行完毕,主动调用 unlock() 释放锁后,WatchDog 会自动停止。

第五步:获取失败后的重试机制
如果第二步发现锁被别人占用了,线程不会傻乎乎地一直循环空转(那样太消耗 CPU),而是进入“订阅等待”模式。

  1. 判断剩余等待时间:如果是 tryLock 模式,会检查是否超过了用户设定的最大等待时间。如果超时,直接返回 false。
  2. 订阅释放信号:如果没有超时,当前线程会订阅一个特定的 Redis 频道(Channel)。
  3. 被动唤醒:线程进入阻塞状态,直到收到“锁已释放”的消息。
  4. 再次尝试:一旦收到消息,线程被唤醒,重新回到第一步尝试获取锁。如果还没抢到,继续等待。这个过程就是高效的“重试机制”。

<3>、释放锁流程详解(右侧)

当业务代码执行完毕,调用 unlock() 方法时,流程如下:

第一步:尝试释放锁
系统会执行一段 Lua 脚本。这段脚本非常关键,它会检查:

  • 锁是否存在?
  • 持有锁的线程 ID 是否是当前线程?
    只有满足这两个条件,才会真正执行删除操作或减少重入计数。如果不是当前线程持有的锁,直接忽略或抛出异常,防止误删别人的锁。

第二步:发送释放消息
一旦锁被成功释放(Key 被删除或计数归零),Redisson 会向之前提到的那个 Redis 频道发送一条消息。
这条消息的作用是:通知所有正在等待这把锁的线程,“锁已经没了,快来抢!”
这就是左侧流程中“订阅并等待释放锁的信号”的消息来源。

第三步:取消 WatchDog
既然锁已经释放了,后台的 WatchDog 线程就没有存在的必要了。系统会立即停止该线程,释放资源。

第四步:异常处理
如果在释放过程中发生网络异常等不可控因素,系统会记录异常日志,确保问题可追踪。

<4>、核心机制总结

为了让大家更清晰地理解,我们将图中的关键点总结如下:

  1. 为什么需要重试机制?
    因为分布式环境下,锁竞争激烈。如果不重试,一次抢不到就失败,业务就无法进行。Redisson 的重试不是盲目轮询,而是基于 Redis 的发布订阅功能,有消息才醒,没消息就睡,极大节省了 CPU 资源。

  2. 为什么需要 WatchDog?
    在实际业务中,我们很难预估一个接口到底要执行多久。如果锁设置的时间太短,业务没跑完锁就断了,会导致并发安全问题;如果设置太长,服务宕机后锁长时间不释放,又会影响其他线程。
    WatchDog 完美解决了这个问题:它让锁的有效期动态跟随业务线程的生命周期,只要线程活着,锁就活着;线程死了(宕机),WatchDog 也死了,锁自动过期。

  3. Lua 脚本的作用是什么?
    图中所有的“尝试获取”和“尝试释放”,底层都是 Lua 脚本。

  • 获取锁时:Lua 保证“检查锁是否存在”和“设置新锁”这两个动作是原子的,不会出现多线程同时认为锁空闲的情况。
  • 释放锁时:Lua 保证“校验线程 ID"和“删除锁/减少计数”是原子的,防止误删。

<5>、注意

在使用 Redisson 锁时,结合上述原理,有几点建议:

  1. 尽量使用默认的 lock() 方法
    除非你有非常明确的短时间锁定需求,否则建议使用无参的 lock() 方法,让 WatchDog 自动帮你续期。手动设置过期时间风险较大,容易因业务超时导致锁失效。

  2. 务必在 finally 块中释放锁
    无论业务是否成功,都必须在 finally 代码块中调用 unlock()。这不仅是为了释放资源,更是为了触发“发送释放消息”和“取消 WatchDog”的流程,唤醒其他等待的线程。

  3. 注意 Redis 集群环境
    上述原理基于主从架构。在极端情况下(如主节点挂掉,锁还没同步到从节点),可能会丢失锁。如果对数据一致性要求极高,建议使用 Redisson 提供的 RedLock(红锁)算法,或者考虑使用 ZooKeeper 等强一致性组件。

Redisson 的分布式锁之所以好用,是因为它把复杂的分布式协调问题(重试、续期、原子性、通知)都封装在了内部。通过这张流程图,我们可以看到它巧妙地结合了 Lua 脚本的原子性、Redis 的发布订阅机制以及本地后台线程(WatchDog),实现了一个高可用、高性能的分布式锁方案。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐