Redis三大缓存问题:缓存穿透、缓存击穿、缓存雪崩的场景以及解决方法

都是缓存惹的祸

在项目开发中,我们的数据都是要持久化到磁盘中去,比如使用 MySQL 进行持久化存储,但是呢由于流量越来越大,查询速度也逐渐变慢了起来,于是我们决定!使用缓存!然而使用缓存导致会经常面临三座大山!缓存穿透!!缓存击穿!!缓存雪崩!!
缓存穿透、缓存击穿和缓存雪崩是在使用缓存机制时可能遇到的问题,它们分别描述了不同的缓存失效场景,以及对系统性能的影响。下面将详细介绍这三种情况及其解决方法。

缓存穿透

缓存穿透

场景描述

缓存穿透通常发生在缓存和数据库间的数据一致性管理不当的情况下。例如,当用户请求一个不存在的数据项时,如果没有适当的处理机制,这个请求会直接穿透缓存层,直接落到数据库上。如果这种请求频繁发生,特别是在高并发环境下,数据库将面临巨大的压力,可能会导致服务响应变慢,甚至服务中断。

解决方法

解决缓存键同时失效和缓存中间件故障是维护分布式系统稳定性的重要方面。以下是针对这两种情况的详细解决方案及Java示例代码。

缓存键同时失效

1. 过期时间随机化

原理
通过为每个缓存项设置一个随机的过期时间,避免大量缓存同时过期,减少数据库的瞬时压力。

Java示例代码

假设我们使用Caffeine作为本地缓存库,以下是一个简单的实现:

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;

import java.util.concurrent.TimeUnit;

public class CacheServiceWithRandomExpire {
    private final Cache<String, Object> cache = Caffeine.newBuilder()
            .maximumSize(1000)
            .build();

    public synchronized Object getData(String key) {
        Object data = cache.getIfPresent(key);
        if (data != null) {
            return data;
        }
        
        data = fetchDataFromDB(key);
        if (data != null) {
            int expireTime = calculateRandomExpireTime();
            cache.put(key, data);
            cache.cleanUp(); // 清除过期项
            return data;
        }
        return null;
    }

    private Object fetchDataFromDB(String key) {
        // 模拟从数据库获取数据的过程
        System.out.println("Fetching data from DB for key: " + key);
        return "Data for key: " + key;
    }

    private int calculateRandomExpireTime() {
        int minExpireTime = 5; // 最小过期时间(分钟)
        int maxExpireTime = 10; // 最大过期时间(分钟)
        return (int) (Math.random() * (maxExpireTime - minExpireTime + 1)) + minExpireTime;
    }
}
2. 使用多级缓存

原理
构建多级缓存体系,如本地缓存+远程缓存,利用本地缓存的高速度和远程缓存的大容量,分担请求压力,提高系统的稳定性和可用性。

Java示例代码

假设我们使用Caffeine作为本地缓存库,Jedis作为远程Redis缓存库,以下是一个简单的实现:

import redis.clients.jedis.Jedis;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;

public class MultiLevelCacheService {
    private final Cache<String, Object> localCache = Caffeine.newBuilder()
            .maximumSize(1000)
            .expireAfterAccess(5, TimeUnit.MINUTES)
            .build();

    private final Jedis jedis = new Jedis("localhost"); // 假设Redis运行在本地

    public synchronized Object getData(String key) {
        Object data = localCache.getIfPresent(key);
        if (data == null) {
            data = getFromRemoteCache(key);
            if (data == null) {
                data = fetchDataFromDB(key);
                if (data != null) {
                    localCache.put(key, data);
                    jedis.set(key, String.valueOf(data));
                }
            }
        }
        return data;
    }

    private Object getFromRemoteCache(String key) {
        String value = jedis.get(key);
        return value != null ? value : null;
    }

    private Object fetchDataFromDB(String key) {
        // 模拟从数据库获取数据的过程
        System.out.println("Fetching data from DB for key: " + key);
        return "Data for key: " + key;
    }
}
3. 缓存预热

