Redis-分布式锁Redisson

2022-04-271531

几个概念

什么是分布式锁?分布式锁又可以解决哪些问题呢?

在我们的系统还没有使用分布式架构的时候,我们可以用同步锁或者Lock锁,来保证多线程并发的时候,同一时间只有一个线程修改共享变量或者执行代码块,但是当我们现在大部分系统都是分布式集群部署的,单纯的同步锁和Lock锁只能保证单个实例上的数据一致性,多实例就失去了作用。

这个时候就需要使用分布式锁来保证共享资源的原子性,比如我们电商系统里面的扣减库存,当单量小的时候问题不大,如果单量很大,同一时间多个实例都在并发处理扣减库存的业务的时候,就可能存在超卖的问题。

分布式:简单来说就是将业务进行拆分,部署到不同的机器来协调处理。比如用户在网上买东西,大致分为:订单系统、库存系统、支付系统、、、、这些系统共同来完成用户买东西这个业务操作。

集群:同一个业务,通过部署多个实例来完成,保证应用的高可用,如果其中某个实例挂了,业务仍然可以正常进行,通常集群和分布式配合使用。来保证系统的高可用、高性能。

分布式事务:按照传统的系统架构,下单、扣库存等等,这一系列的操作都是一在一个应用一个数据库中完成的,也就是说保证了事务的ACID特性。如果在分布式应用中就会涉及到跨应用、跨库。这样就涉及到了分布式事务,就要考虑怎么保证这一系列的操作要么都成功要么都失败。保证数据的一致性。

分布式锁:因为资源有限,要通过互斥来保持一致性,引入分布式事务锁。

分布式锁作用

我们在单机服务器,出现资源的竞争,一般使用synchronized 就可以解决,但是在分布式的服务器上,synchronized 就无法解决这个问题,这就需要一个分布式事务锁。

常见的分布式事务锁

1、数据库级别的锁

乐观锁,基于加入版本号实现

悲观锁,基于数据库的 for update 实现

2、Redis ,基于 SETNX、EXPIRE 实现

3、Zookeeper,基于InterProcessMutex 实现

4、Redisson,lcok、tryLock(背后原理也是Redis)

Redis实现的分布式锁,最为复杂,但是性能确是最佳的,所以在对性能要求更高的系统里,我们都选择使用Redis来实现分布式锁。利用Redis实现分布式锁,一般都是使用SETNX实现

分布式要解决的问题

当我们在设计分布式锁的时候,我们应该考虑分布式锁至少要满足的一些条件,同时考虑如何高效的设计分布式锁,这里我认为以下几点是必须要考虑的。

1、互斥 在分布式高并发的条件下,我们最需要保证,同一时刻只能有一个线程获得锁,这是最基本的一点。

2、防止死锁 在分布式高并发的条件下,比如有个线程获得锁的同时,还没有来得及去释放锁,就因为系统故障或者其它原因使它无法执行释放锁的命令,导致其它线程都无法获得锁,造成死锁。

所以分布式非常有必要设置锁的有效时间,确保系统出现故障后,在一定时间内能够主动去释放锁,避免造成死锁的情况。

3、性能 对于访问量大的共享资源,需要考虑减少锁等待的时间,避免导致大量线程阻塞。

4、重入 我们知道ReentrantLock是可重入锁,那它的特点就是:同一个线程可以重复拿到同一个资源的锁。重入锁非常有利于资源的高效利用。

所以在锁的设计时,需要考虑两点

1、锁的颗粒度要尽量小。比如你要通过锁来减库存,那这个锁的名称你可以设置成是商品的ID,而不是任取名称。这样这个锁只对当前商品有效,锁的颗粒度小。

2、锁的范围尽量要小。比如只要锁2行代码就可以解决问题的,那就不要去锁10行代码了。

redis 原始分布式锁实现

** 加锁** redis 分布式锁背景首先是基于setnx 实现,setnx 当key 不存在时才会创建value ,并且返回1 ,否则key 值存在,创建value 失败,返回0;基于这个属性,我们可以满足分布式做的互斥性。但是还会存在一个问题,比如客户端上锁后,还未释放锁,异常宕机或者hang 住了。这时候其他客户端就始终无法获取锁,造成业务不可用情况;

