杰瑞科技汇

Redis分布式锁Java如何实现?

什么是分布式锁?

理解为什么需要分布式锁。

Redis分布式锁Java如何实现?-图1
(图片来源网络,侵删)

在单机应用中,如果多个线程需要访问同一个共享资源(比如一个计数器),我们可以使用 Java 自带的 synchronizedReentrantLock 来保证线程安全,确保同一时间只有一个线程能访问该资源。

在分布式系统中,应用可能部署在多个不同的服务器上。synchronized 等单机锁就失效了,因为它只能锁住当前 JVM 进程内的线程,一个服务器上的线程无法阻止另一个服务器上的线程。

分布式锁 就是为了解决这个问题而生的,它是一种跨进程、跨机器的锁,可以保证在分布式系统中的多个节点,对于同一个共享资源,同一时间只有一个节点能对其进行操作。

分布式锁的核心需求

一个可靠的分布式锁实现,通常需要满足以下几个核心条件:

Redis分布式锁Java如何实现?-图2
(图片来源网络,侵删)
  1. 互斥性:在任意时刻,只有一个客户端能持有锁,这是最基本的要求。
  2. 不会发生死锁:如果一个客户端在获取锁后崩溃或网络异常,没有能主动释放锁,那么这个锁将永远被占用,导致其他客户端永远无法获取,锁必须有自动过期机制。
  3. 容错性:只要大部分 Redis 节点是正常的,客户端就能获取和释放锁,这要求 Redis 集群是高可用的。
  4. 可重入性:同一个客户端在持有锁的情况下,可以多次获取该锁而不会造成死锁,这类似于 Java 的 ReentrantLock
  5. 安全性:锁的获取和释放必须是原子操作,不能出现“获取了锁但没设置成功”或“释放了锁但没清除成功”的情况。

Redis 实现分布式锁的方案

Redis 提供了多种数据结构,可以用来实现分布式锁,最主流的方案有两种:

基于 SET 命令的简单实现 (NX/XX, EX/PX)

这是 Redis 官方推荐的简单实现方式,适用于大多数场景。

核心命令:SET key value [NX|XX] [EX seconds|PX milliseconds]

  • key: �的名字。
  • value: 锁的值。非常重要,它必须是唯一的,通常是一个随机字符串(UUID)或客户端 ID。
  • NX: 表示 "Not eXists",只有当 key 不存在时,才设置成功,这保证了互斥性。
  • EX seconds: 设置 key 的过期时间,单位是秒。
  • PX milliseconds: 设置 key 的过期时间,单位是毫秒。

加锁过程:

Redis分布式锁Java如何实现?-图3
(图片来源网络,侵删)
  1. 客户端 A 生成一个唯一的随机字符串 valueuuid)。
  2. 客户端 A 执行命令:SET lock_key my_unique_value NX EX 30
    • lock_key 不存在,命令执行成功,客户端 A 成功获取锁,锁会在 30 秒后自动过期。
    • lock_key 已存在,命令执行失败,客户端 A 获取锁失败。

解锁过程:

解锁的关键在于,只能由加锁的客户端来解锁,如果直接用 DEL lock_key 命令,任何客户端都可以删除锁,这会破坏安全性(客户端 A 的锁快过期了,客户端 B 获取了锁,此时客户端 A 的操作完成,执行 DEL 命令误删了客户端 B 的锁)。

Redis 的 Lua 脚本可以保证原子性,解锁的 Lua 脚本逻辑是: lock_key 的值等于 my_unique_value,那么就执行 DEL 命令。

if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

客户端在执行解锁脚本时,会传入 lock_key 和当初加锁时生成的 my_unique_value,只有当两者匹配时,才会删除锁。


Redlock (Redisson 的实现)

对于需要极高可靠性的场景,比如多个客户端同时竞争锁,Redis 是主从架构时,简单的 SET NX 方案存在一个主从切换问题

  1. 客户端 A 在主节点上成功获取了锁。
  2. 主节点还未将锁的同步到从节点,就发生了故障。
  3. 从节点被提升为主节点,此时这个锁在新的主节点上是不存在的。
  4. 客户端 B 可以在新的主节点上再次获取到同一个锁,这就破坏了互斥性。

为了解决这个问题,Redis 的作者 Antirez 提出了 Redlock 算法,Redlock 的思想是,使用多个(通常是 5 个)独立的 Redis 节点,客户端只有在大多数(3 个或以上)节点上成功获取锁,才认为锁获取成功。

