杰瑞科技汇

Redis分布式锁在Java中如何实现?

分布式锁是分布式系统中非常重要的一个组件,它用于控制多个节点对共享资源的互斥访问,Redis 因其高性能、原子性和丰富的命令,是实现分布式锁的常用选择。

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

为什么需要分布式锁?

在单机应用中,我们可以使用 synchronizedReentrantLock 等锁机制来保证线程安全,但在分布式系统中,多个服务实例可能部署在不同的机器上,它们无法共享 JVM 内的锁,当一个服务实例需要修改共享数据(如库存、订单状态)时,必须有一种机制来确保同一时间只有一个实例在执行该操作,这就是分布式锁要解决的问题。


Redis 分布式锁的基本原理

最基础的 Redis 分布式锁利用了 Redis 的 SET 命令,并结合其一些选项来实现原子性。

核心命令

SET key value [NX] [EX] seconds

  • key: 锁的名称,lock:product:123
  • value: 锁的“值”,通常是一个唯一的标识(如 UUID 或请求 ID),用于标识是哪个客户端获取了锁。
  • NX: Not eXists,只有当 key 不存在时,才设置成功,这是实现“互斥”的关键。
  • EX: 设置 key 的过期时间(秒),防止客户端宕机后锁无法释放,导致死锁。

加锁流程

  1. 客户端 A 尝试执行 SET lock:product:123 unique_value NX EX 10
  2. 如果返回 OK:表示客户端 A 成功获取到锁,可以执行业务逻辑。
  3. 如果返回 nil:表示锁已被其他客户端持有,获取失败,客户端 A 可以选择重试或直接放弃。

解锁流程

解锁不能简单地使用 DEL lock:product:123,因为可能会误删其他客户端持有的锁。

Redis分布式锁在Java中如何实现?-图2
(图片来源网络,侵删)
  • 客户端 A 获取了锁。
  • 客户端 A 的业务逻辑执行时间较长,超过了锁的过期时间(10秒),锁自动释放。
  • 客户端 B 获取了同一个锁。
  • 客户端 A 的业务逻辑执行完毕,尝试执行 DEL 命令,结果错误地删除了客户端 B 的锁。

解锁必须是有条件的:只有当锁的值还是当初自己设置的值时,才允许删除。

正确的解锁流程是使用 Lua 脚本,因为它能保证原子性GETDEL 两个操作不会被分开执行):

-- KEYS[1]: 锁的 key,"lock:product:123"
-- ARGV[1]: 锁的值,"unique_value"
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

只有当 GET 的结果和 ARGV[1] 相同时,才会执行 DEL


Java 实现(使用 Jedis/Lettuce)

下面我们用 Java 代码来实现这个逻辑,这里我们使用 Jedis 作为 Redis 客户端,因为它对 Lua 脚本的支持非常直观。

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

1 添加依赖

在你的 pom.xml 中添加 Jedis 依赖:

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

2 实现加锁与解锁

import redis.clients.jedis.Jedis;
import redis.clients.jedis.params.SetParams;
import java.util.Collections;
import java.util.UUID;
public class RedisDistributedLock {
    private final Jedis jedis;
    private final String lockKey;
    private final String lockValue; // 使用 UUID 保证唯一性
    private final long expireTime; // 锁的过期时间(毫秒)
    public RedisDistributedLock(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() {
        // 使用 SetParams 来构建 SET 命令的参数
        SetParams params = SetParams.setParams().nx().px(expireTime);
        String result = jedis.set(lockKey, lockValue, params);
        // SET 命令在 NX 模式下,成功返回 OK,失败返回 nil
        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() 会将脚本发送到 Redis 服务器执行
        jedis.eval(luaScript, Collections.singletonList(lockKey), Collections.singletonList(lockValue));
    }
}

3 使用示例

public class Main {
    public static void main(String[] args) {
        Jedis jedis = new Jedis("localhost", 6379);
        String lockKey = "my_resource_lock";
        // 创建锁实例,设置过期时间为 10 秒
        RedisDistributedLock lock = new RedisDistributedLock(jedis, lockKey, 10000);
        try {
            // 尝试获取锁
            if (lock.tryLock()) {
                System.out.println("获取锁成功,开始执行业务逻辑...");
                // 模拟业务逻辑执行
                Thread.sleep(5000);
                System.out.println("业务逻辑执行完毕。");
            } else {
                System.out.println("获取锁失败,可能是其他客户端正在持有锁。");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            // 在 finally 块中释放锁,确保锁一定会被释放
            // 在实际使用前,最好先判断一下当前线程是否持有锁,避免误删
            // 这里为了简化示例,直接调用
            lock.unlock();
            System.out.println("锁已释放。");
            jedis.close();
        }
    }
}

更健壮的实现:Redisson

虽然上面的实现已经可以工作,但它还有一些问题需要考虑,

