简述缓存击穿

189 阅读4分钟

什么是缓存击穿

缓存击穿是指在高并发场景下,一个缓存中不存在但是数据库中存在的数据被大量请求同时查询,导致请求直接穿透缓存,直接请求到数据库,从而导致数据库压力过大,系统性能下降。

造成缓存击穿的原因通常是因为某个热点数据过期或者被删除,而在此之后的一段时间内,大量请求同时访问该数据,导致缓存未命中,请求都落到了数据库上,引发缓存击穿问题。

下面有一幅图展示缓存击穿的场景:

image.png

解决方案

1. 使用互斥锁

在缓存更新的过程中使用互斥锁,确保只有一个线程去更新缓存,其他线程等待,防止大量线程同时访问查询数据库。

  • 优点:没有额外的内存消耗;保证一致性;实现简单
  • 缺点:线程需要等待,性能受到影响;可能存在死锁的风险

下面是使用互斥锁来应对缓存击穿的时序图(借鉴黑马程序员):

image.png

接着,给大家一个示例代码,演示了如何使用互斥锁来解决缓存击穿问题:

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

public class CacheUtil {
    private static RedissonClient redissonClient;

    // 初始化RedissonClient
    static {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://localhost:6379");
        redissonClient = Redisson.create(config);
    }

    public static Object get(String key) {
        Object value = redissonClient.getBucket(key).get();
        if (value == null) {
            // 缓存未命中,使用互斥锁防止缓存击穿
            RLock lock = redissonClient.getLock(key + "_lock");
            lock.lock();
            try {
                // 再次尝试从缓存获取数据
                value = redissonClient.getBucket(key).get();
                if (value == null) {
                    // 从数据库查询数据
                    value = fetchDataFromDatabase();
                    // 将数据放入缓存,设置一个较短的过期时间,防止缓存穿透保护
                    redissonClient.getBucket(key).set(value, 10, TimeUnit.SECONDS);
                }
            } finally {
                lock.unlock();
            }
        }
        return value;
    }

    private static Object fetchDataFromDatabase() {
        // 从数据库中查询数据的代码
        // 省略具体实现
    }
}

在这个示例代码中,我们使用了RedissonRLock来实现互斥锁,防止缓存击穿。当发现缓存未命中时,先尝试获取互斥锁,如果成功获取锁,则查询数据库并更新缓存,否则等待其他线程更新缓存。这样可以确保只有一个线程去更新缓存,避免缓存击穿问题。同时,我们还设置了一个较短的过期时间,防止缓存穿透保护。请注意根据实际情况配置Redis的连接信息和缓存过期时间。

2. 逻辑过期

方案分析:我们之所以会出现这个缓存击穿问题,主要原因是在于我们对key设置了过期时间,假设我们不设置过期时间,其实就不会有缓存击穿的问题,但是不设置过期时间,这样数据不就一直占用我们内存了吗,我们可以采用逻辑过期方案。

我们把过期时间设置在 redis的value中,注意:这个过期时间并不会直接作用于redis,而是我们后续通过逻辑去处理。假设线程1去查询缓存,然后从value中判断出来当前的数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程他会开启一个 线程去进行 以前的重构数据的逻辑,直到新开的线程完成这个逻辑后,才释放锁, 而线程1直接进行返回,假设现在线程3过来访问,由于线程线程2持有着锁,所以线程3无法获得锁,线程3也直接返回数据,只有等到新开的线程2把重建数据构建完后,其他线程才能走返回正确的数据。

这种方案巧妙在于,异步的构建缓存,缺点在于在构建完缓存之前,返回的都是脏数据。

image.png

  • 优点:线程无需等待,性能较好
  • 缺点:不保证一致性;有额外的内存消耗;实现复杂

其他解决方案

  • 延迟缓存更新:在热点数据过期之后,不立即去更新缓存,而是等待一段时间后再更新,这样可以让大量请求在缓存更新之前落在缓存上,减轻数据库压力。
  • 缓存穿透保护:对于查询结果为空的请求,也将其缓存起来,并设置一个较短的过期时间,防止恶意请求不断访问数据库。
  • 降级策略:如果发现缓存击穿问题已经发生,可以通过降级策略来应对,例如直接返回默认值或者空数据,保证系统的稳定性。