原理
缓存预热是指在系统启动或在预期的高峰访问时段之前,提前将热点数据加载到缓存中,以减少数据库的访问压力。这可以避免在系统启动初期或负载高峰期,因缓存未准备好而导致的数据库压力激增。

Java示例代码

假设我们使用Caffeine作为本地缓存库,以下是一个简单的实现:

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class CacheWarmupService {
    private final Cache<String, Object> cache = Caffeine.newBuilder()
            .maximumSize(1000)
            .expireAfterWrite(5, java.util.concurrent.TimeUnit.MINUTES)
            .build();

    public CacheWarmupService() {
        ExecutorService executor = Executors.newFixedThreadPool(5); // 创建一个固定大小的线程池
        executor.submit(this::warmUpCache); // 提交任务进行缓存预热
    }

    private void warmUpCache() {
        // 模拟加载热点数据到缓存
        for (String key : getHotKeys()) {
            Object data = fetchDataFromDB(key);
            if (data != null) {
                cache.put(key, data);
            }
        }
    }

    private Iterable<String> getHotKeys() {
        // 返回热点数据的key列表
        return List.of("hotKey1", "hotKey2", "hotKey3");
    }

    private Object fetchDataFromDB(String key) {
        // 模拟从数据库获取数据的过程
        System.out.println("Fetching data from DB for key: " + key);
        return "Data for key: " + key;
    }
}
4. 加互斥锁

原理
在缓存失效时,为了避免多个请求同时重建缓存(缓存击穿),可以使用互斥锁(或分布式锁)来确保同一时间只有一个请求能够进入数据库获取数据并更新缓存,其他请求则等待锁释放后从缓存中获取数据。

Java示例代码

假设我们使用ReentrantLock作为本地锁,以下是一个简单的实现:

import java.util.concurrent.locks.ReentrantLock;

public class CacheUpdateWithLock {
    private final Cache<String, Object> cache = Caffeine.newBuilder()
            .maximumSize(1000)
            .expireAfterWrite(5, java.util.concurrent.TimeUnit.MINUTES)
            .build();

    private final ReentrantLock lock = new ReentrantLock();

    public Object getData(String key) {
        Object data = cache.getIfPresent(key);
        if (data != null) {
            return data;
        }

        try {
            lock.lock(); // 获取锁
            data = fetchDataFromDB(key);
            if (data != null) {
                cache.put(key, data);
            }
        } finally {
            lock.unlock(); // 释放锁
        }

        return data;
    }

    private Object fetchDataFromDB(String key) {
        // 模拟从数据库获取数据的过程
        System.out.println("Fetching data from DB for key: " + key);
        return "Data for key: " + key;
    }
}

在分布式环境下,使用ReentrantLock可能不够,因为锁需要在多个节点之间共享。在这种情况下,可以使用分布式锁,如基于RedisRedlockZookeeper的锁机制。

这些示例代码展示了如何在本地环境中实现缓存预热和加锁功能。在实际应用中,你可能需要根据你的具体需求和环境来调整代码,例如在分布式环境中使用分布式锁替代本地锁。

缓存中间件故障

1. 服务熔断 - Java示例

原理
当检测到缓存中间件故障时,暂时停止业务逻辑,直接返回错误或默认值,避免系统整体崩溃。

Java示例代码

这里使用Resilience4j库中的CircuitBreaker来实现服务熔断:

import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;

public class CircuitBreakerDemo {
    private static final CircuitBreakerRegistry circuitBreakerRegistry = CircuitBreakerRegistry.ofDefaults();
    private static final CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("cacheService");

    public static void main(String[] args) {
        try {
            String result = circuitBreaker.executeSupplier(() -> {
                // 调用缓存服务
                return "Data fetched successfully";
            });

            System.out.println(result);
        } catch (Exception e) {
            System.out.println("Error occurred: " + e.getMessage());
        }
    }
}

在实际应用中,你需要根据你的具体业务需求调整这些示例代码,以适应你的系统架构和缓存策略。
构建Redis集群以提高高可用性和扩展性是分布式系统设计中的关键策略之一。Redis集群支持数据分区和自动故障转移,可以有效提高系统的稳定性和响应速度。以下是如何使用Java操作Redis集群的示例。

