Redis复习(二):Redis主从复制、哨兵与集群、分布式锁、布隆过滤器、缓存雪崩、缓存击穿、缓存穿透、常见的几种缓存模式

七、Redis主从复制

在Redis中,用户可以通过执行SLAVEOF命令或者设置slaveof选项,让一个服务器去复制另一个服务器,我们称呼被复制的服务器为主服务器,而对主服务器进行复制的服务器则被称为从服务器

1、同步和命令传播(旧版复制功能)

Redis的复制功能分为同步命令传播两个操作:

  • 同步操作用于将从服务器的数据库状态更新至主服务器当前所处的数据库状态
  • 命令传播操作则用于在主服务器的数据库状态被修改,导致主从服务器的数据库状态出现不一致时,让主从服务器的数据库重新回到一致状态

1)、同步

从服务器对主服务器的同步操作需要通过向主服务器发送SYNC命令来完成,步骤:

1)从服务器向主服务器发送SYNC命令

2)收到SYNC命令的主服务器执行BGSAVE命令,在后台生成一个RDB文件,并使用一个缓冲区记录从现在开始执行的所有写命令

3)当主服务器的BGSAVE命令执行完毕时,主服务器会将BGSAVE命令生成的RDB文件发送给从服务器,从服务器接收并载入这个RDB文件,将自己的数据库状态更新至主服务器执行BGSAVE命令时的数据库状态

4)主服务器将记录在缓冲区里面的所有写命令发送给从服务器,从服务器执行这些写命令,将自己的数据库状态更新至主服务器数据库当前所处的状态

在这里插入图片描述

2)、命令传播

处于一致状态的主从服务器:

在这里插入图片描述

客户端发送DEL k3导致主从服务器处于不一致状态:

在这里插入图片描述

主服务器向从服务器发送命令:

在这里插入图片描述

主服务器会将自己执行的写命令,也就是造成主从服务器不一致的那条写命令,发送给从服务器执行,当从服务器执行了相同的写命令之后,主从服务器将再次回到一致状态

2、完整重同步和部分重同步(新版复制功能)

使用PSYNC命令代替SYNC命令来执行复制时的同步操作,PSYNC命令具有完整重同步部分重同步两种模式:

  • 完整重同步用于处理初次复制情况:完整重同步的执行步骤和SYNC命令的执行步骤基本一样
  • 部分重同步则用于处理断线后重复制情况:当从服务器在断线后重新连接主服务器时,如果条件允许,主服务器可以将主从服务器连接断开期间执行的写命令发送给从服务器,从服务器只要接收并执行这些写命令,就可以将数据库更新至主服务器当前所处的状态

在这里插入图片描述

1)、部分重同步的实现

部分重同步功能由以下三个部分构成:

  • 主服务器的复制偏移量和从服务器的复制偏移量
  • 主服务器的复制积压缓冲区
  • 服务器的运行ID

1)复制偏移量

在这里插入图片描述

在这里插入图片描述

主服务器和从服务器会分别维护一个复制偏移量

  • 主服务器每次向从服务器传播N个字节的数据时,就将自己的复制偏移量的值加上N
  • 从服务器每次收到主服务器传播来的N个字节的数据时,就将自己的复制偏移量的值加上N

通过对比主从服务器的复制偏移量,程序可以很容易地知道主从服务器是否处于一致状态:

  • 如果主从服务器处于一致状态,那么主从服务器两者的偏移量总是相同的
  • 相反,如果主从服务器两者的偏移量并不相同,那么说明主从服务器并未处于一致状态

2)复制积压缓冲区

复制积压缓冲区是由主服务器维护的一个固定长度先进先出队列,默认大小为1MB

在这里插入图片描述

当主服务器进行命令传播时,它不仅会将写命令发送给所有从服务器,还会将写命令写入队到复制积压缓冲区里面

主服务器的复制积压缓冲区里面会保存着一部分最近传播的写命令,并且复制积压缓冲区会为队列中的每个字节记录相应的复制偏移量

当从服务器重新连接上主服务器时,从服务器会通过PSYNC命令将自己的复制偏移量offset发送给主服务器:

  • 如果offset偏移量之后的数据仍然存在于复制积压缓冲区里面,那么主服务器将对从服务器执行部分重同步操作
  • 相反,如果offset偏移量之后的数据已经不存在于复制积压缓冲区,那么主服务器将对从服务器执行完整重同步操作

