【Redis | 第六篇】Redisson

目录

一、前言

[二、解决不可重入:Hash 数据结构 + Lua 原子脚本](#二、解决不可重入:Hash 数据结构 + Lua 原子脚本)

[2.1 不可重入的问题在哪?](#2.1 不可重入的问题在哪?)

[2.2 Redisson 的解决方案:Redis Hash](#2.2 Redisson 的解决方案:Redis Hash)

[2.3 加锁 Lua 脚本详解](#2.3 加锁 Lua 脚本详解)

[2.4 解锁 Lua 脚本](#2.4 解锁 Lua 脚本)

[三、解决不可重试:消息订阅 + 信号量等待](#三、解决不可重试:消息订阅 + 信号量等待)

[3.1 简单方案的缺陷](#3.1 简单方案的缺陷)

四、解决超时释放:看门狗(Watchdog)自动续期

[4.1 为什么需要续期?](#4.1 为什么需要续期?)

[4.2 Watchdog 看门狗机制](#4.2 Watchdog 看门狗机制)

[4.3 看门狗工作原理](#4.3 看门狗工作原理)

[五、MultiLock 联锁:跨 Redis 实例的锁聚合](#五、MultiLock 联锁:跨 Redis 实例的锁聚合)

[5.1 背景:主从一致性问题的延伸](#5.1 背景:主从一致性问题的延伸)

[5.2 RedissonMultiLock 的设计思想](#5.2 RedissonMultiLock 的设计思想)

[5.3 使用示例](#5.3 使用示例)


一、前言

【Redis | 第五篇】分布式锁

在上一篇Redis实现分布式锁,我们是基于SET NX EX命令来实现的简单的分布式锁,虽然上手容易,但是在生产环境中还存在以下问题:

痛点 问题描述
不可重入 同一个线程在持有锁的情况下,再次获取同一把锁会死锁
不可重试 获取锁失败后立刻返回 false,无法自动重试,调用方只能自旋
超时释放 业务还没执行完,锁就过期了,导致并发安全问题
主从一致性 Redis 主节点宕机,从节点还没同步锁数据,导致锁丢失

Redisson 作为 Java 生态中最强大的 Redis 客户端,对分布式锁做了非常完善的封装。它不仅实现了 java.util.concurrent.locks.Lock 接口,还通过精巧的设计把这四个问题一一化解。

二、解决不可重入:Hash 数据结构 + Lua 原子脚本

2.1 不可重入的问题在哪?

传统的 SET NX EX 方案中,锁就是一个简单的 String key,只有 "存在/不存在" 两种状态。同一个线程如果想再次获取同一把锁(比如递归调用或嵌套方法),会因为 key 已存在而直接失败------这就是死锁的根源。

Java 中的 ReentrantLock 是通过 state 变量记录重入次数的:state=0 表示无锁,加锁时 state+1,释放时 state-1,直到归零才真正释放。

Redisson 借鉴了这个思想,但在 Redis 中需要一个能同时存储 "谁持有了锁""重入了多少次" 的数据结构。

2.2 Redisson 的解决方案:Redis Hash

Redisson 使用 Redis 的 Hash 结构来存储锁信息:

复制代码
Key:  锁的名称              →  "myLock"
Field: 线程标识             →  "连接ID:线程ID"   (如 "uuid-123:thread-42")
Value: 重入次数              →  1, 2, 3 ...

示意图:

复制代码
┌──────────────────────────────────┐
│  Key: "myLock"  (Hash)           │
│  ┌────────────────────────────┐  │
│  │ Field: "uuid-xxx:thread-1" │  │
│  │ Value: 2  (重入了2次)       │  │
│  └────────────────────────────┘  │
└──────────────────────────────────┘

2.3 加锁 Lua 脚本详解

Redisson 将加锁逻辑封装在一条 Lua 脚本中,利用 Redis 执行 Lua 脚本的原子性保证并发安全:

Lua 复制代码
-- KEYS[1]: 锁的名称,如 "myLock"
-- ARGV[1]: 锁的过期时间,默认 30000 毫秒
-- ARGV[2]: 线程标识,格式为 "连接ID:线程ID"

-- 情况1: 锁不存在 → 直接加锁
if (redis.call('exists', KEYS[1]) == 0) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);   -- 设置重入次数为1
    redis.call('pexpire', KEYS[1], ARGV[1]);       -- 设置过期时间
    return nil;                                     -- 返回nil表示加锁成功
end;

-- 情况2: 锁存在,且是当前线程持有 → 重入
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);   -- 重入次数+1
    redis.call('pexpire', KEYS[1], ARGV[1]);       -- 刷新过期时间
    return nil;                                     -- 返回nil表示加锁成功
end;

-- 情况3: 锁被别人持有 → 返回剩余TTL
return redis.call('pttl', KEYS[1]);

三种情况对应三条分支:

  1. 锁不存在 → 创建 Hash,field=线程标识,value=1,设置过期时间
  2. 锁存在且 field 匹配 → 这是重入!value+1,刷新过期时间
  3. 锁存在但 field 不匹配 → 被别人持有,返回剩余存活时间(ms)

2.4 解锁 Lua 脚本

释放锁也是通过 Lua 脚本原子执行:

Lua 复制代码
-- 判断锁是否被当前线程持有
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
    return nil;  -- 不是你的锁,不能释放
end;

-- 重入次数-1
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);

if (counter > 0) then
    -- 还有重入层数,只刷新过期时间,不删除key
    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;

解锁时 counter>0 说明还有嵌套层没释放完,只减计数不删 key------这就是可重入锁的释放逻辑。

三、解决不可重试:消息订阅 + 信号量等待

3.1 简单方案的缺陷

原生 SET NX 加锁失败后,通常的做法是让线程 sleep 一段时间再重试。但这样有两个问题:

  • CPU 空转,浪费资源
  • sleep 时间不好把握:太短则频繁重试,太长则响应慢

核心流程如下:

Lua 复制代码
┌─────────────────────────────────────────────────────┐
│  tryLock(waitTime, leaseTime, unit)                   │
│    │                                                  │
│    ▼                                                  │
│  尝试获取锁 (执行 Lua 脚本)                            │
│    │                                                  │
│    ├── 成功 → 返回 true                               │
│    │                                                  │
│    └── 失败,拿到锁的剩余 TTL                          │
│         │                                             │
│         ▼                                             │
│       计算剩余等待时间 = waitTime - 已消耗时间          │
│         │                                             │
│         ├── 剩余时间 ≤ 0 → 返回 false (超时放弃)       │
│         │                                             │
│         └── 剩余时间 > 0 →                             │
│              │                                        │
│              ▼                                        │
│            订阅一个 Redis Channel (锁名对应的频道)       │
│              │                                        │
│              ▼                                        │
│            通过信号量(Semaphore)阻塞等待               │
│            await(time, TimeUnit)                      │
│              │                                        │
│              ├── 收到解锁消息 → 被唤醒,回到「尝试获取锁」 │
│              │                                        │
│              └── 等待超时 → 返回 false                  │
└─────────────────────────────────────────────────────┘

关键设计点

  1. Redis Pub/Sub :当持有锁的线程释放锁时(Lua 脚本中执行 publish),所有订阅该频道的等待线程会被唤醒,避免了无意义的轮询。

  2. 信号量(Semaphore) :Redisson 在 Java 侧使用 Semaphore 让线程阻塞等待。await(time, TimeUnit) 方法支持超时唤醒,与 waitTime 完美配合。

  3. 剩余时间精确计算 :每次被唤醒后重新尝试加锁,并重新计算 waitTime - 已消耗时间,确保总等待时间不超过用户指定的 waitTime

四、解决超时释放:看门狗(Watchdog)自动续期

4.1 为什么需要续期?

传统分布式锁一定会设置过期时间,防止客户端宕机导致死锁。但问题在于:你无法预知业务代码会执行多久

Lua 复制代码
假设锁的过期时间 = 10秒
业务执行时间 = 12秒

时间线:
0s  ─── 加锁成功
  ...
10s ─── 锁自动过期!(Redis 删除了 key)
10s ─── 另一个线程拿到了锁
  ...
12s ─── 第一个线程业务执行完毕,但它持有的"锁"实际上已经失效
         → 并发安全问题!

4.2 Watchdog 看门狗机制

Redisson 的解决方案是 Watchdog(看门狗)自动续期机制。

核心规则只有不指定 leaseTime(或设为 -1)时,才会启动看门狗

java 复制代码
// 不指定leaseTime → 触发看门狗,默认30秒过期,自动续期
lock.lock();

// 指定了leaseTime → 不会触发看门狗,到期自动释放
lock.lock(10, TimeUnit.SECONDS);

4.3 看门狗工作原理

java 复制代码
┌──────────────────────────────────────────────────┐
│  1. 加锁成功(leaseTime = -1)                     │
│       │                                           │
│       ▼                                           │
│  2. 设置默认过期时间 = 30秒 (lockWatchdogTimeout)   │
│       │                                           │
│       ▼                                           │
│  3. 启动定时任务 (Netty Timer / ScheduledExecutor) │
│       │                                           │
│       ▼                                           │
│  4. 每 30/3 = 10秒 执行一次续期                    │
│     ┌─────────────────────────────┐               │
│     │ Lua 脚本:                    │               │
│     │ if 锁存在且是当前线程持有     │               │
│     │   → pexpire KEY 30000       │               │
│     │   → 重置过期时间为30秒       │               │
│     └─────────────────────────────┘               │
│       │                                           │
│       ▼                                           │
│  5. 循环执行,直到客户端主动 unlock                  │
│       │                                           │
│       ▼                                           │
│  6. unlock 时:取消定时任务 + 删除锁                │
└──────────────────────────────────────────────────┘

五、MultiLock 联锁:跨 Redis 实例的锁聚合

5.1 背景:主从一致性问题的延伸

即使有了看门狗,单节点 Redis 仍然存在单点故障风险。如果使用 Redis 主从 + Sentinel 哨兵模式:

java 复制代码
客户端A 在主节点获取锁成功
   ↓
主节点宕机,数据还没同步到从节点
   ↓
哨兵将从节点提升为新主节点
   ↓
客户端B 在新主节点获取同一把锁 → 成功!
   ↓
客户端A 和 B 同时持有同一把锁 → 灾难!

5.2 RedissonMultiLock 的设计思想

Redisson 提供了 MultiLock(联锁) ,可以将多个独立的 RLock 合并成一个逻辑上的"大锁"。每个 RLock 可以来自不同的 Redis 节点 ,只有当所有子锁都加锁成功时,MultiLock 才算加锁成功。

java 复制代码
┌──────────────┐   ┌──────────────┐   ┌──────────────┐
│ Redis Node 1 │   │ Redis Node 2 │   │ Redis Node 3 │
│  lock1 ✓     │   │  lock2 ✓     │   │  lock3 ✓     │
└──────────────┘   └──────────────┘   └──────────────┘
        │                 │                 │
        └─────────────────┼─────────────────┘
                          │
                   ┌──────▼──────┐
                   │  MultiLock   │
                   │  全部成功 →  │
                   │  加锁成功    │
                   └─────────────┘

5.3 使用示例

java 复制代码
// 三个不同的 Redis 实例
RLock lock1 = redissonInstance1.getLock("myLock");
RLock lock2 = redissonInstance2.getLock("myLock");
RLock lock3 = redissonInstance3.getLock("myLock");

// 合并为联锁
RLock multiLock = redisson.getMultiLock(lock1, lock2, lock3);

// 使用方式和普通锁完全一致
multiLock.lock();
try {
    // 业务逻辑
} finally {
    multiLock.unlock();
}
相关推荐
诸葛务农2 小时前
共沸脱水技术及其在光刻胶用PGMEA纯化中的应用(中)
linux·数据库·人工智能
LJianK12 小时前
服务器内存过高排查流程
数据库
李白客2 小时前
SQL Server 迁移注意事项:一次的真实复盘与经验沉淀
数据库·sqlserver·迁移学习
ZC跨境爬虫2 小时前
SQL学习日志 Day_3 :(SELECT查询语句入门)
数据库·sql·学习·oracle
lld9510272 小时前
(二)从验证到执行:策略实时运行全链路
linux·服务器·数据库
ss2732 小时前
ai编程Trae cn生成图书管理系统(1)
java·数据库·spring boot·python·flask·fastapi
AwakeFantasy2 小时前
关于Codex中转站生图比例问题的解决记录
数据库·redis·缓存
tkevinjd2 小时前
事务、ACID与隔离
java·数据库·sql
AI人工智能+电脑小能手2 小时前
【大白话说Java面试题 第91题】【Mysql篇】第21题:分布式锁的使用场景和原理?
java·数据库·分布式·mysql·面试