缓存常见问题与解决方案

发布于:2025-09-14 ⋅ 阅读:(22) ⋅ 点赞:(0)

缓存常见问题与解决方案

1、缓存穿透

1.1、 概述

​ 缓存穿透是指查询一个一定不存在的数据,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。在流量大时,可能DB就挂掉了,要是有人利用不存在的key频繁攻击我们的应用,这就是漏洞。

@Override
    public SkuInfo findBySkuInfoId(Integer skuInfoId) {

        /**
         * 首先去redis中根据key查询是否缓存了key的对应相关信息。
         * 1. 如果没有,说明是第一次访问这个key,那么就查询数据库,再把相关数据存入redis
         *2. 如果有,说明之前缓存过这个key,那么就从redis中取数据,不再查数据库
         */

        //1.查询缓存
        String key = "sku:" + skuInfoId + ":info";
        Object value = redisUtils.get(key);
        SkuInfo skuInfo;
        if (value != null) {
            //说明缓存中缓存这个sku,直接取出缓存数据
            skuInfo = new Gson().fromJson(value.toString(), SkuInfo.class);
        } else {
            //说明缓存中没有缓存这个sku,查数据并缓存
            //1.查询sku info表
            skuInfo = skuInfoMapper.selectByPrimaryKey(skuInfoId.longValue());
            if (skuInfo != null) {
                //2.查询sku_image表:sku对应的图片
                SkuImageExample skuImageExample = new SkuImageExample();
                skuImageExample.createCriteria().andSkuIdEqualTo(skuInfoId.longValue());
                List<SkuImage> skuImages = skuImageMapper.selectByExample(skuImageExample);
                //3.将查询到的结果封装到sku对象中
                skuInfo.setSkuImages(skuImages);
                //4.将sku对象序列化
                String json = new Gson().toJson(skuInfo);
                //5.将序列化后的数据存入缓存中
                redisUtils.set(key, json);
            }
        }
        return skuInfo;
    }

我们分析一下上述代码,如果有人恶意的拿一个不存在的key去查询数据,此时redis中没有相应的缓存数据,这就会绕过redis频繁的去调用数据库查询,这样就会给数据库造成压力。

​ 有很多种方法可以有效地解决缓存穿透问题,我们选择一种,如果一个查询返回的数据为空 (不管是数据不存在,还是系统故障),我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。


1.2 、非注解缓存解决方案

@Override
    public SkuInfo findBySkuInfoId(Integer skuInfoId) {

        /**
         * 首先去redis中根据key查询是否缓存了key的对应相关信息。
         * 1. 如果没有,说明是第一次访问这个key,那么就查询数据库,再把相关数据存入redis
         *2. 如果有,说明之前缓存过这个key,那么就从redis中取数据,不再查数据库
         */

        //1.查询缓存
        String key = "sku:" + skuInfoId + ":info";
        Object value = redisUtils.get(key);
        SkuInfo skuInfo;
        if (value != null) {
            //说明缓存中缓存这个sku,直接取出缓存数据
            skuInfo = new Gson().fromJson(value.toString(), SkuInfo.class);
        } else {
            //说明缓存中没有缓存这个sku,查数据并缓存
            //1.查询sku info表
            skuInfo = skuInfoMapper.selectByPrimaryKey(skuInfoId.longValue());
            if (skuInfo != null) {
                //2.查询sku_image表:sku对应的图片
                SkuImageExample skuImageExample = new SkuImageExample();
                skuImageExample.createCriteria().andSkuIdEqualTo(skuInfoId.longValue());
                List<SkuImage> skuImages = skuImageMapper.selectByExample(skuImageExample);
                //3.将查询到的结果封装到sku对象中
                skuInfo.setSkuImages(skuImages);
                //4.将sku对象序列化
                String json = new Gson().toJson(skuInfo);
                //5.将序列化后的数据存入缓存中
                redisUtils.set(key, json);
            } else {
                //说明数据库中没有这个sku,此时也将这个null数据进行缓存,并且设置过期时间为5min
                redisUtils.set(key, null, 5, TimeUnit.MINUTES);
            }
        }

        return skuInfo;
    }

