在上一章我们用 Lua 脚本实现了一个“能用”的分布式锁,但实战一跑,问题很快暴露出来:不可重入、不可重试、锁超时释放以及主从一致性风险。表面上看只是几个角落,实际上是高并发系统里最容易出事故的地方。本篇将把视角从“自己写锁”转向“框架化落地”,引入 Redisson,并深入它的实现原理与核心源码流程,真正弄明白它为什么可靠。

一、Lua 脚本方案的四个痛点

在 Redis 上用 Lua + setnx/expire 做锁的“原型”非常清爽,但代价是稳定性与可维护性的长期欠账。

第一是不可重入。单线程同一业务流程多层调用时,第一次加锁成功,第二次会被视为“抢锁”,直接失败。业务上最常见的表现就是“本来是自己持有的锁,却把自己拒之门外”。

第二是不可重试。Lua 脚本只返回成功或失败,如果失败,调用方只能自己写 while 循环 + sleep,既丑又不精确,也缺乏被唤醒的机制;线程只能靠轮询消耗 CPU。

第三是锁超时释放的风险。为了防止死锁,我们会设置过期时间。但业务耗时无法完全预测,若业务执行超时,锁会被 Redis 自动释放,其他线程趁机进入,导致并发安全失效。

第四是主从一致性问题。在 Redis 主从切换瞬间,可能发生锁写入主节点但未同步到从节点,主节点宕机后从节点晋升,锁记录丢失,从而出现“锁丢失导致并发穿透”。

这些问题并不是某一条 Lua 能解决的,而是锁体系完整性的系统问题。这正是 Redisson 的价值所在。

二、Redisson 是什么?我们如何改造

Redisson 是一个基于 Redis 的 Java 分布式对象与工具库,提供了分布式锁、队列、布隆过滤器、延迟队列等高级特性。它把“锁”从一个 Lua 脚本,升级为一整套完整协议:可重入、可重试、自动续期、看门狗、订阅通知、故障保护。

项目接入非常简单,我们只需要配置 RedissonClient,通过 getLock 取得 RLock,再像本地锁一样调用 lock/unlock

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 1. 配置
Config config = new Config();
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379")
.setPassword("yourPassword");

RedissonClient redissonClient = Redisson.create(config);

// 2. 使用
RLock lock = redissonClient.getLock("lock:order:" + userId);
try {
lock.lock();
// 核心业务逻辑
} finally {
lock.unlock();
}

注意,我们的“改造”并没有在业务层复杂化,核心只是替换锁实现。但真正的价值不在接口层,而在 Redisson 内部的实现机制。

三、Redisson 原理与源码实现(重点)

以下三部分,是理解 Redisson 的灵魂。

3.1 可重入锁原理:同一线程可多次获得锁

Redisson 的可重入锁实现并不是简单的“锁重入计数”,而是把锁的状态存成一个 Redis Hash,用 “线程粒度计数” 来表达“谁持有锁、持有了几层”。它把 lockKey 对应的 value 设计为一个 Hash:

  • key:锁名,例如 lock:order:123
  • field:uuid:threadId(Redisson 客户端实例唯一标识 + 当前线程 id)
  • value:重入次数(同一线程第几次进入)

把这个结构想象成下面这样会更直观:

1
2
3
lock:order:123  (Hash)
├─ 9c1f...:41 -> 2
└─ (没有其他 field)

这表示:只有一个线程持有这把锁9c1f...:41),而且它已经重入了 2 次。

如果你在 Redis 里执行 HGETALL lock:order:123,看到的就是「field-value」对。换句话说,Redis 并不是“把线程放进锁里排队”,它只保存“当前持有者是谁 + 重入次数是多少”。其他线程在抢锁失败时不会写入 Hash(它们只会拿到一个 TTL 然后去等待/订阅)。

加锁时会走一个 Lua 脚本,核心逻辑如下:

  1. 如果锁不存在,设置 hash 并写入重入次数为 1。
  2. 如果锁存在且 field 是当前线程,重入次数 +1。
  3. 否则返回失败。

