Redisson 使用手册:从 API 误区到看门狗失效,在此终结分布式锁的噩梦

写在前面

在上一篇《分布式锁的代价与选择:为什么我们最终拥抱了Redisson?》中,我们聊到了手写 SETNX 的"茹毛饮血"时代。既然选择了 Redisson,就意味着我们已经告别了那些让人提心吊胆的死锁噩梦。

很多时候,我们以为只是调用了一个简单的 lock.lock(),但背后其实是一整套复杂的自动续期Lua 脚本原子执行发布订阅机制在默默支撑。

这篇文章不讲虚的,我们从常用的 API 起手,一路通过生产环境的避坑实战 ,最后钻进底层数据结构与 Lua 源码里,把 Redisson 彻底扒个干干净净。


一、不仅是 Lock 这么简单:核心 API 全景

Redisson 之所以受欢迎,是因为它把分布式锁封装成了我们最熟悉的 java.util.concurrent.locks.Lock 接口风格,极大地降低了学习成本 。但除了最基础的 lock(),还有核心功能是你必须掌握的。

1. 基础那把锁:RLock

这是 90% 场景下 的默认选择。它对应 Redis 底层的 Hash 结构。

java 复制代码
RLock lock = redisson.getLock("order:1001");
lock.lock(); // 阻塞式等待,默认 30秒过期,自带看门狗
try {
   // 业务逻辑
} finally {
   lock.unlock();
}

2. 更聪明的锁:tryLock (⚡️推荐)

在实际业务中,我们往往不希望线程无限死等,浪费资源。这里有两种常见姿势:

姿势 A:要等待 + 启用看门狗 (最常用)

只指定 waitTime,不指定 leaseTime。这是既想要非阻塞(或有限等待),又想要自动续期的最佳实践。

java 复制代码
// 参数1:wait time,我只愿意排队 3秒,拿不到就走人
// 参数2:时间单位
// 重点:没传 leaseTime,所以看门狗机制会自动生效!
boolean res = lock.tryLock(3, TimeUnit.SECONDS);

if (res) {
   try {
     // 处理业务(哪怕跑 5分钟 也不怕锁过期)
   } finally {
     lock.unlock();
   }
} else {
   log.warn("抢锁失败,别挤了!");
}

姿势 B:要等待 + 自动过期 (慎用)

指定了 leaseTime,看门狗会失效。

java 复制代码
// 参数1:wait time,排队 3秒
// 参数2:lease time,上锁后 10秒 自动强制释放(注意:指定 leaseTime 会让看门狗失效!)
// 参数3:时间单位
boolean res = lock.tryLock(3, 10, TimeUnit.SECONDS);

if (res) {
   try {
     // 处理业务,必须保证在 10秒 内完成!
   } finally {
     lock.unlock();
   }
}

3. 文明的排队:公平锁 FairLock

默认的锁是非公平的(Non-Fair),线程抢锁全靠 CPU 调度,谁快谁得。但如果你的业务要求"先来后到"(比如抢票排队),请务必使用公平锁。

java 复制代码
// 内部利用 Redis 的 List(作为线程等待队列)和 Hash(作为超时记录)实现
RLock fairLock = redisson.getFairLock("ticket:queue");
fairLock.lock();

4. 读多写少的神器:读写锁 ReadWriteLock

这个场景太经典了:商品详情页,读的人多(10000次/秒),改库存的人少(1次/秒)。如果全互斥,性能直接崩盘。

java 复制代码
RReadWriteLock rwLock = redisson.getReadWriteLock("product:stock:101");

// 读锁:多个线程可以同时加读锁,只要没有写锁
rwLock.readLock().lock();

// 写锁:必须等所有读锁和写锁都释放了才能加,全互斥
rwLock.writeLock().lock();

5. 联锁 MultiLock (原子性加多把锁)

有时候我们需要同时锁定多个资源,比如"库存"和"余额",要么都锁住,要么都不锁,防止死锁

java 复制代码
RLock lock1 = redisson.getLock("lock:order");
RLock lock2 = redisson.getLock("lock:stock");
// 同时加锁:lock1 lock2
RedissonMultiLock lock = new RedissonMultiLock(lock1, lock2);
lock.lock();

二、扒开底层:Hash 结构与 Lua 脚本

以下源码基于 Redisson 3.16+ 版本(目前生产环境主流版本)分析。

Redisson 为什么能实现可重入锁 ?为什么它比我们自己写的 SETNX 强?

答案藏在 Redis 的数据结构里。Redisson 并没有使用简单的 String 类型,而是使用了 Hash

1. Redis 里的样子

假设我们对 order:1001 加锁,Redis 里实际存储的数据长这样:

bash 复制代码
KEY: order:1001
TYPE: Hash

