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

发布于:2024-05-05 ⋅ 阅读:(33) ⋅ 点赞:(0)

一、缓存穿透

1.问题描述

缓存穿透是指访问的数据在缓存中并不存在,请求会不经过缓存直接访问后端存储系统。通常情况下,这些请求对应的数据在后端存储中也不存在,因此无论如何都无法从缓存中获取到数据,每次请求都会直接落到后端存储系统上,导致了不必要的资源浪费和系统压力增加。
缓存穿透可能是由于恶意攻击、恶意请求或者缓存系统配置不当等原因导致的。
在这里插入图片描述

2.解决方案

一个一定不存在缓存及查询不到的数据,由于缓存是不命中时被动写的,并且出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。
解决方案:
(1)对空值存储:如果一个查询返回的数据为空(不管是数据是否不存在),我们仍然把这个空结果(null)进行缓存,设置空结果的过期时间会很短,最长不超过五分钟
(2)设置可访问的名单(白名单):使用 bitmaps 类型定义一个可以访问的名单,名单 id 作为 bitmaps 的偏移量,
每次访问和 bitmap 里面的 id 进行比较,如果访问 id 不在 bitmaps 里面,进行拦截,不允许访问。
(3)采用布隆过滤器::(布隆过滤器(Bloom Filter)是 1970 年由布隆提出的。它实际上是一个很长的二进制向量(位图)和一系列随机映射函数(哈希函数)。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难。)
将所有可能存在的数据哈希到一个足够大的 bitmaps 中,一个一定不存在的数据会被 这个 bitmaps 拦截掉,从而避免了对底层存储系统的查询压力。
(4)进行实时监控:当发现Redis命中率开始急速下降,需要排查访问对象和访问的数据,和运维人员配合,可以设置黑名单限制服务。

二、缓存击穿

1.问题描述

缓存击穿指的是当某个特定的键(key)在缓存中不存在但是对其进行了大量的请求时,这些请求会直接访问后端存储系统(如数据库),而不经过缓存。这种情况通常发生在一个热门的数据突然失效,而此时大量的请求同时访问该数据,由于缓存中不存在,每个请求都会落到后端存储系统上,导致了不必要的系统压力增加。
在这里插入图片描述

2.解决方案

由于缓存击穿是由于热点数据没有命中引起的,所以解决方案主要围绕这一点解决。
(1)预先设置热门数据:在 redis 高峰访问之前,把一些热门数据提前存入到redis 里面,加大这些热门数据 key 的时长
(2)实时调整:现场监控哪些数据热门,实时调整 key 的过期时长
(3)使用锁

  1. 就是在缓存失效的时候(判断拿出来的值为空),不是立即去 load db。
  2. 先使用缓存工具的某些带成功操作返回值的操作(比如 Redis 的 SETNX)去 set 一个 mutex key
  3. 当操作返回成功时,再进行 load db 的操作,并回设缓存,最后删除 mutexkey;
  4. 当操作返回失败,证明有线程在 load db,当前线程睡眠一段时间再重试整个 get 缓存的方法。
    在这里插入图片描述
    法一:通过redis中的SETNX指令
import redis.clients.jedis.Jedis;
import java.util.concurrent.TimeUnit;

public class CacheWithMutexLockExample {

    private static final String CACHE_KEY = "cache_key";
    private static final String LOCK_KEY = "cache_lock";
    private static final int LOCK_EXPIRE_TIME = 10; // 锁的过期时间,单位:秒

    public static void main(String[] args) {
        // 连接到 Redis 服务
        try (Jedis jedis = new Jedis("localhost")) {
            // 查询缓存
            String cachedData = jedis.get(CACHE_KEY);

            if (cachedData == null) {
                // 缓存为空,获取互斥锁
                String lockIdentifier = acquireLock(jedis, LOCK_KEY);
                try {
                    // 再次检查缓存,因为在获取锁的过程中,可能已经被其他线程设置了缓存
                    cachedData = jedis.get(CACHE_KEY);
                    if (cachedData == null) {
                        // 执行业务逻辑(例如查询数据库或者其他耗时操作)
                        cachedData = fetchDataFromDatabase();
                        // 设置缓存
                        jedis.set(CACHE_KEY, cachedData);
                    }
                } finally {
                    // 释放锁
                    releaseLock(jedis, LOCK_KEY, lockIdentifier);
                }
            }

            // 使用缓存数据
            System.out.println("Cached Data: " + cachedData);
        }
    }

