redis 缓存雪崩、穿透、击穿、脑裂问题

1、缓存雪崩

缓存雪崩是指大量的应用请求无法在 Redis 缓存中进行处理,紧接着,应用将大量请求发送到数据库层,导致数据库层的压力激增,造成存储层也可能会级联宕机的情况。

一般来说,一个 Redis 实例可以支持数万级别的请求处理吞吐量,而单个数据库可能只能支持数千级别的请求处理吞吐量,它们两个的处理能力可能相差了近十倍。由于缓存雪崩,Redis 缓存失效,所以,数据库就可能要承受近十倍的请求压力,从而因为压力过大而崩溃。

预防和解决缓存雪崩问题:

  1. 保证缓存层服务高可用性,比如使用Redis Sentinel或Redis Cluster。

  2. 依赖隔离组件为后端限流熔断并降级。比如使用Sentinel或Hystrix限流降级组件。
    比如服务降级,我们可以针对不同的数据采取不同的处理方式。当业务应用访问的是非核心数据(例如电商商品属性,用户信息等)时,暂时停止从缓存中查询这些数据,而是直接返回预定义的默认降级信息、空值或是错误提示信息;当业务应用访问的是核心数据(例如电商商品库存)时,仍然允许查询缓存,如果缓存缺失,也可以继续通过数据库读取。
    在这里插入图片描述
    在这里插入图片描述

  3. 提前演练。 在项目上线前, 演练缓存层宕掉后, 应用以及后端的负载情况以及可能出现的问题, 在此基础上做一些预案设定。

  4. 防止缓存中有大量数据同时过期,导致大量请求无法得到处理。给这些数据的过期时间增加一个较小的随机数(例如,随机增加 1~3 分钟),既避免了大量数据同时过期

2、缓存穿透

缓存穿透是指要访问的数据既不在 Redis 缓存中,也不在数据库中,导致请求在访问缓存时,发生缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据。

此时,应用也无法从数据库中读取数据再写入缓存,来服务后续请求,这样一来,缓存也就成了“摆设”,如果应用持续有大量请求访问数据,就会同时给缓存和数据库带来巨大压力。
在这里插入图片描述

2.1、缓存穿透会发生在什么时候呢?

  1. 缓存空值或缺省值。业务层误操作:缓存中的数据和数据库中的数据被误删除了,所以缓存和数据库中都没有数据;
  2. 恶意攻击或爬虫等,造成大量空命中。

2.2、避免缓存穿透的三种应对方案。

  1. 缓存空值或缺省值。业务层误操作:一旦发生缓存穿透,我们就可以针对查询的数据,在 Redis 中缓存一个空值或是和业务层协商确定的缺省值(例如,库存的缺省值可以设为 0)。紧接着,应用发送的后续请求再进行查询时,就可以直接从 Redis 中读取空值或缺省值,返回给业务应用了,避免了把大量请求发送给数据库处理,保持了数据库的正常运行,别忘了给设置的空值或缺省值加过期时间

    String get(String key) {
        // 从缓存中获取数据
        String cacheValue = cache.get(key);
        // 缓存为空
        if (StringUtils.isBlank(cacheValue)) {
            // 从存储中获取
            String storageValue = storage.get(key);
            cache.set(key, storageValue);
            // 如果存储数据为空, 需要设置一个过期时间(300秒)
            if (storageValue == null) {
                cache.expire(key, 60 * 5);
            }
            return storageValue;
        } else {
            // 缓存非空
            return cacheValue;
        }
    }
    
  2. 使用布隆过滤器快速判断数据是否存在,避免从数据库中查询数据是否存在,减轻数据库压力

  3. 在请求入口的前端进行请求检测
    缓存穿透的一个原因是有大量的恶意请求访问不存在的数据,所以,一个有效的应对方案是在请求入口前端,对业务系统接收到的请求进行合法性检测,把恶意的请求(例如请求参数不合理、请求参数是非法值、请求字段不存在)直接过滤掉,不让它们访问后端缓存和数据库。这样一来,也就不会出现缓存穿透问题了。

2.3、布隆过滤器

过滤器由一个初值都为 0 的 bit 数组和 N 个哈希函数组成,可以用来快速判断某个数据是否存在。当我们想标记某个数据存在时(例如,数据已被写入数据库),布隆过滤器会通过三个操作完成标记(例如:一个key ,使用3个不同的hash函数):

  1. 首先,使用 3 个哈希函数,分别计算这个数据的哈希值,得到 3个哈希值。
  2. 然后,我们把这 3 个哈希值对 bit 数组的长度取模,得到每个哈希值在数组中的对应位置。
  3. 最后,我们把对应位置的 bit 位设置为 1,这就完成了在布隆过滤器中标记数据的操作。
  4. 用这3个hash值对应布隆过滤器的位置记录一个值是否存在