2. 构建Redis集群

原理
Redis集群通过在多个Redis实例之间分配数据槽(slot)来实现数据的分布存储。每个实例负责一部分槽,当数据插入或查询时,根据散列算法确定数据应存储在哪个槽,进而定位到正确的实例。

Java示例代码

为了连接和操作Redis集群,我们通常使用客户端库如JedisClusterlettuce。下面使用JedisCluster库演示如何连接和操作Redis集群。

首先,你需要在你的项目中添加Jedis依赖。如果你使用Maven,可以添加如下依赖:

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

接下来,我们将创建一个JedisCluster实例来连接Redis集群:

import redis.clients.jedis.HostAndPort;
import redis.clients.jedis.JedisCluster;
import java.util.HashSet;
import java.util.Set;

public class RedisClusterDemo {
    public static void main(String[] args) {
        Set<HostAndPort> nodes = new HashSet<>();
        nodes.add(new HostAndPort("node1-host", 7000)); // 替换为你的集群节点地址
        nodes.add(new HostAndPort("node2-host", 7001));
        nodes.add(new HostAndPort("node3-host", 7002));

        JedisCluster jedisCluster = new JedisCluster(nodes);
        
        // 使用JedisCluster执行操作
        jedisCluster.set("key", "value");
        String value = jedisCluster.get("key");
        System.out.println(value);
        
        // 关闭连接
        jedisCluster.close();
    }
}

在上述代码中,我们首先定义了一个包含集群节点的Set。然后,我们使用这些节点创建了一个JedisCluster实例。通过JedisCluster,你可以像操作单个Redis实例一样执行命令,但实际上是将命令发送给集群中的适当节点。

注意事项

  1. 故障转移:Redis集群支持自动故障转移。如果主节点失败,集群会自动选举一个新的主节点,但这个过程可能会影响短暂的服务可用性。
  2. 数据分布:确保理解数据是如何在集群中分布的,以及如何通过键来确定数据的位置。
  3. 配置和监控:配置Redis集群时,需要仔细设置集群参数,并且应该定期监控集群的健康状况。

在生产环境中,你可能还需要考虑集群的规模、节点的冗余、网络延迟等因素,以确保集群的高效和稳定运行。此外,为了更好地管理和监控集群,可以使用如Redis Commander、RedisInsight等工具。

注意事项

  1. **空值缓存的过期时间:**设置空值缓存时,过期时间不宜太长,以免占用过多的缓存资源。同时,过期时间也不能太短,否则频繁的请求仍可能造成数据库压力。
  2. **布隆过滤器的误判率:**布隆过滤器存在一定的误判率,这意味着某些不存在的key可能会被误认为存在。设计时需考虑误判对业务的影响,并可能需要结合其他机制(如二次确认)来降低误判的影响

缓存击穿

缓存击穿

场景描述

缓存击穿是指针对某一热点数据的大量请求导致缓存失效,进而直接请求数据库,增加数据库负载。这种情况通常发生在某个特定的缓存 key 在失效时,恰好有大量请求到达。想象一下大家都在抢茅台,但在某一时刻茅台的缓存失效了,大家的请求打到了数据库中,这就是缓存击穿,那他跟缓存雪崩有什么区别呢?缓存雪崩是多个 key 同时,缓存击穿是某个热点 key 崩溃。也可以认为缓存击穿是缓存雪崩的子集。

解决方法

为了解决这个问题,我们可以采取以下两种策略:

1. 加互斥锁(Mutex Lock)

原理
保证同一时间只有一个请求去数据库获取数据并更新缓存,其他请求则等待锁释放后从缓存中读取数据。

这里我们使用ReentrantLock作为本地锁的例子,但在分布式系统中,应当使用分布式锁,例如基于Redis的RedLock或其他分布式协调服务提供的锁。

Java示例代码

import java.util.concurrent.locks.ReentrantLock;
import redis.clients.jedis.Jedis;

