Redisson 分布式锁:SET NX EX 的 3 个坑和 Lua 脚本怎么填的
一句话结论(30s)
Redisson 分布式锁的本质是用「Lua 原子性 + Hash 数据结构」填平手写 SET NX EX 的三个坑:因为 Redis 单线程执行 Lua,校验 owner 和操作 key 之间不再有窗口,所以解决了超时误删、不可重入、释放非原子;再靠 Watch Dog 自动续期解决「锁到期但业务没跑完」。
核心原理(2min)
锁用 Hash 存储(field=UUID:threadId,value=重入计数,同一 JVM 不同线程各自计数)。加锁 Lua 原子完成三分支:key 不存在则 hincrby+1 并 pexpire 加锁;key 存在且 owner 是自己则计数 +1 实现重入;否则返回剩余 TTL 表示失败。释放锁 Lua 原子 hincrby -1,计数归 0 才 DEL 并 PUBLISH 通知等锁方。Watch Dog 默认 leaseTime=-1,用 Netty 时间轮每 leaseTime/3(10s)续期,JVM 崩溃即停止续期、锁 30s 自动释放不死锁;等锁靠订阅 Pub/Sub 而非忙轮询,避免 1000 线程打爆 Redis。
底层深入(5-10min)
手写 SET NX EX 的 3 个经典坑
// 看起来天衣无缝的手写锁
String lockKey = "order:1001";
String lockValue = UUID.randomUUID().toString();
// 加锁
Boolean locked = redis.set(lockKey, lockValue, SetArgs.Builder.nx().ex(30));
// 释放锁
if (lockValue.equals(redis.get(lockKey))) {
redis.del(lockKey); // ← 这里有坑!
}
坑 1:锁超时后业务仍在运行
加锁设置了 30 秒超时,但业务逻辑跑了 35 秒(一次慢 GC 或者外部调用超时)。锁自动过期后,第二个线程拿到了同一把锁,两个线程同时操作同一资源。
坑 2:释放锁时误删别人的锁
校验 owner 和删除 key 之间存在时间窗口:
线程A: if (owner == myUUID) ← 校验通过
↓ ← 此时锁过期,线程B 拿到锁,写入 B 的 UUID
线程A: redis.del(key) ← 删除的是 B 的锁!
思考:为什么「先校验 owner 再删」会有误删窗口?因为「校验」和「删除」是两条独立命令,中间不是原子的——线程 A 校验通过后、DEL 执行前,锁可能刚好过期,线程 B 拿到了锁、写入了自己的 UUID,A 再 DEL 就删掉了 B 的锁。根源是「判断」和「动作」被拆开了,中间插得进别人的写。要消除这个窗口,必须让「判断 owner + 删除」在同一个原子步骤里完成,这正是后面 Lua 脚本的动机。
坑 3:不可重入
同一个线程调用链 OrderService.createOrder() → VoucherService.deduct() 需要两次获取同一把锁——手写 SET NX 第二次直接返回 false(key 已存在),死锁。
Redisson 的解法:一段 Lua 脚本解决 3 个坑
-- Redisson 加锁 Lua 脚本(简化版)
local key = KEYS[1] -- 锁的 key
local ttl = ARGV[1] -- 锁超时时间
local owner = ARGV[2] -- UUID:threadId
-- 1. 不存在 → 直接加锁
if (redis.call('exists', key) == 0) then
redis.call('hincrby', key, owner, 1)
redis.call('pexpire', key, ttl)
return nil -- 加锁成功
end
-- 2. 已存在且 owner 是自己 → 可重入(计数器 +1)
if (redis.call('hexists', key, owner) == 1) then
redis.call('hincrby', key, owner, 1)
redis.call('pexpire', key, ttl)
return nil -- 重入成功
end
-- 3. 已存在且 owner 不是自己 → 加锁失败
return redis.call('pttl', key) -- 返回剩余 TTL
数据结构
锁的 Redis 数据结构是 Hash,不是 String:
Key: "order:1001"
Hash fields:
"a1b2c3d4:thread-1" → 2 (重入 2 次)
"a1b2c3d4:thread-2" → 1
为什么用 Hash + UUID:threadId:同一个 JVM 实例内的不同线程(thread-1 和 thread-2)各自计重入次数,解锁时 HINCRBY -1,计数归 0 才真删 key。
思考:为什么锁用 Hash 而不是 String,value 还要带 UUID:threadId?顺着两个需求推——重入要计数:String 只有一个 value,没法给「同一把锁」记「加了几次」,Hash 的 field=owner、value=重入次数,天然支持计数;释放要校验身份:UUID 区分不同 JVM 实例、threadId 区分同 JVM 内不同线程,field=UUID:threadId 让「谁的锁谁才能减」,避免 A 线程误删 B 线程的锁。一个 Hash 结构同时满足「重入计数」和「归属校验」两个诉求。
释放锁(同样 Lua 原子)
if (redis.call('hexists', key, owner) == 0) then
return nil -- 不是自己的锁,不操作
end
local counter = redis.call('hincrby', key, owner, -1)
if (counter > 0) then
return 0 -- 还是重入状态
else
redis.call('del', key) -- 完全释放
redis.call('publish', 'redisson_lock__channel:{' .. key .. '}', 0)
return 1
end
Watch Dog 自动续期
Redisson 默认 leaseTime = -1(看门狗模式),底层用 Netty HashedWheelTimer 每 leaseTime/3(默认 10 秒)触发续期:
// 源码简化
private void renewExpiration() {
Timeout task = timer.newTimeout(timeout -> {
if (isLocked()) {
// Lua: 如果 key 存在且 owner 匹配 → EXPIRE key 30s
renewExpirationAsync(threadId);
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
}
关键:如果 JVM 崩溃,Watch Dog 停止续期,锁最多 30 秒后自动释放——不会死锁。
思考:为什么 Watch Dog 要每 leaseTime/3(10s)续期一次,而不是到期前最后一刻续?因为它要留出「续期失败的重试余量」——如果等到快到期才续,一旦续期请求抖动或延迟,锁就可能在业务没跑完时过期、别的线程趁虚而入;每 1/3 周期提前续,等于给续期留了 2/3 周期的缓冲。而 JVM 崩溃后续期线程也停了,锁 30s 自动释放、不会死锁——「自动续期」和「崩溃自动释放」由同一套时间轮机制保证。
加锁失败不是忙轮询
1000 并发等同一把锁时,Redisson 订阅 Redis Pub/Sub 频道,持锁方解锁时 PUBLISH 通知,等锁方收到通知再尝试加锁。避免 1000 个线程轮询 Redis 造成压力。
缺陷:时间轮默认精度 100ms,高并发下任务积压导致尖刺 50ms。秒杀场景可以 tryLock(waitTime=0) 快速失败绕过等锁。
章末提问
Q1:手写 SET NX EX 分布式锁有哪些坑?Redisson 分别怎么解决的?
回答思路:结论——三个坑:锁超时后业务没跑完(误放行)、释放时校验与删除有窗口(误删别人的锁)、不可重入(自调用死锁);Redisson 用 Lua + Hash + Watch Dog 一次填平。因为:Redis 单线程执行 Lua 让「校验 owner 和操作 key」之间无窗口,Hash field=UUID:threadId、value=重入计数解决可重入和误删,Watch Dog 自动续期解决锁超时。
Q2:为什么加锁/释放锁必须用 Lua 脚本,而不能拆成多条 Redis 命令?
回答思路:结论——因为加锁、释放都是「多步判断 + 写」的组合,只有 Lua 能保证这些步骤原子执行。因为:Redis 单线程执行整段 Lua,中间不会被其他命令插队,消除了「判断 owner」和「删除」之间的时间窗口;若拆成多条命令,就会重现坑 2 的「校验通过后、DEL 前锁过期导致误删」问题。原子性正是 Lua 在这里的核心价值。
Q3:Redisson 的 Watch Dog 续期机制是什么?leaseTime/3 是什么意思?JVM 崩溃会死锁吗?
回答思路:结论——Watch Dog 默认 leaseTime=-1,用 Netty 时间轮每 leaseTime/3(默认 10s)自动续期,防止业务没跑完锁就过期;JVM 崩溃不会死锁。因为:每 1/3 周期提前续期、留出失败重试余量;JVM 崩溃后 Watch Dog 停止续期,锁最多 30s 自动释放;等锁方靠 Pub/Sub 通知而非忙轮询,避免 1000 线程打爆 Redis。