3)服务器运行ID

  • 每个Redis服务器,不论主服务器还是从服务器,都会有自己的运行ID
  • 运行ID在服务器启动时自动生成,由40个随机的十六进制字符组成

当从服务器对主服务器进行初次复制时,主服务器会将自己的运行ID传送个从服务器,而从服务器则会将这个运行ID保存起来

当从服务器断线并重新连上一个主服务器时,从服务器将向当前连接的主服务器发送之前保存的运行ID:

  • 如果从服务器保存的运行ID和当前连接的主服务器的运行ID相同,那么说明从服务器断线之前复制的就是当前连接的这个主服务器,主服务器可以继续尝试执行部分重同步操作
  • 相反,如果从服务器保存的运行ID和当前连接的主服务器的运行ID并不相同,那么说明从服务器断线之前复制的主服务器并不是当前连接的这个主服务器,主服务器将对从服务器执行完整重同步操作

2)、PSYNC命令的实现

PSYNC命令的调用方法有两种:

  • 如果从服务器以前没有复制过任何主服务器,或者之前执行SLAVEOF no one命令,那么从服务器在开始一次新的复制时将向主服务器发送PSYNC ? -1命令,主动请求主服务器进行完整重同步
  • 相反,如果从服务器已经复制过某个主服务器,那么从服务器开始一次新的复制时将向主服务器发送PSYNC <runid> <offset>命令:其中runid是上一次复制的主服务器的运行ID,而offset则是从服务器当前的复制偏移量,接收到这个命令的主服务器会通过这两个参数来判断应该对从服务器执行哪种同步操作

在这里插入图片描述

  • 如果主服务器返回+FULLRESYNC <runid> <offset>回复,那么表示主服务器将与从服务器执行完整重同步操作:其中runid是这个主服务器的运行ID,从服务器会将这个ID保存起来,在下一次发送PSYNC命令时使用;而offset则是主服务器当前的复制偏移量,从服务器会将这个值作为自己的初始化偏移量
  • 如果主服务器返回+CONTINUE回复,那么表示主服务器将与从服务器执行部分重同步操作,从服务器只要等着主服务器将自己缺少的那部分数据发送过来就可以了
  • 如果主服务器返回-ERR回复,那么表示主服务器的版本低于Redis 2.8,它识别不了PSYNC命令,从服务器将向主服务器发送SYNC命令,并与主服务器执行完整同步操作

八、Redis哨兵与集群

1、Redis哨兵

由一个或多个Sentinel实例组成的Sentinel系统可以监视多个主服务器,以及这些主服务器属下的所有从服务器,并在被监视的主服务器进入下线状态时,自动将下线主服务器属下的某个从服务器升级为新的主服务器,然后由新的主服务器代替已下线的主服务器继续处理命令请求

在这里插入图片描述

上图中,server1为当前的主服务器,server2、server3、server4三个从服务器正在复制主服务器server1,而Sentinel系统则在监视所有四个服务器

在这里插入图片描述

如果这时,主服务器server1进入下线状态,那么从服务器server2、server3、server4对主服务器的复制操作将被中止,并且Sentinel系统会察觉到server1已下线

当server1的下线时长超过用户设定的下线时长上限时,Sentinel系统就会对server1执行故障转移操作:

1)首先,Sentinel系统会挑选server1属下的其中一个从服务器,并将这个被选中的从服务器升级为新的主服务器

2)之后,Sentinel系统会向server1属下的所有从服务器发送新的复制指令,让它们成为新的主服务器的从服务器,当所有从服务器都开始复制新的主服务器时,故障转移操作执行完毕

3)另外,Sentinel还会继续监视已下线的server1,并在它重新上线时,将它设置为新的主服务器的从服务器

在这里插入图片描述

在这里插入图片描述

1)、检测主观下线状态

在默认情况下,Sentinel会以每秒一次的频率向所有命令连接的实例(包括主服务器、从服务器、其他Sentinel在内)发送PING命令,并通过实例返回的PING命令回复来判断实例是否在线

Sentinel向实例发送PING命令

在这里插入图片描述

