Redis分布式锁

378 阅读14分钟

前言

解决临界资源的线程安全问题,如果你们项目是单体项目,即线上只有一个实例,并且你的项目并发量不高,可以使用java的volatile+synchronized来解决,也可以使用数据库锁。如果不符合以上要求,就要使用分布式锁来解决了,分布式锁常用的有zookeeper和redis等,以下我们以redis为例,介绍使用方法和注意事项。

1、Redis锁原理

  • 指定一个key作为锁标记,存入Redis中,指定一个唯一的用户标识作为value
  • 当 key 不存在时才能设置值,确保同一时间只有一个客户端进程获得锁,满足互斥性特性。
  • 设置一个过期时间,防止因系统异常导致没能删除这个 key,满足防死锁特性。
  • 当处理完业务之后需要清除这个 key 来释放锁,清除 key 时需要校验 value 值,需要满足只有加锁的人才能释放锁 。

1.1、互斥是如何实现的

Redis分布式锁的互斥性是通过使用Redis的原子性操作和特定的命令来实现的。通常,最常用的一种Redis分布式锁实现方式是使用SET命令,搭配NX(Not eXists)和PX(Expiration time in milliseconds)选项。

  1. 使用SET命令尝试获取锁

    bashCopy code
    SET key value NX PX timeout
    
    • key: 锁的键名,是一个在系统中唯一的标识符。
    • value: 锁的值,可以是随机生成的唯一值,用于标识持有锁的客户端。
    • NX: 表示只有在键不存在时才能设置成功,即获取锁的操作。
    • PX timeout: 设置键的过期时间,防止锁长时间不释放。
  2. 判断SET命令的返回值

    • 如果SET命令返回成功(表示锁获取成功),则表示当前客户端成功获取了锁。
    • 如果SET命令返回失败(表示锁已经被其他客户端持有),则表示获取锁失败。
  3. 释放锁

    • 为了避免死锁和确保只有持有锁的客户端可以释放锁,通常使用Lua脚本来执行释放锁的操作。
    luaCopy code
    if redis.call("get", KEYS[1]) == ARGV[1] then
        return redis.call("del", KEYS[1])
    else
        return 0
    end
    

    上述Lua脚本检查当前锁的值是否与传入的标识符(通常是随机生成的唯一值)相匹配,如果匹配,则删除键,释放锁。

通过以上方式,当一个客户端成功获取锁后,其他客户端在尝试获取同一个锁时会失败。这样就确保了在同一时刻只有一个客户端能够持有分布式锁,从而实现了互斥性。需要注意的是,为了防止死锁,锁通常设置一个合理的过期时间,在一定时间后自动释放。

2、使用

推荐使用Redisson,因为它有自动续锁功能,并且Redisson的分布式锁只能通过创建锁的线程进行解锁,正所谓解铃还须系铃人,不是同一个线程解锁会报异常。Redisson介绍

2.1、封装方法

/**
 * 尝试加锁,最多等待waitTime,上锁以后leaseTime自动解锁
 *
 * @param lockName
 * @return
 */
public static Boolean lockWithExpire(String lockName, long waitTime, long leaseTime, TimeUnit unit) {
    if (redissonClient == null) {
        log.info("lockWithExpire redissonClient is null");
        return false;
    }

    try {
        RLock lock = redissonClient.getLock(lockName);
        return lock.tryLock(waitTime, leaseTime, unit);
    } catch (Exception e) {
        log.error("lockWithExpire lock error:", e);
        return false;
    }
}

/**
 * 加锁,默认有效期30s,超过自动续期,宕机最多锁30s
 *
 * @param lockName
 * @return
 */
public static Boolean lock(String lockName) {
    if (redissonClient == null) {
        log.info("lock redissonClient is null");
        return false;
    }

    try {
        RLock lock = redissonClient.getLock(lockName);
        // 加锁
        return lock.tryLock();
    } catch (Exception e) {
        log.error("lock lock error:", e);
        return false;
    }
}
    
 /**
 * 释放锁
 *
 * @param lockName
 * @return
 */
public Boolean unlock(String lockName) {
    if (redissonClient == null) {
        log.info("DistributedRedisLock redissonClient is null");
        return false;
    }

    try {
        RLock lock = redissonClient.getLock(lockName);
        if (lock.isLocked() && lock.isHeldByCurrentThread()) {
            lock.unlock();
            log.info(" unlock [{}] success", lockName);
            return true;
        }
        return false;
    } catch (Exception e) {
        log.error("unlock unlock [{}] Exception:", lockName, e);
        return false;
    }
}