Redlock 算法流程:

  1. 客户端获取当前时间(毫秒级)。
  2. 依次向 5 个 Redis 节点尝试获取锁,使用相同的 key、随机 value 和较短的过期时间(例如几十毫秒)。SET lock_key my_unique_value NX EX 100
  3. 客户端在所有 5 个节点上获取锁,计算总耗时,如果客户端在至少 3 个节点上获取了锁,并且总耗时小于锁的过期时间,那么就认为获取锁成功。
  4. 如果获取锁成功,锁的有效时间是初始设定的过期时间减去总耗时。
  5. 如果获取锁失败,客户端会在所有能获取锁的节点上执行解锁(即使没有获取成功,也要尝试删除,避免脏数据)。

Redlock 的实现库: 手动实现 Redlock 算法非常复杂,容易出错,强烈建议使用成熟的客户端库,Redisson,Redisson 提供了开箱即用的 RLock 对象,其底层就是基于 Redlock 算法实现的。


Java 代码实现

下面我们通过 Java 代码来演示这两种方案。

准备工作

你需要添加 Redis 客户端的依赖,这里我们使用 jedis,它是 Redis 的官方 Java 客户端。

Maven 依赖 (pom.xml):

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>4.3.1</version> <!-- 使用较新版本 -->
</dependency>

基于 SET 命令的简单实现

import redis.clients.jedis.Jedis;
import redis.clients.jedis.params.SetParams;
import java.util.Collections;
import java.util.UUID;
public class SimpleRedisLock {
    private final Jedis jedis;
    private final String lockKey;
    private final String lockValue; // 用于标识是哪个客户端加的锁
    private final long expireTime; // 锁的过期时间(毫秒)
    public SimpleRedisLock(Jedis jedis, String lockKey, long expireTime) {
        this.jedis = jedis;
        this.lockKey = lockKey;
        this.lockValue = UUID.randomUUID().toString();
        this.expireTime = expireTime;
    }
    /**
     * 尝试获取锁
     * @return true: 获取成功, false: 获取失败
     */
    public boolean tryLock() {
        // 使用 SET 命令的 NX 和 PX 选项
        SetParams params = SetParams.setParams().nx().px(expireTime);
        String result = jedis.set(lockKey, lockValue, params);
        // "OK" 表示设置成功
        return "OK".equals(result);
    }
    /**
     * 释放锁
     */
    public void unlock() {
        // 使用 Lua 脚本保证原子性
        String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                           "   return redis.call('del', KEYS[1]) " +
                           "else " +
                           "   return 0 " +
                           "end";
        // jedis.eval() 会自动将 KEYS 和 ARGV 正确地传递给 Lua 脚本
        jedis.eval(luaScript, Collections.singletonList(lockKey), Collections.singletonList(lockValue));
    }
}

使用示例:

