跳转至

Redis 分布式锁


1. 引入:为什么需要分布式锁?

单机环境下,可以用 synchronizedReentrantLock 保证线程安全。但在分布式环境下,多个服务实例运行在不同 JVM 进程中,JVM 级别的锁无法跨进程生效。

典型场景: - 秒杀扣库存:多个服务实例同时读到库存为 1,都认为可以下单,导致超卖 - 定时任务防重:多个实例同时触发定时任务,导致重复执行 - 幂等控制:防止同一请求被多个实例重复处理

flowchart LR
    subgraph 分布式环境
        App1["服务实例1"] -->|竞争锁| Redis[(Redis)]
        App2["服务实例2"] -->|竞争锁| Redis
        App3["服务实例3"] -->|竞争锁| Redis
    end
    Redis -->|只有一个实例获得锁| DB[(数据库)]

2. SETNX 手动实现分布式锁

2.1 最简单的实现(有问题)

// ❌ 错误实现:SETNX 和 EXPIRE 不是原子操作
boolean locked = redis.setnx("lock:order", "1");
if (locked) {
    redis.expire("lock:order", 30); // 如果这里崩溃,锁永远不会释放!
    try {
        // 业务逻辑
    } finally {
        redis.del("lock:order");
    }
}

问题SETNXEXPIRE 是两条命令,不是原子操作。如果 SETNX 成功后进程崩溃,EXPIRE 没有执行,锁永远不会释放(死锁)。

2.2 原子加锁(解决死锁问题)

// ✅ 使用 SET NX PX 原子命令
boolean locked = redis.set("lock:order", "1", "NX", "PX", 30000);
// NX = 不存在才设置
// PX 30000 = 过期时间30秒(毫秒)
// 这是一条原子命令,不会出现死锁

2.3 SETNX 实现的三大问题

flowchart TD
    A["SETNX lock 1"] --> B{成功?}
    B -->|是| C["执行业务逻辑"]
    C --> D["DEL lock"]
    B -->|否| E["等待重试"]

    subgraph 存在的问题
        P1["❌ 问题1: 未设置过期时间\n→ 进程崩溃导致死锁\n原因:SETNX 和 EXPIRE 不是原子操作"]
        P2["❌ 问题2: 过期时间设置不合理\n→ 业务未完成锁已过期,其他进程获取锁\n原因:无法预估业务执行时间"]
        P3["❌ 问题3: 释放了别人的锁\n→ 未校验锁的持有者\n原因:DEL 不检查 value,直接删除"]
    end

问题详解

问题2:锁过期但业务未完成

时间线:
T=0  : 进程A获取锁,设置30秒过期
T=30 : 锁过期,进程B获取锁
T=35 : 进程A业务完成,执行 DEL lock
       → 删除的是进程B的锁!(问题3)
T=40 : 进程C获取锁
       → 进程B和进程C同时持有锁!

问题3:误删他人锁

// ❌ 错误:直接删除,不检查是否是自己的锁
redis.del("lock:order");

// ✅ 正确:先检查 value 是否是自己的 UUID,再删除
String lockValue = UUID.randomUUID().toString();
redis.set("lock:order", lockValue, "NX", "PX", 30000);

// 释放锁时,用 Lua 脚本保证"检查+删除"的原子性
String luaScript = """
    if redis.call('get', KEYS[1]) == ARGV[1] then
        return redis.call('del', KEYS[1])
    else
        return 0
    end
    """;
redis.eval(luaScript, Collections.singletonList("lock:order"),
           Collections.singletonList(lockValue));


3. Redisson 分布式锁

3.1 Redisson 解决了哪些问题

flowchart LR
    subgraph Redisson 解决方案
        A["获取锁\nSET key uuid NX PX 30000"] --> B["业务执行中"]
        B --> C["看门狗 Watchdog\n每10秒自动续期\n(默认锁超时30s,每1/3时间续期)"]
        C --> B
        B --> D["业务完成\n校验 uuid 后释放锁\nLua 脚本保证原子性"]
    end
