浅谈分布式锁-Redis

Redis 实现分布式锁

在分布式的系统,分布式锁是绕不开的一个话题。通过客户端访问共享资源,来实现分布式锁是目前解决分布式锁的通过解决方案,不管是redis 还是 zk 或者其他的分布式锁解决方案。但是目前所采用的分布式一般是redis 或者 zk。

基于setnx

redis 的使用是基于redis setnx 命令去做的,当key值不存在时,将key的值设置为value。但是如果ke值已经存在,则setnx不做任何动作。

  • 返回1说明获得该进程的锁
  • 返回0说明有其他进程已经获得锁了,进程不能如临界区。

当线程执行完之后会执行 del命令释放锁命令,这样其他线程就可以继续执行该方法

这样有一个很大的问题,就是当如果获取线程拿到锁后,客户端挂了,或者线程挂了,没有执行del命令,这样就会永久占用这把锁。产生锁的情况。解决方案就是加上超时时间

设置一个超时时间,即使线程没有被显示的释放出来,这把锁也要在一段时间后自动释放。可以使用expire 命令。

假如setnx 和 expire 不是原子操作,就会出现,setnx之后线程就挂了,这时候没有设置超时时间,就会变成死锁。所以这是要将setnx 和 expire 进行原子操作。

设置 set <lock.key> <lock.value> nx ex expireTime

这时候也会存在问题,假如设置的时间是30s,如果A线程执行执行的很慢,这时候过了30s锁自动释放了。线程B得到锁。。
这时候线程B执行一半的时间,线程A执行完了,执行了del命令,这是A删除的实际上B的锁
这种情况可以在del释放锁之前,做一个判断,验证当前锁是不是自己的。加锁的时候把当前线程的ID作为value,并在删除前验证value是不是当前线程,但是这是两个操作,要保证其原子性,就要使用Lua脚本。

锁续期 上面的情况避免掉了误删的情况,但是同一时间会有两个进程在拿到锁。我们可以让一个锁的线程开启守护线程,给快要过期的锁来续期。

假设线程A执行了29秒后还没执行完,这时候守护线程会执行expire指令,为这把锁续期20秒。守护线程从地29秒后开始执行,每20秒执行一次
当线程A执行完成,会显示关掉守护线程
如果服务器挂掉,守护线程也会跟着挂掉,这时候不会再去增加续期时间也就自动释放掉了。

基于setnx、get、getset 的分布式锁

  1. setnx(lockkey,当前时间+过期超时时间),如果返回1,则获取锁成功;如果返回0则没有获取到锁,转向步骤(2)
  2. get(lockkey)获取值oldExpireTime,并将这个value值与当前的系统时间进行比较,如果小于当前系统,则认为这个锁已经超时,可以允许别的请求重新获取转向步骤(3)
  3. 计算重新新的过期时间 newExpireTime = 当前时间 + 锁超时时间,然后getset(lockkey,newExpireTime)会返回当前lockey的值,currentExpireTime
  4. 判断currentExpireTime与oldExpireTime是否一致,如果相等,则说明当前getset设置成功,获得到了锁。如果不相等,则这个锁又被别的线程获取走了,那么当前请求可以是失败,也可以继续获取。
  5. 在获取到了锁之后,当前线程可以开始处理自己的业务,当处理完毕后,比较自己的处理时间和对于所设置的超时时间,如果小于锁设置的超时时间,则直接执行del命令释放锁(释放锁之前需要判断持有锁的线程是不是当前线程);如果大于锁设置的超时时间,则不需要再锁进行处理。
public boolean lock(long acquireTimeout, TimeUnit timeUnit) throws InterruptedException {
    acquireTimeout = timeUnit.toMillis(acquireTimeout);
    long acquireTime = acquireTimeout + System.currentTimeMillis();
    //使用J.U.C的ReentrantLock
    threadLock.tryLock(acquireTimeout, timeUnit);
    try {
    	//循环尝试
        while (true) {
        	//调用tryLock
            boolean hasLock = tryLock();
            if (hasLock) {
                //获取锁成功
                return true;
            } else if (acquireTime < System.currentTimeMillis()) {
                break;
            }
            Thread.sleep(sleepTime);
        }
    } finally {
        if (threadLock.isHeldByCurrentThread()) {
            threadLock.unlock();
        }
    }
 
    return false;
}
 
public boolean tryLock() {
 
    long currentTime = System.currentTimeMillis();
    String expires = String.valueOf(timeout + currentTime);
    //设置互斥量
    if (redisHelper.setNx(mutex, expires) > 0) {
    	//获取锁,设置超时时间
        setLockStatus(expires);
        return true;
    } else {
        String currentLockTime = redisUtil.get(mutex);
        //检查锁是否超时
        if (Objects.nonNull(currentLockTime) && Long.parseLong(currentLockTime) < currentTime) {
            //获取旧的锁时间并设置互斥量
            String oldLockTime = redisHelper.getSet(mutex, expires);
            //旧值与当前时间比较
            if (Objects.nonNull(oldLockTime) && Objects.equals(oldLockTime, currentLockTime)) {
            	//获取锁,设置超时时间
                setLockStatus(expires);
                return true;
            }
        }
 
        return false;
    }
}

tryLock方法中,主要逻辑如下:lock调用tryLock方法,参数为获取的超时时间与单位,线程在超时时间内,获取锁操作自旋在那里,直到自旋的保持保持者释放了锁。

释放锁:

