在单体应用时代,我们使用 synchronizedReentrantLock 就能轻松解决线程安全问题。然而,随着架构演进到分布式集群(如 Tomcat 集群部署),JVM 内部的锁机制只能管住当前的 JVM 进程,无法跨 JVM 互斥。这就引出了分布式锁的需求。

本文将复盘《黑马点评》项目中分布式锁的演进之路,探讨如何一步步用 Redis 实现一个生产可用的分布式锁。

一、 分布式锁的基本原理

1.1 什么是分布式锁?

分布式锁是满足分布式系统或集群模式下多进程可见并且互斥的锁。核心思想是让所有服务节点都去抢占同一个“共享资源”,抢到的人才有资格执行业务。

1.2 分布式锁的核心要素

一个成熟的分布式锁应满足以下条件:

  • 可见性:多个进程都能感知到锁的状态变化。
  • 互斥:最基本条件,同一时刻只能有一个线程持有锁。
  • 高可用:锁服务本身不能轻易崩溃。
  • 高性能:加锁和释放锁的速度要快。
  • 安全性:避免死锁(持有锁的进程挂掉后锁能自动释放)。

1.3 技术选型对比

常见的实现方式有三种:

  1. MySQL:利用数据库锁机制,但在高并发下性能一般,较少使用。
  2. Redis:利用 setnx 互斥特性。性能强,实现相对简单,是企业级开发的主流选择。
  3. Zookeeper:可靠性高,但实现原理复杂,不在本文讨论范围。

二、 阶段一:基于 SETNX 的基础实现

2.1 核心思路

利用 Redis 的 SETNX (SET if Not eXists) 命令。

  • 获取锁:执行 SETNX key value。如果返回 1,说明拿到锁;返回 0,说明锁已存在,需要等待。
  • 释放锁:业务执行完毕后,执行 DEL key
  • 防死锁:在获取锁时必须设置超时时间(TTL),防止服务宕机导致锁无法释放。

2.2 代码实现

Java

// SimpleRedisLock.java

private static final String KEY_PREFIX = "lock:";

@Override
public boolean tryLock(long timeoutSec) {
    // 获取线程标识
    String threadId = Thread.currentThread().getId() + "";
    // 获取锁:利用 setIfAbsent (即 SETNX)
    // 关键点:操作和设置过期时间必须原子性,防止设置过期时间前宕机
    Boolean success = stringRedisTemplate.opsForValue()
            .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
    // 避免空指针,使用 Boolean.TRUE.equals
    return Boolean.TRUE.equals(success);
}

@Override
public void unlock() {
    // 释放锁
    stringRedisTemplate.delete(KEY_PREFIX + name);
}

三、 阶段二:解决“误删锁”问题

3.1 事故场景:锁被别人删了

在极端并发场景下,阶段一的代码存在严重 Bug:

  1. 线程 1 获取锁成功,TTL 设置为 10秒。
  2. 线程 1 业务执行阻塞(比如 Full GC),耗时超过 10秒,锁自动过期释放
  3. 线程 2 此时尝试获取锁,成功拿到锁。
  4. 线程 1 业务终于跑完,执行 unlock()直接删除了对应的 Key
  5. 问题出现:线程 1 删掉的其实是 线程 2 的锁!导致线程 2 的业务裸奔,不再互斥。

3.2 解决方案:身份标识

在释放锁时,必须**“先判断,再删除”。 我们需要在加锁时存入当前线程的唯一标识**(如 UUID + 线程ID),删除前先获取 Redis 中的值,判断是否和自己的一致。

3.3 代码改进

加锁逻辑改造:存入 UUID。

Java

private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";

@Override
public boolean tryLock(long timeoutSec) {
   String threadId = ID_PREFIX + Thread.currentThread().getId();
   // 存入 UUID + ThreadID
   Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
   return Boolean.TRUE.equals(success);
}

释放锁逻辑改造:先查后删。

Java

public void unlock() {
    // 1. 获取当前线程标识
    String threadId = ID_PREFIX + Thread.currentThread().getId();
    // 2. 获取 Redis 中存的标示
    String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
    // 3. 判断是否一致
    if(threadId.equals(id)) {
        // 4. 一致才释放
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }
}

四、 阶段三:解决原子性问题(Lua 脚本)

4.1 潜在隐患:原子性缺失

虽然阶段二解决了大部分误删问题,但仍有极端情况:

  1. 线程 1 判断 if(threadId.equals(id)) 成功,确认锁是自己的。
  2. 就在这一瞬间,发生 Full GC 导致 JVM 暂停,或者网络阻塞。
  3. 在此期间,Redis 中的锁过期自动释放
  4. 线程 2 获取到了新锁。
  5. 线程 1 恢复运行,因为它已经通过了判断,直接执行 delete
  6. 结果:线程 1 还是误删了线程 2 的锁。

究其原因,是 “判断”“删除” 这两个动作在 Java 层面是分开的,不具备原子性

4.2 终极方案:Lua 脚本

Redis 提供了 Lua 脚本功能,能将多条命令打包执行。Redis 保证 Lua 脚本执行期间不会被其他命令插入,从而实现原子性。

编写 Lua 脚本 (unlock.lua):

Lua

-- KEYS[1]: 锁的 key
-- ARGV[1]: 当前线程标识

if (redis.call('GET', KEYS[1]) == ARGV[1]) then
  -- 一致,则删除锁
  return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0

4.3 Java 调用 Lua

修改 unlock 方法,使用 RedisTemplate 调用脚本:

Java

// 初始化脚本
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
    UNLOCK_SCRIPT = new DefaultRedisScript<>();
    UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
    UNLOCK_SCRIPT.setResultType(Long.class);
}

public void unlock() {
    // 执行 Lua 脚本,保证原子性
    stringRedisTemplate.execute(
            UNLOCK_SCRIPT,
            Collections.singletonList(KEY_PREFIX + name), // KEYS[1]
            ID_PREFIX + Thread.currentThread().getId());  // ARGV[1]
}

五、 总结与展望

通过三次迭代,我们实现了一个相对可靠的 Redis 分布式锁:

  1. 基础SET key value NX EX time 保证互斥和防死锁。
  2. 改进:Value 存入 UUID+ThreadID 防止误删。
  3. 完善:利用 Lua 脚本 保证“判断+删除”的原子性。

遗留问题: 虽然解决了原子性和误删,但我们还面临一个核心痛点:“锁不住”。 如果业务执行时间超长(例如 30s),超过了锁的 TTL,锁还是会过期释放。我们不能简单地把 TTL 设置得非常大。 这就需要一种**“续期机制”**(类似网吧上网续费),在业务执行期间自动延长锁的时间。这正是下一章 Redisson 框架要解决的核心问题(看门狗机制)。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