2.1、使用锁

//Redis锁的key单独维护在一个常量类里,REDIS_LOCK_KEY_DEMO就是key
//加锁
if (lock(lockName)) {
    try {
        //执行的业务
    } catch (Exception e) {
       //打印错误日志
       log.error("business error",e);
    } finally {
        //释放锁
        unlock(REDIS_LOCK_KEY_DEMO);
    }
}else{
    log.warn("not get lock");
}

2.1、使用锁场景

  1. 并发控制

    • 在分布式环境中,多个服务或节点需要对共享资源进行并发访问,但希望对这些资源进行互斥控制,避免竞态条件和数据不一致问题。
  2. 定时任务的协调

    • 当多个节点需要执行定时任务,通过Redis分布式锁可以确保同一时刻只有一个节点执行特定的任务,防止任务重复执行或者并发执行的问题。
  3. 防止缓存击穿

    • 当缓存中某个数据失效时,多个节点可能同时发起对该数据的重建请求。使用Redis分布式锁可以确保只有一个节点去重新生成数据,防止缓存击穿。
  4. 避免分布式系统中的死锁

    • 在一些分布式系统中,可能存在多个节点对共享资源进行操作,为了避免死锁,可以使用分布式锁来协调节点之间的操作。
  5. 分布式任务协调

    • 当多个节点需要协同完成某个任务时,通过Redis分布式锁可以确保同一时刻只有一个节点能够执行关键步骤,以维持任务的一致性。
  6. 资源的独占性操作

    • 当需要对某个资源进行独占性的操作时,例如对数据库中某行记录的修改,可以使用Redis分布式锁确保在同一时刻只有一个节点能够执行这个独占性操作。

在这些场景中,Redis分布式锁通过在多个节点之间共享锁状态,帮助实现了分布式环境下的互斥和协同,确保关键操作的一致性和正确性。需要注意的是,在使用Redis分布式锁时,要考虑锁的超时、可重入性、死锁处理等问题,以确保系统的稳定性和健壮性。

3、问题

如果这个锁的过期时间是30秒,但是业务运行超过了30秒,比如40秒,当业务运行到30秒的时候,锁过期了,其他客户端拿到了这个锁,怎么办?

我们可以设置一个合理的过期时间,让业务能够在这个时间内完成业务逻辑,但LockTime的设置原本就很不容易。

  • LockTime设置过小,锁自动超时的概率就会增加,锁异常失效的概率也就会增加;
  • LockTime设置过大,万一服务出现异常无法正常释放锁,那么出现这种异常锁的时间也就越长。

我们只能通过经验去配置,一个可以接受的值,基本上是这个服务历史上的平均耗时再增加一定的buff。我们也可以不设置过期时间,让业务运行结束后解锁,但是如果客户端出现了异常结束了或宕机了,那么这个锁就无法解锁,变成死锁。

4、解决办法

我们可以先给锁设置一个LockTime,然后启动一个守护线程,让守护线程在一段时间后,重新去设置这个锁的LockTime。

看起来很简单,但实现起来并不容易

  • 和释放锁的情况一样,我们需要先判断持有锁客户端是否有变化。否则会造成无论谁持有锁,守护线程都会去重新设置锁的LockTime。
  • 守护线程要在合理的时间再去重新设置锁的LockTime,否则会造成资源的浪费。不能动不动就去续。
  • 如果持有锁的线程已经处理完业务了,那么守护线程也应该被销毁。不能业务运行结束了,守护者还在那里继续运行,浪费资源。

5、使用Redissson的tryLock

图片.png

5.1、tryLock源码解析

Redisson的看门狗机制就是这种机制实现自动续期的.

