redis的分布式锁

分布式锁原理


分布式锁:当多个进程不在同一个系统中,多个进程共同竞争同一个资源,用分布式锁控制多个进程对资源的互斥访问。采用Redis服务器存储锁信息(即SET一个Key表示已加锁),可以实现多进程的并发读锁的状态,如果没有锁,则只允许一个进程加锁。

Redis分布式锁实现的关键点:


 

基于set命令的分布式锁


1、加锁:使用setnx进行加锁,当该指令返回1时,说明成功获得锁

2、解锁:当得到锁的线程执行完任务之后,使用del命令释放锁,以便其他线程可以继续执行setnx命令来获得锁

(1)存在的问题:假设线程获取了锁之后,在执行任务的过程中挂掉,来不及显示地执行del命令释放锁,那么竞争该锁的线程都会执行不了,产生死锁的情况。

(2)解决方案:设置锁超时时间

3、设置锁超时时间:setnx 的 key 必须设置一个超时时间,以保证即使没有被显式释放,这把锁也要在一定时间后自动释放。可以使用expire命令设置锁超时时间

(1)存在问题:

setnx 和 expire 不是原子性的操作,假设某个线程执行setnx 命令,成功获得了锁,但是还没来得及执行expire 命令,服务器就挂掉了,这样一来,这把锁就没有设置过期时间了,变成了死锁,别的线程再也没有办法获得锁了。

(2)解决方案:redis的set命令支持在获取锁的同时设置key的过期时间

4、使用set命令加锁并设置锁过期时间:

命令格式:set <lock.key> <lock.value> nx ex <expireTime>

详情参考redis使用文档:http://doc.redisfans.com/string/set.html

(1)存在问题:

① 假如线程A成功得到了锁,并且设置的超时时间是 30 秒。如果某些原因导致线程 A 执行的很慢,过了 30 秒都没执行完,这时候锁过期自动释放,线程 B 得到了锁。

② 随后,线程A执行完任务,接着执行del指令来释放锁。但这时候线程 B 还没执行完,线程A实际上删除的是线程B加的锁。

(2)解决方案:

可以在 del 释放锁之前做一个判断,验证当前的锁是不是自己加的锁。在加锁的时候把当前的线程 ID 当做value,并在删除之前验证 key 对应的 value 是不是自己线程的 ID。但是,这样做其实隐含了一个新的问题,get操作、判断和释放锁是两个独立操作,不是原子性。对于非原子性的问题,我们可以使用Lua脚本来确保操作的原子性

5、锁续期:(这种机制类似于redisson的看门狗机制,文章后面会详细说明)

虽然步骤4避免了线程A误删掉key的情况,但是同一时间有 A,B 两个线程在访问代码块,仍然是不完美的。怎么办呢?我们可以让获得锁的线程开启一个守护线程,用来给快要过期的锁“续期”。

① 假设线程A执行了29 秒后还没执行完,这时候守护线程会执行 expire 指令,为这把锁续期 20 秒。守护线程从第 29 秒开始执行,每 20 秒执行一次。

② 情况一:当线程A执行完任务,会显式关掉守护线程。

③ 情况二:如果服务器忽然断电,由于线程 A 和守护线程在同一个进程,守护线程也会停下。这把锁到了超时的时候,没人给它续命,也就自动释放了。

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

1、实现原理:

(1)setnx(lockkey, 当前时间+过期超时时间) ,如果返回1,则获取锁成功;如果返回0则没有获取到锁,转向步骤(2)

(2)get(lockkey)获取值oldExpireTime ,并将这个value值与当前的系统时间进行比较,如果小于当前系统时间,则认为这个锁已经超时,可以允许别的请求重新获取,转向步骤(3)

(3)计算新的过期时间 newExpireTime=当前时间+锁超时时间,然后getset(lockkey, newExpireTime) 会返回当前lockkey的值currentExpireTime

(4)判断 currentExpireTime 与 oldExpireTime 是否相等,如果相等,说明当前getset设置成功,获取到了锁。如果不相等,说明这个锁又被别的请求获取走了,那么当前请求可以直接返回失败,或者继续重试。

