从面试经典场景出发,彻底搞懂 Redis 分布式锁的原理、坑点和最佳实践
你用过 Redis 分布式锁吗?说说它是怎么回事?
用过。基本思路是先拿 SETNX 来争抢锁,抢到之后,再用 EXPIRE 给锁加一个过期时间防止锁忘记释放。
嗯,回答得不错。那我问你:如果在 SETNX 执行之后、EXPIRE 执行之前,进程意外 crash 了,或者要重启维护了,那会怎么样?
(愣了一下)
唉,是喔……这样的话,这个锁就永远得不到释放了,其他客户端永远拿不到锁,就死锁了!
(抓抓脑袋,思考片刻……)
等一下!我记得 Redis 的 SET 指令有非常复杂的参数,应该可以同时把 SETNX 和 EXPIRE 合成一条指令来用!
Redis 2.6.12 开始,SET 命令支持 NX(不存在才设置)和 EX/PX(过期时间)参数,一条命令原子性地完成「加锁 + 设过期」。
# 一条命令:原子性地完成 加锁 + 设置过期时间
SET lock_key unique_value NX EX 30
# 参数说明:
# NX — 只在 key 不存在时才设置(Not eXists)
# EX — 过期时间,单位秒(PX 是毫秒)
# unique_value — 唯一标识(用于安全释放锁)
分布式锁是在分布式系统中,用来控制不同节点上的进程对共享资源的互斥访问的一种机制。当多个服务实例同时操作同一份数据(如扣减库存、更新账户余额),必须保证同一时刻只有一个实例在操作。
| 原因 | 说明 |
|---|---|
| 单线程模型 | Redis 命令执行是串行化的,天然不存在并发竞争,保证互斥 |
| 高性能 | 内存操作,微秒级响应,适合高并发场景 |
| 原生原子命令 | SETNX、SET NX EX、Lua 脚本都是原子的 |
| 生态成熟 | Redisson、Jedis、Lettuce 等客户端都有成熟的封装 |
// ❌ 两步操作不是原子的,中间可能 crash
Boolean locked = jedis.setnx("lock:order:1001", "1");
if (locked) {
// ⚡ 如果这里进程 crash 了,锁永远不会释放!
jedis.expire("lock:order:1001", 30);
// 执行业务逻辑...
jedis.del("lock:order:1001");
}
// ✅ 一条命令原子完成,不会有中间状态
String result = jedis.set(
"lock:order:1001", // key
"550e8400-e29b-41d4-a716", // value = 唯一标识
"NX", // 不存在才设置
"EX", // 过期时间单位:秒
30 // 30 秒超时
);
if ("OK".equals(result)) {
// 获取锁成功
}
// 或者用更简洁的 SetParams(Jedis 3.0+)
String result = jedis.set(
"lock:order:1001",
"550e8400-e29b-41d4-a716",
SetParams.setParams().nx().ex(30)
);
// ✅ Go 版本
ok, err := rdb.SetNX(ctx, "lock:order:1001", uniqueID, 30*time.Second).Result()
if ok {
// 获取锁成功
}
💡 关键点:以上只是解决了「加锁原子性」这一个问题。但分布式锁还有更多的坑等着你……
即使用了 SET NX EX,Redis 分布式锁在实际生产中仍然面临以下问题。每个问题都可能造成严重的线上事故。
客户端 A 拿到了锁,但业务执行时间超过了锁的过期时间,锁自动释放了。此时客户端 B 拿到了锁,结果 A 执行完业务后去释放锁,把 B 持有的锁给释放了。
加锁时设置一个唯一标识(如 UUID),释放锁时先判断是不是自己加的锁,是才释放。判断 + 释放必须用 Lua 脚本保证原子性。
-- Lua 脚本:原子性地判断 + 释放
if redis.call("get", KEYS[1]) == ARGV[1]
then
return redis.call("del", KEYS[1])
else
return 0
end
🔑 为什么用 Lua? Redis 执行 Lua 脚本期间,其他命令无法插入执行,保证了 GET + DEL 的原子性。
🔑 为什么要唯一标识? 不能用 PID 或 Thread ID,因为不同 JVM / 容器的 PID 可能重复。用 UUID + 线程ID 的组合最可靠。
设置锁过期 30 秒,但业务逻辑因为 GC 停顿、网络延迟、数据库慢查询等原因执行了 35 秒。在第 30 秒时锁自动释放,另一个线程进来,两个线程同时执行,数据就乱了。
看门狗机制(Watchdog):获取锁后启动一个后台线程,定期(如每 10 秒)检查锁是否还被当前线程持有,如果是就自动续期(延长过期时间)。Redisson 内置了这一机制。
// Redisson 默认开启看门狗机制
// lockWatchdogTimeout 默认 30 秒,每 10 秒续期一次
RLock lock = redissonClient.getLock("lock:order:1001");
// 方式1:不传超时时间 → 启动看门狗,默认30s看门狗
lock.lock();
// 方式2:传超时时间 → 不启动看门狗,到期自动释放
lock.lock(30, TimeUnit.SECONDS);
// 正确姿势:用 tryLock 带超时
boolean locked = lock.tryLock(
10, // waitTime:最多等 10 秒拿锁
30, // leaseTime:锁 30 秒后自动释放
TimeUnit.SECONDS
);
| 参数 | 看门狗行为 |
|---|---|
lock() 不传 leaseTime |
✅ 启动看门狗,默认 30 秒,每 10 秒续期 |
lock(30, SECONDS) |
❌ 不启动看门狗,30 秒后自动释放,不续期 |
tryLock(waitTime, leaseTime, unit) |
❌ 指定了 leaseTime 不启动看门狗 |
客户端 A 在 Master 上拿到了锁,还没来得及同步到 Slave,Master 宕机了。Slave 被提升为新 Master,但锁数据丢失了。此时客户端 B 在新 Master 上拿同一把锁,就会成功 — 两个客户端同时持有锁!
RedLock(红锁)算法:不在单个 Redis 实例上加锁,而是向 N 个独立的 Redis 实例(通常是 5 个)同时请求加锁。只有在大多数实例(N/2+1)上成功,且总耗时小于锁的有效时间,才算获取锁成功。
⚡ RedLock 核心:向 N 个独立 Redis 节点(建议 5 个)依次请求加锁 → 计算总耗时 → 拿到 N/2+1 个节点(即多数)的锁 且 总耗时 < 锁有效期 → 才算成功。释放时向所有节点发释放命令。
// 三个独立的 Redis 实例(不要用同一集群的主从)
RLock lock1 = redissonClient1.getLock("lock:order:1001");
RLock lock2 = redissonClient2.getLock("lock:order:1001");
RLock lock3 = redissonClient3.getLock("lock:order:1001");
// 红锁 = 多数派投票
RLock redLock = redissonClient1.getRedLock(lock1, lock2, lock3);
// 使用和普通锁一样
redLock.lock();
try {
// 业务逻辑
} finally {
redLock.unlock();
}
Redis 作者 Antirez 提出了 RedLock 算法,但分布式系统专家 Martin Kleppmann 提出了反对意见,认为 RedLock 在时钟跳跃、GC 停顿等场景下仍有安全问题。如果你的业务对一致性要求极高(如金融扣款),建议使用 ZooKeeper 或 etcd 这类基于共识协议的方案。
实用建议:绝大多数互联网业务场景,单节点 Redis + 看门狗 + Lua 释放已经足够。真正需要 RedLock 的场景很少。
同一个线程如果递归调用或调用链中有同一把锁的方法,第二次获取锁时会失败——因为 SET NX 发现 key 已经存在,返回失败,导致线程自己阻塞自己。
在锁的 value 中维护一个计数器(或使用 Hash 结构)。加锁时判断是否是同一个客户端(通过唯一标识),如果是则计数器 +1;释放时计数器 -1,到 0 才真正删除 key。Redisson 的 RLock 天然支持可重入。
-- 加锁脚本(简化)
if redis.call('exists', KEYS[1]) == 0 then
redis.call('hincrby', KEYS[1], ARGV[2], 1); -- 首次加锁,计数=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); -- 重入,计数+1
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil; -- 成功
end;
return redis.call('pttl', KEYS[1]); -- 失败,返回剩余时间
高并发下大量线程自旋重试获取锁(while true 循环 + sleep),CPU 空转、Redis 压力增大、无效网络请求增多。
使用 发布/订阅 + 信号量:锁释放时发布消息通知等待的线程,而不是轮询。Redisson 的 RLock 底层使用了 Redis 的 Pub/Sub 机制来实现高效的等待唤醒。
| # | 问题 | 原因 | 解决方案 | 实现方式 |
|---|---|---|---|---|
| 1 | 死锁 | SETNX 和 EXPIRE 非原子,中间 crash | 原子加锁 | SET key value NX EX seconds |
| 2 | 误解锁 | 业务超时后锁被他人持有,释放了别人的锁 | 唯一标识 + Lua 原子释放 | UUID + Lua: if get == id then del |
| 3 | 锁早释 | 业务还没执行完锁就过期 | 看门狗自动续期 | Redisson 内置 Watchdog |
| 4 | 锁丢失 | 主从异步复制,Master 宕机锁丢失 | RedLock 红锁 | 向 N 个独立节点多数派加锁 |
| 5 | 不可重入 | 同一线程无法再次获取已持有的锁 | Hash + 计数器 | Redisson RLock 内置支持 |
| 6 | 自旋消耗 | 大量线程不断重试,CPU 和网络浪费 | Pub/Sub 通知 | Redisson 信号量 + Pub/Sub |
不要自己造轮子。Redisson 已经解决了可重入、看门狗、Pub/Sub 等待等所有问题。
永远不要创建「永久锁」。即使有看门狗,也要设置兜底的过期时间防止看门狗线程挂掉。
锁商品 ID 而不是锁整个商品表。lock:product:12345 而不是 lock:product,减少锁竞争。
普通业务用 Redisson 单节点即可。金融级强一致性用 ZooKeeper / etcd,或者干脆用数据库乐观锁。
锁的释放必须放在 finally 块中,确保异常时也能释放,避免死锁。
等锁时间不宜过长(建议 3-10 秒),锁持有时间要覆盖业务 99 分位的耗时。
public void processOrder(String orderId) {
RLock lock = redissonClient.getLock("lock:order:" + orderId);
boolean locked = false;
try {
// 最多等 5 秒,拿到锁后 30 秒自动释放
locked = lock.tryLock(5, 30, TimeUnit.SECONDS);
if (!locked) {
throw new RuntimeException("获取锁超时");
}
// === 业务逻辑 ===
doBusinessLogic(orderId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock(); // 安全释放
}
}
}
| 特性 | Redis | ZooKeeper | etcd |
|---|---|---|---|
| 一致性模型 | 最终一致 | 强一致 (ZAB) | 强一致 (Raft) |
| 性能 | 极高(内存,微秒级) | 中等 | 中等 |
| 实现复杂度 | 中等 | 较高 | 较高 |
| 客户端成熟度 | Redisson 非常成熟 | Curator 非常成熟 | jetcd 可用 |
| 锁释放机制 | 过期时间 + 看门狗 | 临时节点 + Session 心跳 | Lease 租约 |
| 适合场景 | 高性能、可容忍低概率不一致 | 强一致性要求(如金融) | 云原生、K8s 环境 |
🎯 选型建议:大多数互联网业务场景,Redis 分布式锁足够了。如果你的业务是「扣了钱不能多扣也不能少扣」级别的强一致性,请选择 ZooKeeper 或 etcd。