【Redis】-【应用篇】分布式锁解决方案-Redis分布式锁

分布式锁

在程序中,锁存在的意义便是锁定资源,限制资源的在某个时间点的操作权限。解决在并发情况下幂等操作等。

而锁实现原理也很简单,就是针对某个方法或操作进行是否锁定的标记,并且该标记在应用内部所有线程可见。
在单机情况下,想要达到该目的相对容易,但是在多机情况下,如果仍然以同样的方式维护锁标记,这时候就需要每个应用间进行锁标记的通信同步,这使得应用变得复杂,明显该方法不可取。

于是乎,将针对方法的执行锁定标记进行抽离,存在特定的存储介质中(如内存中),需要加锁执行的方法,在执行前先获取锁操作权限,获取成功认定其有权限操作该方法,如果获取失败,该方法进入循环获取锁的操作中,直到获取到锁,并执行操作,或者请求超时等操作。

Redis 实现分布式锁

原理

其原理,就是通过一个唯一的主键是否被存在存储介质中来判定,该锁是否已经被持有。

下面我们举简单的操作例子,使用 redis 中带有的 setnx 命令。

首先先来了解一下 setnx 的语法:

SETNX key value
只在键 key 不存在的情况下, 将键 key 的值设置为 value
若键 key 已经存在, 则 SETNX 命令不做任何动作。
命令在设置成功时返回 1 , 设置失败时返回 0

再来聊聊实现方式
聊到分布式锁实现,在抛开性能等方面因素,我们需要关注的问题大体可以分为三个主要方向:锁的获取、锁的释放、锁的续期。

  • 1、获取锁

    首先、尝试通过 setnx key value 的方式进行设值。如果返回 0 ,则进入 while 循环,持续尝试获取锁。

    获取锁成功之后, expire key timeout 设置过期时间。之所以设置过期时间是为了防止在出现不可控因素的情况下锁会自动释放。

  • 2、释放锁
    锁的释放有两种
    第一、过期时间到了自动释放
    第二、业务操作完成之后,主动执行 del key 进行释放

  • 3、锁的续期
    由于我们在获取锁的时候,我们设定了锁的过期时间。但是当前请求的时间远大于锁设定的时间,这时候就会出现问题。这时候就需要,在获取锁的时候设定一个定时任务,根据 key 主动去设置 key 的过期时间,保证业务在正常执行完成前,不会出现锁被释放的问题。

但是上面的方案会存在一个问题,那就 锁重入的问题,采用 setnx 方式进行控制,要如何进行锁重入的控制,我能想到的比较好的方式就是在维护一个数据 放 [(业务key + 业务线程id): 锁定的数量 ]。也就是说需要通过维护两条数据,才能实现锁重入的问题。(如果你有更好的方式,欢迎留言讨论)。

这种方式明显比较麻烦,既然这样那就换一种数据格式 hash 进行存储 通过 hset hash field value 的方式,将业务主键,操作线程主键,以及重入的线程数存储在一个数据结构中。

在 redis 中,redis 由于其单线程的原因,所以 redis 的操作命令都属于原子操作,但是在上面我们讨论的操作,如获取锁操作,获取锁操作涉及到设置数值操作,设置过期操作,使得获取锁的操作变成一个非原子操作。在并发操作中,这明显会出现问题,而针对该问题,redis 官方也给出了解决方案,通过 lua 脚本。下面是 redis 官方给出相关介绍

链接:https://redis.io/commands/eval

Atomicity of scripts

Redis uses the same Lua interpreter to run all the commands. Also Redis guarantees that a script is executed in an atomic way: no other script or Redis command will be executed while a script is being executed

下面我们通过一个常用的解决方案 Redisson 再来看看 Redis 分布式锁实现。

(基于 Redisson:3.10.6 中的 RedissonLock 进行讲解 )

我们先来看看 Redisson 中针对 锁的接口定义 RLock.java

public interface RLock extends Lock, RLockAsync {
    
    String getName();
    
    void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException;
    
    boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;
    
    void lock(long leaseTime, TimeUnit unit);
    
    boolean forceUnlock();
    
    boolean isLocked();
    
    boolean isHeldByThread(long threadId);
    
    boolean isHeldByCurrentThread();
    
    int getHoldCount();
    
    long remainTimeToLive();
}