如果一个服务器没有在master-down-after-milliseconds选项所指定的时间内,对向它发送PING命令的Sentinel返回一个有效回复,那么Sentinel就会将这个服务器标记为主观下线

服务器对PING命令的有效回复可以是以下三种回复的其中一种:

  • 返回+PONG
  • 返回-LOADING错误
  • 返回-MASTERDOWN错误

2)、检查客观下线状态

当Sentinel将一个主服务器判断为主观下线之后,为了确认这个主服务器是否真的下线了,它会向同样监视这一主服务器的其他Sentinel进行询问,看它们是否也认为主服务器已经进入了下线状态。当Sentinel从其他Sentinel那里接收到足够数量的已下线判断之后,Sentinel就会将从服务器判定为客观下线,并对主服务器执行故障转移操作(客观下线条件只适用于主服务器

当认为主服务器已经进入下线状态的Sentinel的数量,超过Sentinel配置中设置的quorum参数的值,那么该Sentinel就会认为主服务器已经进入客观下线状态。比如说,如果Sentinel在启动时载入了以下配置:

sentinel monitor mymaster 127.0.0.1 6379 2

那么包括当前Sentinel在内,只要总共有两个Sentinel认为主服务器已经进入下线状态,那么当前Sentinel就将主服务器判断为客观下线

2、Redis集群

Redis集群架构图

在这里插入图片描述

上图蓝色圆圈代表Redis服务器节点,它们两两都相连,所以只要客户端连接到其中一台Redis服务器就可以对其他Redis服务器进行读写操作

1)、数据分片

Redis集群有16384个哈希槽,Redis会根据节点数量大致均等的将哈希槽映射到不同的节点。举个例子,比如当前集群有3个节点,那么

  • 节点A包含0到5500号哈希槽
  • 节点B包含5501到11000号哈希槽
  • 节点C包含11001到16383号哈希槽

当需要在Redis集群中存储一个key-value时,Redis先对key使用CRC16算法算出一个结果,然后把结果对16383求余数,这样每个key都会对应一个编号在0-16383之间的哈希槽(CRC16(key) & 16383

当客户端向节点发送与数据库键有关的命令时,接收命令的节点会计算出命令要处理的数据库键属于哪个槽,并检查这个槽是否指派给了自己

  • 如果键所在的槽正好就指派给了当前节点,那么节点直接执行这个命令
  • 如果键所在的槽并没有指派给当前节点,那么节点会向客户端返回一个MOVED错误,指引客户端转向至正确的节点,并再次发送之前想要执行的命令

在这里插入图片描述

2)、故障检测

在这里插入图片描述

集群中的每个节点都会定期地向集群中的其他节点发送PING消息,以此来检测对方是否在线,如果接收PING消息的节点没有在规定的时间内,向发送PING消息的节点返回PONG消息,那么发送PING消息的节点就会将接收PING消息的节点标记为疑似下线

集群中的各个节点会通过互相发送消息的方式来交换集群中各个节点的状态信息,例如某个节点是处于在线状态、疑似下线状态还是已下线状态

如果在一个集群里面,半数以上负责处理槽的主节点都将某个主节点x报告为疑似下线,那么这个主节点x将被标记为已下线,将主节点x标记为已下线的节点会向集群广播一条关于主节点x的FAIL消息,所有收到这条FAIL消息的节点都会立即将主节点x标记为已下线

3)、故障转移

当一个从节点发现自己正在复制的主节点进入了已下线状态时,从节点将开始对下线主节点进行故障转移,以下是故障转移的执行步骤

1)复制下线主节点的所有从节点里面,会有一个从节点被选出

2)被选中的从节点会执行SLAVEOF no one命令,成为新的主节点

3)新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己

4)新的主节点向集群广播一条PONG消息,这条PONG消息可以让集群中的其他节点立即知道这个节点已经由从节点变成了主节点,并且这个主节点已经接管了原本由已下线节点负责处理的槽

5)新的主节点开始接收和自己负责处理的槽有关的命令请求,故障转移完成

九、分布式锁

为了确保分布式锁可用,锁的实现至少要同时满足以下三个条件:

  • 互斥性。在任意时刻,只有一个客户端能持有锁
  • 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁
  • 加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了

1、加锁的实现