(5)在获取到锁之后,当前线程可以开始自己的业务处理,当处理完毕后,比较自己的处理时间和对于锁设置的超时时间,如果小于锁设置的超时时间,则直接执行del命令释放锁(释放锁之前需要判断持有锁的线程是不是当前线程);如果大于锁设置的超时时间,则不需要再锁进行处理。

2、代码实现:

(1)获取锁的实现方式:

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方法,参数为获取的超时时间与单位,线程在超时时间内,获取锁操作将自旋在那里,直到该自旋锁的保持者释放了锁。

(2)释放锁的实现方式:

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(),如果多台服务器时间不一致,那么问题就出现了,但是这个bug完全可以从服务器运维层面规避的,而且如果服务器时间不一样的话,只要和时间相关的逻辑都是会出问题的

(2)如果前一个锁超时的时候,刚好有多台服务器去请求获取锁,那么就会出现同时执行redis.getset()而导致出现过期时间覆盖问题,不过这种情况并不会对正确结果造成影响

(3)存在多个线程同时持有锁的情况:如果线程A执行任务的时间超过锁的过期时间,这时另一个线程就可以获得这个锁了,造成多个线程同时持有锁的情况。类似于方案一,可以使用“锁续期”的方式来解决。

前两种redis分布式锁的存在的问题
前面两种redis分布式锁的实现方式,如果从“高可用”的层面来看,仍然是有所欠缺,也就是说当 redis 是单点的情况下,当发生故障时,则整个业务的分布式锁都将无法使用。

为了提高可用性,我们可以使用主从模式或者哨兵模式,但在这种情况下仍然存在问题,在主从模式或者哨兵模式下,正常情况下,如果加锁成功了,那么master节点会异步复制给对应的slave节点。但是如果在这个过程中发生master节点宕机,主备切换,slave节点从变为了 master节点,而锁还没从旧master节点同步过来,这就发生了锁丢失,会导致多个客户端可以同时持有同一把锁的问题。来看个图来想下这个过程:

那么,如何避免这种情况呢?redis 官方给出了基于多个 redis 集群部署的高可用分布式锁解决方案:RedLock,在方案三我们就来详细介绍一下。(备注:如果master节点宕机期间,可以容忍多个客户端同时持有锁,那么就不需要redLock)

基于RedLock的分布式锁


redLock的官方文档地址:https://redis.io/topics/distlock

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

1、加锁实现原理:

现在假设有5个Redis主节点(大于3的奇数个),这样基本保证他们不会同时都宕掉,获取锁和释放锁的过程中,客户端会执行以下操作:

(1)获取当前Unix时间,以毫秒为单位,并设置超时时间TTL

TTL 要大于 正常业务执行的时间 + 获取所有redis服务消耗时间 + 时钟漂移

(2)依次尝试从5个实例,使用相同的key和具有唯一性的value获取锁,当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间TTL,这样可以避免客户端死等。比如:TTL为5s,设置获取锁最多用1s,所以如果一秒内无法获取锁,就放弃获取这个锁,从而尝试获取下个锁

(3)客户端 获取所有能获取的锁后的时间 减去 第(1)步的时间,就得到锁的获取时间。锁的获取时间要小于锁失效时间TTL,并且至少从半数以上的Redis节点取到锁,才算获取成功锁

(4)如果成功获得锁,key的真正有效时间 = TTL - 锁的获取时间 - 时钟漂移。比如:TTL 是5s,获取所有锁用了2s,则真正锁有效时间为3s

