Skip to content

这是我学习分布式锁时整理的笔记。在《Redis 快速入门》里我提到 setIfAbsent 能做分布式锁,但那只是最简版。这篇把分布式锁从头捋一遍:为什么要它、一把合格的锁要满足哪些条件,以及从最原始的 SETNX 一路演进到 Redisson——每一版为什么改、改了什么。

如果对"锁"这个大类还不太清楚(本地锁、数据库锁、分布式锁的区别),先看我的《锁的分类与实现原理》。


一、为什么需要分布式锁

先说清楚它和本地锁的区别。

  • 本地锁synchronizedReentrantLock):只在一个 JVM 进程内生效。同一台服务器上的线程互斥,但管不到别的机器。
  • 分布式锁:跨多台服务器的全局互斥。多台服务器同时抢同一份资源(扣库存、抢优惠券、防重复提交),需要一把"大家都认"的锁。

类比一下:本地锁是房间门锁——只管房间内的人互斥;分布式锁是公共储物柜的锁——所有人都按同一把锁排队。这个"公共第三方"通常就是 Redis(当然 ZooKeeper、数据库也能做,Redis 因为快、命令简单用得最多)。

二、一把合格的分布式锁,要满足这些条件

面试常问,生产也按这个清单来检查:

  1. 互斥:同一时刻只有一个客户端能拿到锁;
  2. 防死锁:拿到锁的客户端挂了,锁能自动释放,别把别人永远卡死;
  3. 不误删:不能删掉别人持有的锁;
  4. 可重入(进阶):同一个客户端能重复加锁(否则递归/嵌套调用会自己卡死自己);
  5. 锁续期(进阶):业务还没跑完,锁不能提前过期;
  6. 高可用:Redis 主从切换时不能"丢锁"导致两个客户端同时拿到锁。

下面每一版演进,其实都是在逐个补齐这些条件。

三、演进:从 SETNX 到 Redisson

v1:SETNX(最原始)

bash
SETNX lock:1 holder
# 返回 1 = 抢到锁;返回 0 = 别人已持有

SETNX = SET if Not eXists,key 不存在才设置成功。问题很明显:没有过期时间,客户端一旦崩溃,锁就永远留在 Redis 里,其他所有请求全被卡死——死锁

v2:SETNX + EXPIRE(两条命令补过期)

bash
SETNX lock:1 holder
EXPIRE lock:1 30

给锁加个 30 秒过期,崩溃了也能自动释放。但问题来了:这是两条命令,不是原子的。如果 SETNX 成功后、EXPIRE 执行前进程恰好挂了,锁依然没有过期时间——还是死锁。这个窗口极小,但高并发下一定会发生。

v3:SET NX EX(一条命令,原子)

bash
SET lock:1 holder EX 30 NX

Redis 把 NX(不存在才设)和 EX(过期时间)合并进同一条 SET 里,"占锁 + 设过期"一步完成、天然原子,从此告别死锁。这是入门最常用的写法,对应 Spring Boot 的:

java
Boolean locked = redis.opsForValue()
    .setIfAbsent("lock:1", "holder-" + requestId, Duration.ofSeconds(30));
// true = 抢到锁,false = 别人持有

v4:防误删——释放前先比对持有者

v3 解决了死锁,但引入一个新问题——误删别人的锁

  1. A 拿到锁,业务跑了 40 秒,但锁 30 秒就过期了;
  2. B 趁锁过期抢到了锁;
  3. A 业务跑完,去 DEL lock:1——把 B 的锁删了

解法:value 存一个持有者标识(如 requestId/UUID),释放前先比对是不是自己的:

java
String holder = redis.opsForValue().get("lock:1");
if (requestId.equals(holder)) {
    redis.delete("lock:1");
}

但这又有个漏洞:GETDEL两条命令,非原子。判断完"是我持有"之后、删除之前,锁可能刚好过期被 B 抢走,A 这一 DEL 还是删了 B 的锁。

v5:Lua 脚本原子释放