public class RedisLock {
    private static final String LOCK_SUCCESS = "OK";
    private static final String SET_IF_NOT_EXIST = "NX";
    private static final String SET_WITH_EXPIRE_TIME = "PX";

    /**
     * 尝试获取分布式锁
     *
     * @param jedis      Redis客户端
     * @param lockKey    锁标识
     * @param requestId  请求标识
     * @param expireTime 超期时间(秒)
     * @return 是否获取成功
     */
    public static boolean getLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
        String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime * 1000);
        if (LOCK_SUCCESS.equals(result)) {
            return true;
        }
        return false;
    }

加锁过程中主要使用的redis命令set key value nx px expireTime,当key不存在或者已经过期时,进行set操作,返回OK;当key存在时,不做任何操作

对应的Java代码为:jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime * 1000)

  • key:传入锁标识
  • value:传入的是requestId,目的是为了实现加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了,requestId可以使用UUID.randomUUID().toString()方法生成
  • nxxx:NX
  • expx:PX
  • time:key的过期时间

setnx保证了如果已有key存在,则函数不会调用成功,只有一个客户端能持有锁,满足互斥性

设置了过期时间,即使锁的持有者后续发生崩溃而没有解锁,锁也会因为到了过期时间而自动解锁(即key被删除),不会发生死锁

将value赋值为requestId,代表加锁的客户端请求标识,那么在客户端在解锁的时候就可以进行校验是否是同一个客户端

1)、错误示例1

    public static void getLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
        Long result = jedis.setnx(lockKey, requestId);
        if (result == 1) {
            //若在这里程序突然崩溃,则无法设置过期时间,将发生死锁         
            jedis.expire(lockKey, expireTime);
        }
    }

实现思路:使用jedis.setnx()jedis.expire()组合实现加锁

存在的问题:通过两条Redis命令,不具有原子性,如果程序在执行完jedis.setnx()之后突然崩溃,导致锁没有设置过期时间,那么将会发生死锁(低版本的jedis并不支持多参数的set()方法)

2)、错误示例2

    public static boolean getLock(Jedis jedis, String lockKey, int expireTime) {
        long expires = System.currentTimeMillis() + expireTime;
        String expiresStr = String.valueOf(expires);
        //如果当前锁不存在,返回加锁成功
        if (jedis.setnx(lockKey, expiresStr) == 1) {
            return true;
        }
        //如果锁存在,获取锁的过期时间
        String currentValueStr = jedis.get(lockKey);
        if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
            //锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间
            String oldValueStr = jedis.getSet(lockKey, expiresStr);
            if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
                //考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才有权利加锁
                return true;
            }
        }
        //其他情况,一律返回加锁失败
        return false;
    }

实现思路:使用jedis.setnx()命令实现加锁,其中key是锁,value是锁的过期时间。通过setnx()方法尝试加锁,如果当前锁不存在,返回加锁成功。 如果锁已经存在则获取锁的过期时间,和当前时间比较,如果锁已经过期,则设置新的过期时间,返回加锁成功

存在的问题:

  • 由于是客户端自己生成过期时间,所以需要强制要求分布式下每个客户端的时间必须同步
  • 当锁过期的时候,如果多个客户端同时执行jedis.getSet()方法,那么虽然最终只有一个客户端可以加锁,但是这个客户端的锁的过期时间可能被其他客户端覆盖
  • 锁不具备拥有者标识,即任何客户端都可以解锁

2、解锁的实现

    private static final Long RELEASE_SUCCESS = 1L;

    /**
     * 释放分布式锁
     *
     * @param jedis     Redis客户端
     * @param lockKey   锁标识
     * @param requestId 请求标识
     * @return 是否释放成功
     */
    public static boolean releaseLock(Jedis jedis, String lockKey, String requestId) {
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
        if (RELEASE_SUCCESS.equals(result)) {
            return true;
        }
        return false;
    }

这段Lua代码的功能是首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)。使用eval()方法执行Lua语言来实现可以确保上述操作是原子性的

1)、错误示例

    public static void releaseLock(Jedis jedis, String lockKey, String requestId) {
        //判断加锁与解锁是不是同一个客户端
        if (requestId.equals(jedis.get(lockKey))) {
            //若在此时,这把锁突然不是这个客户端的,则会误解锁
            jedis.del(lockKey);
        }
    }