1.3 、注解缓存解决方案

基于 Spring Cache 注解式缓存解决缓存穿透,核心思路与非注解式一致:缓存空值并设置较短过期时间。需要通过配置CacheManager和注解属性配合实现。

代码示例:
// 1. 配置Redis缓存管理器(设置默认过期时间及空值处理)
@Configuration
@EnableCaching
public class RedisCacheConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
        // 默认配置(非空值缓存)
        RedisCacheConfiguration defaultCacheConfig = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofHours(2)) // 非空值默认过期时间2小时
                .serializeKeysWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer()));

        // 空值缓存配置(单独设置较短过期时间)
        RedisCacheConfiguration nullValueCacheConfig = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(5)) // 空值缓存5分钟
                .serializeKeysWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer()))
                // 允许缓存null值
                .disableCachingNullValues(false);

        // 针对不同缓存名称设置不同配置(这里对skuInfo缓存单独配置空值策略)
        Map<String, RedisCacheConfiguration> configMap = new HashMap<>();
        configMap.put("skuInfo", nullValueCacheConfig);

        return RedisCacheManager.builder(factory)
                .cacheDefaults(defaultCacheConfig) // 默认配置
                .withInitialCacheConfigurations(configMap) // 特殊缓存配置
                .build();
    }
}

// 2. 业务层使用注解
@Service
public class SkuInfoService {

    @Autowired
    private SkuInfoMapper skuInfoMapper;

    @Autowired
    private SkuImageMapper skuImageMapper;

    /**
     * @Cacheable:查询缓存,不存在则执行方法并缓存结果
     *  key:缓存key
     *  cacheNames:缓存名称(对应上面配置的skuInfo)
     *  unless:结果为null时不缓存?这里设置为false,允许缓存null
     */
    @Cacheable(key = "'sku:'+#skuInfoId+':info'", cacheNames = "skuInfo", unless = "#result == null ? false : false")
    public SkuInfo findBySkuInfoId(Integer skuInfoId) {
        // 1.查询数据库
        SkuInfo skuInfo = skuInfoMapper.selectByPrimaryKey(skuInfoId.longValue());
        if (skuInfo != null) {
            // 2.查询关联图片
            SkuImageExample example = new SkuImageExample();
            example.createCriteria().andSkuIdEqualTo(skuInfoId.longValue());
            skuInfo.setSkuImages(skuImageMapper.selectByExample(example));
        }
        // 注意:这里会返回null,而注解配置会缓存null值(5分钟过期)
        return skuInfo;
    }
}
  1. 注解式缓存需通过RedisCacheConfiguration显式开启disableCachingNullValues(false)允许缓存 null
  2. 空值缓存必须设置较短过期时间(5 分钟内),避免长期占用内存
  3. unless属性用于控制是否缓存,这里配置为始终缓存(包括 null)
  4. 优势:代码更简洁,无需手动编写缓存逻辑;劣势:空值过期时间配置较固定,灵活性略低

2 、缓存雪崩

2.1、概述

缓存雪崩是指在我们设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时压力过重雪崩。

一个简单方案就时讲缓存失效时间分散开,比如我们可以在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。


2.2 、非注解缓存解决方案

核心方案:给缓存过期时间添加随机偏移量,避免大量缓存同时失效。