public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
        long time = unit.toMillis(waitTime);
        long current = System.currentTimeMillis();
        long threadId = Thread.currentThread().getId();
        // 1.尝试获取锁
        Long ttl = tryAcquire(leaseTime, unit, threadId);
        // lock acquired
        if (ttl == null) {
            return true;
        }

        // 申请锁的耗时如果大于等于最大等待时间,则申请锁失败.
        time -= System.currentTimeMillis() - current;
        if (time <= 0) {
            acquireFailed(threadId);
            return false;
        }

        current = System.currentTimeMillis();

        /**
         * 2.订阅锁释放事件,并通过 await 方法阻塞等待锁释放,有效的解决了无效的锁申请浪费资源的问题:
         * 基于信息量,当锁被其它资源占用时,当前线程通过 Redis 的 channel 订阅锁的释放事件,一旦锁释放会发消息通知待等待的线程进行竞争.
         *
         * 当 this.await 返回 false,说明等待时间已经超出获取锁最大等待时间,取消订阅并返回获取锁失败.
         * 当 this.await 返回 true,进入循环尝试获取锁.
         */
        RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
        // await 方法内部是用 CountDownLatch 来实现阻塞,获取 subscribe 异步执行的结果(应用了 Netty 的 Future)
        if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
            if (!subscribeFuture.cancel(false)) {
                subscribeFuture.onComplete((res, e) -> {
                    if (e == null) {
                        unsubscribe(subscribeFuture, threadId);
                    }
                });
            }
            acquireFailed(threadId);
            return false;
        }

        try {
            // 计算获取锁的总耗时,如果大于等于最大等待时间,则获取锁失败.
            time -= System.currentTimeMillis() - current;
            if (time <= 0) {
                acquireFailed(threadId);
                return false;

              }

            /**
             * 3.收到锁释放的信号后,在最大等待时间之内,循环一次接着一次的尝试获取锁
             * 获取锁成功,则立马返回 true,
             * 若在最大等待时间之内还没获取到锁,则认为获取锁失败,返回 false 结束循环
             */
            while (true) {
                long currentTime = System.currentTimeMillis();

                // 再次尝试获取锁
                ttl = tryAcquire(leaseTime, unit, threadId);
                // lock acquired
                if (ttl == null) {
                    return true;
                }
                // 超过最大等待时间则返回 false 结束循环,获取锁失败
                time -= System.currentTimeMillis() - currentTime;
                if (time <= 0) {
                    acquireFailed(threadId);
                    return false;
                }

                /**
                 * 6.阻塞等待锁(通过信号量(共享锁)阻塞,等待解锁消息):
                 */
                currentTime = System.currentTimeMillis();
                if (ttl >= 0 && ttl < time) {
                    //如果剩余时间(ttl)小于wait time ,就在 ttl 时间内,从Entry的信号量获取一个许可(除非被中断或者一直没有可用的许可)。
                    getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                } else {
                    //则就在wait time 时间范围内等待可以通过信号量
                    getEntry(threadId).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
                }

                // 更新剩余的等待时间(最大等待时间-已经消耗的阻塞时间)
                time -= System.currentTimeMillis() - currentTime;
                if (time <= 0) {
                    acquireFailed(threadId);
                    return false;
                }
            }
        } finally {
            // 7.无论是否获得锁,都要取消订阅解锁消息
            unsubscribe(subscribeFuture, threadId);
        }
        return get(tryLockAsync(waitTime, leaseTime, unit));
    }

解释:

  • 尝试获取锁,返回 null 则说明加锁成功,返回一个数值,则说明已经存在该锁,ttl 为锁的剩余存活时间。
  • 如果此时客户端 2 进程获取锁失败,那么使用客户端 2 的线程 id(其实本质上就是进程 id)通过 Redis 的 channel 订阅锁释放的事件。如果等待的过程中一直未等到锁的释放事件通知,当超过最大等待时间则获取锁失败,返回 false,也就是第 39 行代码。如果等到了锁的释放事件的通知,则开始进入一个不断重试获取锁的循环。
  • 循环中每次都先试着获取锁,并得到已存在的锁的剩余存活时间。如果在重试中拿到了锁,则直接返回。如果锁当前还是被占用的,那么等待释放锁的消息,具体实现使用了信号量 Semaphore 来阻塞线程,当锁释放并发布释放锁的消息后,信号量的 release() 方法会被调用,此时被信号量阻塞的等待队列中的一个线程就可以继续尝试获取锁了。
  • 当锁正在被占用时,等待获取锁的进程并不是通过一个 while(true) 死循环去获取锁,而是利用了 Redis 的发布订阅机制,通过 await 方法阻塞等待锁的进程,有效的解决了无效的锁申请浪费资源的问题。

5.2、看门狗如何自动续期

Redisson看门狗机制, 只要客户端加锁成功,就会启动一个 Watch Dog。

