Redis 分布式锁

举报
yd_94907100 发表于 2026/11/04 08:43:26 2026/11/04
【摘要】 从 SET NX PX 到 Redisson 看门狗,讲清实现细节与 RedLock 争议

单机环境下用 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]);

逐段解释:

  1. 锁不存在,直接 HINCRBY 创建并把计数置为 1,设置过期时间,返回 nil 表示加锁成功。
  2. 锁存在且当前持有者是自己,计数加一,刷新过期时间,返回 nil,实现可重入。
  3. 锁被他人持有,返回剩余 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) 的等待逻辑不是自旋,而是订阅锁释放的频道:

  1. 首次尝试加锁失败,拿到剩余 TTL;
  2. 订阅 redisson_lock__channel:{锁名};
  3. 在 waitTime 内等待频道消息,收到后重新尝试;
  4. 超时返回 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 方法内加锁,如果锁在事务提交前释放,其他线程可能读到未提交的数据。

常见坑 / 最佳实践

  1. 绝不要用 SETNX + EXPIRE 两条命令。 中间崩溃会导致锁永不过期,必须用 SET key value NX PX ms 一条命令。
  2. 释放锁必须校验 value 并用 Lua 原子执行。 直接 DEL 会误删他人的锁,GET 后 DEL 两步同样有竞态。
  3. lock(leaseTime) 会关闭看门狗。 很多"锁提前失效"的线上问题都源于此,除非能准确估算业务耗时,否则不要传 leaseTime。
  4. Redis 主从切换会导致锁丢失。 关键业务必须在数据库层加唯一约束或版本号校验,不能把 Redis 锁当作唯一防线。
  5. 不要在锁内做远程调用或长事务。 锁的持有时间直接决定并发度,锁内调用第三方接口会放大超时风险。

小结

  • 最小实现是 SET key uuid NX PX ttl,释放时用 Lua 校验持有者后删除。
  • Redisson 用 Hash 存「持有者 → 重入次数」,加锁脚本处理首次加锁与重入,解锁脚本在计数归零时才真正删除并发布通知。
  • 看门狗每 lockWatchdogTimeout/3 续期一次,仅在未指定 leaseTime 时生效,是防止业务超时导致锁提前释放的关键机制。
  • RedLock 试图用多实例多数派提升可靠性,但因依赖时钟和 GC 停顿,无法提供严格正确性保证,Martin Kleppmann 的质疑成立。
  • 工程上优先用单实例 Redisson 锁 + 数据库层兜底,把 Redis 锁定位为「降低冲突概率」而不是「保证互斥」。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。