解决方案就是在给锁的key 设置过期时间,及expire key score ; 需要注意的是,要保证setNx 操作和expire 两个操作是原子性的,否则setNx 设置后,expire 还未执行,同样无法解决上述问题。redis 想要保证两个操作是原子性,可以通过lua 脚本来实现;实现方法如下

  private boolean doTryLock(String lockKey, int lockSeconds) {
    RedisScript<Boolean> SETNX_AND_EXPIRE_SCRIPT;
    StringBuilder sb = new StringBuilder();
    sb.append("if (redis.call('setnx', KEYS[1], ARGV[1]) == 1) then\n");
    sb.append("\tredis.call('expire', KEYS[1], tonumber(ARGV[2]))\n");
    sb.append("\treturn true\n");
    sb.append("else\n");
    sb.append("\treturn false\n");
    sb.append("end");
    SETNX_AND_EXPIRE_SCRIPT = new RedisScriptImpl<Boolean>(sb.toString(), Boolean.class);
    return stringRedisTemplate
        .execute(SETNX_AND_EXPIRE_SCRIPT, Collections.singletonList(lockKey), lockValue,
            String.valueOf(lockSeconds));
  }

释放锁 锁的释放要遵循解铃还须系铃人,不可以出现a 把b 的锁给释放,这样的话锁就失去了意义;redis 释放锁首先判断释放锁的线程是否是加锁的线程,如果是允许删除,不是则无法删除key。集成实现下图所示

  public void unlock(String lockKey) {
    RedisScript<Boolean> DEL_IF_GET_EQUALS;
    StringBuilder sbr = new StringBuilder();
    sbr.append("if (redis.call('get', KEYS[1]) == ARGV[1]) then\n");
    sbr.append("\tredis.call('del', KEYS[1])\n");
    sbr.append("\treturn true\n");
    sbr.append("else\n");
    sbr.append("\treturn false\n");
    sbr.append("end");
    DEL_IF_GET_EQUALS = new RedisScriptImpl<Boolean>(sbr.toString(), Boolean.class);
    // 忽略结果
    stringRedisTemplate.execute(DEL_IF_GET_EQUALS, Collections.singletonList(lockKey), lockValue);
  }

redis 分布式锁存在的问题 上面的redis 分布式锁解决方案近乎完美,但是需要考虑的一种情况,就是锁续期的问题,比如我们锁的超时时间设置3s ,但是业务逻辑复杂还是其他原因导致在3s 内导致锁被释放了,这样其他的客户端同样拿到了锁,这样就没有做到锁的互斥性,同样出现并发问题。

解决问题的思路:

延长锁过期时间(治标不治本)

为锁添加守护线程,进行续期(推荐,redisson watch dog 实现)

基于redis 订阅 pub/sub 实现

基于redis 原始分布式锁的一些不便,可以考虑引入redisson 来解决这些问题;比如watch dog 就可以解决锁续期的问题;

Redisson原理分析

redisson 是什么 redisson 是基于redis 基础上实现的java 驻内存数据网格,Redisson还采用了基于NIO的Netty框架,不仅能作为Redis底层驱动客户端,具备提供对Redis各种组态形式的连接功能,对Redis命令能以同步发送、异步形式发送、异步流形式发送或管道形式发送的功能,LUA脚本执行处理,以及处理返回结果的功能等等

大体流程

Redisson集群模式获取锁的实现就是,在不同节点上获取锁,每个节点上获取锁都有超时时间,如果获取锁超时就认为这个节点不可用,当成功获取锁的个数超过Redis节点的半数,且获取锁消耗的时间还没超过锁过期时间,则认为获取锁成功。获取锁成功后重新计算锁释放时间,由原来的锁释放时间减去获取锁消耗的时间,如果最终获取锁失败,已经获取锁成功的节点也会释放锁。

Redisson使用

redisson的lock()、tryLock()方法 底层 其实是发送一段lua脚本到一台服务器:

  1. 锁互斥

    假如客户端A已经拿到了 myLock,现在 有一客户端(未知) 想进入:

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

    2、第二个if判断,判断一下,myLock锁key的hash数据结构中, 如果是客户端A重新请求,证明当前是同一个客户端同一个线程重新进入,所以可从入标志+1,重新刷新生存时间(可重入);否则进入下一个if。

    3、第三个if判断,客户端B 会获取到pttl myLock返回的一个数字,这个数字代表了myLock这个锁key的剩余生存时间。比如还剩15000毫秒的生存时间。

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

  2. watch dog 看门狗自动延期机制 官方介绍:

    lockWatchdogTimeout(监控锁的看门狗超时,单位:毫秒) 默认值:30000 监控锁的看门狗超时时间单位为毫秒。该参数只适用于分布式锁的加锁请求中未明确使用leaseTimeout参数的情况。(如果设置了leaseTimeout那就会自动失效了呀~) 看门狗有什么用呢?

    假如客户端A在超时时间内还没执行完毕怎么办呢?redisson于是提供了这个看门狗,如果还没执行完毕,监听到这个客户端A的线程还持有锁,就去续期,默认是 LockWatchdogTimeout/ 3 即 10 秒监听一次,如果还持有,就不断的延长锁的有效期(重新给锁设置过期时间,30s)

  3. 释放锁机制 lock.unlock(),就可以释放分布式锁。就是每次都对myLock数据结构中的那个加锁次数减1。

    如果发现加锁次数是0了,说明这个客户端已经不再持有锁了,此时就会用:“del myLock”命令,从redis里删除这个key。

    为了安全,会先校验是否持有锁再释放,防止

    业务执行还没执行完,锁到期了。(此时没占用锁,再unlock就会报错)

    主线程异常退出、或者假死

