Redis 分布式锁

某团队使用 Redis 实现分布式锁时,遇到了一个严重问题:

// 错误实现
public String acquireLock(String key) {
    return redisTemplate.opsForValue().setIfAbsent(key, "lock", 10, TimeUnit.SECONDS);
}
// 释放锁时
public void releaseLock(String key) {
    redisTemplate.delete(key);
}

问题:

  1. 锁自动过期时间设为 10 秒,但业务执行需要 15 秒
  2. 锁在业务执行到一半时被自动释放
  3. 其他线程获取了锁,导致同一份数据被重复处理
  4. 严重时导致数据重复、资损等问题

这就是 Redis 分布式锁最常见的"锁自动释放"问题。

【架构权衡】 Redis 分布式锁看似简单,但正确实现非常困难。SET NX EX + 唯一值 + Lua 脚本释放 是标准实现,但这只是基础。真正的生产环境还需要:看门狗自动续期、高可用集群支持、RedLock 算法等。


一、核心问题 🔴

1.1 为什么需要分布式锁?

单机场景:synchronized 可以解决
分布式场景:synchronized 只能锁住单个 JVM,需要分布式锁
分布式锁的场景:

场景1:防止重复操作
├─ 用户重复下单
├─ 幂等键控制
└─ Redis 锁用户ID

场景2:保证任务互斥
├─ 定时任务多实例部署
├─ 同一时刻只有一个实例执行
└─ Redis 锁任务名

场景3:共享资源互斥
├─ 多台机器同时修改同一份数据
├─ 库存扣减
└─ Redis 锁商品ID

1.2 分布式锁的正确实现

分布式锁的五个要求:

1. 互斥性:同一时刻只有一个客户端能获取锁
2. 不会死锁:即使持锁客户端崩溃,锁也会自动释放
3. 可重入:同一个客户端可以多次获取锁
4. 性能:高并发下的性能要可接受
5. 安全性:只能释放自己持有的锁

二、方案实现

2.1 标准实现:SET NX EX + 唯一值

// 正确实现
@Service
public class RedisDistributedLock {

    @Autowired
    private StringRedisTemplate redisTemplate;

    // 获取锁
    public String acquireLock(String lockKey, long expireTime, TimeUnit unit) {
        // 生成唯一值(用于释放锁时校验)
        String uuid = UUID.randomUUID().toString();
        long timeout = unit.toSeconds(expireTime);

        // SET key value NX EX timeout
        // NX: 只有 key 不存在时才设置
        // EX: 设置过期时间
        Boolean result = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, uuid, expireTime, unit);

        if (Boolean.TRUE.equals(result)) {
            return uuid; // 获取锁成功,返回唯一值
        }
        return null; // 获取锁失败
    }

    // 释放锁
    public boolean releaseLock(String lockKey, String uuid) {
        // 错误的释放方式:
        // redisTemplate.delete(lockKey); // ❌ 直接删除会删掉别人的锁

        // 正确的释放方式:用 Lua 脚本保证原子性
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                        "return redis.call('del', KEYS[1]) " +
                        "else return 0 end";

        RedisScript<Long> redisScript = RedisScript.of(script, Long.class);
        Long result = redisTemplate.execute(redisScript,
            Collections.singletonList(lockKey), uuid);

        return result != null && result == 1L;
    }

    // 续期(看门狗)
    public boolean renewLock(String lockKey, String uuid, long expireTime, TimeUnit unit) {
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                        "return redis.call('expire', KEYS[1], ARGV[2]) " +
                        "else return 0 end";

        RedisScript<Long> redisScript = RedisScript.of(script, Long.class);
        Long result = redisTemplate.execute(redisScript,
            Collections.singletonList(lockKey), uuid, unit.toSeconds(expireTime));

        return result != null && result == 1L;
    }
}

2.2 看门狗机制

// Redisson 的看门狗实现
@Service
public class RedissonDistributedLock {

    @Autowired
    private RedissonClient redissonClient;