  • 锁续期:如果一个任务执行时间超过了锁的初始过期时间,锁会自动释放,导致其他任务进入,造成数据不一致,我们需要一个机制,在任务执行期间,定期“续期”锁的过期时间。
  • 可重入性:一个线程在持有锁的情况下,可以再次获取同一把锁,而不会造成死锁,上面的实现是不可重入的。
  • 代码易用性:手动管理 tryLockunlock 容易出错,最好能使用类似 synchronized 的代码块。

Redisson 是一个强大的 Java Redis 客户端,它已经为我们解决了上述所有问题,提供了功能完善的分布式锁实现。

1 添加 Redisson 依赖

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

2 使用 Redisson 实现分布式锁

Redisson 提供了 RLock 接口,用法非常简单。

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 RedissonLockExample {
    public static void main(String[] args) {
        // 1. 配置 Redisson 客户端
        Config config = new Config();
        config.useSingleServer().setAddress("redis://localhost:6379");
        RedissonClient redisson = Redisson.create(config);
        // 2. 获取锁对象
        // 锁的名称是全局唯一的
        RLock lock = redisson.getLock("my_resource_lock");
        try {
            // 3. 尝试获取锁
            // waitTime: 等待获取锁的最大时间,超过则放弃
            // leaseTime: 锁的自动释放时间(看门狗会自动续期)
            // TimeUnit: 时间单位
            boolean locked = lock.tryLock(10, 30, TimeUnit.SECONDS);
            if (locked) {
                System.out.println("Redisson 获取锁成功,开始执行业务逻辑...");
                // 模拟业务逻辑执行时间很长
                Thread.sleep(15000);
                System.out.println("业务逻辑执行完毕。");
            } else {
                System.out.println("Redisson 获取锁失败。");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            // 4. 释放锁
            // 只有在锁是当前线程持有的时候,才会释放
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
                System.out.println("Redisson 锁已释放。");
            }
        }
        // 关闭 Redisson 客户端
        redisson.shutdown();
    }
}

Redisson 的核心优势

  1. 看门狗机制:当线程获取锁后,Redisson 会启动一个后台线程(看门狗),定期检查锁是否还存在,如果存在就延长其过期时间,这解决了任务执行时间超长导致锁过期的问题。
  2. 可重入锁RLock 是可重入的,同一线程可以多次调用 lock() 方法,而不会阻塞自己。
  3. 锁续期tryLock(leaseTime) 方法的 leaseTime 参数只是初始的租约时间,看门狗会在此基础上进行续期,直到你主动 unlock()
  4. 优雅的 API:提供了类似 synchronizedlock.lock()lock.unlock(),以及带有超时和租约时间的 lock.tryLock(),使用非常方便。
  5. 公平锁/非公平锁:Redisson 还支持公平锁,按照请求的顺序来获取锁,避免“饥饿”问题。

总结与最佳实践

特性 手动实现 (Jedis/Lettuce) Redisson
实现复杂度 较高,需要自己处理 Lua 脚本、锁续期等 低,开箱即用
锁续期 需要自己实现看门狗逻辑 内置看门狗机制,自动续期
可重入性 不可重入,需要额外代码实现 支持可重入锁
公平锁 不支持 支持公平锁和非公平锁
易用性 较低,容易出错 高,API 设计优雅
性能 直接操作 Redis,性能高 性能稍好于手动实现,因为内部有优化和连接池管理

最佳实践建议:

  • 优先使用 Redisson:在绝大多数生产环境中,强烈推荐使用 Redisson,它是一个成熟、稳定、功能完备的解决方案,能让你专注于业务逻辑,而不是底层的锁实现细节。
  • 设置合理的锁超时时间:即使有看门狗,也应该为锁设置一个合理的初始超时时间(leaseTime),作为兜底,防止极端情况下看门狗失效。
  • finally 块中释放锁:确保锁一定会被释放,避免死锁。
  • 保证 value 的唯一性:在手动实现中,value 必须是唯一的,通常使用 UUID,在 Redisson 中,这个值由客户端内部生成和管理。
  • 处理锁获取失败:当获取锁失败时,应该有相应的重试策略或直接降级处理(如返回错误、返回默认数据等),而不是无限循环等待。
分享:
扫描分享到社交APP
上一篇
下一篇