    // 获取互斥锁
    private static String acquireLock(Jedis jedis, String lockKey) {
        String lockIdentifier = generateLockIdentifier();
        // 尝试设置键值对,如果键不存在则设置成功,即加锁成功
        String result = jedis.set(lockKey, lockIdentifier, "NX", "EX", LOCK_EXPIRE_TIME);
        if ("OK".equals(result)) {
            return lockIdentifier;
        } else {
            return null;
        }
    }

    // 释放锁
    private static void releaseLock(Jedis jedis, String lockKey, String lockIdentifier) {
        // Lua脚本保证原子性的删除锁
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        jedis.eval(script, 1, lockKey, lockIdentifier);
    }

    // 生成唯一的锁标识符
    private static String generateLockIdentifier() {
        return "lock_" + System.nanoTime();
    }

    // 模拟从数据库中获取数据的方法
    private static String fetchDataFromDatabase() {
        // 模拟耗时操作
        try {
            TimeUnit.SECONDS.sleep(3);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return "Data from Database";
    }
}

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>3.11.0</version>
</dependency>

法二:通过java代码Lock或synchronized实现

public class CacheManager {
    private Object lock = new Object(); // 创建一个对象作为互斥锁

    public Object getData(String key) {
        Object data = cache.get(key); // 先尝试获取缓存,不需要加锁
        if (data != null) {
            // 缓存不为空,直接返回数据
            return data;
        }

        // 缓存为空,使用 synchronized 关键字获取互斥锁
        synchronized(lock) {
            // 再次检查缓存是否被其他线程更新
            data = cache.get(key);
            if (data == null) {
                // 缓存为空,执行数据库查询操作并写入缓存
                data = queryDatabase(key);
                cache.put(key, data);
            }
        }
        return data;
    }
}


加锁后,为什么还需要再次检查缓存呢?这是因为可能存在这样的情况:
1.线程A进入加锁的代码块,发现缓存为空,于是开始从数据库中加载数据并放入缓存。
2.同时,线程B也发现缓存为空,进入加锁的代码块,但此时线程A正在将数据写入缓存,线程B处于等待状态。
3.当线程A写完数据后,释放锁。
4.线程B获取到锁后,它并不知道缓存已经被线程A更新了,因此需要再次检查缓存是否为空。
这个过程称为“双重检查”,它可以确保在并发情况下,即使其他线程获取到锁后也能正确地获取到缓存数据,而不是再次执行数据库查询操作。

三、缓存雪崩

1.问题描述

缓存雪崩是指在缓存中大量的缓存数据同时过期失效,导致大量的请求直接打到数据库或其他后端存储,造成了后端系统的瞬间压力激增,甚至导致系统崩溃的现象。这种情况类似于雪崩效应,因为一旦缓存中的大量数据同时过期,就像是一场雪崩一样,压垮了后端系统。

2.解决方案

缓存雪崩是由大量缓存数据同时过期引起的,所以解决方案就是基于这个实现的
(1) 构建多级缓存架构:nginx 缓存 + redis 缓存 +其他缓存(ehcache 等)
(2) 使用锁或队列:
用加锁或者队列的方式保证来保证不会有大量的线程对数据库一次性进行读写,从而避免失效时大量的并发请求落到底层存储系统上。不适用高并发情况
(3) 设置过期标志更新缓存:
记录缓存数据是否过期(设置提前量),如果过期会触发通知另外的线程在后台去更新实际 key 的缓存。
(4) 将缓存失效时间分散开:
比如我们可以在原有的失效时间基础上增加一个随机值,比如 1-5 分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。