Redis 分布式锁
单机环境下用 synchronized 或 ReentrantLock 就够了,多实例部署后锁必须跨进程生效,于是有了分布式锁。Redis 因为性能高、接入简单,是最常见的实现载体。但"能加锁"和"锁是对的"是两回事:误删、超时、续期、主从切换,每一个都会让锁失效。本文从最原始的 SET NX 讲起,拆解 Redisson 的实现,最后讨论 RedLock 的争议。
一、最小可用实现
加锁要满足三个条件:互斥、带过期时间、原子执行。Redis 2.6.12 起可以用一条命令完成:
SET lock:order:1001 <uuid> NX PX 30000
NX:key 不存在才设置成功;PX 30000:30 秒后自动过期,防止持锁进程崩溃导致死锁;<uuid>:锁的持有者标识,释放时用于校验。
释放必须校验持有者,否则会误删别人的锁。判断和删除要用 Lua 保证原子性:
-- unlock.lua
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
EVAL "$(cat unlock.lua)" 1 lock:order:1001 <uuid>
如果拆成 GET 再 DEL 两条命令,中间可能发生:锁过期、别人加锁成功、你执行 DEL 删掉了别人的锁。Lua 脚本在 Redis 中单线程执行,天然原子。
这个实现仍有两个缺陷:不可重入(同一线程再次加锁会失败)、没有自动续期(业务执行超过 30 秒锁就失效了)。
二、Redisson 的加锁 Lua 脚本
Redisson 用 Hash 结构存锁:key 是锁名,field 是「客户端 ID + 线程 ID」,value 是重入次数。
加锁脚本(RedissonLock.tryLockInnerAsync):
-- KEYS[1] 锁名,ARGV[1] 过期时间,ARGV[2] 持有者标识
if (redis.call('exists', KEYS[1]) == 0) then
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
return redis.call('pttl', KEYS[1]);
逐段解释:
- 锁不存在,直接
HINCRBY创建并把计数置为 1,设置过期时间,返回nil表示加锁成功。 - 锁存在且当前持有者是自己,计数加一,刷新过期时间,返回
nil,实现可重入。 - 锁被他人持有,返回剩余 TTL,调用方据此决定等待多久后重试。
解锁脚本:
-- KEYS[1] 锁名,KEYS[2] 频道名,ARGV[1] 发布消息,ARGV[2] 过期时间,ARGV[3] 持有者标识
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
return nil;
end;
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0) then
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;
计数减一后如果仍大于 0,说明还有重入层级,只刷新 TTL;减到 0 才真正删除,并向频道发布消息,唤醒等待中的线程。
三、看门狗续期
固定 30 秒的锁有个问题:业务执行了 40 秒,锁在第 30 秒自动释放,第二个线程进来,两个线程同时操作共享资源。
Redisson 的解法是看门狗(watchdog):加锁成功后启动一个定时任务,每 lockWatchdogTimeout / 3 执行一次,把锁的过期时间重置为 lockWatchdogTimeout。默认 lockWatchdogTimeout 是 30 秒,所以每 10 秒续期一次。
RLock lock = redisson.getLock("lock:order:1001");
lock.lock(); // 不传 leaseTime,启用看门狗
try {
doBusiness();
} finally {
lock.unlock();
}
关键细节:只有不指定 leaseTime 时才启用看门狗。下面这种写法会关闭续期,锁 10 秒后必然失效:
lock.lock(10, TimeUnit.SECONDS); // 不续期
看门狗本身也有边界:
- 续期是客户端行为,客户端进程假死(比如长时间 Full GC)时续期会停,锁最终过期,这是正确的兜底;
- 如果业务真的执行很久,续期会一直进行,此时锁的"有效期"实际上由业务决定,必须保证业务最终能结束;
- Redisson 的定时任务是 JVM 内的
HashedWheelTimer,客户端与 Redis 网络断开超过lockWatchdogTimeout后锁会丢失。
四、等待与公平锁
tryLock(waitTime, leaseTime, unit) 的等待逻辑不是自旋,而是订阅锁释放的频道:
- 首次尝试加锁失败,拿到剩余 TTL;
- 订阅
redisson_lock__channel:{锁名}; - 在
waitTime内等待频道消息,收到后重新尝试; - 超时返回
false。
这比轮询省 CPU,也避免了大量客户端同时重试。RedissonFairLock 在此基础上用 List 维护等待队列,按先来后到分配锁,代价是每次加解锁都要操作队列,开销更高。
五、RedLock 及其争议
单机 Redis 锁有一个致命弱点:主从异步复制。客户端在主库加锁成功,主库还没把数据同步到从库就宕机了,哨兵把从库提升为主库,锁数据丢失,另一个客户端可以立刻加锁。
Redis 作者 antirez 提出 RedLock:向 N 个(通常 5 个)相互独立的 Redis 实例依次加锁,如果在超过半数实例上加锁成功,且总耗时小于锁的有效期,就认为加锁成功。
RedissonRedLock redLock = new RedissonRedLock(
redisson1.getLock("lock:order:1001"),
redisson2.getLock("lock:order:1001"),
redisson3.getLock("lock:order:1001"));
redLock.lock();
分布式系统学者 Martin Kleppmann 提出反对意见,核心论点是:RedLock 无法保证锁的正确性,因为它依赖各实例的时钟。
- 客户端 1 在 3 个实例上加锁成功,随后发生长时间 GC 停顿;
- 锁在多个实例上因 TTL 到期而释放;
- 客户端 2 在 3 个实例上加锁成功;
- 客户端 1 从 GC 中恢复,以为自己仍持有锁,继续写共享资源。
此时两个客户端同时持锁。只要存在 GC 停顿、网络延迟、时钟漂移,这个场景就可能发生,与实例数量无关。Kleppmann 的建议是:如果只是追求效率(偶发冲突可接受),单实例锁足够;如果追求正确性(冲突不可接受),必须引入 fencing token,即每次加锁返回一个单调递增的序号,共享资源(如存储层)拒绝序号更小的写入。
antirez 的回应是:RedLock 的时钟假设在工程上可以接受,通过合理设置 TTL 和重试可以降低风险。但争论的实质是"分布式锁能提供什么保证",而不是实现细节。
实践结论:
- 绝大多数业务用单实例或主从 + 哨兵的 Redisson 锁就够,接受极小概率的锁失效;
- 需要强正确性的场景(资金、库存扣减)不要只依赖 Redis 锁,应在数据库层用唯一索引、乐观锁版本号或
SELECT ... FOR UPDATE兜底; - RedLock 的实际收益有限,维护成本高,新项目不建议引入。
六、锁的粒度与超时
- 锁名要带业务维度:
lock:order:1001而不是lock:order,粒度越细冲突越少。 leaseTime要大于业务 P99 耗时:使用看门狗时可以不传,但业务必须有超时控制,避免无限续期。- 加锁与业务要在同一线程:Redisson 的持有者标识包含线程 ID,跨线程
unlock会抛IllegalMonitorStateException。 - 事务提交后再释放锁:
@Transactional方法内加锁,如果锁在事务提交前释放,其他线程可能读到未提交的数据。
常见坑 / 最佳实践
- 绝不要用
SETNX+EXPIRE两条命令。 中间崩溃会导致锁永不过期,必须用SET key value NX PX ms一条命令。 - 释放锁必须校验 value 并用 Lua 原子执行。 直接
DEL会误删他人的锁,GET后DEL两步同样有竞态。 lock(leaseTime)会关闭看门狗。 很多"锁提前失效"的线上问题都源于此,除非能准确估算业务耗时,否则不要传leaseTime。- Redis 主从切换会导致锁丢失。 关键业务必须在数据库层加唯一约束或版本号校验,不能把 Redis 锁当作唯一防线。
- 不要在锁内做远程调用或长事务。 锁的持有时间直接决定并发度,锁内调用第三方接口会放大超时风险。
小结
- 最小实现是
SET key uuid NX PX ttl,释放时用 Lua 校验持有者后删除。 - Redisson 用 Hash 存「持有者 → 重入次数」,加锁脚本处理首次加锁与重入,解锁脚本在计数归零时才真正删除并发布通知。
- 看门狗每
lockWatchdogTimeout/3续期一次,仅在未指定leaseTime时生效,是防止业务超时导致锁提前释放的关键机制。 - RedLock 试图用多实例多数派提升可靠性,但因依赖时钟和 GC 停顿,无法提供严格正确性保证,Martin Kleppmann 的质疑成立。
- 工程上优先用单实例 Redisson 锁 + 数据库层兜底,把 Redis 锁定位为「降低冲突概率」而不是「保证互斥」。
- 点赞
- 收藏
- 关注作者
评论(0)