Redisson 分布式锁:从原理到实战

Redisson 分布式锁:从原理到实战

> 一次把分布式锁的所有坑踩完

目录


一、为什么需要分布式锁

单机环境下,用 synchronizedReentrantLock 就能解决线程安全问题。但在分布式系统中,多个 JVM 进程同时访问共享资源(如数据库同一条记录、库存扣减等),Java 内置锁就不够用了——它只在单个 JVM 内有效。

分布式锁需要满足以下几个核心条件:

1. 互斥性:同一时刻只有一个客户端能持有锁

2. 防死锁:即使持有锁的客户端崩溃,锁也能被释放

3. 锁的归属:谁加的锁,只能由谁来释放

4. 可重入:同一个客户端可以对同一把锁多次加锁

5. 高可用:锁服务本身不能成为单点

Redis 凭借单线程模型 + SETNX 原子操作,天然适合做分布式锁。但裸用 Redis 命令很容易踩坑,Redisson 把这些坑全部填平了。


二、Redis 分布式锁的演进之路

2.1 第一代:SETNX + 手动过期

最早的做法是用 SETNX(SET if Not eXists)命令:


// 加锁
Boolean locked = jedis.setnx("lock:order:1001", "thread-1");
if (locked) {
    try {
        // 业务逻辑
    } finally {
        // 释放锁
        jedis.del("lock:order:1001");
    }
}

致命缺陷: 如果业务逻辑抛异常或者进程崩溃,finally 块没执行,锁永远不会被释放 → 死锁

2.2 第二代:SET NX EX 原子命令

给锁加上过期时间:


// Redis 2.6.12+ 支持 SET + NX + EX 原子操作
jedis.set("lock:order:1001", "thread-1", "NX", "EX", 30);

这一步解决了死锁问题:即使客户端崩溃,30 秒后锁自动过期释放。

新问题: 业务执行时间如果超过 30 秒,锁自动释放了,其他线程就能拿到锁,导致并发安全问题。而且锁被其他线程持有后,原线程执行完还会误删别人的锁。

2.3 第三代:Lua 脚本保障原子性

释放锁时必须保证"判断归属 + 删除"是原子操作:


-- 释放锁的 Lua 脚本
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

Java 端调用:


String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                "return redis.call('del', KEYS[1]) else return 0 end";
jedis.eval(script, Collections.singletonList("lock:order:1001"),
           Collections.singletonList("thread-1"));

这样就不会出现"先判断再删除"之间被其他线程插入的问题。

2.4 第四代:Redisson 完整框架

Redisson 在上述基础上做了完整封装:

  • 可重入锁(同一个线程多次加锁不会死锁自己)
  • 看门狗自动续期(不用担心业务超时锁被释放)
  • 公平锁 / 红锁 / 读写锁 / 联锁等丰富变体
  • 基于 Redis 发布订阅的等待线程唤醒机制

下面深入看 Redisson 是怎么做的。


三、Redisson 核心原理

3.1 底层数据结构

Redisson 可重入锁在 Redis 中用 Hash 结构存储:


KEY: "redisson_lock:myLock"
VALUE (Hash):
  {
    "uuid:threadId": 1   // value 是重入计数
  }
  • Key 是锁的名称(带前缀 redisson_lock:
  • Hash 的 fieldUUID:线程ID,唯一标识哪个客户端的哪个线程持有了锁
  • Hash 的 value 是重入计数器,第一次加锁值为 1,重入一次加 1,释放时减 1

为什么用 Hash 而不是 String?

1. String 只能存一个值,无法区分哪个线程持有锁

2. Hash 天然支持 field 级别的原子增减,方便实现可重入

3. 多个线程等待同一把锁时,Hash 可以扩展为排队队列

3.2 加锁流程

Redisson 加锁的核心逻辑简化后如下(Lua 脚本):


-- 尝试加锁
if (redis.call('exists', KEYS[1]) == 0) then
    -- 锁不存在,可以加锁
    redis.call('hincrby', KEYS[1], ARGV[2], 1);  -- 重入计数+1
    redis.call('expire', KEYS[1], ARGV[1]);       -- 设置过期时间
    return nil;                                    -- 返回 nil 表示加锁成功
end;

-- 锁已存在,判断是否是当前线程(可重入)
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);  -- 重入计数+1
    redis.call('expire', KEYS[1], ARGV[1]);       -- 刷新过期时间
    return nil;                                    -- 可重入成功