# hash 对应 value 内容
{
    "UUID:ThreadID" : 1  # 锁的持有者 : 重入次数
}
  • KEY: 锁的名字。
  • FIELD (Key): UUID:ThreadId。这里由客户端生成的唯一 UUID 加上当前线程 ID 拼接而成。为什么要加 UUID? 因为不同服务器上的 JVM 进程 ID 可能一样,必须通过客户端启动时生成的 UUID(ConnectionManagerId)来唯一标识一个 Redisson 实例。
  • VALUE : 1。这是重入计数器。如果同一个线程再 lock 一次,这里变成 2。

2. 加锁的 Lua 脚本

Redisson 为了保证一系列判断和写入是原子的,把它封装在 Lua 脚本里发给 Redis。

lua 复制代码
-- KEYS[1] = 锁名称
-- ARGV[1] = 过期时间 (默认 30000ms)
-- ARGV[2] = 锁持有者唯一ID (UUID:ThreadId)

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

-- 情况 2:锁存在,且持有者就是我(重入)
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    -- 重入次数 +1
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    -- 重新续期
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end;

-- 情况 3:锁存在,但不是我
-- 返回当前锁还剩多少毫秒过期,方便客户端等待
return redis.call('pttl', KEYS[1]);

这段脚本完美解释了:

  1. 原子性:这一大坨逻辑在 Redis 里是原子执行的,不会插队。
  2. 可重入 :通过 hexists 判断是不是自己,是的话就 hincrby
  3. 互斥性:如果既不是新锁,也不是自己的锁,直接返回剩余时间,让你可以去睡一会儿再来。

三、拆开看门狗的黑盒:源码漫游

经常听说"看门狗",它到底长什么样?

其实,它本质上是一个 HashedWheelTimer(时间轮) 驱动的定时任务。

1. 启动入口

当我们调用 lock() 不传时间时,最终会走到这里:

java 复制代码
// RedissonLock.java
private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
    long threadId = Thread.currentThread().getId();
    Long ttl = tryAcquire(leaseTime, unit, threadId);

    // 如果 lock 成功,ttl 会返回 null
    if (ttl == null) {
        return;
    }
    
    // 如果失败,会订阅一个 Redis Channel,等待锁释放的消息(不用死循环空转)
    // ... 省略订阅逻辑
}

关键在 tryAcquireAsync 里:

java 复制代码
private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, long threadId) {
    if (leaseTime != -1) {
        // 如果你传了时间,就按你的时间走,不启动看门狗
        return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    }
    
    // 没传时间(leaseTime = -1)
    // 先设置默认 30秒 过期
    RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
    
    // 加锁成功后,开启续期任务
    ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
        if (e == null) {
           if (ttlRemaining == null) {
               // 重点:启动定时续期
               scheduleExpirationRenewal(threadId);
           }
        }
    });
    return ttlRemainingFuture;
}

2. 续期的无限套娃

scheduleExpirationRenewal 最终会调用 renewExpiration

java 复制代码
private void renewExpiration() {
    // 这里的 1/3 是硬编码的规则
    // 默认 lockWatchdogTimeout 是 30000ms
    // 所以每 10000ms 执行一次
    Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
        @Override
        public void run(Timeout timeout) throws Exception {
            
            // 执行 Lua 脚本,把 ttl 重新刷回 30秒
            RFuture<Boolean> future = renewExpirationAsync(threadId);
            
            future.onComplete((res, e) -> {
                if (res) {
                    // 如果续期成功,这就形成了递归调用:自己调自己
                    renewExpiration();
                }
            });
        }
    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
}

核心逻辑总结

  1. 三分之一原则:每隔锁超时时间的 1/3(默认10秒),检查一次。
  2. 无限递归:只要检查到锁还在,就重置过期时间,并注册下一次检查。
  3. 生死绑定:这个任务跑在客户端进程里,如果客户端宕机,任务停止,Redis 里的锁在 30秒 后自动过期。

四、我在生产环境踩过的坑:避坑实战

API 谁都会调,但能避开坑的才是老司机。这六个坑,都是真金白银换来的教训。

💣 陷阱一:好心办坏事 ------ 弄死看门狗

这是新手最容易犯的错。

❌ 错误姿势

java 复制代码
// 我怕死锁,所以强行指定 10秒 过期
lock.lock(10, TimeUnit.SECONDS); 
// 或者
lock.tryLock(1, 10, TimeUnit.SECONDS);

⚠️ 后果
Redisson 的看门狗(WatchDog)机制只有在你未指定锁过期时间时才会生效!

一旦你手动传了 leaseTime,Redisson 就会认为你有自己的想法,不再插手。如果你的业务因为数据库卡顿跑了 15秒,第 10秒 时锁就会强制过期,其他线程长驱直入,爆发并发事故。

✅ 正确姿势

除非你非常确定业务能在指定时间内跑完,否则尽量不要传 leaseTime,让看门狗帮你自动续期

💣 陷阱二:锁粒度太粗 ------ 全服暂停键

❌ 错误姿势

