🌺The Begin🌺点点关注,收藏不迷路🌺

前言

在分布式系统中,当多个服务实例同时访问共享资源时,如何保证数据一致性?传统单机锁(如 synchronizedReentrantLock)在多进程多线程环境下完全失效。这时候就需要分布式锁来协调多个节点对共享资源的互斥访问。

Redis 凭借其高性能、原子操作和丰富的数据结构,成为实现分布式锁最流行的方案之一。本文将深入剖析 Redis 分布式锁的核心原理,从最简单的 SETNX 到生产级可靠的 Redlock 算法,带你彻底掌握分布式锁的方方面面。

1. 分布式锁的核心要求

一个合格的分布式锁必须满足以下条件:

分布式锁核心要求

互斥性

同一时刻只能一个客户端持有锁

安全性

只有持锁者才能解锁

防止误删他人锁

死锁避免

锁必须有超时机制

自动释放防止客户端崩溃

容错性

Redis 集群部分故障时仍可用

重入性(可选)

同一线程可重复获取锁

高性能

加锁解锁延迟低

2. Redis 分布式锁的演进之路

2.1 第一代:SETNX + EXPIRE(有缺陷)

// ❌ 错误示例:非原子操作,存在死锁风险
public boolean wrongLock(String key, String value, long expireTime) {
    Long result = jedis.setnx(key, value);  // 设置锁
    if (result == 1) {
        // 如果这里服务宕机,EXPIRE 未执行 -> 死锁!
        jedis.expire(key, expireTime);       // 设置过期时间
        return true;
    }
    return false;
}

问题SETNXEXPIRE 不是原子操作,中间若服务崩溃,锁永不过期。

2.2 第二代:SET NX EX(原子操作)

// ✅ Redis 2.6.12+ 支持原子操作
public boolean lock(String key, String value, long expireTime) {
    String result = jedis.set(key, value, "NX", "EX", expireTime);
    return "OK".equals(result);
}

命令详解

  • NX:key 不存在时才设置(互斥性)
  • EX:设置过期时间(秒)
  • 原子性:一条命令完成,无死锁风险

2.3 第三代:Lua 脚本保证原子解锁

-- unlock.lua
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end
public boolean unlock(String key, String value) {
    String luaScript = 
        "if redis.call('get', KEYS[1]) == ARGV[1] then " +
        "   return redis.call('del', KEYS[1]) " +
        "else " +
        "   return 0 " +
        "end";
    Object result = jedis.eval(luaScript, Collections.singletonList(key), Collections.singletonList(value));
    return Long.valueOf(1).equals(result);
}

为什么需要 Lua 脚本?

客户端2 Redis 客户端1 客户端2 Redis 客户端1 业务执行中... GC 停顿超过 10 秒 C2 的锁被误删 SET lock value1 EX 10 OK 锁自动过期 SET lock value2 EX 10 OK DEL lock(误删 C2 的锁!)

Lua 脚本保证:先 GET 检查 value 是否是自己,是才 DEL,原子执行。

3. 完整的单节点分布式锁实现

3.1 核心代码(Jedis 实现)

public class RedisDistributedLock {
    
    private Jedis jedis;
    private static final String LOCK_SUCCESS = "OK";
    private static final Long UNLOCK_SUCCESS = 1L;
    private static final String SET_IF_NOT_EXIST = "NX";
    private static final String SET_WITH_EXPIRE_TIME = "PX";  // 毫秒
    
    /**
     * 加锁
     * @param lockKey 锁的 key
     * @param requestId 唯一标识(UUID)
     * @param expireTime 过期时间(毫秒)
     */
    public boolean lock(String lockKey, String requestId, int expireTime) {
        String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
        return LOCK_SUCCESS.equals(result);
    }
    
    /**
     * 解锁(Lua 脚本保证原子性)
     */
    public boolean unlock(String lockKey, String requestId) {
        String script = 
            "if redis.call('get', KEYS[1]) == ARGV[1] then " +
            "   return redis.call('del', KEYS[1]) " +
            "else " +
            "   return 0 " +
            "end";
        Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
        return UNLOCK_SUCCESS.equals(result);
    }
}

3.2 Redisson 封装的使用(推荐)

