这是我学习分布式锁时整理的笔记。在《Redis 快速入门》里我提到 setIfAbsent 能做分布式锁,但那只是最简版。这篇把分布式锁从头捋一遍:为什么要它、一把合格的锁要满足哪些条件,以及从最原始的 SETNX 一路演进到 Redisson——每一版为什么改、改了什么。
如果对"锁"这个大类还不太清楚(本地锁、数据库锁、分布式锁的区别),先看我的《锁的分类与实现原理》。
一、为什么需要分布式锁
先说清楚它和本地锁的区别。
- 本地锁(
synchronized、ReentrantLock):只在一个 JVM 进程内生效。同一台服务器上的线程互斥,但管不到别的机器。 - 分布式锁:跨多台服务器的全局互斥。多台服务器同时抢同一份资源(扣库存、抢优惠券、防重复提交),需要一把"大家都认"的锁。
类比一下:本地锁是房间门锁——只管房间内的人互斥;分布式锁是公共储物柜的锁——所有人都按同一把锁排队。这个"公共第三方"通常就是 Redis(当然 ZooKeeper、数据库也能做,Redis 因为快、命令简单用得最多)。
二、一把合格的分布式锁,要满足这些条件
面试常问,生产也按这个清单来检查:
- 互斥:同一时刻只有一个客户端能拿到锁;
- 防死锁:拿到锁的客户端挂了,锁能自动释放,别把别人永远卡死;
- 不误删:不能删掉别人持有的锁;
- 可重入(进阶):同一个客户端能重复加锁(否则递归/嵌套调用会自己卡死自己);
- 锁续期(进阶):业务还没跑完,锁不能提前过期;
- 高可用:Redis 主从切换时不能"丢锁"导致两个客户端同时拿到锁。
下面每一版演进,其实都是在逐个补齐这些条件。
三、演进:从 SETNX 到 Redisson
v1:SETNX(最原始)
SETNX lock:1 holder
# 返回 1 = 抢到锁;返回 0 = 别人已持有SETNX = SET if Not eXists,key 不存在才设置成功。问题很明显:没有过期时间,客户端一旦崩溃,锁就永远留在 Redis 里,其他所有请求全被卡死——死锁。
v2:SETNX + EXPIRE(两条命令补过期)
SETNX lock:1 holder
EXPIRE lock:1 30给锁加个 30 秒过期,崩溃了也能自动释放。但问题来了:这是两条命令,不是原子的。如果 SETNX 成功后、EXPIRE 执行前进程恰好挂了,锁依然没有过期时间——还是死锁。这个窗口极小,但高并发下一定会发生。
v3:SET NX EX(一条命令,原子)
SET lock:1 holder EX 30 NXRedis 把 NX(不存在才设)和 EX(过期时间)合并进同一条 SET 里,"占锁 + 设过期"一步完成、天然原子,从此告别死锁。这是入门最常用的写法,对应 Spring Boot 的:
Boolean locked = redis.opsForValue()
.setIfAbsent("lock:1", "holder-" + requestId, Duration.ofSeconds(30));
// true = 抢到锁,false = 别人持有v4:防误删——释放前先比对持有者
v3 解决了死锁,但引入一个新问题——误删别人的锁:
- A 拿到锁,业务跑了 40 秒,但锁 30 秒就过期了;
- B 趁锁过期抢到了锁;
- A 业务跑完,去
DEL lock:1——把 B 的锁删了。
解法:value 存一个持有者标识(如 requestId/UUID),释放前先比对是不是自己的:
String holder = redis.opsForValue().get("lock:1");
if (requestId.equals(holder)) {
redis.delete("lock:1");
}但这又有个漏洞:GET 和 DEL 是两条命令,非原子。判断完"是我持有"之后、删除之前,锁可能刚好过期被 B 抢走,A 这一 DEL 还是删了 B 的锁。
v5:Lua 脚本原子释放
把"比对 + 删除"放进 Lua 脚本,Redis 单线程执行脚本,两条动作天然原子:
-- 只有持有者是自己才删
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
endprivate 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 帮你补齐的:
- 可重入:上面所有版本,同一个客户端方法里嵌套再
setIfAbsent一次会失败(锁被自己占着)。Redisson 用 hash 结构记录"持有者 + 重入次数",天然可重入。 - 锁续期(看门狗 watchdog):锁 30 秒过期,但业务要跑 40 秒怎么办?Redisson 默认锁 30 秒,后台每 10 秒检查一次,只要业务没结束就自动续期到 30 秒——业务多久锁就跟多久,解决了"锁过期业务没跑完"。
- 主从切换丢锁:主节点挂了,锁数据还没同步到从节点,另一个客户端在从节点上又抢到同一把锁——两个客户端同时"持有"。Redisson 提供 RedLock(多节点过半同意才加锁)来缓解,但 RedLock 本身有争议(依赖时钟、开销大),实际生产多数场景主从 + 等待同步就够了。
Redisson 用法极简:
RLock lock = redissonClient.getLock("lock:1");
lock.lock(); // 默认 30s + 看门狗自动续期
try {
// 业务逻辑
} finally {
lock.unlock(); // 内部校验持有者,不会误删
}Redisson 的看门狗续期、可重入原理、还有它自带的布隆过滤器、限流器等,完整讲解见我整理的《Redisson 实战》。
四、演进对照表
| 版本 | 写法 | 解决的问题 | 遗留问题 |
|---|---|---|---|
| v1 | SETNX | 互斥 | 无过期 → 死锁 |
| v2 | SETNX + EXPIRE | 防死锁(加过期) | 两条命令非原子 → 仍可能死锁 |
| v3 | SET NX EX | 原子"占锁 + 过期" | 会误删别人的锁 |
| v4 | 比对持有者再删 | 防误删 | 比对+删除非原子 |
| v5 | Lua 原子释放 | 原子"比对+删除" | 不可重入、不续期、主从丢锁 |
| v6 | Redisson | 可重入 + 看门狗 + 高可用 | 复杂,需额外依赖 |
五、常见坑总结
- 别用
SETNX+EXPIRE两条命令:中间一挂就死锁,直接SET NX EX原子。 - 释放别直接
DEL:会误删别人的锁,一定带持有者标识 + Lua 原子释放。 - 锁过期业务没跑完:要么预估足够长的过期时间,要么用 Redisson 看门狗续期。
- 主从切换丢锁:追求绝对互斥用 RedLock(有争议),一般场景主从 + 等待同步即可。
- 别自己造轮子:单机 Redis 用 v5 方案就够;一旦要可重入、续期、高可用,直接上 Redisson,别手写。
小结
- 分布式锁解决跨机器互斥,本地锁管不到其他服务器
- 一把合格的锁要满足:互斥、防死锁、不误删、可重入、续期、高可用
- 演进主线:
SETNX(死锁)→ 加EXPIRE(非原子)→SET NX EX(原子)→ 比对持有者(非原子)→ Lua 释放(原子)→ Redisson(补齐重入/续期/高可用) - 记住两个"原子":加锁用
SET NX EX一条命令,释放用 Lua 脚本——这是裸用 Redis 做锁的核心 - 生产环境:单机 Redis 用
SET NX EX+ Lua 释放;要重入/续期/高可用上 Redisson
想了解 Redis 本身的更多用法(数据结构、持久化、缓存一致性),看我整理的《Redis 快速入门》。