public class SimpleLockDemo {
    public static void main(String[] args) throws InterruptedException {
        Jedis jedis = new Jedis("localhost", 6379);
        jedis.flushAll(); // 清空数据库,方便测试
        SimpleRedisLock lock = new SimpleRedisLock(jedis, "my_resource_lock", 10000); // 10秒过期
        // 模拟两个客户端
        Thread client1 = new Thread(() -> {
            if (lock.tryLock()) {
                System.out.println("Client 1: Lock acquired!");
                try {
                    // 模拟业务处理
                    Thread.sleep(5000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    System.out.println("Client 1: Releasing lock...");
                    lock.unlock();
                }
            } else {
                System.out.println("Client 1: Failed to acquire lock.");
            }
        });
        Thread client2 = new Thread(() -> {
            // 让 client2 稍后启动
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            if (lock.tryLock()) {
                System.out.println("Client 2: Lock acquired!");
                try {
                    // 模拟业务处理
                    Thread.sleep(5000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    System.out.println("Client 2: Releasing lock...");
                    lock.unlock();
                }
            } else {
                System.out.println("Client 2: Failed to acquire lock.");
            }
        });
        client1.start();
        client2.start();
        client1.join();
        client2.join();
        jedis.close();
    }
}

预期输出:

Client 1: Lock acquired!
Client 2: Failed to acquire lock.
Client 1: Releasing lock...

可以看到,Client 1 成功获取锁,Client 2 获取失败,当 Client 1 释放锁后,Client 2 的逻辑已经执行完毕,所以不会有输出。


使用 Redisson 实现 Redlock

这是生产环境中最推荐的方式。

Maven 依赖 (pom.xml):

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.23.4</version> <!-- 使用较新版本 -->
</dependency>

代码实现:

import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import java.util.concurrent.TimeUnit;
public class RedissonLockDemo {
    public static void main(String[] args) throws InterruptedException {
        // 1. 配置 Redisson 客户端
        Config config = new Config();
        config.useSingleServer().setAddress("redis://localhost:6379");
        RedissonClient redisson = Redisson.create(config);
        // 2. 获取锁
        // lockKey 是锁的名字
        RLock lock = redisson.getLock("my_resource_redisson_lock");
        // 3. 尝试获取锁
        // 参数:waitTime(等待获取锁的时间),leaseTime(锁的持有时间),timeUnit(时间单位)
        // waitTime 为 -1,则无限期等待直到获取锁
        // leaseTime 为 -1,则锁不会自动过期,需要手动 unlock
        boolean isLocked = lock.tryLock(10, 30, TimeUnit.SECONDS);
        if (isLocked) {
            try {
                System.out.println("Thread " + Thread.currentThread().getName() + ": Lock acquired!");
                // 模拟业务处理
                Thread.sleep(15000);
            } finally {
                System.out.println("Thread " + Thread.currentThread().getName() + ": Releasing lock...");
                // 4. 释放锁
                lock.unlock();
            }
        } else {
            System.out.println("Thread " + Thread.currentThread().getName() + ": Failed to acquire lock.");
        }
        // 关闭 Redisson 客户端
        redisson.shutdown();
    }
}

多线程测试:

// 在 main 方法中启动多个线程
for (int i = 0; i < 3; i++) {
    new Thread(() -> {
        RLock lock = redisson.getLock("my_resource_redisson_lock");
        try {
            if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
                try {
                    System.out.println(Thread.currentThread().getName() + ": Lock acquired!");
                    Thread.sleep(5000);
                } finally {
                    System.out.println(Thread.currentThread().getName() + ": Releasing lock...");
                    lock.unlock();
                }
            } else {
                System.out.println(Thread.currentThread().getName() + ": Failed to acquire lock.");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }, "Thread-" + i).start();
}

预期输出: 只有一个线程会输出 "Lock acquired!",其他线程会输出 "Failed to acquire lock.",获取锁的线程在 5 秒后释放锁,其他线程可以立即尝试获取。


最佳实践和注意事项

  1. 锁的 value 必须唯一:这是实现安全释放锁的关键。UUID 是一个很好的选择。
  2. 设置合理的过期时间:过期时间需要根据业务逻辑的执行时间来设定,既要保证业务能完成,又要防止因异常导致锁长期不释放。
  3. 使用 tryLock 而非阻塞式 lock:在分布式系统中,直接阻塞等待获取锁可能会导致资源耗尽。tryLock 可以设置超时时间,失败后可以快速失败,进行重试或降级处理。
  4. 锁的粒度要小:只锁定必要的资源,不要用一个锁来保护所有业务逻辑。
  5. 优先使用成熟的库:除非有特殊需求,否则强烈建议使用 Redisson,它已经解决了 Redlock 算法、锁续期(看门狗机制)、可重入性等复杂问题,稳定性和可靠性远高于自己实现的简单版本。
  6. 处理异常:在 finally 块中释放锁,确保即使业务代码抛出异常,锁也能被正确释放。
  7. 监控:对锁的获取成功率、持有时间等指标进行监控,以便及时发现系统中的瓶颈和问题。
特性 简单 SET NX 实现 Redisson (Redlock) 实现
实现复杂度 低,几行代码即可 高,但封装在库中,使用简单
可靠性 较高,但在主从切换场景下有风险 非常高,通过多节点解决了主从切换问题
可重入性 不支持,需要额外实现 支持RLock 是可重入锁
锁续期 不支持,锁一旦设置过期时间就不会变 支持,看门狗机制在锁快过期时自动续期
适用场景 简单业务,对可靠性要求不是极致的场景 关键业务,高并发,需要极高可靠性的场景

对于绝大多数 Java 应用,直接使用 Redisson 是最省心、最可靠的选择,如果你只是想快速实现一个简单的功能,并且对主从切换问题不敏感,SET NX 的方案也是一个可行的选择。

分享:
扫描分享到社交APP
上一篇
下一篇