Redis实现分布式锁

分布式锁


一、前言

在单体应用中,我们可以使用 Java 的 synchronized 或 ReentrantLock 来保证多个线程对共享资源的互斥访问。这些锁都依赖于单一的 JVM 进程------锁监视器存在于同一个堆内存中,不同线程看到的是同一把锁

然而在分布式集群环境下,多个服务实例运行在不同的 JVM 进程中,每个进程都有自己独立的内存空间,单体锁无法跨越 JVM 实现线程互斥。此时就需要一把所有进程都能"看见"的公共锁,这就是分布式锁。

分布式锁是跨进程、跨机器可见的互斥锁,它能保证在分布式系统中,同一时刻只有一个进程或线程可以执行临界区代码。实现分布式锁的方式有很多,如数据库、ZooKeeper、Etcd,Redis 等。

二、Redis实现分布式锁

Redis 实现分布式锁主要依赖两个关键操作:

  • 获取锁:互斥且非阻塞。尝试设置一个 key,仅当 key 不存在时才写入,这就能保证只有一个客户端能成功获取锁。
bash 复制代码
#添加锁,利用setnx的互斥特性
SETNX lock thread1
#添加锁过期时间,避免服务宕机引起的死锁
EXPIRE lock 10

SETNX 和 EXPIRE 是两个独立的命令,不具备原子性。如果在执行 SETNX 成功后、EXPIRE 执行前服务器突然宕机,这个锁 key 将永远不会过期,造成死锁。因此为保证原子性修改为

bash 复制代码
SET lock thread1 EX 10 NX
  • 释放锁:删除该 key,或让 key 自动过期。
bash 复制代码
DEL key

Java 代码实现如下所示,

java 复制代码
@Override
public boolean tryLock(long timeoutSec) {
    // 获取线程标示
    long threadId = Thread.currentThread().getId();
    // 获取锁
    Boolean success = stringRedisTemplate.opsForValue()
           .setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
    return Boolean.TRUE.equals(success);
}

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

三、分布式锁的误删问题

由于线程阻塞导致的删除其他人的锁

对于下面的情况:线程 1 获取锁成功,业务执行耗时较长(如 GC 停顿、网络延迟、阻塞等),锁在业务完成前自动过期了(蓝色线)。随后线程 2 成功获取锁,开始业务执行。线程 1 的阻塞恢复后,业务执行完成,调用 unlock() 删除了锁,但此时锁实际上属于线程 2。线程 3 在锁被误删后也能获取锁,造成并发安全问题

解决方案:释放锁时进行标识判断

因此,在获取锁时,还要存储当前持有者的唯一标识。释放锁时,先判断锁中的标识是否与当前请求者一致,一致才删除,否则不做任何操作。


修改内容如下

  • 添加全局唯一的前缀(可以用UUID表示),如果只存线程 ID,在分布式环境中可能发生碰撞,不同 JVM 中的线程 ID 都是从 1 开始自增的,可能出现相同值。
    • Key:lock:业务名称,例如 lock:seckill。
    • Value:UUID前缀 + "-" + 线程ID,例如 a1b2-3,用来标识是谁持有了锁。
  • 在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致
    如果一致则释放锁,如果不一致则不释放锁

key 的值:JVM唯一前缀(UUID) + 线程ID

ID_PREFIX/UUID:区分不同 JVM进程,不同 JVM 进程生成的 UUID不同

线程ID:区分同一个 JVM 下的不同线程

java 复制代码
private static final String KEY_PREFIX ="lock:";
private static final String ID_PREFIx =UUID.randomUUID().toString(isSimple:true) + "-";

@Override
public boolean tryLock(long timeoutSec) {
    // 获取线程标示
    String threadId = ID_PREFIX + Thread.currentThread().getId();
    // 获取锁
    Boolean success = stringRedisTemplate.opsForValue()
           .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
    return Boolean.TRUE.equals(success);
}


@Override
public void unlock() {
    // 获取线程标示
    String threadId = ID_PREFIX + Thread.currentThread().getId();
    // 获取锁中的标示
    String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
    // 判断标示是否一致
    if(threadId.equals(id)) {
        // 释放锁
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }
}

四、原子性问题

判断锁标识和释放锁是两个步骤,判断后阻塞了,也会误删锁,需要保证两个操作的原子性

上述 unlock 方法中,"判断锁标识"和"释放锁"是两个独立的 Redis 操作,不具备原子性。如果线程 A 在判断 threadId.equals(id) 为 true 后、执行 delete 之前,恰好锁过期,线程 B 获得了锁并写入了自己的标识,此时线程 A 的 delete 就会把线程 B 的锁误删。

1.Lua脚本语法

Redis 单线程执行 Lua 脚本,可以把多个命令打包成原子操作。

(1)Redis 提供的 Lua 调用函数

在 Lua 脚本中,可以通过 redis.call('命令名称', 'key', '参数', ...) 来执行 Redis 命令。例如:

lua 复制代码
-- 执行 set name jack
redis.call('set', 'name', 'jack')

-- 先执行 set,再执行 get,并把结果赋给 local 变量
local name = redis.call('get', 'name')
return name

(2)Redis 如何执行 Lua 脚本