end;

-- 锁被其他线程持有,返回剩余 TTL
return redis.call('pttl', KEYS[1]);

加锁流程图:


加锁请求
  │
  ├─ 锁不存在 → 创建 Hash field,计数=1,设过期时间 → 成功返回
  │
  ├─ 锁存在,且 field 匹配 → 计数+1,刷新过期时间 → 成功返回(可重入)
  │
  └─ 锁存在,且 field 不匹配 → 返回剩余 TTL → 客户端订阅 Pub/Sub → 阻塞等待

等待线程通过 Redis 发布订阅机制监听锁释放事件,而不是轮询。这比轮询高效得多。

3.3 释放锁流程

释放锁的 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);

-- 计数 > 0,说明还在重入范围内,只刷新过期时间
if (counter > 0) then
    redis.call('expire', KEYS[1], ARGV[2]);
    return 0;
-- 计数 = 0,彻底释放锁,删除 key,并发布解锁消息
else
    redis.call('del', KEYS[1]);
    redis.call('publish', KEYS[2], ARGV[1]);  -- 唤醒等待线程
    return 1;
end;
return nil;

关键点:

1. 先判断归属,不是自己的锁不操作(解决锁误删)

2. 可重入计数 -1,没归零就不真正删除(支持可重入)

3. 真正释放后通过 publish 唤醒等待线程


四、Redisson 解决了哪些问题

4.1 死锁问题:过期时间兜底

问题描述:客户端加锁后崩溃,锁永远不会被释放,其他所有线程永久阻塞。

Redisson 的方案:每次加锁都会设置过期时间。默认是 lockWatchdogTimeout(30 秒)。


// Redisson 源码常量
private static final long LOCK_EXPIRATION_INTERVAL = 30_000L; // 30秒

即使客户端进程直接 kill -9,最多 30 秒后锁自动释放。

但光有过期时间还不够——如果业务执行超过 30 秒怎么办?这就引出了看门狗机制。

4.2 锁误删问题:Lua 原子性保障

问题场景


线程 A 加锁,设置过期 10 秒
线程 A 业务执行很慢,10 秒后锁自动过期释放
线程 B 拿到锁,开始执行业务
线程 A 终于执行完,执行 del 命令 → 把线程 B 的锁删了!
线程 C 也拿到锁... 并发灾难

Redisson 的方案:释放锁时,用 Lua 脚本保证"判断归属 + 删除"原子执行:


if redis.call('hexists', KEYS[1], ARGV[3]) == 0 then
    return nil  -- 不是自己的锁,直接返回,不删
end

因为 Lua 脚本在 Redis 中是原子执行的,不存在"判断完、删除前"被其他线程插入的可能。

但要注意:Redisson 的 Lua 方案解决的是主动释放时的误删。如果业务超时导致锁被看门狗放弃续期、自动过期后误删,Redisson 本身无法阻止。这是所有基于 Redis 的分布式锁都无法彻底解决的问题,只能靠合理设置超时时间来规避。

4.3 可重入性:Hash 计数器

什么是可重入:同一个线程对同一把锁多次加锁,不会把自己阻塞住。

Java 的 ReentrantLock 就是可重入的,Redisson 也实现了这一点。

实现原理


锁不存在:
  HSET lockName "uuid:threadId" 1
  EXPIRE lockName 30

同一个线程再次加锁(可重入):
  HINCRBY lockName "uuid:threadId" 1  → 值变为 2
  EXPIRE lockName 30

第一次释放:
  HINCRBY lockName "uuid:threadId" -1  → 值变为 1
  EXPIRE lockName 30  (还没归零,只刷新过期时间)

第二次释放:
  HINCRBY lockName "uuid:threadId" -1  → 值变为 0
  DEL lockName  (归零,真正删除)

为什么需要可重入?看一个典型场景:


RLock lock = redisson.getLock("inventory");

public void methodA() {
    lock.lock();
    try {
        methodB();  // methodB 也会尝试加同一把锁
    } finally {
        lock.unlock();
    }
}