特性 SETNX 手动实现 Redisson 为什么 Redisson 更好
自动续期 ❌ 需手动处理 ✅ 看门狗自动续期 业务执行时间不可预估,自动续期更安全
可重入 ❌ 不支持 ✅ 支持(Hash 结构记录重入次数) 同一线程多次获取同一锁不会死锁
释放安全 ❌ 可能释放他人锁 ✅ Lua 脚本原子校验 Lua 脚本保证"检查+删除"的原子性
红锁(多节点) ❌ 不支持 ✅ RedLock 算法 单节点 Redis 宕机时锁失效,红锁保证多节点安全

3.2 Redisson 基本使用

// 引入依赖
// <dependency>
//     <groupId>org.redisson</groupId>
//     <artifactId>redisson-spring-boot-starter</artifactId>
//     <version>3.23.0</version>
// </dependency>

@Autowired
private RedissonClient redissonClient;

public void deductStock(Long productId) {
    RLock lock = redissonClient.getLock("lock:stock:" + productId);

    // 方式1:阻塞等待(一直等到获取锁)
    lock.lock();

    // 方式2:超时等待(等待最多3秒,获取锁后持有最多30秒)
    boolean locked = lock.tryLock(3, 30, TimeUnit.SECONDS);

    try {
        if (locked) {
            // 业务逻辑:查库存、扣库存
            int stock = getStock(productId);
            if (stock > 0) {
                updateStock(productId, stock - 1);
            }
        }
    } finally {
        if (locked && lock.isHeldByCurrentThread()) {
            lock.unlock(); // 只有持有锁的线程才能释放
        }
    }
}

3.3 看门狗(Watchdog)机制

问题:业务执行时间不可预估,如果锁过期了业务还没完成,其他进程会获取锁,导致并发问题。

解决方案:看门狗在后台定期给锁续期。

默认配置:
  锁超时时间:30秒(lockWatchdogTimeout)
  续期间隔:10秒(每 1/3 超时时间续期一次)

续期逻辑:
  T=0  : 获取锁,设置30秒过期
  T=10 : 看门狗检测到锁还在使用,重置为30秒
  T=20 : 看门狗再次续期,重置为30秒
  T=25 : 业务完成,主动释放锁
  T=30 : 如果业务未完成(进程崩溃),锁自然过期,不会死锁

为什么看门狗默认 30 秒,每 10 秒续期:30 秒是经验值,足够大多数业务操作完成;每 1/3 时间(10 秒)续期,保证在锁过期前有足够时间续期,即使一次续期失败还有两次机会。

⚠️ 注意:如果调用 lock.tryLock(waitTime, leaseTime, unit) 并指定了 leaseTime,看门狗不会自动续期。只有不指定 leaseTime(或使用 lock.lock())时,看门狗才生效。

3.4 可重入锁原理

可重入:同一线程可以多次获取同一把锁,不会死锁。

Redis 中存储结构(Hash):
key: lock:order
field: uuid:threadId  → value: 重入次数

加锁:HINCRBY lock:order uuid:threadId 1
释放:HINCRBY lock:order uuid:threadId -1,减到0时删除key

Lua 脚本实现(加锁)

-- KEYS[1] = 锁的 key
-- ARGV[1] = 锁的过期时间(毫秒)
-- ARGV[2] = uuid:threadId