在这里插入图片描述
向布隆过滤器询问 key 是否存在时,也会把 hash 的几个位置都算出来,看看位数组中这几个位置是否都为 1,只要有一个位为 0,那么说明布隆过滤器中这个key 不存在如果都是 1,这并不能说明这个 key 就一定存在,只是极有可能存在因为这些位被置为 1 可能是因为其它的 key 存在所致(hash冲突所致)。如果这个位数组长度比较大,存在概率就会很大,如果这个位数组长度比较小,存在概率就会降低。

这种方法适用于数据命中不高、 数据相对固定、 实时性低(通常是数据集较大)的应用场景,代码维护较为复杂,但是缓存空间占用很少。

可以用redisson实现布隆过滤器,引入依赖:

<dependency>
   <groupId>org.redisson</groupId>
   <artifactId>redisson</artifactId>
   <version>3.6.5</version>
</dependency>
package com.redisson;

import org.redisson.Redisson;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;

public class RedissonBloomFilter {

    public static void main(String[] args) {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://localhost:6380");
        //构造Redisson
        RedissonClient redisson = Redisson.create(config);

        RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList");
        //初始化布隆过滤器:预计元素为100000000L,误差率为3%,根据这两个参数会计算出底层的bit数组大小
        bloomFilter.tryInit(100000000L,0.03);
        //将zhangsan插入到布隆过滤器中
        bloomFilter.add("zhuge");

        //判断下面号码是否在布隆过滤器中
        System.out.println(bloomFilter.contains("lisi"));//false
        System.out.println(bloomFilter.contains("wangwu"));//false
        System.out.println(bloomFilter.contains("zhangsan"));//true
    }
}

注意:布隆过滤器不能删除数据,如果要删除得重新初始化数据。

使用布隆过滤器需要把所有数据提前放入布隆过滤器,并且在增加数据时也要往布隆过滤器里放,布隆过滤器缓存过滤伪代码:

//初始化布隆过滤器
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList");
//初始化布隆过滤器:预计元素为100000000L,误差率为3%
bloomFilter.tryInit(100000000L,0.03);
        
//把所有数据存入布隆过滤器
void init(){
    for (String key: keys) {
        bloomFilter.put(key);
    }
}

String get(String key) {
    // 从布隆过滤器这一级缓存判断下key是否存在
    Boolean exist = bloomFilter.contains(key);
    if(!exist){
        return "";
    }
    // 从缓存中获取数据
    String cacheValue = cache.get(key);
    // 缓存为空
    if (StringUtils.isBlank(cacheValue)) {
        // 从存储中获取
        String storageValue = storage.get(key);
        cache.set(key, storageValue);
        // 如果存储数据为空, 需要设置一个过期时间(300秒)
        if (storageValue == null) {
            cache.expire(key, 60 * 5);
        }
        return storageValue;
    } else {
        // 缓存非空
        return cacheValue;
    }
}

3、缓存击穿

缓存击穿是指,针对某个访问非常频繁的热点数据的请求,无法在缓存中进行处理,紧接着,访问该数据的大量请求,一下子都发送到了后端数据库,导致了数据库压力激增,会影响数据库处理其他请求。缓存击穿的情况,经常发生在热点数据过期失效时。
在这里插入图片描述
避免缓存击穿给数据库带来的激增压力的解决方法

  1. 对于访问特别频繁的热点数据,不设置过期时间,通过业务程序删除或替换
  2. 避免热点缓存key同时过期,可以设置随机数如缓存雪崩中方式

4、缓存雪崩、穿透、击穿对比

雪崩、穿透、缓存击穿这类问题相比,缓存穿透的影响更大一些。从预防的角度来说,我们需要避免误删除数据库和缓存中的数据;从应对角度来说,我们可以在业务系统中使用缓存空值或缺省值、使用布隆过滤器,以及进行恶意请求检测等方法。
在这里插入图片描述
服务熔断、服务降级、请求限流这些方法都是属于“有损”方案,在保证数据库和整体系统稳定的同时,会对业务应用带来负面影响

  • 使用服务降级时,有部分数据的请求就只能得到错误返回信息,无法正常处理。
  • 使用服务熔断,那么,整个缓存系统的服务都被暂停了,影响的业务范围更大。
  • 使用请求限流机制后,整个业务系统的吞吐率会降低,能并发处理的用户请求会减少,会影响到用户体验。