java 复制代码
// 所有订单共用一把锁
RLock lock = redisson.getLock("LOCK_ORDER");

⚠️ 后果

这相当于把高速公路封成了独木桥。不管有多少个用户下单,同一时间只能处理一个。性能直接归零。

✅ 正确姿势
锁的粒度越细越好。只锁那个具体产生竞争的资源 ID。

java 复制代码
// 只锁这个订单
RLock lock = redisson.getLock("order:pay:" + orderId);

💣 陷阱三:解锁的艺术 ------ 谁加的锁谁来解

❌ 错误姿势

java 复制代码
try {
    // 业务逻辑
} finally {
    lock.unlock(); // 直接解锁
}

⚠️ 后果

  1. 如果业务执行超时,锁已经被自动释放了,你再去 unlock 会抛出 IllegalMonitorStateException
  2. 如果不小心解了别人的锁(虽然 Redisson 有 ID 校验防止误删,但异常处理依然重要)。

✅ 正确姿势

java 复制代码
if (lock.isLocked() && lock.isHeldByCurrentThread()) {
    lock.unlock();
}

💣 陷阱四:重入锁的"递归噩梦"

Redisson 的锁虽然是可重入的(Reentrant),但如果你在递归或嵌套调用中不注意,很容易逻辑混乱。

❌ 风险代码

java 复制代码
void methodA() {
    lock.lock();
    try {
        methodB(); // methodB 里又 lock 了一次
    } finally {
        lock.unlock(); // 只解了一层
    }
}

⚠️ 后果

Redis 里的锁计数器(Counter)如果不归零,锁是不会释放的。确保你的加锁次数和解锁次数严格匹配

💣 陷阱五:主从切换的"幽灵锁"

这是 Redis 架构天生的短板。

  1. Client A 在 Master 节点拿到了锁。
  2. Master 还没来得及把锁同步给 Slave,就宕机了。
  3. Slave 升级为新的 Master。
  4. Client B 来加锁,发现新 Master 上没锁,于是也加锁成功

⚠️ 后果

A 和 B 同时持有了锁。
解法 :如果你不能容忍这个概率(极低),请看下文的 RedLock,或者转投 Zookeeper。对于 99% 的业务,我们选择接受这个风险。


五、RedLock 的爱恨情仇

有些面试官特别喜欢问 RedLock,但在实际工作中,它是一个让人爱恨交加的存在。

1. 它是为了解决什么?

解决 Redis 主从集群在 Failover(故障转移)时可能丢锁的问题。

2. 怎么用?

你需要准备 3个或5个 完全独立的 Redis 实例(不是 Cluster,不是 Sentinel,就是干干净净的单实例)。

java 复制代码
RLock lock1 = redissonInstance1.getLock("lock");
RLock lock2 = redissonInstance2.getLock("lock");
RLock lock3 = redissonInstance3.getLock("lock");

// 创建红锁
RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);

try {
    // 同时向 3个 Redis 申请锁
    // 只要有 > 1.5个 (即2个) 申请成功,就算赢
    lock.lock();
    // 业务逻辑
} finally {
    lock.unlock();
}

3. 灵魂拷问:值得吗?

我的看法是:不值得

  • 运维成本飙升:为了个锁,我要多维护好几个独立的 Redis?
  • 性能打折:串行或者并发去多个节点请求,网络开销大。
  • 并非绝对安全:Martin Kleppmann 指出,如果发生 STW(Stop-The-World)GC,或者时钟发生跳跃,RedLock 依然可能失效。

建议

如果你在做银行核心账务系统,请用 ZookeeperEtcd

除此之外的 99% 的场景,Redisson 配合主从集群 已经足够优秀了。


结语

很多时候,我们在技术选型时容易陷入"既要又要"的怪圈。但软件工程的本质,就是权衡(Trade-off)。

Redisson 不是神,它只是一把被打磨得足够锋利的刀。它不能解决所有的一致性问题,但它在易用性性能可靠性之间找到了一个极佳的平衡点。

希望这篇文章能帮你不仅"会用"锁,更能"懂"锁。愿你的系统在洪峰流量下,依然稳如泰山;愿你的代码,既有逻辑的骨架,又有温度的血肉。、


文章的最后,想和你多聊两句。

技术之路,常常是热闹与孤独并存。那些深夜的调试、灵光一闪的方案、还有踩坑爬起后的顿悟,如果能有人一起聊聊,该多好。

为此,我建了一个小花园------我的微信公众号「[努力的小郑]」。

这里没有高深莫测的理论堆砌,只有我对后端开发、系统设计和工程实践的持续思考与沉淀。它更像我的数字笔记本,记录着那些值得被记住的解决方案和思维火花。

如果你觉得今天的文章还有一点启发,或者单纯想找一个同行者偶尔聊聊技术、谈谈思考,那么,欢迎你来坐坐。

愿你前行路上,总有代码可写,有梦可追,也有灯火可亲。