public class CacheBusterSolution {
    private final Jedis jedis = new Jedis("localhost"); // 假设Redis运行在本地
    private final ReentrantLock lock = new ReentrantLock();

    public String getData(String key) {
        String data = jedis.get(key);
        if (data == null) {
            lock.lock();
            try {
                // 再次检查数据,因为在上锁期间可能已经有其他线程更新了缓存
                data = jedis.get(key);
                if (data == null) {
                    data = fetchFromDatabase(key);
                    jedis.set(key, data); // 将数据写入缓存
                }
            } finally {
                lock.unlock();
            }
        }
        return data;
    }

    private String fetchFromDatabase(String key) {
        // 模拟从数据库获取数据
        System.out.println("Fetching data from database for key: " + key);
        return "Data for key: " + key;
    }
}

注意事项

  • 使用互斥锁时,在分布式环境中应当使用分布式锁,否则锁的可见性和原子性无法得到保证。

2. 永久缓存热点数据

原理
对于热点数据,可以不设置过期时间,这样只要数据不被显式地删除或替换,就一直存在于缓存中,避免了过期问题。

Java示例代码

import redis.clients.jedis.Jedis;

public class HotDataPermanentCache {
    private final Jedis jedis = new Jedis("localhost"); // 假设Redis运行在本地

    public void cacheHotData(String key, String value) {
        jedis.set(key, value); // 不设置过期时间
    }

    public String getData(String key) {
        return jedis.get(key);
    }
}

注意事项

  • 永久缓存热点数据需要谨慎,因为这可能会导致缓存中的数据永远不更新,除非你有机制去检查并更新数据。
  • 在高并发场景下,即使使用了锁,也可能出现锁竞争激烈的情况,需要权衡锁的使用频率和锁的性能成本。

以上示例代码仅作演示之用,实际应用中需要根据具体情况进行调整和优化。例如,对于分布式锁的实现,可以考虑使用RedissonJedisCluster等高级客户端库提供的功能。

注意事项

  1. 锁的粒度:使用互斥锁时,锁的粒度不宜太细,否则可能降低并发处理能力;粒度过粗可能导致锁等待时间过长,影响系统响应速度。
  2. 锁的实现:在分布式环境中,必须使用分布式锁,如RedLock或Etcd,以确保锁的正确性和一致性。
  3. 预热策略:缓存预热时要考虑到数据的时效性,避免预热的数据已经过期或不再热点

缓存雪崩

缓存雪崩
缓存穿透问题通常出现在缓存系统的设计中,当用户请求的数据既不在缓存中也不在数据库中时,这种情况会导致所有的请求都直接落到了数据库上,增加了数据库的负担,尤其是在高并发的情况下,可能会导致数据库服务的崩溃。

解决方案

1. 防止非法请求

原理
通过检查请求的有效性,如对请求的参数进行验证,对于疑似非法的请求,可以记录IP地址或用户ID,并在一段时间内对其进行封禁。

Java示例代码
使用Spring Security或其他框架进行请求的拦截和验证:

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class DataController {

    @GetMapping("/data")
    @PreAuthorize("hasRole('USER')") // 示例权限检查
    public String getData(@RequestParam String key) {
        // 此处可以添加更多的参数验证逻辑
        return service.getData(key);
    }
}

2. 缓存空值

原理
当查询到数据库中不存在的数据时,将空值(如null或特定标识符)缓存起来,设置一个合理的过期时间,这样下次相同的查询就可以直接从缓存中返回,不再需要访问数据库。
使用CaffeineRedis缓存空值:

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;

import java.util.concurrent.TimeUnit;

public class CacheService {
    private final Cache<String, Object> cache = Caffeine.newBuilder()
            .maximumSize(1000)
            .expireAfterWrite(5, TimeUnit.MINUTES) // 设置空值过期时间为5分钟
            .build();

    public Object getData(String key) {
        return cache.get(key, k -> {
            Object data = fetchDataFromDB(k);
            if (data == null) {
                data = "NULL"; // 缓存空值
            }
            return data;
        });
    }

    private Object fetchDataFromDB(String key) {
        // 数据库查询逻辑
        return null;
    }
}