存在的问题:如果调用jedis.del()方法的时候,这把锁已经不属于当前客户端的时候会解除他人加的锁比如客户端A加锁,一段时间之后客户端A解锁,在执行jedis.del()之前,锁突然过期了,此时客户端B尝试加锁成功,然后客户端A再执行del()方法,则将客户端B的锁给解除了

3、Redisson实现原理

1)、加锁源码分析

org.redisson.RedissonLock中的tryLock(long waitTime, long leaseTime, TimeUnit unit)方法

    @Override
    public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
      	//获取最大等待时间
        long time = unit.toMillis(waitTime);
      	//记录当前时间
        long current = System.currentTimeMillis();
      	//获取当前线程id(判断是否可重入锁的关键)
        long threadId = Thread.currentThread().getId();
      	//尝试申请锁,返回还剩余的锁过期时间
        Long ttl = tryAcquire(leaseTime, unit, threadId);
        //如果为空,表示申请锁成功
        if (ttl == null) {
            return true;
        }
        
      	//申请锁的耗时如果大于等于最大等待时间,则申请锁失败
        time -= System.currentTimeMillis() - current;
        if (time <= 0) {
            acquireFailed(threadId);
            return false;
        }
        
        current = System.currentTimeMillis();
      	//订阅锁释放事件,并通过await方法阻塞等待锁释放,解决了无效的锁申请浪费资源的问题
      	//当锁被其它资源占用时,当前线程通过Redis的channel订阅锁的释放事件,一旦锁释放会发消息通知待等待的线程进行竞争
        RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
      	//await()方法返回false,说明等待时间已经超出获取锁最大等待时间,取消订阅并返回获取锁失败
      	//await()方法返回true,进入循环尝试获取锁
        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;
            }
        
          	//收到锁释放的信号后,在最大等待时间之内,循环一次接着一次的尝试获取锁
          	//获取锁成功,则立马返回true
         		//若在最大等待时间之内还没获取到锁,则认为获取锁失败,返回false结束循环
            while (true) {
                long currentTime = System.currentTimeMillis();
              	//再次尝试申请锁
                ttl = tryAcquire(leaseTime, unit, threadId);
                //成功获取锁则直接返回true结束循环
                if (ttl == null) {
                    return true;
                }

              	//超过最大等待时间则返回false结束循环,获取锁失败
                time -= System.currentTimeMillis() - currentTime;
                if (time <= 0) {
                    acquireFailed(threadId);
                    return false;
                }

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

              	//更新剩余的等待时间(最大等待时间-已经消耗的阻塞时间)
                time -= System.currentTimeMillis() - currentTime;
                if (time <= 0) {
                    acquireFailed(threadId);
                    return false;
                }
            }
        } finally {
          	//无论是否获得锁,都要取消订阅解锁消息
            unsubscribe(subscribeFuture, threadId);
        }
    }
    private Long tryAcquire(long leaseTime, TimeUnit unit, long threadId) {
        return get(tryAcquireAsync(leaseTime, unit, threadId));
    }
    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;
    }
    <T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
        internalLockLeaseTime = unit.toMillis(leaseTime);

        return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
                  //如果缓存中的key不存在,则执行hset命令(hset key UUID+threadId 1),然后通过pexpire命令设置锁的过期时间                            
                  "if (redis.call('exists', KEYS[1]) == 0) then " +
                      "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                      "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                      "return nil; " +
                  "end; " +
                  //如果key已经存在,并且value也匹配,表示是当前线程持有的锁,则执行hincrby命令,重入次数加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已经存在,但是value不匹配,说明锁已经被其他线程持有,通过pttl命令获取锁的剩余存活时间并返回,至此获取锁失败                            
                  "return redis.call('pttl', KEYS[1]);",
                    Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
    }

参数说明

  • KEYS[1]:Collections.<Object>singletonList(getName()),表示分布式锁的key
  • ARGV[1]:internalLockLeaseTime,即锁的租约时间(持有锁的有效时间),默认30s
  • ARGV[2]:getLockName(threadId),是获取锁时set的唯一值value,即UUID+threadId

加锁流程图

在这里插入图片描述