所以,尽量使用预防式方案

  • 针对缓存雪崩,合理地设置数据过期时间,以及搭建高可靠缓存集群;
  • 针对缓存击穿,在缓存访问非常频繁的热点数据时,不要设置过期时间;
  • 针对缓存穿透,提前在入口前端实现恶意请求检测,或者规范数据库的数据删除操作,避免误删除。

5、脑裂

在主从集群中,同时有两个主节点,它们都能接收写请求。而脑裂最直接的影响,就是客户端不知道应该往哪个主节点写入数据,结果就是不同的客户端会往不同的主节点上写入数据。而且,严重的话,脑裂会进一步导致数据丢失。

5.1、主从集群中为什么会发生脑裂?

在主从集群中发生数据丢失,最常见的原因就是主库的数据还没有同步到从库,结果主库发生了故障,等从库升级为主库后,原主库中未同步的数据就丢失了
在这里插入图片描述

  • 查看主库上的 master_repl_offset,以及从库上的slave_repl_offset。如果新主库升级前的slave_repl_offset,以及原主库的 master_repl_offset,它们是一致的,也就是说,这个升级为新主库的从库,在升级时已经和原主库的数据保持一致了

  • 排查客户端的操作日志,如果,在主从切换后的一段时间内,有一个客户端仍然在和原主库通信,并没有和升级的新主库进行交互。这就相当于主从集群中同时有了两个主库。脑裂现象

  • 当主从切换发生时,一定是有超过预设数量(quorum 配置项)的哨兵实例和主库的心跳都超时了,才会把主库判断为客观下线,然后,哨兵开始执行切换操作,redis 集群模式同理。哨兵切换完成后,客户端会和新主库进行通信,发送请求操作。

    • 集群选举流程
      • 1.每个slave会与自己的master通讯,当slave发现自己的master变为fail时。每个slave都会参与竞争,推举自己为master。
      • 2.增加currentEpoch的值,并且每个slave向集群中的其他所有节点广播FAILOVER_AUTH_REQUEST。
      • 3.其他master会受到多个slave的广播,但是只会给第一个slave回复FAILOVER_AUTH_ACK。
      • 4.slave接收到ack之后,会使用过半机制开始统计。即:当前有多少master给自己ack,如果超过一半的master发送ack,则成为master。
      • 5.广播PONG,通知给集群中的其节点。至此,选举流程结束。
  • 在切换过程中,既然客户端仍然和原主库通信,这就表明,原主库并没有真的发生故障,而是因为其他原因导致和其他节点通讯延迟导致的(所在服务器部署了其他服务,突然负载过高等原因导致)
    在这里插入图片描述

5.2、脑裂为什么又会导致数据丢失呢?

主从切换后,从库一旦升级为新主库,哨兵就会让原主库执行 slave of 命令,和新主库重新进行全量同步。而在全量同步执行的最后阶段,原主库需要清空本地的数据,加载新主库发送的 RDB 文件,这样一来,原主库在主从切换期间保存的新写数据就丢失了

在这里插入图片描述

5.3、如何避免脑裂

脑裂发生的原因主要是原主库发生了假故障

  1. 和主库部署在同一台服务器上的其他程序临时占用了大量资源(例如 CPU 资源),导致主库资源使用受限,短时间内无法响应心跳。其它程序不再使用资源时,主库又恢复正常。
  2. 主库自身遇到了阻塞的情况,例如,处理 bigkey 或是发生内存 swap(内存与硬盘空间转换),短时间内无法响应心跳,等主库阻塞解除后,又恢复正常的请求处理了。

Redis 提供了两个配置项来限制主库的请求处理,分别是 min-slaves-to-write 和 min-slaves-max-lag

  • min-slaves-to-write:这个配置项设置了主库能进行数据同步的最少从库数量;
  • min-slaves-max-lag:这个配置项设置了主从库间进行数据复制时,从库给主库发送ACK 消息的最大延迟(以秒为单位)。

把 min-slaves-to-write 和 min-slaves-max-lag 这两个配置项搭配起来使用,分别给它们设置一定的阈值,假设为 N 和 T。这两个配置项组合后的要求是,主库连接的从库中至少有 N 个从库,和主库进行数据复制时的 ACK 消息延迟不能超过 T 秒,否则,主库就不会再接收客户端的请求了。

假设从库有 K 个,可以将 min-slaves-to-write 设置为K/2+1(如果 K 等于 1,就设为 1),将 min-slaves-max-lag 设置为十几秒(例如 10~20s),在这个配置下,如果有一半以上的从库和主库进行的 ACK 消息延迟超过十几秒,我们就禁止主库接收客户端写请求。

  • 2
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值