public void methodB() {
    lock.lock();  // 如果不可重入,这里就把自己阻塞了
    try {
        // 扣减库存
    } finally {
        lock.unlock();
    }
}

4.4 看门狗机制:自动续期

问题:业务执行时间不确定。设太短的过期时间,锁可能在业务执行中自动释放;设太长,客户端崩溃后其他线程要等很久。

Redisson 的方案:看门狗(WatchDog)——后台线程定时续期。

工作流程


加锁成功(默认 30 秒过期)
  │
  ▼
启动后台线程(看门狗)
  │
  ▼
每隔 10 秒(30 / 3)检查一次
  ├─ 当前线程还持有锁 → EXPIRE 刷新到 30 秒
  └─ 当前线程已释放锁 → 停止续期,线程退出

看门狗核心代码逻辑(简化版):


// Redisson 源码中的续期调度
private void scheduleExpirationRenewal(long threadId) {
    renewalTask = new TimeoutTask();
    renewalTask = commandExecutor.getConnectionManager()
        .newTimeout(new TimerTask() {
            @Override
            public void run(Timeout timeout) {
                // Lua 脚本续期:刷新过期时间为 30 秒
                RFuture<Boolean> future = commandExecutor.evalWriteAsync(
                    getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
                    "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                    "    redis.call('expire', KEYS[1], ARGV[1]); " +
                    "    return 1; " +
                    "end; " +
                    "return 0;",
                    Collections.singletonList(getName()),
                    internalLockLeaseTime, getLockName(threadId)
                );

                future.onResponse((res, e) -> {
                    if (res && !e) {
                        // 续期成功,10 秒后再执行
                        scheduleExpirationRenewal(threadId);
                    }
                });
            }
        }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
}

关键参数

| 参数 | 默认值 | 说明 |

|------|--------|------|

| lockWatchdogTimeout | 30 秒 | 锁的默认过期时间 |

| 续期间隔 | 10 秒 | lockWatchdogTimeout / 3 |

| 续期条件 | 当前线程仍持有锁 | 通过 HEXISTS 判断 |

看门狗的局限

1. 只有调用 lock()(不传过期时间)时才启动看门狗

2. 调用 lock(leaseTime, TimeUnit) 指定过期时间时,看门狗不启动

3. 如果 Redis 主从切换导致锁丢失,看门狗也无力回天(这是 RedLock 解决的问题)

实际使用建议


// ✅ 推荐:使用看门狗,不传过期时间
lock.lock();

// ❌ 指定过期时间,看门狗不启动,超时后锁自动释放
lock.lock(10, TimeUnit.SECONDS);

五、Redisson 进阶知识点

5.1 公平锁实现

Redisson 的公平锁基于 Redis 的 List + Pub/Sub 实现排队:


RLock fairLock = redisson.getFairLock("myLock");
fairLock.lock();

原理

1. 客户端尝试加锁时,将自己的 clientId 追加到 List 尾部:RPUSH lockName:queue clientId

2. 检查自己是否是 List 的第一个元素

  • 是 → 加锁成功
  • 不是 → 订阅该锁的 Pub/Sub 频道,阻塞等待

3. 释放锁时,弹出 List 第一个元素,并通过 Pub/Sub 通知下一个等待者

相比非公平锁(直接竞争,先到先得无保证),公平锁按请求顺序获取锁,适合对顺序敏感的场景。

5.2 红锁 RedLock

RedLock 是 Redis 作者 Antirez 提出的多 Redis 实例分布式锁算法:


RLock lock1 = redisson1.getLock("myLock");
RLock lock2 = redisson2.getLock("myLock");
RLock lock3 = redisson3.getLock("myLock");

RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
redLock.lock();

算法核心

1. 依次向 N 个独立的 Redis 实例加锁(5 个)

2. 计算加锁总耗时

3. 如果在 过半 实例上加锁成功,且总耗时 < 锁有效时间,则加锁成功

4. 否则向所有实例释放锁,重新尝试

争议:Martin Kleppmann 曾发论文质疑 RedLock 的正确性(时钟跳跃、GC 停顿等场景下仍可能出问题)。但在大多数生产环境中,RedLock 已经比单实例方案安全得多。

5.3 读写锁

Redisson 支持读写锁(ReadWriteLock),读读不互斥,读写/写写互斥:


RReadWriteLock rwLock = redisson.getReadWriteLock("myLock");
RLock readLock = rwLock.readLock();
RLock writeLock = rwLock.writeLock();

readLock.lock();   // 多个读线程可同时持有
writeLock.lock();  // 写线程独占

底层实现

  • 读锁:Redis Hash 中记录多个 reader 的 clientId,只要存在 reader,写锁就无法获取
  • 写锁:独占,有 reader 或 writer 存在时都不能获取

5.4 联锁 MultiLock

MultiLock 要求所有锁实例都加锁成功才算成功(与 RedLock 的"过半"不同):


RLock lock1 = redisson1.getLock("myLock");
RLock lock2 = redisson2.getLock("myLock");

RedissonMultiLock multiLock = new RedissonMultiLock(lock1, lock2);
multiLock.lock();  // lock1 和 lock2 都必须成功

5.5 信号量 Semaphore

Redisson 基于 Redis 实现了信号量:


RSemaphore semaphore = redisson.getSemaphore("mySemaphore");
semaphore.trySetPermits(5);  // 设置 5 个许可

semaphore.acquire();  // 获取一个许可
// 业务逻辑...
semaphore.release();  // 释放一个许可

底层用 Redis Hash 存储许可数量,通过 Lua 脚本原子增减。


六、生产环境避坑指南

坑 1:指定过期时间导致看门狗不启动


// ❌ 看门狗不启动,业务超时后锁自动释放
lock.lock(5, TimeUnit.SECONDS);

// ✅ 让看门狗管理
lock.lock();

坑 2:Redis 主从切换导致锁丢失

单实例 Redis 的 AOF 默认是 everysec,最多丢 1 秒数据。主从异步复制,主节点加锁后如果还没同步到从节点就宕机,新主节点上就没有这把锁。

解决:使用 RedLock 部署多实例,或接受 Redis 分布式锁的 CAP 取舍(Redis 选 AP,不是强一致)。

坑 3:锁粒度太粗


// ❌ 锁住整个库存
RLock lock = redisson.getLock("inventory");

// ✅ 锁住具体商品
RLock lock = redisson.getLock("inventory:product:1001");

坑 4:忘记释放锁或异常路径没释放


// ❌ 业务异常时锁不释放
lock.lock();
doSomething();   // 抛异常 → finally 没执行
lock.unlock();

// ✅ 标准写法
lock.lock();
try {
    doSomething();
} finally {
    // 只有当前线程持有锁才释放
    if (lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

坑 5:大量线程竞争同一把锁导致 Redis 压力

每个加锁请求都是一次 Redis 命令调用,高并发场景下可能成为瓶颈。

建议

  • 锁的 key 尽量分散(按业务维度拆分)
  • 业务执行时间尽量短,减少锁持有时间
  • 如果并发极高,考虑改用分段锁或其他并发方案

坑 6:Redisson 版本 bug

老版本 Redisson 存在续期任务内存泄漏、Pub/Sub 断连等问题。建议 3.17.0+


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

七、总结

Redisson 分布式锁的核心知识点可以归纳为四句话:

| 问题 | 解决方案 | 实现手段 |

|------|----------|----------|

| 死锁 | 过期时间兜底 | EXPIRE 命令 |

| 锁误删 | 判断归属再删除 | Lua 脚本原子执行 |

| 可重入 | 同一线程多次加锁 | Hash 计数器 + HINCRBY |

| 业务超时锁被释放 | 自动续期 | 看门狗后台线程,每 10 秒续期一次 |

设计取舍:Redis 分布式锁本质是 AP 模型(可用 + 分区容忍),不是强一致的。在大多数业务场景(库存扣减、防止重复提交、定时任务互斥等)完全够用。如果你的场景要求强一致(如金融级),需要考虑 ZooKeeper 或 etcd 实现的分布式锁(CP 模型)。

一句话总结:Redisson 在 Redis 原生命令的基础上,用 Hash 结构实现了可重入,用 Lua 脚本保障了原子性,用后台线程实现了看门狗续期,用 Pub/Sub 实现了高效等待——这些加起来才是一个生产可用的分布式锁。

posted @ 2026-05-07 14:37  xzlrf  阅读(253)  评论(0)    收藏  举报