源码路径大致为:RedissonLock.java -> tryLockInnerAsync,内部通过 EVAL 执行一段 Lua 脚本来保证原子性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
-- 参数约定(Redisson 内部传入)
-- KEYS[1] = 锁的 key,例如:lock:order:123
-- ARGV[1] = 锁的租期(毫秒),例如:30000
-- ARGV[2] = 当前线程标识 field,例如:{uuid}:{threadId}

-- 1) 这把锁 key 不存在:说明“无人持有”
if (redis.call('exists', KEYS[1]) == 0) then
-- 用 Hash 记录持有者:field={uuid:threadId},value=重入次数(1)
redis.call('hset', KEYS[1], ARGV[2], 1)
-- 设置过期时间(毫秒级),防止死锁
redis.call('pexpire', KEYS[1], ARGV[1])
-- 返回 nil 表示加锁成功(Redisson 用 nil 代表 acquired)
return nil
end

-- 2) 锁存在,但持有者就是“我”:允许重入
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
-- 重入次数 +1
redis.call('hincrby', KEYS[1], ARGV[2], 1)
-- 重入也会刷新 TTL,避免业务深层调用导致过期
redis.call('pexpire', KEYS[1], ARGV[1])
return nil
end

-- 3) 锁存在且持有者不是我:返回剩余 TTL(毫秒)
-- pttl = “还剩多久过期”,调用方可据此决定等待/订阅超时时间
return redis.call('pttl', KEYS[1])

可重入的关键,是“同一线程再次获取锁时并不阻塞,而是把自己的 field 的 value 递增”。释放时则相反:每次 unlock 会把 value -1,只有当计数减到 0,才会真正删除 lockKey 并通知其他线程。

你可以把解锁想象为这两步(逻辑上):

  1. HINCRBY lockKey field -1;如果结果 > 0,说明还有重入层,锁仍归我。
  2. 结果 == 0,删除 key(DEL lockKey)并 PUBLISH 唤醒等待者。
1
2
3
4
5
6
7
8
flowchart TD
A[请求加锁] --> B{锁是否存在?}
B -- 否 --> C[HSET field=threadId, value=1]
C --> D[PEXPIRE 设置过期]
B -- 是 --> E{field是否为当前线程?}
E -- 是 --> F[HINCRBY 计数+1]
F --> D
E -- 否 --> G[返回失败/等待]

这解决了 Lua 锁的第一个致命缺陷:可重入。

3.2 锁重试与 WatchDog 机制:安全与性能的双保险

Lua 方案失败就只能 sleep 重试,而 Redisson 把“等待”做成了两件事:该阻塞就阻塞(但不忙等)该续期就续期(但只给真正持有锁的线程续期)

锁重试机制(关键:不忙等)

tryLock 失败时,上面 Lua 会返回一个 TTL(pttl),这对 Redisson 很重要:它让等待变得“有边界”。Redisson 的典型策略是:

  1. 先执行一次 tryAcquire(Lua)。
  2. 如果返回 nil,说明拿到锁,直接进入业务。
  3. 如果返回 TTL(比如 17842ms),说明被别人占着。
  4. 这时不会 while(true){sleep(50)} 轮询,而是:
    • 订阅解锁通知频道(内部是 redisson_lock__channel:{lockKey} 这类命名)
    • 在本地用一个“信号量/门闩”阻塞等待
    • 等到两种事件之一发生:
      • 收到 unlockPUBLISH 通知:立刻醒来,马上重试
      • TTL 时间到了还没通知:也会醒来重试(避免漏通知/网络抖动导致永久睡死)

这样做的结果是:等待线程几乎不消耗 CPU,并且在锁释放的那一刻能快速响应。

WatchDog 机制(关键:只给“仍然持有锁”的线程续期)

