redis缓存常见问题

redis缓存常见问题

一、缓存三剑客(穿透、雪崩、击穿)

1.redis穿透

(1)什么是redis缓存穿透

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求就都打到数据库里面了。

穿透穿透,顾名思义,就是请求穿过了redis又穿过了数据库(Mysql)就像下面的图片一样,请求就是子弹,redis就是防弹衣,Mysql就是身体,当我们请求redis缓存没有命中时,就会打到数据库上。这就是穿透。

(注:下面这个图片是我在哔哩哔哩观看徐庶老师的课所截的图,感觉非常的形象,视频地址如下:Redis夺命连环40问,1天掌握别人半个月刷的redis数据库面试内容,直接让你上岸,成功拿下45K!_哔哩哔哩_bilibili

(2)解决方法
(2.1)缓存空对象

当数据库中没有查询到数据时,可以将这个请求存储到Redis中,并设置一个较短的过期时间(如5分钟)。这样,在发送这样的请求就打到redis上,不会打到数据库上了(在这设置的5分钟里)。

其实就是将请求过来在redis缓存和数据库里都没有的数据存储到redis中,防止这个请求在打到数据库。

如下代码的CACHE_SHOP_TTL是一个常量,设置的过期时间。

java 复制代码
public Shop queryWithPassThrough(Long id) {
    String key = CACHE_SHOP_KEY + id;
    // 1. 从Redis查询商铺缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    
    // 2. 缓存命中(包含空值)直接返回
    if (shopJson != null) {
        // 反序列化空字符串为null
        return shopJson.equals("") ? null : JSONUtil.toBean(shopJson, Shop.class);
    }
    
    // 3. 缓存未命中,查询数据库
    Shop shop = getById(id);
    
    // 4. 数据库中不存在,缓存空值
    if (shop == null) {
        // 缓存空字符串(或特定标识),并设置较短过期时间
        stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
        return null;
    }
    
    // 5. 数据库中存在,写入Redis缓存(正常TTL)
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
    
    // 6. 返回结果
    return shop;
}
(2.2)布隆过滤

在缓存和数据库之间加入一个布隆过滤器,它可以预存储一些可能存在的键。如果查询的键不在布隆过滤器中,直接返回不存在,避免查询数据库。布隆过滤器通过哈希函数实现,误判率可以通过调整其大小和哈希函数的数量来控制。(有黑名单与白名单两种)(其实都是判断数据库里,有没有,没有就存入布隆过滤器,这只是我个人的理解)

(注:该图片来自黑马的哔哩哔哩视频,视频地址如下:实战篇-商户查询缓存-06.缓存穿透的解决思路_哔哩哔哩_bilibili

2.缓存雪崩

(1)什么是缓存雪崩

缓存雪崩是指同一时段大量的缓存的key同时失效或redis服务宕机,导致大量请求到达数据库,带来巨大压力。

(注:下面这个图片是我在哔哩哔哩观看徐庶老师的课所截的图,感觉非常的形象,视频地址如下:Redis夺命连环40问,1天掌握别人半个月刷的redis数据库面试内容,直接让你上岸,成功拿下45K!_哔哩哔哩_bilibili

(2)解决办法
(2.1)给不同的key添加随机时间(防止大量key同时过期问题)

实现方式

在设置缓存时,为每个 key 的过期时间增加一个随机偏移量。基础过期时间保证数据不会长时间不更新,随机范围则确保各个 key 不会集中失效。

给不同的 key 添加随机时间

java 复制代码
public class RandomExpireTimeCacheService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    // 基础过期时间(秒)
    private static final long BASE_EXPIRE_TIME = 60 * 30;  // 30分钟
    // 随机范围(秒)
    private static final long RANDOM_RANGE = 60 * 15;  // 15分钟
    
    private final Random random = new Random();
    
    /**
     * 设置带有随机过期时间的缓存
     * @param key 缓存键
     * @param value 缓存值
     */
    public void setWithRandomExpireTime(String key, Object value) {
        // 生成随机过期时间:基础时间 + 随机时间
        long expireTime = BASE_EXPIRE_TIME + random.nextInt((int) RANDOM_RANGE);
        redisTemplate.opsForValue().set(key, value, expireTime, TimeUnit.SECONDS);
    }
}
(2.2)利用redis集群(如哨兵模式,有效防止redis宕机带来的问题)

核心思想:通过部署 Redis 集群提高可用性,避免因单点故障导致的缓存服务整体不可用,从而引发雪崩。

哨兵模式工作原理

  • 哨兵节点 (Sentinel) 监控主从节点状态
  • 当主节点故障时,自动进行主从切换
  • 客户端通过哨兵获取 Redis 服务地址

利用 Redis 集群(哨兵模式)配置

java 复制代码
@Configuration
public class RedisSentinelConfig {

    @Bean
    public RedisSentinelConfiguration sentinelConfiguration() {
        RedisSentinelConfiguration sentinelConfig = new RedisSentinelConfiguration()
                .master("mymaster")  // 主节点名称
                .sentinels(
                        Arrays.asList(
                                new RedisNode("sentinel1.host", 26379),
                                new RedisNode("sentinel2.host", 26379),
                                new RedisNode("sentinel3.host", 26379)
                        )
                );
        return sentinelConfig;
    }

    @Bean
    public JedisConnectionFactory jedisConnectionFactory() {
        return new JedisConnectionFactory(sentinelConfiguration());
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(jedisConnectionFactory());
        return template;
    }
}
(2.3)限流降级

核心思想:当系统负载过高时,通过限制流量和降级非核心服务,确保核心功能的可用性,防止整个系统被拖垮。

  • 限流:控制请求的访问速率,防止过多请求进入系统
  • 降级:当检测到系统异常时,自动返回预设的默认值或错误信息
java 复制代码
// 使用Sentinel注解定义受保护的资源
@SentinelResource(value = "protectedResource", blockHandler = "handleBlock")
public String process(String param) {
    // 正常业务逻辑
}

// 限流降级处理方法
public String handleBlock(String param, BlockException ex) {
    // 资源被限流或降级时的处理逻辑
    return "系统繁忙,请稍后再试";
}
(2.4)添加多级缓存

核心思想:通过组合本地缓存和分布式缓存,减少对 Redis 的访问频率,提高系统响应速度,同时增强系统容错能力。

java 复制代码
@Service
public class MultiLevelCacheService {

    // Caffeine本地缓存
    private final Cache<String, Object> localCache;
    
    // Redis分布式缓存
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    // 缓存默认过期时间(分钟)
    private static final long DEFAULT_EXPIRE_TIME = 30;
    
    public MultiLevelCacheService() {
        // 初始化本地缓存
        this.localCache = Caffeine.newBuilder()
                .maximumSize(1000)  // 最大缓存条目数
                .expireAfterWrite(DEFAULT_EXPIRE_TIME, TimeUnit.MINUTES)  // 写入后过期时间
                .build();
    }
    
    /**
     * 从多级缓存中获取数据
     * @param key 缓存键
     * @param dataLoader 数据加载器,当缓存未命中时用于加载数据
     * @param expireTime 缓存过期时间(分钟)
     * @return 缓存值
     */
    public <T> T get(String key, Supplier<T> dataLoader, long expireTime) {
        // 1. 先从本地缓存获取
        T value = (T) localCache.getIfPresent(key);
        if (value != null) {
            return value;
        }
        
        // 2. 本地缓存未命中,从Redis获取
        value = (T) redisTemplate.opsForValue().get(key);
        if (value != null) {
            // 将数据写入本地缓存
            localCache.put(key, value);
            return value;
        }
        
        // 3. Redis未命中,从数据源加载数据
        value = dataLoader.get();
        if (value != null) {
            // 将数据写入Redis
            redisTemplate.opsForValue().set(key, value, expireTime, TimeUnit.MINUTES);
            // 将数据写入本地缓存
            localCache.put(key, value);
        }
        
        return value;
    }
    
    /**
     * 从多级缓存中获取数据(使用默认过期时间)
     * @param key 缓存键
     * @param dataLoader 数据加载器,当缓存未命中时用于加载数据
     * @return 缓存值
     */
    public <T> T get(String key, Supplier<T> dataLoader) {
        return get(key, dataLoader, DEFAULT_EXPIRE_TIME);
    }
    
    /**
     * 更新缓存
     * @param key 缓存键
     * @param value 缓存值
     * @param expireTime 过期时间(分钟)
     */
    public void update(String key, Object value, long expireTime) {
        // 更新Redis缓存
        redisTemplate.opsForValue().set(key, value, expireTime, TimeUnit.MINUTES);
        // 更新本地缓存
        localCache.put(key, value);
    }
    
    /**
     * 删除缓存
     * @param key 缓存键
     */
    public void delete(String key) {
        // 删除Redis缓存
        redisTemplate.delete(key);
        // 删除本地缓存
        localCache.invalidate(key);
    }
}

3、缓存击穿

(1)什么是缓存击穿

缓存击穿问题也叫热点 Key 问题,就是一个被高并发访问并且缓存重建业务较复杂的 key 突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

(注:下面这个图片是我在哔哩哔哩观看徐庶老师的课所截的图,感觉非常的形象,视频地址如下:Redis夺命连环40问,1天掌握别人半个月刷的redis数据库面试内容,直接让你上岸,成功拿下45K!_哔哩哔哩_bilibili

(2)解决方案
(2.1)互斥锁

使用互斥锁确保只有一个线程可以查询数据库并更新缓存。其他线程等待锁释放后直接从缓存中获取数据。可以使用Redis的SETNX命令实现分布式锁。

注意:可能发送死锁问题,需要设置有效期。(当一个线程获取锁成功之后,程序出问题了,没有释放锁,就可能发生死锁)

(注:该图片来自黑马的哔哩哔哩视频,视频地址如下:实战篇-商户查询缓存-06.缓存穿透的解决思路_哔哩哔哩_bilibili

相关代码(通过redis的SETNX命令实现)

java 复制代码
//写一个自定义锁
    //获取锁
    private boolean tryLock(String key){
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }
    //释放锁
    private void unLock(String key){
        stringRedisTemplate.delete(key);
    }

使用锁的代码块(主要是第4部分,实现缓存重建)

java 复制代码
//互斥锁解决缓存击穿代码块

    private Shop queryWithMutex(Long id) {
        String key = RedisConstants.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.实现缓存重建
        String lock = RedisConstants.LOCK_SHOP_KEY + id;
        Shop shop = null;
        try {
//        4.1  获取互斥锁
            boolean isLock = tryLock(lock);
//        4.2判断获取锁是否成功
            if (!isLock){
    //            4.3不成功,休眠一段时间重试
                Thread.sleep(50);
            }
//        4.成功,查询数据库
            shop = getById(id);
            if (shop == null) {
                //将空值存入redis
                stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL);
                return null;
            }
//        5.写入redis
            stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop),  RedisConstants.CACHE_SHOP_TTL);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            // 6.释放互斥锁
            unLock(lock);
        }
//        7.返回
        return shop;
    }
(2.2)逻辑过期

逻辑过期并非真正在缓存层面设置过期时间,而是在缓存数据结构中增加一个代表过期时间的字段 。当应用程序读取缓存时,通过判断该字段与当前时间的关系,来确定数据是否 "过期"。如果数据被判定为 "过期",应用程序会在后台异步地对数据进行更新,而在更新完成前,仍然返回旧数据给请求方。

(注:该图片来自黑马的哔哩哔哩视频,视频地址如下:实战篇-商户查询缓存-06.缓存穿透的解决思路_哔哩哔哩_bilibili

定义一个 RedisData 类,用于将实际缓存数据和逻辑过期时间封装在一起,方便后续操作和判断。

java 复制代码
// 用于封装缓存数据及其逻辑过期时间
public class RedisData {
    private LocalDateTime expireTime; // 逻辑过期时间
    private Object data; // 实际缓存的数据
}

重建缓存代码(设置过期时间)

java 复制代码
  //逻辑过期解决缓存击穿代码块
    private void saveShop2Redis(Long id, Long expireSeconds) {
        //1查询店铺数据
        Shop shop = getById(id);
        //2封装逻辑过期时间
        RedisData redisData = new RedisData();
        redisData.setData(shop);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
        //3写入redis
        stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
    }

通过逻辑过期实现缓存击穿代码块

java 复制代码
 //线程池
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
    //逻辑过期解决缓存击穿代码块
    private Shop queryWithLogicalExpire(Long id) {
        String key = RedisConstants.CACHE_SHOP_KEY + id;
//        1.从redis查询缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
//        2.判断缓存是否存在
        if (StrUtil.isBlank(shopJson)) {
            //        3.不存在,返回
            return null;
        }
//        4.命中,把JSON反序列化为对象
        RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
        Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
        LocalDateTime expireTime = redisData.getExpireTime();
//        5.判断是否过期
        if (expireTime.isAfter(LocalDateTime.now())) {
            //        5.1未过期,直接返回信息
            return shop;
        }
//        5.2  已过期,需要重建缓存
//        6.缓存重建
//        6.1获取互斥锁
        String lock = RedisConstants.LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(lock);
//        6.2判断获取锁是否成功
        if (!isLock){
//        6.3  成功,开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    //        6.3.1缓存重建
                    this.saveShop2Redis(id, 20L);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    //        6.3.2释放锁
                    unLock(lock);
                }
            });
        }
//        7.失败,返回过期信息
        return shop;
    }
相关推荐
fengye20716115 分钟前
板凳-------Mysql cookbook学习 (十)
学习·mysql·adb
小白杨树树35 分钟前
【SSM】SpringBoot学习笔记1:SpringBoot快速入门
spring boot·笔记·学习
夕泠爱吃糖39 分钟前
MySQL中的部分问题(1)
数据库·mysql
百度Geek说42 分钟前
Redis 数据恢复的月光宝盒,闪回到任意指定时间
数据库
万能程序员-传康Kk1 小时前
智能教育个性化学习平台-java
java·开发语言·学习
秃了也弱了。1 小时前
DBSyncer:开源数据库同步利器,MySQL/Oracle/ES/SqlServer/PG/
数据库·mysql·开源
玄辰星君2 小时前
PostgreSQL 入门教程
数据库·postgresql
字节高级特工2 小时前
【Linux篇】0基础之学习操作系统进程
linux·运维·服务器·数据结构·windows·学习·list
hopetomorrow2 小时前
学习路之PHP--webman安装及使用
android·学习·php