一、缓存穿透

1. 概念

Key 对应的数据在 Redis 中并不存在,每次针对此 Key 的请求从缓存获取不到,请求将会从数据库中查询,访问量大了可能压垮数据库。比如用一个不存在的用户 ID 获取用户信息,Redis 缓存和数据库中都没有,若黑客利用此漏洞进行攻击可能压垮数据库(黑客访问肯定不存在的数据,造成服务器压力大)。

2. 解决方案

一个一定不存在的数据,由于缓存是不命中时查询后被动写入,并且出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。

  • 对空值缓存: 如果一个查询返回的数据为空(不管是数据是否不存在),我们仍然将这个空结果(null)进行缓存,这样可以缓解数据库的访问压力,然后设置空结果的过期时间一般短一些,最长不超过五分钟。(只能作为简单的应急方案)
  • 设置可访问的名单(白名单): 使用 Bitmaps 类型定义一个可以访问的名单,名单 ID 作为 Bitmaps 的偏移量,每次访问进行与 Bitmaps 里面的 ID 进行比较,如果访问 ID 不在 Bitmaps 里面,进行拦截不允许访问。
  • 布隆过滤器: 将所有可能存在的数据哈希到一个足够大的 Bitmaps 中,一个一定不存在的数据会被这个过滤器拦截掉,从而避免了对底层存储系统的查询压力。
  • 进行实时监控: 当发现 Redis 的命中率开始急速降低,需要排查访问对象和访问的数据和运维人员配合可以设置黑名单限制服务。

二、缓存击穿

1. 概念

Key 对应的数据存在,但在 Redis 中过期,此时若有大量并发请求过来,这些请求发现缓存过期一般都会从数据库中查询并回写至缓存,这个时候大量并发的请求可能会瞬间数据库压垮。(Redis某个 Key 过期,大量的合理数据请求到达数据库

2. 解决方案

  • 预先设置热门数据: 在 Redis 高峰访问之前,把一些热门数据提前加载到 Redis 里面,加大这些热门数据 Key 的时长。
  • 实时调整: 现场监控哪些数据是热门数据,实时调整其对应 Key 的过期时长。
  • 使用互斥锁加递归:
    (1)即在缓存失效的时候(判断拿出来的值为空),不是立即从数据库查询;
    (2)先使用互斥锁进行加锁;
    (3)当加锁成功后,再进行从数据库查询,并回写至缓存中,最后释放锁。
    (4)当加锁失败,证明有线程在从数据库查询,当前线程睡眠一段时间再重试从缓存中获取数据,若不存在,再递归执行加锁后查询数据。

代码示例:

  • 获得锁的方法

    1
    2
    3
    4
    private boolean tryLock(String key) {
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
    return BooleanUtil.isTrue(flag);
    }
  • 释放锁的方法

    1
    2
    3
    private void unlock(String key) {
    stringRedisTemplate.delete(key);
    }
  • 请求数据方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    @Override
    public Result queryById(Long id) {
    //互斥锁解决缓存击穿
    Shop shop = queryWithMutex(id);
    if (shop == null) {
    return Result.fail("店铺不存在!");
    }
    //返回
    return Result.ok(shop);
    }

    public Shop queryWithMutex(Long id) {
    String key = CACHE_SHOP_KEY + id;
    //1.从redis查询商铺缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    //2.判断是否存在
    if (StrUtil.isNotBlank(shopJson)) {
    //3.存在,直接返回
    Shop shop = JSONUtil.toBean(shopJson, Shop.class);
    return shop;
    }
    if (shopJson != null) {
    return null;
    }
    //4.实现缓存重建
    //4.1获取互斥锁
    String lockKey = "lock:shop:" + id;
    Shop shop = null;
    try {
    boolean isLock = tryLock(lockKey);
    //4.2判断是否获取成功
    if (!isLock) {
    //4.3失败,则休眠并重试
    Thread.sleep(50);
    //递归
    return queryWithMutex(id);
    }
    //4.4成功,根据id查询数据库
    shop = getById(id);
    //5.不存在,返回错误
    if (shop == null) {
    //缓存击穿问题
    //将空值写入redis
    stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
    return null;
    }
    //6.存在,写入redis
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
    } catch (InterruptedException e) {
    throw new RuntimeException(e);
    } finally {
    //7.释放互斥锁
    unlock(lockKey);
    }
    //8.返回
    return shop;
    }

三、缓存雪崩

1. 概念

Key 对应的数据存在,但在 Redis 中过期缓存雪崩针 对是极小时间段内,大量 Key 失效缓存导致,引发数据库压力激增;缓存击穿 则是极小时间段内,某一个热门 Key 缓存导致,引发数据库压力激增。

2. 解决方案

  • 构建多级缓存架构: nginx 缓存 + redis 缓存 + 其他缓存(ehcache 等)。

  • 使用锁或队列: 用加锁或者队列的方式来保证不会有大量的线程对数据库一次性进行读写,从而避免失效时大量的并发请求落到底层存储系统上。(效率低,不适用高并发情况

  • 设置过期标志更新缓存: 记录缓存数据是否过期(设置提前量),如果过期会触发通知另外的线程在后台去更新实际 Key 的缓存。

  • 将缓存失效时间分散开: 可以在原有的失效时间基础上增加一个随机值,比如 1-5 分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。