(5)如果因为某些原因,获取锁失败(没有在半数以上实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁,无论Redis实例是否加锁成功,因为可能服务端响应消息丢失了但是实际成功了。

设想这样一种情况:客户端发给某个Redis节点的获取锁的请求成功到达了该Redis节点,这个节点也成功执行了SET操作,但是它返回给客户端的响应包却丢失了。这在客户端看来,获取锁的请求由于超时而失败了,但在Redis这边看来,加锁已经成功了。因此,释放锁的时候,客户端也应该对当时获取锁失败的那些Redis节点同样发起请求。实际上,这种情况在异步通信模型中是有可能发生的:客户端向服务器通信是正常的,但反方向却是有问题的。

(6)失败重试:当client不能获取锁时,应该在随机时间后重试获取锁;同时重试获取锁要有一定次数限制;

在随机时间后进行重试,主要是防止过多的客户端同时尝试去获取锁,导致彼此都获取锁失败的问题。

算法示意图如下:

2、RedLock性能及崩溃恢复的相关解决方法:

由于N个Redis节点中的大多数能正常工作就能保证Redlock正常工作,因此理论上它的可用性更高。前面我们说的主从架构下存在的安全性问题,在RedLock中已经不存在了,但如果有节点发生崩溃重启,还是会对锁的安全性有影响的,具体的影响程度跟Redis持久化配置有关:

(1)如果redis没有持久化功能,在clientA获取锁成功后,所有redis重启,clientB能够再次获取到锁,这样违法了锁的排他互斥性;

(2)如果启动AOF永久化存储,事情会好些, 举例:当我们重启redis后,由于redis过期机制是按照unix时间戳走的,所以在重启后,然后会按照规定的时间过期,不影响业务;但是由于AOF同步到磁盘的方式默认是每秒一次,如果在一秒内断电,会导致数据丢失,立即重启会造成锁互斥性失效;但如果同步磁盘方式使用Always(每一个写命令都同步到硬盘)造成性能急剧下降;所以在锁完全有效性和性能方面要有所取舍;

(3)为了有效解决既保证锁完全有效性 和 性能高效问题:antirez又提出了“延迟重启”的概念,redis同步到磁盘方式保持默认的每秒1次,在redis崩溃单机后(无论是一个还是所有),先不立即重启它,而是等待TTL时间后再重启,这样的话,这个节点在重启前所参与的锁都会过期,它在重启后就不会对现有的锁造成影响,缺点是在TTL时间内服务相当于暂停状态;

3、Redisson中RedLock的实现:

在JAVA的redisson包已经实现了对RedLock的封装,主要是通过 redisClient 与 lua 脚本实现的,之所以使用 lua 脚本,是为了实现加解锁校验与执行的事务性。

(1)唯一ID的生成:

分布式事务锁中,为了能够让作为中心节点的存储节点获取锁的持有者,从而避免锁被非持有者误解锁,每个发起请求的 client 节点都必须具有全局唯一的 id。通常我们是使用 UUID 来作为这个唯一 id,redisson 也是这样实现的,在此基础上,redisson 还加入了 threadid 避免了多个线程反复获取 UUID 的性能损耗

protected final UUID id = UUID.randomUUID();
String getLockName(long threadId) {
    return id + ":" + threadId;
}


(2)加锁逻辑:

redisson 加锁的核心代码非常容易理解,通过传入 TTL 与唯一 id,实现一段时间的加锁请求。下面是可重入锁的实现逻辑:

<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) 
{
    internalLockLeaseTime = unit.toMillis(leaseTime);
 
    // 获取锁时向5个redis实例发送的命令
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
              // 校验分布式锁的KEY是否已存在,如果不存在,那么执行hset命令(hset REDLOCK_KEY uuid+threadId 1),并通过pexpire设置失效时间(也是锁的租约时间)
              "if (redis.call('exists', KEYS[1]) == 0) then " +
                  "redis.call('hset', KEYS[1], ARGV[2], 1); " +
                  "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                  "return nil; " +
              "end; " +
              // 如果分布式锁的KEY已存在,则校验唯一 id,如果唯一 id 匹配,表示是当前线程持有的锁,那么重入次数加1,并且设置失效时间
              "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                  "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                  "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                  "return nil; " +
              "end; " +
              // 获取分布式锁的KEY的失效时间毫秒数
              "return redis.call('pttl', KEYS[1]);",
              // KEYS[1] 对应分布式锁的 key;ARGV[1] 对应 TTL;ARGV[2] 对应唯一 id
                Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}

(3)释放锁逻辑:

