• 如何使用Redis实现分布式锁功能?
  • 发布于 2个月前
  • 290 热度
    0 评论
对于多线程安全问题,在单机模式下,我们常常使用乐观锁和悲观锁解决
悲观锁:认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。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命令时,会进行以下操作:
1.如果键key不存在,则将键 key 的值设置为 value。
2.如果键 key 已经存在,则不进行任何操作,返回0。

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


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


实现分布式锁时需要实现的两个基本方法:
获取锁:
互斥:确保只能有一个线程获取锁
非阻塞:尝试一次,成功返回true,失败返回false

利用setnx方法进行加锁,同时增加过期时间,防止死锁,此方法可以保证加锁和增加过期时间具有原子性
private static final String KEY_PREFIX="myLock:"
@Override
public boolean tryLock(long timeoutSec) {
   // 堆代码 duidaima.com
    // 获取线程标示
    String threadId = Thread.currentThread().getId()
    // 获取锁
    Boolean success = stringRedisTemplate.opsForValue()
            .setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
    return Boolean.TRUE.equals(success);
}

释放锁:
.手动释放
.超时释放:获取锁时添加一个超时时间

释放锁,防止删除别人的锁
public void unlock() {
    //通过del删除锁
    stringRedisTemplate.delete(KEY_PREFIX + name);
}

锁误删
前提:线程1和线程2 操作的是同一把锁(即同一共享资源,key一致)

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


解决方案
在获取锁时存入线程标示(可以用UUID表示) 在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致:
1.如果一致则释放锁
2.如果不一致则不释放锁
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脚本:获取锁中的线程标示,判断是否与指定的标示(当前线程标示)一致,如果一致则释放锁(删除)如果不一致则什么都不做
-- 这里的 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脚本
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());
}
用户评论