2)、解锁源码分析

    public void unlock() {
        try {
            get(unlockAsync(Thread.currentThread().getId()));
        } catch (RedisException e) {
            if (e.getCause() instanceof IllegalMonitorStateException) {
                throw (IllegalMonitorStateException) e.getCause();
            } else {
                throw e;
            }
        }
    }
    public RFuture<Void> unlockAsync(long threadId) {
        RPromise<Void> result = new RedissonPromise<Void>();
        RFuture<Boolean> future = unlockInnerAsync(threadId);

        future.onComplete((opStatus, e) -> {
            cancelExpirationRenewal(threadId);

            if (e != null) {
                result.tryFailure(e);
                return;
            }

            if (opStatus == null) {
                IllegalMonitorStateException cause = new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: "
                        + id + " thread-id: " + threadId);
                result.tryFailure(cause);
                return;
            }

            result.trySuccess(null);
        });

        return result;
    }
    protected RFuture<Boolean> unlockInnerAsync(long threadId) {
        return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
                //如果分布式锁存在,但是value不匹配,表示锁已经被其他线程占用,无权释放锁,那么直接返回空值                              
                "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
                    "return nil;" +
                "end; " +
                //如果value匹配,则就是当前线程占有分布式锁,那么将重入次数减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; " +
                //重入次数减1后的值如果为0,这时就可以删除这个key,并发布解锁消息,返回1                              
                "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));

    }

解锁流程图

在这里插入图片描述

参考:http://ifeve.com/慢谈-redis-实现分布式锁-以及-redisson-源码解析

十、布隆过滤器

布隆过滤器可以用来判断一个元素是否在一个集合中。它的优势是只需要占用很小的内存空间以及有着高效的查询效率

对于布隆过滤器而言,它的本质是一个位数组:位数组就是数组的每个元素都只占用1bit ,并且每个元素只能是0或者1

布隆过滤器除了一个位数组,还有K个哈希函数。当一个元素加入布隆过滤器中的时候,会进行如下操作:

  • 使用K个哈希函数对元素值进行K次计算,得到K个哈希值
  • 根据得到的哈希值,在位数组中把对应下标的值置为1

下图表示有三个hash函数,比如一个集合中有x、y、z三个元素,分别用三个hash函数映射到二进制序列的某些位上

假设判断w是否在集合中,同样用三个hash函数来映射,结果发现取得的结果不全为1,则表示w不在集合里面

在这里插入图片描述

数组的容量即使再大,也是有限的。那么随着元素的增加,插入的元素就会越多,位数组中被置为1的位置因此也越多,这就会造成一种情况:当一个不在布隆过滤器中的元素,经过同样规则的哈希计算之后,得到的值在位数组中查询,有可能这些位置因为之前其它元素的操作先被置为1了

所以,有可能一个不存在布隆过滤器中的会被误判成在布隆过滤器中。这就是布隆过滤器的一个缺陷。但是,如果布隆过滤器判断某个元素不在布隆过滤器中,那么这个值就一定不在布隆过滤器中。总结就是:

  • 布隆过滤器说某个元素在,可能会被误判
  • 布隆过滤器说某个元素不在,那么一定不在

十一、缓存雪崩、缓存击穿、缓存穿透

1、缓存雪崩

1)、什么是缓存雪崩?

如果缓存集中在一段时间内失效,发生大量的缓存击穿,所有的查询都落在数据库上,造成了缓存雪崩

由于原有缓存失效,新缓存未到期间所有原本应该访问缓存的请求都去查询数据库了,而对数据库CPU和内存造成巨大压力,严重的会造成数据库宕机

2)、有什么解决方案来防止缓存雪崩?

1)加锁排队

mutex互斥锁解决,Redis的SETNX去set一个mutex key,当操作返回成功时,再进行加载数据库的操作并回设缓存,否则,就重试整个get缓存的方法

2)数据预热

缓存预热就是系统上线后,将相关的缓存数据直接加载到缓存系统。这样就可以避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题。用户直接查询事先被预热的缓存数据。可以通过缓存reload机制,预先去更新缓存,再即将发生大并发访问前手动触发加载缓存不同的key

3)双层缓存策略

C1为原始缓存,C2为拷贝缓存,C1失效时,可以访问C2,C1缓存失效时间设置为短期,C2设置为长期

