基于Redis实现分布式锁

246 阅读7分钟

介绍基于Redis实现的分布式锁,包括Redis实现、Redission类实现及可重入锁的实现。文章侧重自己的理解,代码参考chatGPT。 (分享太密了,今天在肝一篇)

1、分布式锁的概念

锁是解决针对于对共享变量进行操作的安全性问题,分布式锁则是用于在分布式系统中控制。这里主要介绍基于Redis实现的分布式锁。

2、分布式锁的实现
2.1、Redis实现
2.1.1、SETNX

Redis是单线程处理命令,可以使用SETNX来实现分布式锁。
介绍:当键不存在时,设置键值,并返回 1;如果键已经存在,则返回 0。通过这一特性,可以确保同一时刻只有一个客户端能够成功获取锁,从而实现分布式锁的功能。
问题:锁如何设置过期时间

  1. 使用 SETNX 先设置锁,然后再使用 EXPIRE 命令设置锁的过期时间,这两个操作并非原子操作
  2. 借助Lua脚本,保证上述操作的原子性。

参考代码:

if redis.call("SETNX", KEYS[1], ARGV[1]) == 1 then
    redis.call("PEXPIRE", KEYS[1], ARGV[2])
    return 1
else
    return 0
end
  • KEYS[1]:表示锁的键名(即 lock_key)。
  • ARGV[1]:表示锁的值(用于标识持有锁的客户端,可以是唯一的 UUID 或随机字符串)。
  • ARGV[2]:表示锁的过期时间(单位为毫秒,保证锁在客户端故障时不会永久存在)。
2.1.2、使用 SET 命令结合 NXPX 参数

从 Redis 2.6.12 版本开始,通过 SET key value NX PX expiration_time 来同时设置键值和过期时间。

  • NX:表示只有在键不存在时才会设置键值,相当于 SETNX 的功能。
  • PX expiration_time:设置键的过期时间,单位为毫秒。

示例:

SET lock_key unique_value NX PX 10000
  • lock_key:表示锁的键。
  • unique_value:表示用于标识锁持有者的唯一值(比如 UUID)。
  • PX 10000:表示锁的过期时间为 10 秒(10000 毫秒)。
2.2、Redission实现

方法lock()tryLock()unlock()
示例:

import org.redisson.api.RedissonClient;

public class DistributedLockExample {

    public static void main(String[] args) {
        RedissonClient redisson = RedissonManager.getClient();
        
        // 获取锁实例
        RLock lock = redisson.getLock("myLock");

        // 尝试加锁
        try {
            // 加锁(默认非阻塞,如果获取到锁则立即返回)
            lock.lock();

            // 或者可以设置锁的超时时间,比如10秒后自动释放锁
            // lock.lock(10, TimeUnit.SECONDS);

            // 业务逻辑
            System.out.println("锁定中,执行任务...");

        } finally {
            // 释放锁
            lock.unlock();
        }

        redisson.shutdown();
    }
}

2.2.1、tryLock()lock()

lock()(阻塞直到成功获取锁)

tryLock():

  • 非阻塞:调用 tryLock() 后,如果无法立即获取锁,它不会无限期地等待,而是返回 false 表示获取失败,从而避免线程长时间阻塞。
  • 设置等待时间:可以指定一个最大等待时间,在这个时间段内会尝试获取锁。如果在规定时间内获取到了锁,则返回 true,否则返回 false
  • 设置锁的自动释放时间:除了设置等待时间,还可以指定锁的持有时间,即使持有锁的线程忘记手动释放锁,它也会在设定时间后自动释放。
2.2.2、可重入性

Redisson 的 RLock可重入的分布式锁,这意味着同一线程可以多次获取同一把锁,而不必担心死锁问题。例如:

java
复制代码
// 线程已经持有锁,可以再次获取锁
lock.lock();
// 在任务完成后必须释放相应次数的锁
lock.unlock();

可重入锁能够确保线程在持有锁的情况下多次进入临界区,并在相应次数的 unlock() 调用后才真正释放锁。

2.2.3自动续期(Watchdog)

Redisson 具有看门狗机制(Watchdog),在你没有指定锁的过期时间时,它会默认将锁的过期时间设置为 30 秒。如果在这段时间内,持有锁的线程没有主动释放锁,Redisson 的看门狗机制会自动延长锁的过期时间,直到持有锁的线程主动释放锁为止。

  • 如果没有手动指定锁的超时时间,Redisson 会启动一个后台任务来每隔 10 秒自动续期,保证锁不会意外过期。
3、Redis如何实现可重入性