private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, long threadId) {
    if (leaseTime != -1) {
        return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    }
    RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
    ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
        if (e != null) {
            return;
        }

        // lock acquired
        if (ttlRemaining == null) {
            scheduleExpirationRenewal(threadId);
        }
    });
    return ttlRemainingFuture;
}

解释:

  • leaseTime 必须是 -1 才会开启 Watch Dog 机制,如果需要开启 Watch Dog 机制就必须使用默认的加锁时间为 30s。
  • 如果你自己自定义时间,超过这个时间,锁就会自定释放,并不会自动续期。

图片.png Watch Dog 机制其实就是一个后台定时任务线程,获取锁成功之后,会将持有锁的线程放入到一个 RedissonLock.EXPIRATION_RENEWAL_MAP里面,然后每隔 10 秒 (internalLockLeaseTime / 3) 检查一下,如果客户端还持有锁 key(判断客户端是否还持有 key,其实就是遍历 EXPIRATION_RENEWAL_MAP 里面线程 id 然后根据线程 id 去 Redis 中查,如果存在就会延长 key 的时间),那么就会不断的延长锁 key 的生存时间。如果服务宕机了,Watch Dog 机制线程也就没有了,此时就不会延长 key 的过期时间,到了 30s 之后就会自动过期了,其他线程就可以获取到锁。

5.3、续期原理

续期原理其实就是用lua脚本,将锁的时间重置为30s

private void scheduleExpirationRenewal(long threadId) {
    ExpirationEntry entry = new ExpirationEntry();
    ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
    if (oldEntry != null) {
        oldEntry.addThreadId(threadId);
    } else {
        entry.addThreadId(threadId);
        renewExpiration();
    }
}

protected RFuture<Boolean> renewExpirationAsync(long threadId) {
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
            "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                "return 1; " +
            "end; " +
            "return 0;",
        Collections.<Object>singletonList(getName()),
        internalLockLeaseTime, getLockName(threadId));
}

6、锁失效的原因

  1. 过期时间到期

    • 当使用SET命令创建锁时,可以通过PX选项设置锁的过期时间(以毫秒为单位)。一旦锁的过期时间到期,Redis会自动删除这个键,释放锁。因此,如果没有手动续约锁的过期时间,锁将会失效。
  2. 业务逻辑执行时间过长

    • 如果获取锁的客户端在持有锁的期间,执行业务逻辑的时间超过了锁的过期时间,那么在业务逻辑执行完毕之后,Redis会自动删除这个键,释放锁。
  3. 锁的被动释放

    • 如果一个客户端成功获取锁,并在持有锁期间发生了一些异常,导致没有手动释放锁,或者客户端因为某些原因断开了与Redis的连接,那么锁可能会一直存在,直到过期时间到期或者其他客户端尝试获取锁成功为止。
  4. 网络故障或Redis故障

    • 如果在获取锁的客户端和Redis服务器之间发生网络故障,导致锁的释放请求未能到达,那么这个锁可能会一直存在,直到过期时间到期或者其他客户端尝试获取锁成功为止。
  5. 不同客户端使用相同的键

    • 如果不同的客户端使用相同的键名创建锁,它们可能会相互覆盖对方的锁。在分布式环境中,确保每个客户端使用唯一的键名是非常重要的。
  6. 发生了垃圾回收

    • 如果在获取锁的过程中发生了一次长时间的垃圾收集,导致线程被阻塞的时间超过了锁的超时时间,那么这个线程可能会在规定的时间内无法获取锁而放弃等待。

为了避免分布式锁失效的问题,通常建议在获取锁后执行业务逻辑前,通过定时任务或者其他机制来定期续约锁的过期时间。这样可以确保即使业务逻辑执行时间较长,也能保持锁的有效性。此外,合理的设置锁的过期时间,以及使用异常处理机制来确保锁的释放,也是提高分布式锁稳定性的重要策略。

7、总结

终上所述,在我们使用redis锁的时候,要根据自己的业务判断要不要续锁。如果不要续锁,就要设置过期时间的,过期时间不可设置的过大,或者过小,通过经验去配置,一个可以接受的值,基本上是这个服务历史上的平均耗时再增加一定的buff。如果要续锁,使用redission加锁,默认有效期30s,只剩下20s的时候自动续期到30秒,因此宕机最多锁30s。注意,续锁逻辑需要起定时器,会影响代码执行性能。