redisson

几个概念

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

在我们的系统还没有使用分布式架构的时候,我们可以用同步锁或者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实现,俗称看门狗

具体的代码实现:

引入依赖

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.13.1</version>
</dependency>

Redisson配置文件:

@Bean
public RedissonClient redissonClient() {
    Config config = new Config();
    config.useClusterServers()
            .setScanInterval(3000) // 集群状态扫描间隔时间,单位是毫秒
            .addNodeAddress("redis://192.168.0.1:6379).setPassword("666")
            .addNodeAddress("redis://192.168.0.2:6379").setPassword("666")
            .addNodeAddress("redis://192.168.0.3:6379")
            .setPassword("666");
    return Redisson.create(config);
}

获取锁操作:

long waitTimeout = 10;
long leaseTime = 1;
RLock lock1 = redissonClient1.getLock("lock1");
RLock lock2 = redissonClient2.getLock("lock2");
RLock lock3 = redissonClient3.getLock("lock3");

RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);

redLock.trylock(waitTimeout,leaseTime,TimeUnit.SECONDS);
try{
    //...
}finally{
    redLock.unlock();
}

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。

// 多个实例---redlock的思想
RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance2.getLock("lock2");
RLock lock3 = redissonInstance3.getLock("lock3");
// RedLock
RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
// 同时加锁:lock1 lock2 lock3
// 红锁在大部分节点上加锁成功就算成功。
lock.lock();
...
lock.unlock();

Redis分布式锁的缺点

redis的多节点分为三种:主从模式、哨兵模式和集群模式

不管是那种模式,都存在节点间的数据同步,也都会有某个节点挂掉的情况,所以才引出了redis实现分布式锁在多节点下可能会出现的问题。

Redis复制是异步的。

客户端1 对某个master节点写入了redisson锁,此时会异步复制给对应的 slave节点。但是这个过程中一旦发生 master节点宕机,主备切换,slave节点从变为了 master节点。

这时客户端2 来尝试加锁的时候,在新的master节点上也能加锁,此时就会导致多个客户端对同一个分布式锁完成了加锁。

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

缺陷在哨兵模式或者主从模式下,如果 master实例宕机的时候,可能导致多个客户端同时完成加锁。

上面描述的集群模式下的问题,由于节点之间是采用异步通信的方式。如果刚刚在 Master 节点上加了锁,但是数据还没被同步到 Salve。这时 Master 节点挂了,它上面的锁就没了,等新的 Master 出来后(主从模式的手动切换或者哨兵模式的一次 failover 的过程),就可以再次获取同样的锁,出现一把锁被拿到了两次的场景。

Redlock算法
作者认为,我们应该使用多个Redis,这些节点是完全独立的,不需要使用复制或者任何协调数据的系统,多个redis系统获取锁的过程就变成了如下步骤:

  1. 获取当前 Unix 时间,以毫秒为单位。

  2. 依次尝试从 N 个实例,使用相同的 key 和随机值获取锁。在步骤 2,当向 Redis 设置锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为 10 秒,则超时时间应该在 5-50 毫秒之间。这样可以避免服务器端 Redis 已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试另外一个 Redis 实例。

  3. 客户端使用当前时间减去开始获取锁时间(步骤 1 记录的时间)就得到获取锁使用的时间。当且仅当从大多数(这里是 3 个节点)的 Redis 节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。

  4. 如果取到了锁,key 的真正有效时间等于有效时间减去获取锁所使用的时间(步骤 3 计算的结果)。

  5. 如果因为某些原因,获取锁失败(没有在至少 N/2+1 个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的 Redis 实例上进行解锁(即便某些 Redis 实例根本就没有加锁成功)。

RedLock算法思想,意思是不能只在一个redis实例上创建锁,应该是在多个redis实例上创建锁,n / 2 + 1,必须在大多数redis节点上都成功创建锁,才能算这个整体的RedLock加锁成功,避免说仅仅在一个redis实例上加锁而带来的问题。

通过上面的步骤我们可以知道,只要大多数的节点可以正常工作,就可以保证 Redlock 的正常工作。这样就可以解决前面单点 Redis 的情况下我们讨论的节点挂掉,由于异步通信,导致锁失效的问题。

但是,还是不能解决故障重启后带来的锁的安全性的问题。你想一下下面这个场景:

我们一共有 A、B、C 这三个节点。

  • 客户端 1 在 A,B 上加锁成功。C 上加锁失败。
  • 这时节点 B 崩溃重启了,但是由于持久化策略导致客户端 1 在 B 上的锁没有持久化下来。
  • 客户端 2 发起申请同一把锁的操作,在 B,C 上加锁成功。
  • 这个时候就又出现同一把锁,同时被客户端 1 和客户端 2 所持有了。

备注

redisson中整合了很多其他jdk juc包中的功能,如读写锁