Java示例代码

3. 使用布隆过滤器

原理
布隆过滤器是一种空间效率极高的概率型数据结构,可以用来判断一个元素是否在一个集合中。虽然可能存在一定的误判率,但对于缓存穿透问题,它可以有效地过滤掉不存在的数据请求,避免它们直接到达数据库。

Java示例代码
使用Guava库中的BloomFilter

import com.google.common.hash.Funnels;
import com.google.common.hash.BloomFilter;

import java.nio.charset.StandardCharsets;

public class BloomFilterDemo {
    private static final BloomFilter<CharSequence> bloomFilter = BloomFilter.create(Funnels.stringFunnel(StandardCharsets.UTF_8), 100000, 0.001);

    public static boolean mightExist(String key) {
        // 添加已知存在的key到布隆过滤器
        bloomFilter.put("existingKey");
        // 检查key是否可能存在
        return bloomFilter.mightContain(key);
    }
}

在实际应用中,你可以在缓存层先使用布隆过滤器检查数据是否存在,如果布隆过滤器返回false,则说明数据一定不存在,无需查询数据库;如果返回true,则需要进一步查询缓存或数据库以确认数据的存在。

请根据实际情况调整示例代码,以满足你的具体需求和环境。

注意事项

  1. **过期时间的设定:**过期时间的随机化策略需要合理设置范围,避免极端情况下的数据集中过期。
  2. 多级缓存架构:使用多级缓存时,需要平衡各级缓存的容量和过期策略,以达到最佳的性能和资源利用率。
  3. **限流策略:**在实施限流时,需要考虑到用户体验,避免过度限流导致正常用户的请求也无法及时响应。
  4. 监控与告警:无论哪种策略,都需要有实时的监控和告警机制,以便在出现问题时能够迅速定位并解决。
  5. 测试与验证:任何缓存策略的更改都应该经过充分的测试,包括单元测试、集成测试以及压力测试,确保在高并发情况下系统依然稳定。
  6. **数据一致性:**在设计缓存策略时,要特别注意数据的一致性问题,避免因缓存更新机制不当导致的数据不一致。
  7. **容灾与恢复:**设计缓存系统时,还应该考虑到灾难恢复计划,包括数据备份、快速切换至备用缓存服务器等措施。

参考:https://www.code-nav.cn/post/1780838173472006145#heading-0

  • 31
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
缓存穿透缓存击穿缓存雪崩是常见的缓存相关问题,它们可能导致缓存失效或性能下降。下面是对它们的原因和解决方法的简要说明: 1. 缓存穿透缓存穿透是指请求的数据在缓存数据库中都不存在,导致每次请求都要访问数据库,增加了数据库负载。主要原因是恶意攻击或错误的查询。 解决方法: - 使用布隆过滤器:在查询前使用布隆过滤器检查请求是否有效,如果无效则直接返回,避免对数据库的查询。 - 设置空对象缓存:将数据库中不存在的值也缓存起来,可以防止频繁查询。 2. 缓存击穿缓存击穿是指一个热点数据失效,导致大量请求同时访问数据库,造成数据库压力过大。主要原因是热点数据过期或删除。 解决方法: - 设置热点数据永不过期:针对热点数据设置永不过期,确保即使失效也能从缓存中获取,并在后台异步更新缓存。 - 互斥锁(Mutex):当缓存失效时,只允许一个线程访问数据库并更新缓存,其他线程等待获取缓存数据。 3. 缓存雪崩缓存雪崩是指缓存中大量的数据同时失效,导致所有请求都要访问数据库,造成数据库负载过大。主要原因是缓存中的数据同时过期。 解决方法: - 设置随机过期时间:为缓存数据设置随机的过期时间,避免大量数据同时失效。 - 使用分布式缓存:将缓存分布在不同的节点上,提高系统的可用性和容错能力。 - 数据预热:提前加载热点数据到缓存中,避免在高并发时突然访问数据库。 以上是对缓存穿透缓存击穿缓存雪崩问题的原因和解决方法的简要介绍,实际应用中可能还需要结合具体场景进行调整和优化。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值