    public void executeWithLock(String lockKey, Runnable task) {
        RLock lock = redissonClient.getLock(lockKey);

        try {
            // 尝试获取锁,等待 10 秒,锁定 30 秒
            // 看门狗机制:每 10 秒自动续期
            boolean acquired = lock.tryLock(10, 30, TimeUnit.SECONDS);

            if (acquired) {
                task.run();
            } else {
                throw new RuntimeException("获取锁失败");
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("获取锁被中断");
        } finally {
            // 释放锁
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}
看门狗机制原理:

业务执行时间 < 锁过期时间:
├─ 正常执行完成
├─ 释放锁
└─ 锁自动过期

业务执行时间 > 锁过期时间:
├─ 看门狗检测到锁即将过期
├─ 自动续期(重新设置过期时间)
├─ 续期成功:继续执行
└─ 续期失败:锁已过期,被其他线程获取

2.3 RedLock 算法

// RedLock:多个 Redis 节点实现分布式锁
@Service
public class RedissonRedLock {

    private RedissonClient[] redissonClients;

    public boolean tryLockWithRedLock(String lockKey, long waitTime, long leaseTime) {
        // 获取多个 RedissonClient 实例
        RedissonClient[] clients = getRedissonClients();
        int N = clients.length;
        int successCount = 0;
        long startTime = System.currentTimeMillis();

        // 向 N 个实例获取锁
        for (int i = 0; i < N; i++) {
            try {
                RLock lock = clients[i].getLock(lockKey);
                boolean acquired = lock.tryLock(waitTime, leaseTime, TimeUnit.MILLISECONDS);
                if (acquired) {
                    successCount++;
                }
            } catch (Exception e) {
                // 单个实例失败,继续尝试其他实例
            }
        }

        // 计算是否获取了多数派的锁
        return successCount > N / 2;
    }

    public void unlockWithRedLock(String lockKey) {
        // 向所有实例释放锁
        for (RedissonClient client : getRedissonClients()) {
            try {
                RLock lock = client.getLock(lockKey);
                if (lock.isHeldByCurrentThread()) {
                    lock.unlock();
                }
            } catch (Exception e) {
                // 单个实例失败,继续释放其他
            }
        }
    }
}

【架构权衡】 RedLock 的核心思想是:少数 Redis 节点故障不影响整体锁的正确性。但 RedLock 也有争议:它假设多个 Redis 节点是相互独立的,如果所有节点都在同一个物理机上,网络分区仍然会影响 RedLock 的正确性。

三、生产避坑

3.1 锁自动释放问题

// ❌ 错误:锁过期时间设置不合理
public void badLock(String lockKey) {
    String uuid = acquireLock(lockKey, 10, TimeUnit.SECONDS);
    if (uuid == null) return;

    // 业务执行需要 15 秒
    doBusiness(); // 15 秒后锁已自动释放
    // 其他线程获取了锁,导致重复执行

    releaseLock(lockKey, uuid);
}

// ✅ 正确:使用看门狗或预估合理的过期时间
public void goodLock(String lockKey) {
    // Redisson 自动续期
    RLock lock = redissonClient.getLock(lockKey);
    lock.tryLock(); // 自动续期,不用担心过期

    try {
        doBusiness(); // 任意时长
    } finally {
        lock.unlock();
    }
}

3.2 主从切换问题

Redis 主从切换导致锁丢失的场景:

T1: 客户端A 在主节点获取锁
T2: 主节点将锁同步到从节点
T3: 主节点崩溃
T4: 从节点晋升为主节点
T5: 客户端B 在新主节点获取锁 —— 成功!
T6: 客户端A 以为自己还持有锁 —— 但实际上已经丢了

问题:SET NX EX 只能保证单机 Redis 的正确性
     主从切换会导致锁丢失

解决方案:使用 RedLock(多主)或 Redisson + Sentinel/Cluster。

四、工程代价评估

维度评估
实现复杂度中(标准实现简单,看门狗复杂)
可靠性中(单节点有风险,需要集群)
性能高(Redis 是内存操作)
一致性最终一致(不是强一致)
适用场景分布式锁、幂等控制

五、落地 Checklist

  • 唯一值:锁值必须使用唯一标识
  • 原子释放:使用 Lua 脚本释放锁
  • 过期时间:合理设置过期时间或使用看门狗
  • 续期机制:业务执行时间可能超过锁过期时间时,使用看门狗
  • 高可用:使用 Redis Sentinel 或 Cluster 避免单点故障
  • 监控告警:监控锁获取失败率
  • 测试验证:测试 Redis 故障、网络抖动时的锁行为