在 Redis 客户端中,可以使用 EVAL 命令来执行 Lua 脚本,语法如下:

lua 复制代码
EVAL script numkeys key [key ...] arg [arg ...]
  • script:双引号包裹的 Lua 脚本字符串。
  • numkeys:脚本中 KEY 类型参数的个数,后面的 key [key ...] 会按顺序放入 KEYS 数组。
  • arg [arg ...]:可选的自定义参数,会依次放入 ARGV 数组。

例如执行 redis.call('set', 'name', 'jack'),0 表示没有 key 参数

lua 复制代码
EVAL "return redis.call('set', 'name', 'jack')" 0

key、value可以作为参数传递(keý类型参数会放入KEYS数组,其它参数会放入ARGV数组),带参数的示例

lua 复制代码
#调用脚本
EVAL "return redis.call('set', KEYS[1], ARGV[1])" 1 name Rose
  • 1 表示有 1 个 key 参数,即 name,它被放入 KEYS[1]
  • Rose 被放入 ARGV[1]

注意:KEYS 和 ARGV 的下标从 1 开始,而不是 0。

(3)解锁脚本 unlock.lua

lua 复制代码
-- 获取锁中的线程标示  get key
local id = redis.call('get', KEYS[1])
-- 比较线程标示与锁中的标示是否一致
if(id ==  ARGV[1]) then
    -- 释放锁 del key
    return redis.call('del', KEYS[1])
end
return 0
  • KEYS[1]:锁的 key,由外部传入(如 lock:seckill)。
  • ARGV[1]:当前线程的唯一标识(UUID + 线程ID)。
  • 如果锁中的值等于当前线程标识,则删除并返回 1(成功),否则返回 0。

2.Java调用Lua脚本

RedisTemplate 调用 Lua 脚本的 API 如下,

java 复制代码
public <T> T execute(RedisScript<T> script, List<K> keys, Object... args)
  • script 对应脚本内容,封装了 Lua 脚本内容和期望的返回类型
  • List<K> keys对应 KEYS 数组,个数即 numkeys
  • Object... args对应 ARGV 数组

调用 execute 时,底层会自动将这些参数转换为 EVAL script numkeys key1 key2 ... arg1 arg2 ... 发送给 Redis。

3.使用Lua进行调用

首先在类中加载并编译解锁脚本:

java 复制代码
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;

static {
    UNLOCK_SCRIPT = new DefaultRedisScript<>();
    // 指定脚本文件位置(classpath下)
    UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
    // 设置返回类型
    UNLOCK_SCRIPT.setResultType(Long.class);
}
  • DefaultRedisScript 是 Spring 提供的脚本封装类
  • setLocation 加载 classpath 下的 .lua 文件,避免将大段脚本写在 Java 字符串中
  • setResultType(Long.class) 指定脚本返回值类型,这里因为脚本返回 0 或 1,所以用 Long

在 unlock 方法中调用脚本

java 复制代码
@Override
public void unLock() {
    //使用lua脚本保证操作原子性
    stringRedisTemplate.execute(UNLOCK_SCRIPT
            , Collections.singletonList(KET_PREFIX+name)
            ,ID_PREFIX+Thread.currentThread().getId());
}
  • Collections.singletonList(...) 生成只有一个元素的不可变列表,对应 KEYS 数组。因为只有一个 key,numkeys 即为 1
  • ID_PREFIX + Thread.currentThread().getId() 作为 ARGV[1] 传入

总结

基于 Redis 的分布式锁实现核心要点

  • 利用 SET NX EX 命令原子性地获取锁并设置过期时间,既保证互斥,又避免死锁。
  • 存储全局唯一的线程标识(UUID + 线程ID),防止释放其他线程的锁。
  • 使用 Lua 脚本将"判断标识"与"删除锁"原子化,解决解锁过程中的并发误删问题。
  • 分布式锁只适用于单机 Redis 或主从哨兵(AP)模型,在 Redis Cluster 下需配合 RedLock 等更为复杂的算法。
相关推荐
张~颜1 小时前
autovacuum
数据库·postgresql
山峰哥1 小时前
SQL优化从入门到精通:20个案例破解性能密码
数据库·sql·oracle·性能优化·深度优先
努力努力再努力wz1 小时前
【MySQL进阶系列】拒绝冗余SQL:带你透彻理解视图的底层逻辑
android·c语言·数据结构·数据库·c++·sql·mysql
历程里程碑1 小时前
MySQL数据类型全解析 + 代码实操讲解
大数据·开发语言·数据库·sql·mysql·elasticsearch·搜索引擎
杨云龙UP1 小时前
Windows Server 2012 环境下 Oracle 11.2 使用 expdp 实现自动备份、异地复制与定期清理_20260504
服务器·数据库·windows·mysql·docker·oracle·容器
nbwenren2 小时前
MySQL数据库误删恢复_mysql 数据 误删
数据库·mysql·adb
Rick19932 小时前
sql慢查询优化
数据库
IT邦德2 小时前
OGG 26ai实时同步Oracle
数据库·oracle
Python大数据分析@2 小时前
有哪些好用又免费的SQL工具?
数据库·sql