概述:使用Redis的hash结构存储可重入次数结合lua脚本来实现。hash结构存储“用户信息owner”、“重入次数count”、“过期时间PEXPIRE”。

可重入锁的基本思路

  1. 线程标识: 为了实现可重入锁,锁需要知道哪个线程已经持有锁。因此,每次获取锁时,需要将线程的唯一标识(如 UUID)作为锁的值存储在 Redis 中。
  2. 重入计数: 当一个线程多次获取同一把锁时,不应该阻塞或失败,而是增加锁的重入计数器。每次释放锁时,计数器减 1,直到计数器为 0 时才真正释放锁。
  3. 锁的过期时间: 为了防止锁在系统出现故障时无法释放,可重入锁同样需要设置锁的过期时间。每次重入时,可以延长锁的过期时间,确保锁在正常情况下不会自动过期。

参考代码:
获取锁的 Lua 脚本

-- KEYS[1] 是锁的键名
-- ARGV[1] 是线程唯一标识 (UUID)
-- ARGV[2] 是过期时间 (毫秒)

-- 检查当前锁是否已经存在
local lockOwner = redis.call("HGET", KEYS[1], "owner")

-- 如果锁不存在,创建锁
if not lockOwner then
    redis.call("HSET", KEYS[1], "owner", ARGV[1])
    redis.call("HSET", KEYS[1], "count", 1)
    redis.call("PEXPIRE", KEYS[1], ARGV[2])
    return 1  -- 获取锁成功
end

-- 如果锁存在,且是当前线程持有的,则递增重入计数
if lockOwner == ARGV[1] then
    redis.call("HINCRBY", KEYS[1], "count", 1)
    redis.call("PEXPIRE", KEYS[1], ARGV[2])  -- 更新锁的过期时间
    return 1  -- 获取锁成功
end

-- 如果锁存在,且不是当前线程持有的,返回 0 表示获取锁失败
return 0

这个 Lua 脚本做了以下几件事情:

  1. 检查 Redis hash 中是否有名为 owner 的字段(表示锁是否已经存在)。
  2. 如果锁不存在,创建锁,设置持有者(即 owner),重入计数为 1,并设置过期时间。
  3. 如果锁已经由当前线程持有,递增重入计数并延长锁的过期时间。
  4. 如果锁由其他线程持有,返回 0,表示获取锁失败。

释放锁的 Lua 脚本

-- KEYS[1] 是锁的键名
-- ARGV[1] 是线程唯一标识 (UUID)

-- 检查当前锁是否由该线程持有
local lockOwner = redis.call("HGET", KEYS[1], "owner")

-- 如果锁不存在或不属于当前线程,返回 0 表示释放锁失败
if lockOwner ~= ARGV[1] then
    return 0
end

-- 如果重入计数大于 1,则递减计数
local count = redis.call("HINCRBY", KEYS[1], "count", -1)

-- 如果重入计数为 0,释放锁
if count == 0 then
    redis.call("DEL", KEYS[1])
end

return 1  -- 释放锁成功

这个 Lua 脚本执行以下操作:

  1. 检查当前线程是否持有锁(通过 hash 中的 owner 字段)。
  2. 如果当前线程是锁的持有者,则递减重入计数。
  3. 当重入计数减少到 0 时,彻底删除锁。
  4. 如果锁不是当前线程持有,返回 0 表示释放失败。
4、防止其他线程误删

Reids:上面已经介绍了Redis中实现可重入锁时加入用户信息,只需在hash中添加线程的唯一信息标识,在解锁时判断是否为当前线程。
Redisson ,不需要手动处理误删锁的问题,Redisson内部通过多个机制,如唯一标识符(UUID)Lua 脚本,来确保锁的安全性,防止其他线程误删锁。

5、锁续命

Reids:不具备自动续期机制,开发者需要在加锁时指定一个固定的过期时间,并且如果任务执行时间超过了过期时间,需要手动延长锁的过期时间。

Redis 实现锁续命的思路

在使用 Redis 分布式锁时,你可以通过定期刷新锁的过期时间来实现“续命”机制。实现思路如下:

  1. 获取锁时设置初始过期时间:在首次获取锁时,设置一个合理的过期时间,防止锁因程序异常或崩溃而长期存在,导致死锁。
  2. 定期续命:在持有锁的线程中,定期检查锁的剩余时间。如果锁快要过期,则发送一个命令来延长锁的过期时间,确保任务执行期间锁不会过期。
  3. 任务完成后手动释放锁:一旦任务完成,主动释放锁。

Redisson :可以自动管理锁的续期,它会监控持有锁的线程,在锁即将过期时自动延长过期时间。