Redis实现分布式锁

对于多线程安全问题,在单机模式下,我们常常使用乐观锁和悲观锁解决

  • 悲观锁:认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行

    • Synchronized 和 lock 是悲观锁的典型代表
  • 乐观锁:认为线程安全问题不一定会发生,所以并不真正加锁,只是在更新数据时去判断有没有其他线程对数据进行修改,如果没有修改,则认为线程是安全的,而后才更新数据,否则认为线程是不安全的

    • 乐观锁常见的实现方式是依靠版本号,共享的实体存在一个版本号字段version,每次操作数据会对版本号+1,提交回数据时,先去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功;
    • 乐观锁的典型代表:cas
    • 乐观锁的核心思想是:在数据更改之前先对当前数据进行校验

乐观锁常用于解决更改数据时的多线程安全问题,悲观锁则更多用于修改数据时的线程安全问题

通过加锁可以解决在单机情况下的多线程安全问题,但是在集群模式下就不行了。 当我们部署了多个tomcat,每个tomcat都有一个属于自己的jvm,那么假设在服务器A的tomcat内部,有两个线程,他们的锁对象是同一个,是可以实现互斥的,但是如果现在是服务器B的tomcat内部,又有两个线程,但是他们的锁对象写的虽然和服务器A一样,但是锁对象却与服务器A的不是同一个,所以线程3和线程4可以实现互斥,但是却无法和线程1和线程2实现互斥,这就是集群环境下,syn锁失效的原因,在这种情况下,我们就需要使用分布式锁来解决这个问题。

什么是分布式锁

分布式锁是一种用于在分布式系统中实现资源的互斥访问的机制。在分布式环境中,多个节点同时访问共享资源时,为了保证数据的一致性和正确性,需要确保同一时间只有一个节点可以对资源进行操作,其他节点需要等待。

常见的实现分布式锁的方式包括:

  • 基于数据库:通过在数据库中创建唯一索引或者使用悲观锁机制来实现分布式锁。
  • 基于缓存:利用分布式缓存(如Redis)的原子性操作和过期时间特性,通过设置一个特定的键值对来实现分布式锁。
  • 基于ZooKeeper:利用ZooKeeper的有序临时节点和Watch机制来实现分布式锁。

此处我们重点讨论基于Redis实现分布式的方法和原理

基于SETNX实现分布式锁

Redis的SETNX命令是一种用于设置键值对的原子性操作。它在键不存在的情况下设置键的值,并返回设置成功与否的结果。

执行SETNX命令时,会进行以下操作:

  • 如果键key不存在,则将键 key 的值设置为 value。
  • 如果键 key 已经存在,则不进行任何操作,返回0。

redis基于setnx实现分布式锁的核心思想:利用setnx方法,将某个键作为锁的标识,通过 SETNX 来尝试获取锁,如果返回1表示获取锁成功,否则表示锁已被其他节点占用。

需要注意的是,由于 SETNX 命令是原子性的,当获取到锁后,需要在适当的时候释放锁,以避免锁的长期占用。

实现分布式锁时需要实现的两个基本方法:

  • 获取锁:
    • 互斥:确保只能有一个线程获取锁
    • 非阻塞:尝试一次,成功返回true,失败返回false

利用setnx方法进行加锁,同时增加过期时间,防止死锁,此方法可以保证加锁和增加过期时间具有原子性

java 复制代码
private static final String KEY_PREFIX="myLock:"
@Override
public boolean tryLock(long timeoutSec) {
    // 获取线程标示
    String threadId = Thread.currentThread().getId()
    // 获取锁
    Boolean success = stringRedisTemplate.opsForValue()
            .setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
    return Boolean.TRUE.equals(success);
}
  • 释放锁:
    • 手动释放
    • 超时释放:获取锁时添加一个超时时间

释放锁,防止删除别人的锁

java 复制代码
public void unlock() {
    //通过del删除锁
    stringRedisTemplate.delete(KEY_PREFIX + name);
}

锁误删

前提:线程1和线程2 操作的是同一把锁(即同一共享资源,key一致) 当持有锁的线程1在锁的内部出现了阻塞,导致他的锁自动释放,这时线程2来尝试获得锁,就拿到了这把锁,然后线程2在持有锁执行过程中,线程1反应过来,继续执行,而线程1执行过程中,走到了删除锁逻辑,由于key一致,此时就会把本应该属于线程2的锁进行删除,这就是锁误删

解决方案 在获取锁时存入线程标示(可以用UUID表示) 在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致

  • 如果一致则释放锁
  • 如果不一致则不释放锁
java 复制代码
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);
    }
}

在实际业务中,还存在更为极端的误删情况:线程1持有锁完成业务后,进入删除锁的方法,获取锁标识完成了条件判断,但是此时他的锁到期了,线程1超时释放锁,同时线程2进来,获得锁;当阻塞完成后,线程1会接着往后执行,进行锁删除,相当于条件判断并没有起到作用

这是删锁时的原子性问题,之所以有这个问题,是因为线程1的拿锁,比锁,删锁,实际上并不是原子性的,我们要防止刚才的情况发生,

Lua脚本解决多条命令原子性问题

Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。

Redis提供的调用函数 redis.call('命令名称', 'key', '其它参数', ...)

lua脚本:获取锁中的线程标示,判断是否与指定的标示(当前线程标示)一致,如果一致则释放锁(删除)如果不一致则什么都不做

lua 复制代码
-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
  -- 一致,则删除锁
  return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0

RedisTemplate中,可以利用execute方法去执行lua脚本

java 复制代码
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }

public void unlock() {
    // 调用lua脚本
    stringRedisTemplate.execute(
            UNLOCK_SCRIPT,
            Collections.singletonList(KEY_PREFIX + name),
            ID_PREFIX + Thread.currentThread().getId());
}
相关推荐
NCIN EXPE2 小时前
redis 使用
数据库·redis·缓存
lUie INGA2 小时前
在2023idea中如何创建SpringBoot
java·spring boot·后端
hERS EOUS3 小时前
nginx 代理 redis
运维·redis·nginx
geBR OTTE3 小时前
SpringBoot中整合ONLYOFFICE在线编辑
java·spring boot·后端
Porunarufu3 小时前
博客系统UI自动化测试报告
java
Aurorar0rua4 小时前
CS50 x 2024 Notes C - 05
java·c语言·数据结构
NoSi EFUL4 小时前
redis存取list集合
windows·redis·list
Deepincode5 小时前
Redis源码探究系列—SDS 扩容策略与内存预分配机制
redis
Cosmoshhhyyy5 小时前
《Effective Java》解读第49条:检查参数的有效性
java·开发语言
布谷歌5 小时前
常见的OOM错误 ( OutOfMemoryError全类型详解)
java·开发语言