其方法定义,简单明了,对应的注释也写得很清楚(这里没有贴注释)。下面我们基于 RedissonLock 以获取锁、释放锁、锁的续期为主要线索进行展开。

  • 1、获取锁

    RedissonLock # lock() # lock(long leaseTime, TimeUnit unit, boolean interruptibly) # tryAcquire(long leaseTime, TimeUnit unit, long threadId) # tryAcquireAsync(long leaseTime, TimeUnit unit, long threadId) # tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand command)

    public class RedissonLock{
        ......
        <T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
            internalLockLeaseTime = unit.toMillis(leaseTime);
            return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
                      "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; " +
                      "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; " +
                      "return redis.call('pttl', KEYS[1]);",
                        Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
        }
        ......
    }
    

    这里的代码就很直观了,通过 lua 脚本保证原子性的前提下进行一些列的操作。
    1、判定 ( 业务key ) 对应 hset 如果不存在
    进行生成,并设置过期时间,返回 nil
    2、判定 ( 业务key + 线程 ) 对应数值如果存在
    针对该数值,进行累加操作,并更新过期时间(这个步骤就是锁重入的操作步骤)

    3、如果 该锁已经被占用返回,已经被占用的锁的剩余时间

  • 2、锁的续期
    在上面获取锁的操作完成之后,其上一个方法 tryAcquireAsync(long leaseTime, TimeUnit unit, long threadId) 会继续执行

    public class RedissonLock{
        ......
        private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, long threadId) {
    		......
    		scheduleExpirationRenewal(threadId);
    		......
        }
        private void scheduleExpirationRenewal(long threadId) {
    		......
    		renewExpiration();
    		......
        }
        private void renewExpiration() {
            ......
            Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
                @Override
                public void run(Timeout timeout) throws Exception {
                  
                    ......
    	               // 真正执行 续期的操作方法
    				  RFuture<Boolean> future = renewExpirationAsync(threadId);	
    				  // 只有续期成功才会继续递归调用
                       future.onComplete((res, e) -> {
                        if (e != null) {
                            log.error("Can't update lock " + getName() + " expiration", e);
                            return;
                        }
                        
                        // reschedule itself
                        renewExpiration();
                    });
                    ......
                }
            }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
            ee.setTimeout(task);
        }
        
        .....
    }
    

    到这里我们可以知道, Redisson 在进行获取到锁之后会有一个 递归调用续期的操作方法,该方法中有一个定时器时间间隔为 锁过期时间的 1/3 。
    之后我们再来看看,续期操作究竟做了些什么

    public class RedissonLock{
    	......
         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));
        }
        ......
    }
    

    看到这,想必一目了然了,还通过 lua 的脚本进行操作:根据 业务key ,线程id 进行查询 数据是否存在,如果存在更新 过期时间。

  • 3、释放锁
    RedissonLock # unlock() # unlockAsync(long threadId) # unlockInnerAsync(long threadId)

    public class RedissonLock{
        protected RFuture<Boolean> unlockInnerAsync(long threadId) {
            return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
                    "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
                        "return nil;" +
                    "end; " +
                    "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
                    "if (counter > 0) then " +
                        "redis.call('pexpire', KEYS[1], ARGV[2]); " +
                        "return 0; " +
                    "else " +
                        "redis.call('del', KEYS[1]); " +
                        "redis.call('publish', KEYS[2], ARGV[1]); " +
                        "return 1; "+
                    "end; " +
                    "return nil;",
                    Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
    
        }
    }
    

    还是lua 脚本。
    1、根据 业务key ,线程id 获取 锁信息,如果不存在,返回 nuill 直接结束
    2、根据 业务key ,线程id 对锁只有的数量进行减1操作(锁重入的释放)
    3、判定减少之后的 持有对象是否 > 0,大于零,进行续期操作
    4、判定减少之后的 持有对象是否 > 0, 小于等于0,进行删除操作。

通过以上的分析,我们对使用 Redis 实现分布式锁的方案有了明显的认知,并且对我们常用的工具类 Redisson 也有了相关的了解。那么这样就完了?
当然没有。

在单机的情况下,Redis 服务能够正常的提供分布式锁的服务功能,但是随着业务发展,Redis 需要从单机扩展为集群,这个时候传统的主从复制的 Redis 集群还能够提供正常的 分布式锁服务吗?

我们来看看 Redis 官方给定的介绍信息