// Redisson 提供了更完善的分布式锁
@Autowired
private RedissonClient redissonClient;

public void doSomething() {
    RLock lock = redissonClient.getLock("myLock");
    try {
        // 尝试加锁,最多等待 100 秒,锁自动释放时间 30 秒
        if (lock.tryLock(100, 30, TimeUnit.SECONDS)) {
            try {
                // 业务逻辑
            } finally {
                lock.unlock();
            }
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}

Redisson 的优势

  • 自动续期(Watch Dog 机制)
  • 可重入锁支持
  • 公平锁、读写锁等多种实现

4. 分布式锁的难点与解决方案

4.1 锁过期时间设置

问题:业务执行时间 > 锁过期时间,导致锁被提前释放。

获取锁成功

业务执行中...

锁过期?

锁自动释放

其他客户端获取锁

原客户端继续执行

释放锁时误删他人锁

解决方案 1:Redisson Watch Dog

// Redisson 自动续期机制
// 默认锁过期 30 秒,每 10 秒自动续期
RLock lock = redissonClient.getLock("myLock");
lock.lock();  // 内部自动启动续期线程

解决方案 2:手动续期

// 开启定时任务,在锁过期前续期
ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
Future<?> renewalFuture = executor.scheduleAtFixedRate(() -> {
    jedis.expire(lockKey, expireTime);
}, expireTime / 3, expireTime / 3, TimeUnit.SECONDS);

4.2 可重入锁实现

问题:同一线程多次获取同一把锁时,需要支持重入。

// Redisson 支持可重入
RLock lock = redissonClient.getLock("myLock");
lock.lock();
lock.lock();  // 重入成功,计数器 +1
// ...
lock.unlock();  // 计数器 -1
lock.unlock();  // 计数器为 0 时真正释放

原理:使用 Redis Hash 存储

HSET myLock uuid:threadId 1  -- 存储持有者 + 重入次数
HINCRBY myLock uuid:threadId 1  -- 重入时递增

4.3 加锁失败的重试策略

public boolean lockWithRetry(String key, String requestId, int expireTime, 
                             int retryTimes, long retryInterval) {
    for (int i = 0; i < retryTimes; i++) {
        if (lock(key, requestId, expireTime)) {
            return true;
        }
        try {
            Thread.sleep(retryInterval);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return false;
        }
    }
    return false;
}

改进:指数退避

// 第 n 次重试等待 2^n * base
long waitTime = Math.min(baseWaitTime * (1 << retryCount), maxWaitTime);

5. 集群环境下的挑战:Redlock 算法

5.1 单节点问题

在 Redis 主从架构下,如果主节点加锁后未同步到从节点就宕机,会发生什么?

C2 Slave Master 客户端 C2 Slave Master 客户端 主节点宕机,锁未同步 从节点晋升为主节点 两个客户端都认为持有锁! SET lock value1 NX EX 10 OK 数据丢失 SET lock value2 NX EX 10 OK

5.2 Redlock 算法(Redis 作者提出)

Redlock 在多个独立 Redis 节点上获取锁,需要超过半数节点成功才算成功。

获取当前时间戳

依次向 N 个节点请求锁

超过 N/2 + 1 个节点成功?

计算获取锁总耗时

总耗时 < 锁有效期?

获得锁成功

释放所有节点的锁

执行业务

释放所有节点的锁

Redlock 核心代码(简化)

public boolean redlock(String resource, String requestId, int ttl) {
    int N = 5;  // 5 个独立 Redis 节点
    int quorum = N / 2 + 1;  // 需要至少 3 个节点成功
    long start = System.currentTimeMillis();
    
    int successCount = 0;
    for (Jedis jedis : jedisList) {
        if (lock(jedis, resource, requestId, ttl)) {
            successCount++;
        }
    }
    
    long elapsed = System.currentTimeMillis() - start;
    if (successCount >= quorum && elapsed < ttl) {
        // 锁获取成功,有效剩余时间 = ttl - elapsed
        return true;
    }
    
    // 获取失败,释放所有节点的锁
    for (Jedis jedis : jedisList) {
        unlock(jedis, resource, requestId);
    }
    return false;
}

5.3 Redlock 的争议

支持观点反对观点
提供了多节点容错依赖时钟同步,时钟跳跃可能导致问题
算法清晰,易于实现性能开销大,需要 N 次网络请求
大多数场景够用存在理论上的安全性边界问题

结论:大多数业务场景下,单节点 + 主从 + 合理超时配置已足够。Redlock 仅在极高标准的一致性场景下才需要。

6. 完整的生产级分布式锁工具类

@Component
public class RedisDistributedLockTemplate {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    private static final String LOCK_PREFIX = "lock:";
    
    /**
     * 执行带锁的业务逻辑
     * @param key 锁的 key
     * @param expireSeconds 锁过期时间(秒)
     * @param waitTimeout 获取锁等待超时(毫秒)
     * @param task 业务逻辑
     */
    public <T> T executeWithLock(String key, long expireSeconds, 
                                  long waitTimeout, Supplier<T> task) {
        String requestId = UUID.randomUUID().toString();
        long startTime = System.currentTimeMillis();
        
        // 自旋获取锁
        while (System.currentTimeMillis() - startTime < waitTimeout) {
            Boolean success = redisTemplate.opsForValue()
                .setIfAbsent(LOCK_PREFIX + key, requestId, 
                             Duration.ofSeconds(expireSeconds));
            if (Boolean.TRUE.equals(success)) {
                try {
                    return task.get();
                } finally {
                    // 使用 Lua 脚本释放锁
                    releaseLock(key, requestId);
                }
            }
            // 短暂休眠后重试
            try {
                Thread.sleep(50);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                throw new RuntimeException("获取锁被中断", e);
            }
        }
        throw new RuntimeException("获取锁超时: " + key);
    }
    
    private void releaseLock(String key, String requestId) {
        String luaScript = 
            "if redis.call('get', KEYS[1]) == ARGV[1] then " +
            "   return redis.call('del', KEYS[1]) " +
            "else " +
            "   return 0 " +
            "end";
        redisTemplate.execute(
            new DefaultRedisScript<>(luaScript, Long.class),
            Collections.singletonList(LOCK_PREFIX + key),
            requestId
        );
    }
}

使用示例

@Autowired
private RedisDistributedLockTemplate lockTemplate;

public void deductStock(Long productId) {
    lockTemplate.executeWithLock("product:" + productId, 10, 3000, () -> {
        // 扣减库存业务逻辑
        int stock = stockMapper.getStock(productId);
        if (stock > 0) {
            stockMapper.deduct(productId);
        }
        return null;
    });
}

7. 使用场景与注意事项

7.1 典型应用场景

场景说明
秒杀/抢购防止库存超卖
重复提交接口幂等性控制
分布式调度多实例环境下只让一个执行定时任务
缓存重建防止缓存击穿时的并发重建

7.2 注意事项清单

使用 Redis 分布式锁

✓ 锁的 value 必须是唯一标识
防止误删他人锁

✓ 必须设置过期时间
防止死锁

✓ 解锁必须原子操作
使用 Lua 脚本

✓ 业务执行时间 < 锁过期时间
或使用看门狗续期

✓ 非必须不用 Redlock
大多数场景单节点足够

✓ 锁粒度要合理
避免锁范围过大

结语

Redis 分布式锁是每个后端开发者必须掌握的技能。从最早的 SETNX + EXPIRE,到原子 SET NX EX,再到 Lua 脚本的安全解锁,每一步演进都在解决之前的问题。

核心要点总结

  1. 原子性:加锁和解锁必须原子操作
  2. 唯一标识:使用 UUID 区分不同客户端
  3. 超时机制:防止死锁,但注意业务执行时间
  4. 自动续期:Redisson 的 Watch Dog 解决了锁过期问题
  5. 集群容错:普通业务单节点够用,极高要求考虑 Redlock

记住一个原则:分布式锁不是为了解决 100% 的问题,而是把问题概率降低到可接受的范围。大多数场景下,主从架构 + 合理超时配置已经足够。

你在项目中用过 Redis 分布式锁吗?遇到过哪些坑?欢迎评论区分享!

在这里插入图片描述


🌺The End🌺点点关注,收藏不迷路🌺
Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