Spring Cache 中解决缓存穿透、缓存雪崩和缓存击穿的笔记

发布于:2025-02-11 ⋅ 阅读:(38) ⋅ 点赞:(0)

Spring Cache 中解决缓存穿透、缓存雪崩和缓存击穿的笔记

目录
  1. 引言
  2. 环境准备
  3. 缓存穿透
  4. 缓存雪崩
  5. 缓存击穿
  6. 总结

引言

在使用Spring Cache时,缓存穿透、缓存雪崩和缓存击穿是常见的问题。这些问题可能导致系统性能下降,甚至影响系统的稳定性。本文将详细介绍如何在Spring Cache中解决这些问题,并以queryShopById方法为例进行说明。

环境准备

为了实现以下解决方案,我们需要确保项目中已经引入了必要的依赖。假设我们使用的是Maven构建工具,可以在pom.xml中添加以下依赖:

<dependencies>
    <!-- Spring Boot Starter for Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Spring Boot Starter for Redis -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

    <!-- Jackson for JSON serialization -->
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
    </dependency>

    <!-- Caffeine for local caching (optional) -->
    <dependency>
        <groupId>com.github.ben-manes.caffeine</groupId>
        <artifactId>caffeine</artifactId>
    </dependency>

    <!-- Lombok for reducing boilerplate code (optional) -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <scope>provided</scope>
    </dependency>
</dependencies>

同时,在application.yml中配置Redis连接信息:

spring:
  redis:
    host: localhost
    port: 6379
  cache:
    type: redis

缓存穿透

问题描述:如果请求的id对应的店铺不存在(即shopService.getById(id)返回null),每次请求都会直接访问数据库,导致数据库压力增大。

解决方案

  1. 缓存空对象

    • 通过@Cacheable注解的unless属性或自定义逻辑来缓存null值或默认响应。
    @RestController
    @RequestMapping("/shops")
    public class ShopController {
    
        @Autowired
        private ShopService shopService;
    
        @Cacheable(cacheNames = "shop:detail", key = "'shop'+#id", unless = "#result == null")
        public Result queryShopById(@PathVariable("id") Long id) {
            Shop shopDetail = shopService.getById(id);
            if (shopDetail == null) {
                // 如果需要缓存null值,可以在这里返回一个默认结果
                return Result.ok(new Shop()); // 或者其他表示未找到的默认对象
            }
            return Result.ok(shopDetail);
        }
    }
    
  2. 布隆过滤器

    • 在服务层之前添加布隆过滤器检查,确保不存在的id不会到达数据库查询逻辑。

    创建一个新的类BloomFilterService来管理布隆过滤器:

    package com.hmdp.service;
    
    import com.google.common.hash.Funnel;
    import com.google.common.hash.Hashing;
    import org.springframework.stereotype.Service;
    
    import java.nio.charset.StandardCharsets;
    import java.util.concurrent.ConcurrentHashMap;
    
    @Service
    public class BloomFilterService {
    
        private final ConcurrentHashMap<String, com.google.common.hash.BloomFilter<Long>> bloomFilters = new ConcurrentHashMap<>();
    
        public void initBloomFilter(String cacheName, int expectedInsertions, double fpp) {
            Funnel<Long> funnel = (from, into) -> into.putString(Long.toString(from), StandardCharsets.UTF_8);
            bloomFilters.putIfAbsent(cacheName, com.google.common.hash.BloomFilter.create(funnel, expectedInsertions, fpp));
        }
    
        public boolean mightContain(String cacheName, Long id) {
            com.google.common.hash.BloomFilter<Long> bloomFilter = bloomFilters.get(cacheName);
            return bloomFilter != null && bloomFilter.mightContain(id);
        }
    
        public void put(String cacheName, Long id) {
            com.google.common.hash.BloomFilter<Long> bloomFilter = bloomFilters.get(cacheName);
            if (bloomFilter != null) {
                bloomFilter.put(id);
            }
        }
    }
    

    修改ShopController以使用布隆过滤器:

    @RestController
    @RequestMapping("/shops")
    public class ShopController {
    
        @Autowired
        private ShopService shopService;
    
        @Autowired
        private BloomFilterService bloomFilterService;
    
        @PostConstruct
        public void initBloomFilter() {
            bloomFilterService.initBloomFilter("shop:detail", 10000, 0.01);
        }
    
        @Cacheable(cacheNames = "shop:detail", key = "'shop'+#id", unless = "#result == null")
        public Result queryShopById(@PathVariable("id") Long id) {
            if (!bloomFilterService.mightContain("shop:detail", id)) {
                // 假设不存在于布隆过滤器中,直接返回404或其他错误信息
                return Result.error("Shop not found");
            }
    
            Shop shopDetail = shopService.getById(id);
            if (shopDetail != null) {
                bloomFilterService.put("shop:detail", id); // 将存在的ID加入布隆过滤器
            }
            return Result.ok(shopDetail);
        }
    }
    