手写 Lua 锁通常会写死一个过期时间:比如 10 秒。业务一旦超过 10 秒,锁就“自然死亡”,并发安全直接失效。Redisson 的处理更贴近生产:

  • 如果你调用的是 lock()(不指定 leaseTime),Redisson 默认开启 WatchDog。
  • 默认租期通常是 30s(由 lockWatchdogTimeout 控制)。
  • WatchDog 会启动一个定时续期任务,典型续期间隔约为租期的 1/3(例如 30s 的租期,每 10s 续一次)。

续期时并不是无脑 PEXPIRE lockKey 30000,而是会先确认“锁还归我”。内部同样靠 Lua 保证原子性,大意是:

  1. HEXISTS lockKey field 必须为 1(我仍是持有者)
  2. 才会 PEXPIRE 刷新 TTL
  3. 否则不续期(说明锁已释放/已转移,WatchDog 立刻停止)

这让 WatchDog 成为一个非常克制的机制:只要业务线程还在、且锁还在自己名下,锁就不会因为“固定 TTL”而误释放;一旦业务线程崩溃或执行结束,续期自然停止,锁也会在 TTL 到期后释放,避免死锁。

还有一个容易忽略的点:如果你显式调用 lock(leaseTime, unit)tryLock(waitTime, leaseTime, unit) 指定了租期,Redisson 通常不会启用 WatchDog(因为你已经明确声明了租期)。这能避免无限续期带来的资源占用,同时把超时语义交给业务。

源码中关键点(便于你带着目的去看源码):

  • 竞争锁:RedissonLock.lock()lockInterruptibly()tryAcquire()/tryAcquireAsync()
  • 订阅与唤醒:内部 LockPubSub(订阅 channel,收到消息释放本地门闩)
  • WatchDog 启动:scheduleExpirationRenewal()
  • WatchDog 续期:renewExpiration()(Lua 校验持有者后再 PEXPIRE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
sequenceDiagram
participant T as 业务线程
participant R as Redis
participant W as WatchDog
T->>R: tryLock (Lua)
alt 成功
T->>W: scheduleExpirationRenewal
loop 每10s左右
W->>R: PEXPIRE续期
end
else 失败
T->>R: SUBSCRIBE lockChannel
R-->>T: 收到unlock通知
T->>R: tryLock 重试
end

这一套机制让“重试”不再靠 sleep,性能更优;也让“超时释放”不再由固定 TTL 决定,而是与业务线程生命周期绑定,极大提升了安全性。

3.3 MultiLock 机制:多节点一致性保证

在 Redis 主从切换问题上,单点锁有概率失效。Redisson 提供 MultiLock(也叫联锁),它会将同一把锁分布在多个独立 Redis 实例上,只有当全部实例都加锁成功时,才认为整体加锁成功。否则会释放已加的锁并重试。

核心实现位于 RedissonMultiLock.java,逻辑类似分布式事务中的“两阶段尝试”:

  1. 依次尝试获取所有子锁。
  2. 若有任意子锁失败,则释放已获取的锁并等待重试。
1
2
3
4
5
6
flowchart TD
A[请求MultiLock] --> B[依次获取N个子锁]
B --> C{是否全部成功?}
C -- 是 --> D[返回加锁成功]
C -- 否 --> E[释放已成功的子锁]
E --> F[等待/重试]

它的优势在于“抗单点故障”,代价是性能下降与复杂度增加,因此只用于高价值场景(如资金、订单扣减)。在企业落地中,一般先用单实例 RedissonLock,极端场景再升级到 MultiLock 或 RedLock。

四、总结:别再手写锁,选择合适框架更重要

Lua 分布式锁让我们理解了锁的本质,但它解决不了工程化落地的全套问题。Redisson 把“锁”做成了一个体系:可重入、可重试、自动续期、锁通知、联锁保障,真正适用于生产环境。

企业开发中,最可贵的不是“自己写出来”,而是“用对正确的框架”。选对框架,意味着安全、稳定、可维护,意味着团队把精力放在业务增长而不是基础设施踩坑上。这正是 Redisson 给我们的核心启示。