4)定时更新缓存策略

实效性要求不高的缓存,容器启动初始化加载,采用定时任务更新或移除缓存

5)设置不同的过期时间,让缓存失效的时间点尽量均匀

2、缓存击穿

1)、什么是缓存击穿?

在平常高并发的系统中,大量的请求同时查询一个key时,此时这个key正好失效了,就会导致大量的请求都打到数据库上面去。这种现象我们称为缓存击穿

2)、会带来什么问题

会造成某一时刻数据库请求量过大,压力剧增

3)、如何解决

上面的现象是多个线程同时去查询数据库的这条数据,那么我们可以在第一个查询数据的请求上使用一个互斥锁来锁住它

其他的线程走到这一步拿不到锁就等着,等第一个线程查询到了数据,然后做缓存。后面的线程进来发现已经有缓存了,就直接走缓存

3、缓存穿透

1)、什么是缓存穿透?

缓存穿透是指用户查询数据,在数据库没有,自然在缓存中也不会有。这样就导致用户查询的时候,在缓存中找不到对应key的value,每次都要去数据库再查询一遍,然后返回空(相当于进行了两次无用的查询)。这样请求就绕过缓存直接查数据库

2)、有什么解决方案来防止缓存穿透?

1)缓存空值

如果一个查询返回的数据为空(不管是数据不存在,还是系统故障)我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过5分钟。通过这个设置的默认值存放到缓存,这样第二次到缓存中获取就有值了,而不会继续访问数据库

2)采用布隆过滤器BloomFilter

优势:占用内存空间很小,位存储;性能特别高,使用key的hash判断key存不存在

将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力

在缓存之前在加一层BloomFilter,在查询的时候先去BloomFilter去查询key是否存在,如果不存在就直接返回,存在再去查询缓存,缓存中没有再去查询数据库

十二、常见的几种缓存模式

1、Cache Aside

  • 应用在查询数据的时候,先从缓存Cache中读取数据,如果缓存中没有,则再从数据库中读取数据,得到数据库的数据之后,将这个数据也放到缓存Cache中
  • 如果应用要更新某个数据,也是先去更新数据库中的数据,更新完成之后,则通过指令让缓存Cache中的数据失效

1)这里为什么不让更新操作在写完数据库之后,紧接着去把缓存Cache中的数据也修改了呢?

主要是因为这样做的话,就有2个写操作的事件了,可能在并发的情况下会导致脏数据,举个例子:假如同时有2个请求,请求A和请求B,并发的执行。请求A是要去读数据,请求B是要去更新数据。初始状态缓存中是没有数据的,当请求A读到数据之后,准备往回写的时候,此刻,请求B正好要更新数据,更新完了数据库之后,又去把缓存更新了,那请求A再往缓存中写的就是旧数据了,属于脏数据

2)那么Cache Aside模式就没有脏数据问题了吗?

在极端情况下也可能会产生脏数据。例如,同时有2个请求,请求A和请求B,并发的执行。请求A是要去读数据,请求B是要去写数据。假如初始状态缓存中没有这个数据,那请求A发现缓存中没有数据,就会去数据库中读数据,读到了数据准备写回缓存中,就在这个时候,请求B是要去写数据的,请求B在写完数据库的数据之后,又去设置了缓存失效。这个时候,请求A由于在数据库中读到了之前的旧数据,开始往缓存中写数据了,此时写进入的就也是旧数据。那么最终就会导致,缓存中的数据与数据库的数据不一致,造成了脏数据

2、Read/Write Through

  • 应用要读数据和更新数据都直接访问缓存服务
  • 缓存服务同步地将数据更新到数据库

出现脏数据的概率较低,但是就强依赖缓存,对缓存服务的稳定性有较大要求

3、Write Behind模式

  • 应用要读数据和更新数据都直接访问缓存服务
  • 缓存服务异步地将数据更新到数据库(通过异步任务)

速度快,效率会非常高,但是数据的一致性比较差,还可能会有数据的丢失情况,实现逻辑也较为复杂

发布了190 篇原创文章 · 获赞 442 · 访问量 12万+

猜你喜欢

转载自blog.csdn.net/qq_40378034/article/details/104599488