把"比对 + 删除"放进 Lua 脚本,Redis 单线程执行脚本,两条动作天然原子:

lua
-- 只有持有者是自己才删
if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
else
    return 0
end
java
private static final String UNLOCK_LUA =
    "if redis.call('GET', KEYS[1]) == ARGV[1] then " +
    "return redis.call('DEL', KEYS[1]) else return 0 end";

public void unlock(String key, String requestId) {
    redis.execute(
        new DefaultRedisScript<>(UNLOCK_LUA, Long.class),
        List.of(key), requestId);
}

到这一步,"SET NX EX 加锁 + Lua 原子释放" 就是裸用 Redis 做分布式锁的标准答案了,单机 Redis 场景完全够用。

v6:Redisson——可重入 + 看门狗 + 高可用

v5 还缺三样,这三样正是 Redisson 帮你补齐的:

  1. 可重入:上面所有版本,同一个客户端方法里嵌套再 setIfAbsent 一次会失败(锁被自己占着)。Redisson 用 hash 结构记录"持有者 + 重入次数",天然可重入。
  2. 锁续期(看门狗 watchdog):锁 30 秒过期,但业务要跑 40 秒怎么办?Redisson 默认锁 30 秒,后台每 10 秒检查一次,只要业务没结束就自动续期到 30 秒——业务多久锁就跟多久,解决了"锁过期业务没跑完"。
  3. 主从切换丢锁:主节点挂了,锁数据还没同步到从节点,另一个客户端在从节点上又抢到同一把锁——两个客户端同时"持有"。Redisson 提供 RedLock(多节点过半同意才加锁)来缓解,但 RedLock 本身有争议(依赖时钟、开销大),实际生产多数场景主从 + 等待同步就够了。

Redisson 用法极简:

java
RLock lock = redissonClient.getLock("lock:1");
lock.lock();          // 默认 30s + 看门狗自动续期
try {
    // 业务逻辑
} finally {
    lock.unlock();    // 内部校验持有者,不会误删
}

Redisson 的看门狗续期、可重入原理、还有它自带的布隆过滤器、限流器等,完整讲解见我整理的《Redisson 实战》。

四、演进对照表

版本写法解决的问题遗留问题
v1SETNX互斥无过期 → 死锁
v2SETNX + EXPIRE防死锁(加过期)两条命令非原子 → 仍可能死锁
v3SET NX EX原子"占锁 + 过期"会误删别人的锁
v4比对持有者再删防误删比对+删除非原子
v5Lua 原子释放原子"比对+删除"不可重入、不续期、主从丢锁
v6Redisson可重入 + 看门狗 + 高可用复杂,需额外依赖

五、常见坑总结

  1. 别用 SETNX + EXPIRE 两条命令:中间一挂就死锁,直接 SET NX EX 原子。
  2. 释放别直接 DEL:会误删别人的锁,一定带持有者标识 + Lua 原子释放。
  3. 锁过期业务没跑完:要么预估足够长的过期时间,要么用 Redisson 看门狗续期。
  4. 主从切换丢锁:追求绝对互斥用 RedLock(有争议),一般场景主从 + 等待同步即可。
  5. 别自己造轮子:单机 Redis 用 v5 方案就够;一旦要可重入、续期、高可用,直接上 Redisson,别手写。

小结

  • 分布式锁解决跨机器互斥,本地锁管不到其他服务器
  • 一把合格的锁要满足:互斥、防死锁、不误删、可重入、续期、高可用
  • 演进主线:SETNX(死锁)→ 加 EXPIRE(非原子)→ SET NX EX(原子)→ 比对持有者(非原子)→ Lua 释放(原子)→ Redisson(补齐重入/续期/高可用)
  • 记住两个"原子":加锁用 SET NX EX 一条命令,释放用 Lua 脚本——这是裸用 Redis 做锁的核心
  • 生产环境:单机 Redis 用 SET NX EX + Lua 释放;要重入/续期/高可用上 Redisson

想了解 Redis 本身的更多用法(数据结构、持久化、缓存一致性),看我整理的《Redis 快速入门》。