redisson watchdog 是什么? 通过redission 加锁源码阅读,了解到一个概念就是watch dog (俗称看门狗);他的作用,就是解决redis 原生分布式锁的锁续期的问题;首先需要注意的问题,就是watch dog 只有在不设置leaseTime ,或者leaseTime 为-1 时,才会有效,否则锁的强制过期时间是我们设置的leaseTime ;watch dog 默认锁的时间是30s ,他会每隔 lockWatchdogTimeout/3 (例如默认是10秒跑一次) 秒就会检查锁是否存在,如果存在则进行续期;

当服务器宕机后,因为锁的有效期是 30 秒,所以会在 30 秒内自动解锁。(30秒等于宕机之前的锁占用时间+后续锁占用的时间)

Redisson中RLock的实现原理

Redisson中RLock的实现是基本参照了Redis的red lock算法进行实现,不过在原始的red lock算法下进行了改良,主要包括下面的特性:

  • 互斥
  • 无死锁
  • 可重入,类似于ReentrantLock,同一个线程可以重复获取同一个资源的锁(一般使用计数器实现),锁的重入特性一般情况下有利于提高资源的利用率
  • 续期,这个是一个比较前卫解决思路,也就是如果一个客户端对资源X永久锁定,那么并不是直接对KEY生存周期设置为-1,而是通过一个守护线程每隔固定周期延长KEY的过期时间,这样就能实现在守护线程不被杀掉的前提下,避免客户端崩溃导致锁无法释放长期占用资源的问题
  • 锁状态变更订阅,依赖于org.redisson.pubsub.LockPubSub,用于订阅和通知锁释放事件
  • 不是完全参考red lock算法的实现,数据类型选用了HASH,配合Lua脚本完成多个命令的原子性

续期或者说延长KEY的过期时间在Redisson使用watch dog实现,理解为用于续期的守护线程,底层依赖于Netty的时间轮HashedWheelTimer和任务io.netty.util.Timeout实现,俗称看门狗 。

但是,这种方式也还是有问题

Redisson主从架构节点问题

由于redis实现的分布式锁,在向redis中存锁的时候,是马上返回结果告诉程序加锁成功的,此时可能锁还没有被同步到从节点,或者集群中其他节点。

由此来看,在高并发场景下如下问题:

  • 会出现在其他请求加锁的时候,连接的redis可能还没有同步到第一次加的锁,造成锁失效。
  • 当加锁完成了,还未同步到从节点或者集群中其他节点的时候,当前节点挂掉了。

zookeeper在存放数据的时候,也会同步给其他节点,但是至少同步了半数节点之后才会返回数据操作结果,这点很重要。

所以这么看下来,redis和zookper的区别在于:

  • redis满足了CAP理论的AP(高可用和分区容忍性)
  • zookeeper满足了CAP理论的CP(一致性和分区容忍性)

Redisson琐最大的缺点就是它加锁时只作用在一个Redis节点上,即使Redis通过sentinel(哨兵)保证高可用,如果这个master节点由于某些原因发生了主从切换,那么就会出现锁丢失的情况:

  • 在Redis的master节点上拿到了锁;
  • 但是这个加锁的key还没有同步到slave节点;
  • master故障,发生故障转移,slave节点升级为master节点;
  • 导致锁丢失。

正因为如此,Redis作者antirez基于分布式环境下提出了一种更高级的分布式锁的实现方式:Redlock。 感兴趣的可以自行了解

分享
点赞1
打赏
上一篇:Docker常用命令笔记(一)
下一篇:Debug|OpenFeign Get请求变成了Post请求