Redis has built-in replication, Lua scripting, LRU eviction, transactions and different levels of on-disk persistence, and provides high availability via Redis Sentinel and automatic partitioning with Redis Cluster.

官方对 Redis 集群的定义是一个 high availability 高可用的集群。根据 CAP 原理。Redis 集群便是一个 AP 模型。从而可以判定 Redis 提供的是数据最终一致性的服务(通过数据模拟也可以验证)。而对于分布式锁来说,需要的却是CP 模型的服务。

下面我们简单的分析分析,就能够知道为啥 分布式锁需要 CP 模型的服务,传统方式 搭建的Redis 集群为啥无法提供可靠的分布式锁服务。

背景

微信支付回调,在回调中我们根据回调成功信息生成若干数据。此时微信平台,一次性给我们的服务接口发起了连续的2次请求。

针对回调接口,我们通过 锁 + 业务主键 的方式实现接口的幂等性。此时用到的锁为 Redis 集群提供的分布式锁。

此时 2 次请求,一前一后进入到回调方法中,间隔时间很短。
第 1 次请求,最先进来,所以他先获取到锁。准备进行业务处理。

这时候 Redis master 节点将锁数据写入到内存中,此时数据还未被同步到 Redis 从节点中。
而此时Redis master 主节点挂了,其中某一台从节点上位成为 master 节点。而此时 第一次请求获取到的锁信息,在此时提供服务的 Redis 集群中依然不存在,也就是说,这时候锁丢失了。

这时候,第2次请求开始获取锁,而由于锁的丢失,第2次请求也获取到了锁。

很明显,这时候,该回调接口,被执行了2次,数据库数据被污染,对业务数据造成了影响。

那既然 Redis 集群无法提供服务,我们是否需要转用其他的工具进行分布式锁服务的提供。当然不。因为这篇文章还没结束。而且针对大部分传统的公司,redis 通常是必备的组件,所以还是有必要聊聊 redis 提供的解决方案。(在后期会有其他 分布式锁解决方案的相关文章,如:zk,etcd 等

Redis 分布式锁 集群解决方案 -Redlock

Redlock Redis 官网介绍
Redisson 官方文档

高可用模型的 Redis 注定其无法提供强一致性服务, Redlock 算法是针对Redis AP 模型缺陷上的弥补。通过少数服从多数的逻辑来进行锁的获取和释放。在获取锁时,同时对多个实例( 可以是多个单机实例,也可以是多个集群 ) 进行锁获取操作,当操作成功的实例数满足一定条件才认定锁获取成功。而释放锁很简单,无论实例是否拥有锁数据,直接执行删除操作。

下面我们来分析一下 Redisson 针对 RedLock 的实现 RedissonRedLock

public class RedissonRedLock extends RedissonMultiLock {
    public RedissonRedLock(RLock... locks) {
        super(locks);
    }

    @Override
    protected int failedLocksLimit() {
        return locks.size() - minLocksAmount(locks);
    }
    
    protected int minLocksAmount(final List<RLock> locks) {
        return locks.size()/2 + 1;
    }

    @Override
    protected long calcLockWaitTime(long remainTime) {
        return Math.max(remainTime / locks.size(), 1);
    }
    
    @Override
    public void unlock() {
        unlockInner(locks);
    }
}

RedissonRedLock 中重写了 failedLocksLimit() ,从该方法可以得到公式: 锁数量 - ( 锁数量 / 2 + 1 ) 。该公式是用来判定锁是否获取成功判定公式。

从代码中能够得到,RedissonRedLock 继承自 RedissonMultiLock ,锁操作的方法,由 RedissonMultiLock 实现。

下面我们再来看看 获取锁的方法
以 **RedissonMultiLock ** 中的 tryLock() 为例
主线:RedissonMultiLock # tryLock() # tryLock(long waitTime, long leaseTime, TimeUnit unit)

public class RedissonMultiLock implements RLock {
    public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
        ......
        int failedLocksLimit = failedLocksLimit();
        List<RLock> acquiredLocks = new ArrayList<>(locks.size());
        for (ListIterator<RLock> iterator = locks.listIterator(); iterator.hasNext();) {
            RLock lock = iterator.next();
            boolean lockAcquired;
            try {
                if (waitTime == -1 && leaseTime == -1) {
				  // 调用 RedissonLock 中的 tryLock() 方法,默认带有定时器续租
                    lockAcquired = lock.tryLock();
                } else {
                    long awaitTime = Math.min(lockWaitTime, remainTime);
				  // 调用 RedissonLock 中的方法,没有续租功能
                    lockAcquired = lock.tryLock(awaitTime, newLeaseTime, TimeUnit.MILLISECONDS);
                }
            } catch (RedisResponseTimeoutException e) {
                // 出现异常,释放所有锁
                unlockInner(Arrays.asList(lock));
                lockAcquired = false;
            } catch (Exception e) {
                lockAcquired = false;
            }
            
            if (lockAcquired) {
                // 获取锁成功,加入成功列表中
                acquiredLocks.add(lock);
            } else {
                // 如果成功获取到的锁达到了标准,直接返回,获取锁成功
                if (locks.size() - acquiredLocks.size() == failedLocksLimit()) {
                    break;
                }

                // 如果允许最小失败数量为0
                if (failedLocksLimit == 0) {
                    // 释放所有的锁
                    unlockInner(acquiredLocks);
				  // 如果 默认调用的 tryLock() 方法,直接结束
                    if (waitTime == -1 && leaseTime == -1) {
                        return false;
                    }
                    failedLocksLimit = failedLocksLimit();
                    acquiredLocks.clear();
                    // reset iterator
                    while (iterator.hasPrevious()) {
                        iterator.previous();
                    }
                } else {
                    failedLocksLimit--;
                }
            }
            ......
        }
        ......
        return true;
    }
}