protected RFuture<Boolean> unlockInnerAsync(long threadId) 
{
    // 向5个redis实例都执行如下命令
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
            // 如果分布式锁 KEY 不存在,那么向 channel 发布一条消息
            "if (redis.call('exists', KEYS[1]) == 0) then " +
                "redis.call('publish', KEYS[2], ARGV[1]); " +
                "return 1; " +
            "end;" +
            // 如果分布式锁存在,但是唯一 id 不匹配,表示锁已经被占用
            "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
                "return nil;" +
            "end; " +
            // 如果就是当前线程占有分布式锁,那么将重入次数减 1
            "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
            // 重入次数减1后的值如果大于0,表示分布式锁有重入过,那么只设置失效时间,不删除
            "if (counter > 0) then " +
                "redis.call('pexpire', KEYS[1], ARGV[2]); " +
                "return 0; " +
            "else " +
                // 重入次数减1后的值如果为0,则删除锁,并发布解锁消息
                "redis.call('del', KEYS[1]); " +
                "redis.call('publish', KEYS[2], ARGV[1]); " +
                "return 1; "+
            "end; " +
            "return nil;",
            // KEYS[1] 表示锁的 key,KEYS[2] 表示 channel name,ARGV[1] 表示解锁消息,ARGV[2] 表示 TTL,ARGV[3] 表示唯一 id
            Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.unlockMessage, internalLockLeaseTime, getLockName(threadId));
}

(4)redisson中RedLock的使用:

Config config = new Config();
config.useSentinelServers()
        .addSentinelAddress("127.0.0.1:6369","127.0.0.1:6379", "127.0.0.1:6389")
        .setMasterName("masterName")
        .setPassword("password").setDatabase(0);
 
RedissonClient redissonClient = Redisson.create(config);
RLock redLock = redissonClient.getLock("REDLOCK_KEY");
 
try {
    // 尝试加锁,最多等待500ms,上锁以后10s自动解锁
    boolean isLock = redLock.tryLock(500, 10000, TimeUnit.MILLISECONDS);
    if (isLock) {
        //获取锁成功,执行对应的业务逻辑
    }
} catch (Exception e) {
    e.printStackTrace();
} finally {
    redLock.unlock();
}

可以看到,redisson 包的实现中,通过 lua 脚本校验了解锁时的 client 身份,所以我们无需再在 finally 中去判断是否加锁成功,也无需做额外的身份校验,可以说已经达到开箱即用的程度了。

同样,基于RedLock实现的分布式锁也存在 client 获取锁之后,在 TTL 时间内没有完成业务逻辑的处理,而此时锁会被自动释放,造成多个线程同时持有锁的问题。而Redisson 在实现的过程中,自然也考虑到了这一问题,redisson 提供了一个“看门狗”的特性,当锁即将过期还没有释放时,不断的延长锁key的生存时间。(具体实现原理会在方案四进行介绍)

基于Redisson的分布式锁


前面说了,如果某些原因导致持有锁的线程在锁过期时间内,还没执行完任务,而锁因为还没超时被自动释放了,那么就会导致多个线程同时持有锁的现象出现,而为了解决这个问题,可以进行“锁续期”。其实,在JAVA的Redisson包中有一个"看门狗"机制,已经帮我们实现了这个功能。

1、redisson原理:

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

(1)加锁机制
咱们来看上面那张图,现在某个客户端要加锁。如果该客户端面对的是一个redis cluster集群,他首先会根据hash节点选择一台机器。

这里注意,仅仅只是选择一台机器!这点很关键!

紧接着,就会发送一段lua脚本到redis上,那段lua脚本如下所示:

为啥要用lua脚本呢?

因为一大坨复杂的业务逻辑,可以通过封装在lua脚本中发送给redis,保证这段复杂业务逻辑执行的原子性。(lua脚本的原子性见文章最后补充内容)

那么,这段lua脚本是什么意思呢?

KEYS[1]代表的是你加锁的那个key,比如说:

RLock lock = redisson.getLock("myLock");


这里你自己设置了加锁的那个锁key就是“myLock”。

**ARGV[1]**代表的就是锁key的默认生存时间,默认30秒。