public boolean unlock() {
    //只有锁的持有线程才能解锁
    if (lockHolder == Thread.currentThread()) {
        //判断锁是否超时,没有超时才将互斥量删除
        if (lockExpiresTime > System.currentTimeMillis()) {
            redisHelper.del(mutex);
            logger.info("删除互斥量[{}]", mutex);
        }
        lockHolder = null;
        logger.info("释放[{}]锁成功", mutex);
 
        return true;
    } else {
        throw new IllegalMonitorStateException("没有获取到锁的线程无法执行解锁操作");
    }
}

存在问题:
1.这个锁是基于System.currentTimeMillis()如果多太服务器的时间不一致,就会出现问题,但是这个是可以从运维层面规避的
2.如果一台服务器锁超时的时候,刚好会有多台服务器请求锁,会同时出现执行redis.getset()而导致过期时间覆盖的问题
3.存在多个线程同时持有锁的情况,如果线程A执行任务的时间超过锁的过期时间,这时另外一个线程就可以获得锁了,造成多个线程同时持有一把锁的情况。类似于解决方案一,可以使用锁延期的方式来解决

前面两种redis解决方案,从高可用的层面来看都是有所欠缺的,也就是说当redis是单点的情况下,当发生故障时,则整个业务分布式锁都无法使用
为了提高可用性,我们可以使用主从模式或者哨兵模式,但在这种情况下任然存在问题,从主从模式或者哨兵模式下,正常情况下,如果加锁成功了,那么master节点会异步复制给对应的slave阶段,但是如果在这个过程中发生了宕机,主从切换的时候,这个slave阶段就会变成maste节点,但是这个锁并没有同步过来,这就发生了锁的丢失,会导致多个客户端可以同时持有一把锁的问题。

基于RedLock的分布式锁

redLock 是redis的作者Antirez在单Redis节段基础上引入的高可用的模式。RedLock的加锁要结合单点分布式锁算法共同实现,因为它是RedLock的基础。

实现原理

假设现在有5个redis主节点,这样基本保证不会他们同时宕机。

  1. 获取当前服务器时间,以毫秒为单位,并且设置超时时间TTL
  2. 依次从这5个实例中获取具有相同Key和具有唯一性vaule获取锁,当redis请求获取锁时,客户端应该设置一个网络连接和超时响应时间,这个时间应该小TTL,假设TTL是5秒,这个超时时间就应该是1秒。这样就可以避免客户端死等。从而放弃这个锁。
  3. 获取所有的锁有的时间减去第一步的时间,就得到锁的时间,所得获取时间要小于TTL。并且至少半数以上的 Redis节点获取到锁,才算锁成功。
  4. 如果成功获得锁,key的真正的有效时间 = TTL-锁的获取时间-时钟漂移时间。
  5. 如果因为某些原因,获取锁失败,客户端应该在所有的Redis实例上进行解锁,无论redis实例是否加锁成功。

这是因为在redis返回给客户端的数据包丢失了,这时候客户端认为没有获取到锁。但实际上redis里已经有这个客户端的锁了。这时候要释放所有的锁。

  1. 失败重试:当client不能获取锁时,应该在随机事件后重试获取锁;同时重试锁一定要有限制

防止过多的客户端多次重试获取锁,导致批次都获取锁失效得到问题。

redLock性能及崩溃恢复的相关解决方法

由于N个节点中大多数能正常工作就能够保证RedLock的正常工作,因此理论上它的可用性更高。前面我们说的主从架构的安全问题,在RedLock中已经不存在了,但如果有发生节点重启,还是会对锁的安全性有影响的。

  1. 如果redis没有持久化功能,在A客户端获取到锁后,重启redis,B客户端仍然能够获取到锁,这违背了锁的排他性。
  2. 如果启动AOF永久化存储,事情会好些,当redis重启后,redis机制是按照服务器时间过期的,所以重启后,然后会在规定的时间内过期,不会影响业务。但是由于AOF同步到磁盘方式默认是每一秒,如果在一秒内断电,就会导致数据丢失,立即重启就会导致互斥锁失效;但如果同步磁盘的方式是使用always,会造成性能急剧下降。所以在锁的完全有效性和性能方面要有取舍。
  3. 为了既有有效性,有保证性能,antirez提出了延迟重启的概念,redis还是以AOF的存储方式,但是redis在崩溃后,都先不立即重启它,而是在TTL时间后再重启,这样的话,这个节点重启前所参与的锁都会过期,它在重启后就不会出现对现有锁的影响,但是在TTL这段时间内服务相当于暂停的状态。

RedLock算法的安全性

链接: 基于Redis的分布式锁到底安全吗

基于Redisson看门狗机制

在某些情况下,还没有程序还没有执行完,锁因为还没超时就自动释放了,就会导致多个线程同时持有锁的现象出现,而为了解决这个问题,可以进行“续锁期”。

原理

redisson在获取锁之后,会维护一个看门狗线程,当锁即将过期没有释放的时候不断延长锁的key的生存时间。

加锁机制

线程获取锁,获取成功,执行lua脚本,保存数据到redis。
线程获取锁,获取失败,一直通过while循环尝试获取锁,获取成功后执行lua脚本,保存数据到redis数据库。

watch dog自动延时机制

看门狗开启后,会对整体的性能有一定影响,所以默认情况是不启动的,如果使用redisson进行加锁的同时设置了过期时间,也会导致看门狗机制失效

redisson在获取锁之后,会维护一个看门狗线程,在每一个锁设置的过期时间的1/3处,如果线程还没任务还没有执行完,则不断延长锁的有效期。看门狗的检查超时时间默认是30秒。
一旦业务机器宕机了,看门狗线程就执行不了了,锁到期自然就释放了。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值