当我们调用默认的RedissonMultiLocktryLock() 方法时

循环体操作:

  • RedissonMultiLock 中获取锁,通过循环遍历所有的锁对象,分别进行锁对象的获取。
    此时 waitTime 和 leaseTime 都为 -1。默认调用 RedissonLock 中的 tryLock() 方法获取锁(RedissonLock 中的 tryLock() 方法默认拥有续期功能)。

  • 如果锁获取成功,加入 acquiredLocks 列表中。
    如果锁获取失败
    – > 如果达到锁获取成功的条件 locks.size() - acquiredLocks.size() == failedLocksLimit() 跳出循环
    – > 如果 failedLocksLimit == 0 成立,判定锁获取失败,释放所有的锁。如果 failedLocksLimit == 0 不成立,failedLocksLimit 减 1。

下面我们来简单的计算加深理解:

如果有 3 个实例提供服务分布式锁服务,分别为 redis1 、redi2、redi3

通过公式我们可以知道 failedLocksLimit = 3 - ( 3 / 2 + 1 ) = 1 ,也就是说允许最多 1个实例获取锁失败,换句话说就是如果有2个实例获取锁成功,就能认定锁获取成功。在 RedissonMultiLock 中的 tryLock() 方法中,当锁获取失败,会进行 failedLocksLimit -1 操作,当 failedLocksLimit == 0 时,代表锁获取失败,也就是说 3个实例提供的分布式锁,在第二次获取锁失败时就认定该锁获取失败。

如果说是 5 个实例的分布式锁, failedLocksLimit = 5 - ( 5 / 2 + 1 ) = 2,也就是说在第三次获取锁失败时,认定此次锁获取失败。

以上是 Redisson 对 RedLock 算法的实现。通过分析我们可以得知,RedissonRedLock 就是一个联锁( RedissonMultiLock ) , 而联锁的实现就是一个个独立的 RedissonLock 锁的组合,通过同时对多个锁进行加锁和解锁操作 并通过 RedissonRedLock 重写的判定锁获取成功的标准 failedLocksLimit() ,进行最终判定锁是否获取成功。

总结

我们通过业界比较常用的工具类 Redisson 对 Redis 的分布式锁的解决方案进行分析。在单机情况下能够提供分布式锁服务时,使用 Redisson 中的 RedissonLock 就能够满足业务场景,但是在单机无法满足服务,进行多机扩展,使用 RedLock 算法能够避免主从复制导致的重复获取锁的问题。但是相对应的,RedLock 是通过同时对多个Redis 实例进行获取锁操作,然后根据成功获取的锁数量,根据计算公式进行判定锁的获取是否成功。

篇幅有限,加上操作代码简单,文章不进行案例黏贴,有兴趣的小伙伴可以留言讨论。

精彩内容推送,请关注公众号!
【Redis】-【应用篇】分布式锁解决方案-Redis分布式锁_第1张图片

你可能感兴趣的:(微信公众号同步)