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,它被放入 KEYS1
  • Rose 被放入 ARGV1

注意: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
  • KEYS1:锁的 key,由外部传入(如 lock:seckill)。
  • ARGV1:当前线程的唯一标识(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() 作为 ARGV1 传入

总结

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

  • 利用 SET NX EX 命令原子性地获取锁并设置过期时间,既保证互斥,又避免死锁。
  • 存储全局唯一的线程标识(UUID + 线程ID),防止释放其他线程的锁。
  • 使用 Lua 脚本将"判断标识"与"删除锁"原子化,解决解锁过程中的并发误删问题。
  • 分布式锁只适用于单机 Redis 或主从哨兵(AP)模型,在 Redis Cluster 下需配合 RedLock 等更为复杂的算法。
相关推荐
muddjsv1 分钟前
HBase与Hadoop:基于什么开发?深度剖析与架构图
数据库·hadoop·hbase
muddjsv1 分钟前
HBase 与 Hadoop 安装与上手使用全指导
数据库·hadoop·hbase
学计算机的计算基4 分钟前
MySQL 锁体系全解:从 MDL 到间隙锁,一次讲透
java·数据库·笔记·python·mysql
Trouvaille ~6 分钟前
【Redis篇】Redis 事务:原子性与脚本执行机制
数据库·redis·后端·算法·junit·lua·原子性
努力攻坚操作系统9 分钟前
Elasticsearch 完全教学指南:从入门到精通
大数据·数据库·elasticsearch·搜索引擎·全文检索
睡不醒男孩03082312 分钟前
行业解决方案二:CLup打造企业级数据库私有云(DBaaS)平台解决方案
数据库·云计算·clup
猴哥聊项目管理13 分钟前
2026年信创项目管理:如何用甘特图提升进度管控
大数据·数据库·项目管理·企业数字化转型·甘特图·敏捷开发·项目进度管理软件
冷色调的咖啡师21 分钟前
1.大数据架构技术 上——搭建分布式Hadoop集群
大数据·linux·hadoop·分布式·hdfs·架构·yarn
j7~21 分钟前
MySQL C语言连接库和MYSQL连接池原理与简易数据网站数据流动是如何进行的
c语言·数据库·mysql·连接池·mysqlc语言连接库
暗夜猎手-大魔王33 分钟前
转载--Hermes Agent 10 | 7 层安全防线:从用户授权到输入净化
java·数据库·安全