**ARGV[2]**代表的是加锁的客户端的ID,类似于下面这样:

8743c9c0-0795-4907-87fd-6c719a6b4586:1


给大家解释一下,第一段if判断语句,就是用“exists myLock”命令判断一下,如果你要加锁的那个锁key不存在的话,你就进行加锁。

如何加锁呢?很简单,用下面的命令:

hset myLock 8743c9c0-0795-4907-87fd-6c719a6b4586:1 1


通过这个命令设置一个hash数据结构,这行命令执行后,会出现一个类似下面的数据结构:

上述就代表“8743c9c0-0795-4907-87fd-6c719a6b4586:1”这个客户端对“myLock”这个锁key完成了加锁。

接着会执行“pexpire myLock 30000”命令,设置myLock这个锁key的生存时间是30秒。

好了,到此为止,ok,加锁完成了。

(2)锁互斥机制
那么在这个时候,如果客户端2来尝试加锁,执行了同样的一段lua脚本,会咋样呢?

很简单,第一个if判断会执行“exists myLock”,发现myLock这个锁key已经存在了。

接着第二个if判断,判断一下,myLock锁key的hash数据结构中,是否包含客户端2的ID,但是明显不是的,因为那里包含的是客户端1的ID。

所以,客户端2会获取到pttl myLock返回的一个数字,这个数字代表了myLock这个锁key的**剩余生存时间。**比如还剩15000毫秒的生存时间。

此时客户端2会进入一个while循环,不停的尝试加锁。

(3)watch dog自动延期机制
客户端1加锁的锁key默认生存时间才30秒,如果超过了30秒,客户端1还想一直持有这把锁,怎么办呢?

简单!只要客户端1一旦加锁成功,就会启动一个watch dog看门狗,他是一个后台线程,会每隔10秒检查一下,如果客户端1还持有锁key,那么就会不断的延长锁key的生存时间。

(4)可重入加锁机制
那如果客户端1都已经持有了这把锁了,结果可重入的加锁会怎么样呢?

比如下面这种代码:

这时我们来分析一下上面那段lua脚本。

第一个if判断肯定不成立,“exists myLock”会显示锁key已经存在了。

第二个if判断会成立,因为myLock的hash数据结构中包含的那个ID,就是客户端1的那个ID,也就是“8743c9c0-0795-4907-87fd-6c719a6b4586:1”

此时就会执行可重入加锁的逻辑,他会用:

 

incrby myLock 8743c9c0-0795-4907-87fd-6c71a6b4586:1 1


通过这个命令,对客户端1的加锁次数,累加1。

此时myLock数据结构变为下面这样:

大家看到了吧,那个myLock的hash数据结构中的那个客户端ID,就对应着加锁的次数

(5)释放锁机制
如果执行lock.unlock(),就可以释放分布式锁,此时的业务逻辑也是非常简单的。

其实说白了,就是每次都对myLock数据结构中的那个加锁次数减1。

如果发现加锁次数是0了,说明这个客户端已经不再持有锁了,此时就会用:

“del myLock”命令,从redis里删除这个key。

然后呢,另外的客户端2就可以尝试完成加锁了。

这就是所谓的分布式锁的开源Redisson框架的实现机制。

一般我们在生产系统中,可以用Redisson框架提供的这个类库来基于redis进行分布式锁的加锁与释放锁。

Redis分布式锁缺点


其实上面那种方案最大的问题,就是如果你对某个redis master实例,写入了myLock这种锁key的value,此时会异步复制给对应的master slave实例。

但是这个过程中一旦发生redis master宕机,主备切换,redis slave变为了redis master。

接着就会导致,客户端2来尝试加锁的时候,在新的redis master上完成了加锁,而客户端1也以为自己成功加了锁。

此时就会导致多个客户端对一个分布式锁完成了加锁。

这时系统在业务语义上一定会出现问题,导致各种脏数据的产生。

所以这个就是redis cluster,或者是redis master-slave架构的主从异步复制导致的redis分布式锁的最大缺陷:

在redis master实例宕机的时候,可能导致多个客户端同时完成加锁。

  • 35
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值