代码示例:
@Override
public SkuInfo findBySkuInfoId(Integer skuInfoId) {
    String key = "sku:" + skuInfoId + ":info";
    Object value = redisUtils.get(key);
    SkuInfo skuInfo;

    if (value != null) {
        skuInfo = new Gson().fromJson(value.toString(), SkuInfo.class);
    } else {
        // 查询数据库
        skuInfo = skuInfoMapper.selectByPrimaryKey(skuInfoId.longValue());
        if (skuInfo != null) {
            // 补充关联数据
            SkuImageExample example = new SkuImageExample();
            example.createCriteria().andSkuIdEqualTo(skuInfoId.longValue());
            skuInfo.setSkuImages(skuImageMapper.selectByExample(example));
            
            // 缓存逻辑:基础过期时间+随机偏移量
            String json = new Gson().toJson(skuInfo);
            long baseExpire = 30; // 基础30分钟
            long random = new Random().nextInt(5); // 0-5分钟随机值
            redisUtils.set(key, json, baseExpire + random, TimeUnit.MINUTES);
        } else {
            // 空值缓存(同样加随机偏移,避免空值缓存同时失效)
            long nullExpire = 5 + new Random().nextInt(2); // 5-7分钟
            redisUtils.set(key, null, nullExpire, TimeUnit.MINUTES);
        }
    }
    return skuInfo;
}

2.3 、注解缓存解决方案

一般可以采用多级缓存,不同级别的缓存设置不同的超时时间,尽量避免集体失效,由于注解式的灵活度很低(高度封装),建议使用非注解式解决方案

注解式通过自定义缓存过期时间生成器,为不同 key 分配随机过期时间。

代码示例:
// 1. 自定义缓存过期时间生成器
public class RandomTtlRedisCacheWriter extends DefaultRedisCacheWriter {

    private final Duration baseTtl;
    private final int randomRange; // 随机范围(分钟)

    public RandomTtlRedisCacheWriter(RedisConnectionFactory connectionFactory, 
                                    Duration baseTtl, int randomRange) {
        super(connectionFactory);
        this.baseTtl = baseTtl;
        this.randomRange = randomRange;
    }

    @Override
    public void put(String name, byte[] key, byte[] value, Duration ttl) {
        // 覆盖默认ttl,使用基础时间+随机值
        Duration actualTtl = baseTtl.plusMinutes(new Random().nextInt(randomRange));
        super.put(name, key, value, actualTtl);
    }
}

// 2. 配置缓存管理器
@Configuration
@EnableCaching
public class RedisCacheConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
        // 创建带随机过期时间的缓存写入器
        RandomTtlRedisCacheWriter writer = new RandomTtlRedisCacheWriter(
                factory, 
                Duration.ofHours(2), // 基础2小时
                30 // 随机0-30分钟
        );

        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
                .serializeKeysWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer()));

        return RedisCacheManager.builder(writer)
                .cacheDefaults(config)
                .build();
    }
}

// 3. 业务层使用(与普通注解一致)
@Service
public class SkuInfoService {
    @Cacheable(key = "'sku:'+#skuInfoId+':info'", cacheNames = "skuInfo")
    public SkuInfo findBySkuInfoId(Integer skuInfoId) {
        // 数据库查询逻辑(同上)
    }
}

3、缓存击穿

3.1、概述

在高并发的情况下,大量的请求同时查询同一个key时,此时这个key正好失效了,就会导致同一时间,这些请求都会去查询数据库,这样的现象我们称为缓存击穿

3.2、非注解缓存解决方案

核心方案:分布式锁 + 双重检查,确保同一时间只有一个请求查询数据库。

