Redis 分布式锁深度解析

从面试经典场景出发,彻底搞懂 Redis 分布式锁的原理、坑点和最佳实践

0 经典面试开场

你用过 Redis 分布式锁吗?说说它是怎么回事?

用过。基本思路是先拿 SETNX 来争抢锁,抢到之后,再用 EXPIRE 给锁加一个过期时间防止锁忘记释放。

嗯,回答得不错。那我问你:如果在 SETNX 执行之后、EXPIRE 执行之前,进程意外 crash 了,或者要重启维护了,那会怎么样?

(愣了一下)

唉,是喔……这样的话,这个锁就永远得不到释放了,其他客户端永远拿不到锁,就死锁了!

(抓抓脑袋,思考片刻……)

等一下!我记得 Redis 的 SET 指令有非常复杂的参数,应该可以同时把 SETNXEXPIRE 合成一条指令来用!

✅ 正确答案

Redis 2.6.12 开始,SET 命令支持 NX(不存在才设置)和 EX/PX(过期时间)参数,一条命令原子性地完成「加锁 + 设过期」。

正确的加锁方式
# 一条命令:原子性地完成 加锁 + 设置过期时间
SET lock_key unique_value NX EX 30

# 参数说明:
#   NX  — 只在 key 不存在时才设置(Not eXists)
#   EX  — 过期时间,单位秒(PX 是毫秒)
#   unique_value — 唯一标识(用于安全释放锁)

1 什么是分布式锁?为什么用 Redis?

分布式锁是在分布式系统中,用来控制不同节点上的进程对共享资源的互斥访问的一种机制。当多个服务实例同时操作同一份数据(如扣减库存、更新账户余额),必须保证同一时刻只有一个实例在操作。

为什么选择 Redis 做分布式锁?

原因说明
单线程模型 Redis 命令执行是串行化的,天然不存在并发竞争,保证互斥
高性能 内存操作,微秒级响应,适合高并发场景
原生原子命令 SETNXSET NX EX、Lua 脚本都是原子的
生态成熟 Redisson、Jedis、Lettuce 等客户端都有成熟的封装

2 基础实现与致命缺陷

2.1 错误姿势:SETNX + EXPIRE(非原子)

⚠️ 这是错误的!

Java — 错误示范
// ❌ 两步操作不是原子的,中间可能 crash
Boolean locked = jedis.setnx("lock:order:1001", "1");
if (locked) {
    // ⚡ 如果这里进程 crash 了,锁永远不会释放!
    jedis.expire("lock:order:1001", 30);
    // 执行业务逻辑...
    jedis.del("lock:order:1001");
}
SETNX 成功
💥 进程 Crash
EXPIRE 未执行
🔒 死锁!

2.2 正确姿势:SET NX EX(原子操作)

✅ 这才是正确的!

Java (Jedis)
// ✅ 一条命令原子完成,不会有中间状态
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 (go-redis)
// ✅ Go 版本
ok, err := rdb.SetNX(ctx, "lock:order:1001", uniqueID, 30*time.Second).Result()
if ok {
    // 获取锁成功
}

💡 关键点:以上只是解决了「加锁原子性」这一个问题。但分布式锁还有更多的坑等着你……


3 Redis 分布式锁的五大核心问题

即使用了 SET NX EX,Redis 分布式锁在实际生产中仍然面临以下问题。每个问题都可能造成严重的线上事故。


4 问题总结对照表

# 问题 原因 解决方案 实现方式
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

5 生产环境最佳实践

🥇

优先用 Redisson

不要自己造轮子。Redisson 已经解决了可重入、看门狗、Pub/Sub 等待等所有问题。

🔐

加锁必须设过期

永远不要创建「永久锁」。即使有看门狗,也要设置兜底的过期时间防止看门狗线程挂掉。

🎯

锁粒度要细

锁商品 ID 而不是锁整个商品表。lock:product:12345 而不是 lock:product,减少锁竞争。

📊

评估一致性需求

普通业务用 Redisson 单节点即可。金融级强一致性用 ZooKeeper / etcd,或者干脆用数据库乐观锁。

🔄

finally 中释放

锁的释放必须放在 finally 块中,确保异常时也能释放,避免死锁。

⏱️

合理设置超时

等锁时间不宜过长(建议 3-10 秒),锁持有时间要覆盖业务 99 分位的耗时。

✅ 生产级 Redis 分布式锁模板(Redisson)

Java — 生产级模板
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();  // 安全释放
        }
    }
}

6 Redis vs ZooKeeper vs etcd 分布式锁对比

特性 Redis ZooKeeper etcd
一致性模型 最终一致 强一致 (ZAB) 强一致 (Raft)
性能 极高(内存,微秒级) 中等 中等
实现复杂度 中等 较高 较高
客户端成熟度 Redisson 非常成熟 Curator 非常成熟 jetcd 可用
锁释放机制 过期时间 + 看门狗 临时节点 + Session 心跳 Lease 租约
适合场景 高性能、可容忍低概率不一致 强一致性要求(如金融) 云原生、K8s 环境

🎯 选型建议:大多数互联网业务场景,Redis 分布式锁足够了。如果你的业务是「扣了钱不能多扣也不能少扣」级别的强一致性,请选择 ZooKeeper 或 etcd。