Skip to content

缓存穿透、缓存击穿和缓存雪崩是什么?以及解决方案

  • 缓存穿透:在高并发下,查询一个不存在的值时,缓存不会被命中,导致大量请求直接落到数据库上,如活动系统里面查询一个不存在的活动
  • 缓存击穿:在高并发下,对一个特定的值进行查询,但是这个时候缓存正好过期了,缓存没有命中,导致大量请求直接落到数据库上,如活动系统里面查询活动信息,但是在活动进行过程中活动缓存突然过期了
  • 缓存雪崩:在高并发下,大量的缓存 key 在同一时间失效,导致大量的请求落到数据库上,如活动系统里面同时进行着非常多的活动,但是在某个时间点所有的活动缓存全部过期。

方案 1:缓存 null 值。用于应对缓存穿透问题,将不存在的值也缓存,但值设置为 null,然后缓存 null 值的 key 不宜设置太长,以防万一刚好该 key 值有数据的更新。当 NULL 缓存过期还可以使用限流,缓存预热等手段来防止穿透

方案 2:限流。用于应对缓存穿透问题,其中一种实现方案就是使用 redis 分布式锁。读取数据的核心代码示例:

java
private <T> T executeCacheMethod(RedisCacheKey redisCacheKey, Callable<T> valueLoader) {
    Lock redisLock = new Lock(redisTemplate, redisCacheKey.getKey() + "_sync_lock");
    // 同一个线程循环20次查询缓存,每次等待20毫秒,如果还是没有数据直接去执行被缓存的方法
    for (int i = 0; i < RETRY_COUNT; i++) {
        try {
            // 先取缓存,如果有直接返回,没有再去做拿锁操作
            Object result = redisTemplate.opsForValue().get(redisCacheKey.getKey());
            if (result != null) {
                logger.debug("redis缓存 key= {} 获取到锁后查询查询缓存命中,不需要执行被缓存的方法", redisCacheKey.getKey());
                return (T) fromStoreValue(result);
            }

            // 获取分布式锁去后台查询数据
            if (redisLock.lock()) {
                T t = loaderAndPutValue(redisCacheKey, valueLoader, true);
                logger.debug("redis缓存 key= {} 从数据库获取数据完毕,唤醒所有等待线程", redisCacheKey.getKey());
                // 唤醒线程
                container.signalAll(redisCacheKey.getKey());
                return t;
            }
            // 线程等待
            logger.debug("redis缓存 key= {} 从数据库获取数据未获取到锁,进入等待状态,等待{}毫秒", redisCacheKey.getKey(), WAIT_TIME);
            container.await(redisCacheKey.getKey(), WAIT_TIME);
        } catch (Exception e) {
            container.signalAll(redisCacheKey.getKey());
            throw new LoaderCacheValueException(redisCacheKey.getKey(), e);
        } finally {
            redisLock.unlock();
        }
    }
    logger.debug("redis缓存 key={} 等待{}次,共{}毫秒,任未获取到缓存,直接去执行被缓存的方法", redisCacheKey.getKey(), RETRY_COUNT, RETRY_COUNT * WAIT_TIME, WAIT_TIME);
    return loaderAndPutValue(redisCacheKey, valueLoader, true);
}

当需要加载缓存的时候,需要获取到锁才有权限到后台去加载缓存数据,否则就会等待(同一个线程循环 20 次查询缓存,每次等待 20 毫秒,如果还是没有数据直接去执行被缓存的方法,这个主要是为了防止获取到锁并且去加载缓存的线程出问题,没有返回而导致死锁)。当获取到锁的线程执行完成会将获取到的数据放到缓存中,并且唤醒所有等待线程。

方案 3:缓存预热。应对缓存的击穿和雪崩,设置二级缓存,请求时先获取一级缓存,没有则获取二级缓存,每次二级缓存被命中都会去检查缓存的过去时间是否小于刷新时间,如果小于就会开启一个异步线程预先去更新缓存,并将新的值放到缓存中,有效的保证了热点数据**"永不过期"**。这里预先更新缓存也是需要加锁的,并不是所有的线程都会落到库上刷新缓存,如果没有获取到锁就直接结束当前线程。

关于接口限制

有第三方的接口,qps 有限制只有 10,存在 1000 个用户,现在要访问接口,qps 限制了,要怎么解决

  1. 限流方案:可以使用限流算法来限制请求的速率,例如使用令牌桶算法或漏桶算法来控制请求的速率。可以通过配置限流器的参数来控制每个用户可以访问的 QPS,以确保接口不会被过度访问。当用户的请求速率超过限制时,可以使用拒绝策略,例如返回 429 状态码或自定义的错误信息,来通知用户请求被限制。
  2. 批量请求方案:可以将多个用户的请求合并成一个批量请求,然后一次性发送给第三方接口。可以通过配置批量请求的大小来平衡 QPS 限制和请求的延迟。可以使用异步请求或多线程请求来加快请求的处理速度,以便尽快响应用户请求。需要注意的是,在使用批量请求时需要考虑接口的参数限制和返回结果的处理问题,以确保每个用户都能得到正确的结果。
  3. 增加缓存方案:如果第三方接口的数据更新较为缓慢,可以考虑增加缓存来减少对第三方接口的访问次数。可以使用缓存中间件,例如 Redis 或 Memcached 来缓存接口的返回结果。可以设置缓存的过期时间来确保缓存数据的及时性和正确性。需要注意的是,在使用缓存时需要考虑缓存的一致性和容量问题。

如何保证 qps 不超出限制

  1. 设置并发数控制:在系统设计时可以设置并发数控制,例如使用线程池或连接池等机制来限制系统的并发数,以控制系统的 QPS。可以根据系统的资源情况和预期的负载来设置合适的并发数,以充分利用系统资源并避免过度消耗资源。
  2. 实时监控和调整:可以使用监控系统对系统的 QPS 进行实时监控,当系统的 QPS 接近或超过预设值时,可以及时进行调整,例如增加服务器资源或调整负载均衡策略等。可以使用系统监控工具,例如 Prometheus、Grafana 等,来实现监控和调整功能。
  3. 限流机制:可以使用限流算法,例如令牌桶算法或漏桶算法来限制系统的请求速率,以确保系统的 QPS 不超过预设值。
  4. 使用 redis 分布式锁,让用户请求一直自旋等待或者快速失败。

I know where dry desert ends, green grass grows · MooN