代码示例:
@Override
public SkuInfo findBySkuInfoId(Integer skuInfoId) {
    String key = "sku:" + skuInfoId + ":info";
    String lockKey = "lock:sku:" + skuInfoId; // 分布式锁key
    Object value = redisUtils.get(key);
    SkuInfo skuInfo;

    if (value != null) {
        // 缓存命中
        skuInfo = new Gson().fromJson(value.toString(), SkuInfo.class);
        return skuInfo;
    }

    // 缓存未命中,尝试获取分布式锁
    boolean locked = false;
    try {
        // 获取锁(设置3秒过期,避免死锁)
        locked = redisUtils.tryLock(lockKey, 3, TimeUnit.SECONDS);
        if (locked) {
            // 双重检查:获取锁后再次检查缓存(防止锁等待期间已被其他请求更新)
            Object doubleCheck = redisUtils.get(key);
            if (doubleCheck != null) {
                return new Gson().fromJson(doubleCheck.toString(), SkuInfo.class);
            }

            // 查询数据库
            skuInfo = skuInfoMapper.selectByPrimaryKey(skuInfoId.longValue());
            if (skuInfo != null) {
                // 补充关联数据
                SkuImageExample example = new SkuImageExample();
                example.createCriteria().andSkuIdEqualTo(skuInfoId.longValue());
                skuInfo.setSkuImages(skuImageMapper.selectByExample(example));
                
                // 缓存数据(带随机过期时间)
                String json = new Gson().toJson(skuInfo);
                long expire = 30 + new Random().nextInt(5);
                redisUtils.set(key, json, expire, TimeUnit.MINUTES);
            } else {
                // 缓存空值
                redisUtils.set(key, null, 5, TimeUnit.MINUTES);
            }
            return skuInfo;
        } else {
            // 未获取到锁,等待50ms后重试
            Thread.sleep(50);
            return findBySkuInfoId(skuInfoId); // 递归重试
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        return null;
    } finally {
        // 释放锁
        if (locked) {
            redisUtils.unlock(lockKey);
        }
    }
}
优化备注:
  1. 分布式锁必须设置过期时间,防止锁持有者宕机导致死锁
  2. 双重检查机制:获取锁后再次查询缓存,避免重复查询数据库
  3. 未获取到锁时应重试(而非直接返回),重试间隔建议 50-100ms
  4. 推荐使用 Redisson 等成熟框架实现分布式锁,而非自行实现tryLock

3.3 注解缓存解决方案

基于 Spring AOP 和分布式锁实现,通过自定义注解封装锁逻辑。

代码示例:
// 1. 自定义防击穿注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CacheBreakdownProtection {
    String lockKeyPrefix() default "lock:"; // 锁key前缀
    long lockExpire() default 3; // 锁过期时间(秒)
    long retryInterval() default 50; // 重试间隔(毫秒)
}

// 2. AOP切面实现
@Aspect
@Component
public class CacheBreakdownAspect {

    @Autowired
    private RedisUtils redisUtils;

    @Around("@annotation(protection)")
    public Object around(ProceedingJoinPoint joinPoint, CacheBreakdownProtection protection) throws Throwable {
        // 获取方法参数(假设第一个参数为ID)
        Object[] args = joinPoint.getArgs();
        String id = args[0].toString();
        String lockKey = protection.lockKeyPrefix() + id;

        try {
            // 尝试获取锁
            boolean locked = redisUtils.tryLock(lockKey, protection.lockExpire(), TimeUnit.SECONDS);
            if (locked) {
                // 获取锁成功,执行原方法
                return joinPoint.proceed();
            } else {
                // 未获取到锁,重试
                Thread.sleep(protection.retryInterval());
                return around(joinPoint, protection); // 递归重试
            }
        } finally {
            // 释放锁(需判断当前线程是否持有锁,避免误释放)
            if (redisUtils.isLocked(lockKey)) {
                redisUtils.unlock(lockKey);
            }
        }
    }
}

// 3. 业务层使用
@Service
public class SkuInfoService {

    @Cacheable(key = "'sku:'+#skuInfoId+':info'", cacheNames = "skuInfo")
    @CacheBreakdownProtection(lockKeyPrefix = "lock:sku:") // 应用防击穿注解
    public SkuInfo findBySkuInfoId(Integer skuInfoId) {
        // 数据库查询逻辑(同上)
    }
}
优化备注:
  1. 注解式通过 AOP 封装锁逻辑,业务代码更简洁
  2. 需注意锁的粒度:建议按 ID 维度加锁(如lock:sku:1001),避免全局锁影响性能
  3. 重试次数需有限制(可在注解中增加maxRetry属性),防止无限重试导致栈溢出
  4. 适用于高并发读、低并发写的场景,如商品详情查询

4、总结

问题 核心解决方案 非注解式优势 注解式优势
缓存穿透 缓存空值 + 短期过期 灵活性高 代码简洁
缓存雪崩 随机过期时间 + 多级缓存 易定制 全局管理方便
缓存击穿 分布式锁 + 双重检查 控制粒度细 无侵入性