缓存雪崩

问题描述:如果大量店铺详情数据在同一时间失效,可能导致短时间内所有请求都打到数据库上,造成数据库压力。

解决方案

  1. 设置不同的过期时间

    • 使用Caffeine等支持过期策略的缓存提供者,并为每个缓存条目设置随机的过期时间。

    修改CacheMapper配置类,创建一个具有自定义过期时间的RedisCacheManager

    package com.hmdp.config;
    
    import com.fasterxml.jackson.annotation.JsonInclude;
    import com.fasterxml.jackson.databind.ObjectMapper;
    import com.fasterxml.jackson.databind.SerializationFeature;
    import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
    import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.data.redis.cache.RedisCacheConfiguration;
    import org.springframework.data.redis.cache.RedisCacheManager;
    import org.springframework.data.redis.connection.RedisConnectionFactory;
    import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
    import org.springframework.data.redis.serializer.RedisSerializationContext;
    
    import java.time.Duration;
    import java.util.HashMap;
    import java.util.Map;
    
    /**
     * 配置类用于设置缓存的序列化方式和默认过期时间
     */
    @Configuration
    public class CacheMapper {
    
        /**
         * 根据传入的TTL值配置Redis缓存
         *
         * @param ttl 缓存的存活时间(秒)
         * @return RedisCacheConfiguration 对象
         */
        private RedisCacheConfiguration instanceConfig(Long ttl) {
            // 使用Jackson进行序列化
            Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
    
            ObjectMapper objectMapper = new ObjectMapper();
            // 确保日期类型以ISO-8601格式序列化
            objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
            // 注册Java Time模块以支持Java 8日期/时间API
            objectMapper.registerModule(new JavaTimeModule());
            // 忽略注解,避免性能问题
            objectMapper.configure(MapperFeature.USE_ANNOTATIONS, false);
            // 只序列化非空对象
            objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
            // 启用类型信息的默认处理
            objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
                    ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
    
            jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
            // 配置默认缓存项的存活时间和序列化方式
            return RedisCacheConfiguration.defaultCacheConfig()
                    .entryTtl(Duration.ofSeconds(ttl))
                    .disableCachingNullValues()
                    .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jackson2JsonRedisSerializer));
        }
    
        /**
         * 创建一个具有自定义过期时间的Redis缓存管理器
         *
         * @param connectionFactory Redis连接工厂
         * @param customCacheTimes  Map<String, Long>,键为缓存名称,值为对应的TTL(秒)
         * @return RedisCacheManager 对象
         */
        @Bean
        public RedisCacheManager redisCacheManager(RedisConnectionFactory connectionFactory, 
                                                   @Value("#{${custom.cache-times:}}") Map<String, Long> customCacheTimes) {
            // 默认配置
            RedisCacheConfiguration defaultConfig = instanceConfig(3600L); // 默认1小时
    
            // 自定义缓存配置
            Map<String, RedisCacheConfiguration> customCacheConfigurations = new HashMap<>();
            customCacheTimes.forEach((cacheName, ttl) -> {
                customCacheConfigurations.put(cacheName, instanceConfig(ttl));
            });
    
            return RedisCacheManager.builder(connectionFactory)
                    .cacheDefaults(defaultConfig)
                    .withInitialCacheConfigurations(customCacheConfigurations)
                    .transactionAware()
                    .build();
        }
    }
    

    application.yml中配置自定义缓存时间:

    spring:
      cache:
        type: redis
        redis:
          time-to-live: 3600s # 默认1小时
    
    custom:
      cache-times:
        shop:detail: 600 # shop:detail缓存存活10分钟
        product:info: 300 # product:info缓存存活5分钟
    
  2. 热点数据永不过期

    • 对于特别重要的店铺详情数据,可以考虑不设置过期时间,或者设置非常长的过期时间,并通过后台任务定期更新缓存。
    @Cacheable(cacheNames = "shop:detail", key = "'shop'+#id", cacheManager = "neverExpiringCacheManager")
    public Result queryShopById(@PathVariable("id") Long id) {
        Shop shopDetail = shopService.getById(id);
        return Result.ok(shopDetail);
    }
    
    @Bean(name = "neverExpiringCacheManager")
    public RedisCacheManager neverExpiringCacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration config = instanceConfig(365 * 24 * 3600L); // 设置一年的过期时间
        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(config)
                .transactionAware()
                .build();
    }
    
  3. 多级缓存架构

    • 结合本地缓存(如Caffeine)和分布式缓存(如Redis)来构建多级缓存体系。

    创建一个新的类MultiLevelCacheResolver来实现多级缓存解析器:

    package com.hmdp.config;
    
    import org.springframework.cache.Cache;
    import org.springframework.cache.CacheManager;
    import org.springframework.cache.interceptor.CacheOperationInvocationContext;
    import org.springframework.cache.interceptor.CacheResolver;
    import org.springframework.cache.support.SimpleValueWrapper;
    import org.springframework.stereotype.Component;
    
    import java.util.Collections;
    import java.util.Optional;
    
    @Component
    public class MultiLevelCacheResolver implements CacheResolver {
    
        private final CacheManager localCacheManager;
        private final CacheManager distributedCacheManager;
    
        @Autowired
        public MultiLevelCacheResolver(CacheManager localCacheManager, CacheManager distributedCacheManager) {
            this.localCacheManager = localCacheManager;
            this.distributedCacheManager = distributedCacheManager;
        }
    
        @Override
        public Collection<Cache> resolveCaches(CacheOperationInvocationContext<?> context) {
            String cacheName = context.getCacheNames().iterator().next();
            Cache localCache = localCacheManager.getCache(cacheName);
            Cache distributedCache = distributedCacheManager.getCache(cacheName);
    
            // 先尝试从本地缓存获取
            if (localCache != null && localCache.get(context.getKey()) != null) {
                return Collections.singletonList(localCache);
            }
    
            // 如果本地缓存未命中,再尝试从分布式缓存获取
            if (distributedCache != null) {
                Object value = distributedCache.get(context.getKey(), Object.class);
                if (value != null) {
                    // 将分布式缓存中的数据同步到本地缓存
                    localCache.put(context.getKey(), value);
                }
                return Collections.singletonList(distributedCache);
            }
    
            return Collections.emptyList();
        }
    }
    

    修改ShopController以使用多级缓存解析器:

    @RestController
    @RequestMapping("/shops")
    public class ShopController {
    
        @Autowired
        private ShopService shopService;
    
        @Cacheable(cacheNames = "shop:detail", key = "'shop'+#id", cacheResolver = "multiLevelCacheResolver")
        public Result queryShopById(@PathVariable("id") Long id) {
            Shop shopDetail = shopService.getById(id);
            return Result.ok(shopDetail);
        }
    }
    