if (redis.call('exists', KEYS[1]) == 0) then
    -- 锁不存在,直接获取
    redis.call('hset', 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
    -- 锁存在且是当前线程持有,重入次数+1
    redis.call('hincrby', KEYS[1], ARGV[2], 1)
    redis.call('pexpire', KEYS[1], ARGV[1])
    return nil
end

-- 锁被其他线程持有,返回剩余过期时间
return redis.call('pttl', KEYS[1])


4. RedLock(红锁)

4.1 为什么需要红锁?

单节点 Redis 的分布式锁存在以下问题: - Redis 主节点宕机,锁数据丢失(从节点还未同步) - 主从切换期间,可能出现两个客户端同时持有锁

RedLock 算法:在 N 个独立的 Redis 节点(通常 5 个)上同时加锁,超过半数(3 个)成功才认为加锁成功。

4.2 RedLock 流程

sequenceDiagram
    participant Client as 客户端
    participant R1 as Redis节点1
    participant R2 as Redis节点2
    participant R3 as Redis节点3
    participant R4 as Redis节点4
    participant R5 as Redis节点5

    Client->>R1: SET lock uuid NX PX 30000
    Client->>R2: SET lock uuid NX PX 30000
    Client->>R3: SET lock uuid NX PX 30000
    Client->>R4: SET lock uuid NX PX 30000
    Client->>R5: SET lock uuid NX PX 30000

    R1-->>Client: 成功
    R2-->>Client: 成功
    R3-->>Client: 成功
    R4-->>Client: 失败(节点宕机)
    R5-->>Client: 失败(网络超时)

    Note over Client: 3/5 节点成功,且耗时 < 锁有效期
    Note over Client: 加锁成功!有效期 = 30000ms - 耗时

4.3 Redisson 使用红锁

RLock lock1 = redissonClient1.getLock("lock:order");
RLock lock2 = redissonClient2.getLock("lock:order");
RLock lock3 = redissonClient3.getLock("lock:order");

RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
boolean locked = redLock.tryLock(3, 30, TimeUnit.SECONDS);
try {
    if (locked) {
        // 业务逻辑
    }
} finally {
    redLock.unlock();
}

⚠️ 注意:RedLock 存在争议(Martin Kleppmann 等人认为在时钟漂移等极端情况下仍不安全)。大多数业务场景下,单节点 Redis 锁 + 哨兵/集群高可用已经足够,不必使用 RedLock。


5. 分布式锁最佳实践

5.1 选型建议

场景 推荐方案
一般业务(秒杀、防重等) Redisson 可重入锁
对锁安全性要求极高 Redisson RedLock(或 ZooKeeper 分布式锁)
简单场景,不想引入 Redisson SET NX PX + Lua 脚本释放

5.2 常见错误

❌ 错误:SETNX 后未设置过期时间
✅ 正确:使用 SET key value NX PX milliseconds 原子命令

❌ 错误:直接 DEL 释放锁,未校验持有者
✅ 正确:用 Lua 脚本先校验 value(UUID),再删除

❌ 错误:锁过期时间设置过短,业务未完成锁已释放
✅ 正确:使用 Redisson 看门狗自动续期,或根据业务预估合理的超时时间

❌ 错误:获取锁失败后直接报错,未做重试
✅ 正确:使用 tryLock(waitTime, ...) 等待一段时间,或配合消息队列做异步重试

❌ 错误:在 finally 中无条件释放锁
✅ 正确:释放前检查 lock.isHeldByCurrentThread(),避免释放他人的锁

6. 面试高频问题

Q:Redis 分布式锁和 ZooKeeper 分布式锁的区别?

Redis 锁基于内存,性能更好(微秒级),但存在锁过期、主从切换等安全隐患;ZooKeeper 锁基于临时节点,客户端断开连接锁自动释放,安全性更高,但性能较差(毫秒级)。大多数业务场景用 Redis 锁即可,对安全性要求极高时考虑 ZooKeeper。

Q:Redisson 看门狗是如何实现的?

Redisson 获取锁成功后,会启动一个定时任务(基于 Netty 的 HashedWheelTimer),每隔 lockWatchdogTimeout/3(默认10秒)检查锁是否还被当前线程持有,如果是则重置过期时间为 lockWatchdogTimeout(默认30秒)。当锁被释放或线程结束时,定时任务停止。

Q:为什么释放锁要用 Lua 脚本?

释放锁需要两步:① 检查 value 是否是自己的 UUID;② 删除 key。这两步不是原子操作,如果检查后、删除前锁刚好过期,其他进程获取了锁,此时再执行删除就会误删他人的锁。Lua 脚本在 Redis 中是原子执行的,保证了"检查+删除"的原子性。