缓存击穿

问题描述:对于热门店铺,当其缓存失效时,可能会有大量并发请求同时查询该店铺的数据,给数据库带来巨大压力。

解决方案

  1. 互斥锁机制

    • 使用同步机制确保同一时刻只有一个线程能够更新缓存。可以通过sync = true属性或手动实现同步逻辑。

    使用sync = true

    @RestController
    @RequestMapping("/shops")
    public class ShopController {
    
        @Autowired
        private ShopService shopService;
    
        @Cacheable(cacheNames = "shop:detail", key = "'shop'+#id", sync = true)
        public Result queryShopById(@PathVariable("id") Long id) {
            Shop shopDetail = shopService.getById(id);
            return Result.ok(shopDetail);
        }
    }
    

    手动实现同步逻辑

    创建一个新的类ShopCacheService来管理缓存和同步逻辑:

    package com.hmdp.service;
    
    import com.hmdp.config.CacheMapper;
    import org.springframework.cache.CacheManager;
    import org.springframework.cache.annotation.Cacheable;
    import org.springframework.stereotype.Service;
    
    import java.util.concurrent.locks.Lock;
    import java.util.concurrent.locks.ReentrantLock;
    
    @Service
    public class ShopCacheService {
    
        private final Map<Long, Lock> lockMap = new ConcurrentHashMap<>();
        private final ShopService shopService;
        private final CacheManager cacheManager;
    
        @Autowired
        public ShopCacheService(ShopService shopService, CacheManager cacheManager) {
            this.shopService = shopService;
            this.cacheManager = cacheManager;
        }
    
        @Cacheable(cacheNames = "shop:detail", key = "'shop'+#id", unless = "#result == null")
        public Result queryShopById(Long id) {
            Lock lock = lockMap.computeIfAbsent(id, k -> new ReentrantLock());
    
            try {
                // 尝试获取锁
                if (lock.tryLock()) {
                    try {
                        // 再次检查缓存,防止重复加载
                        Result cachedResult = getFromCache(id);
                        if (cachedResult != null) {
                            return cachedResult;
                        }
    
                        // 执行实际的数据加载逻辑
                        Shop shopDetail = shopService.getById(id);
                        putToCache(id, Result.ok(shopDetail)); // 更新缓存
                        return Result.ok(shopDetail);
                    } finally {
                        lock.unlock(); // 确保释放锁
                    }
                } else {
                    // 如果无法获取锁,等待一段时间后再尝试
                    Thread.sleep(100); // 可以根据需要调整等待时间
                    return queryShopById(id); // 递归调用,再次尝试获取锁
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                throw new RuntimeException("Interrupted while waiting for lock", e);
            }
        }
    
        private Result getFromCache(Long id) {
            // 实现从缓存获取逻辑
            return (Result) cacheManager.getCache("shop:detail").get(id, Result.class);
        }
    
        private void putToCache(Long id, Result result) {
            // 实现向缓存写入逻辑
            cacheManager.getCache("shop:detail").put(id, result);
        }
    }
    

    修改ShopController以使用ShopCacheService

    @RestController
    @RequestMapping("/shops")
    public class ShopController {
    
        @Autowired
        private ShopCacheService shopCacheService;
    
        @GetMapping("/{id}")
        public Result queryShopById(@PathVariable("id") Long id) {
            return shopCacheService.queryShopById(id);
        }
    }
    
  2. 预热机制

    • 编写定时任务,在缓存即将过期前主动加载数据,确保缓存中的数据始终保持有效。

    创建一个新的类ShopPreloadTask来实现定时任务:

    package com.hmdp.task;
    
    import com.hmdp.service.ShopService;
    import org.springframework.scheduling.annotation.Scheduled;
    import org.springframework.stereotype.Component;
    
    import java.util.List;
    
    @Component
    public class ShopPreloadTask {
    
        private final ShopService shopService;
    
        @Autowired
        public ShopPreloadTask(ShopService shopService) {
            this.shopService = shopService;
        }
    
        @Scheduled(cron = "0 0/1 * * * ?") // 每分钟执行一次
        public void preloadHotShops() {
            List<Long> hotShopIds = shopService.findTop10HotShopIds(); // 获取热门店铺ID
            for (Long id : hotShopIds) {
                shopService.getById(id); // 触发缓存加载
            }
        }
    }
    
  3. 双层缓存

    • 如前所述,结合本地缓存和分布式缓存,可以有效缓解缓存击穿问题。具体的实现方式已经在缓存雪崩部分提到,这里不再赘述。

总结

通过上述优化,可以有效地解决queryShopById方法中可能出现的缓存穿透、缓存雪崩和缓存击穿问题。根据具体的业务需求和技术栈,选择合适的策略来优化缓存机制,是提高系统性能和稳定性的关键。