Spring Cache

360 阅读7分钟

Spring 3.1 引入了激动人心的基于annotation的缓存(cache)技术,它本质上不是一个具体的缓存实现方案(比如EHCache 或者 OSCache),而是一个对缓存使用的抽象,通过在既有代码中加入少量它定义的各种 annotation,即能够达到缓存方法的返回对象的效果。 Spring 的缓存技术还具备相当的灵活性。不仅能够使用 SpEL(Spring Expression Language)来定义缓存的 key 和各种 condition,还提供开箱即用的缓存暂时存储方案,也支持和主流的专业缓存比如 EHCache,redis等集成。

image.png

  • 通过少量的配置 annotation 就可以使得既有代码支持缓存
  • 支持开箱即用 Out-Of-The-Box,即不用安装和部署额外第三方组件就可以使用缓存
  • 支持 Spring Express Language,能使用对象的不论什么属性或者方法来定义缓存的 key 和 condition
  • 支持 AspectJ,并通过事实上现不论什么方法的缓存支持
  • 支持自己定义 key 和自己定义缓存管理者,具有相当的灵活性和扩展性 详细可以参考spring官方文档关于cache部分:

docs.spring.io/spring-fram…

image.png 下面开始使用spring cache

一:导入依赖

<!--引入SpringCache 简化缓存开发-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

二:编写配置

在编写配置以前,首先得弄清楚spring cache帮我们自动配置了哪些配置,点开CacheAutoConfiguration类,其中里面有这样一段代码

static class CacheConfigurationImportSelector implements ImportSelector {

   @Override
   public String[] selectImports(AnnotationMetadata importingClassMetadata) {
      CacheType[] types = CacheType.values();
      String[] imports = new String[types.length];
      for (int i = 0; i < types.length; i++) {
         imports[i] = CacheConfigurations.getConfigurationClass(types[i]);
      }
      return imports;
   }

}

继续点击getConfigurationClass方法,

public static String getConfigurationClass(CacheType cacheType) {
   Class<?> configurationClass = MAPPINGS.get(cacheType);
   Assert.state(configurationClass != null, () -> "Unknown cache type " + cacheType);
   return configurationClass.getName();
}

看到这样一段代码:MAPPINGS.get(cacheType);,继续点进去

private static final Map<CacheType, Class<?>> MAPPINGS;
static {
   Map<CacheType, Class<?>> mappings = new EnumMap<>(CacheType.class);
   mappings.put(CacheType.GENERIC, GenericCacheConfiguration.class);
   mappings.put(CacheType.EHCACHE, EhCacheCacheConfiguration.class);
   mappings.put(CacheType.HAZELCAST, HazelcastCacheConfiguration.class);
   mappings.put(CacheType.INFINISPAN, InfinispanCacheConfiguration.class);
   mappings.put(CacheType.JCACHE, JCacheCacheConfiguration.class);
   mappings.put(CacheType.COUCHBASE, CouchbaseCacheConfiguration.class);
   mappings.put(CacheType.REDIS, RedisCacheConfiguration.class);
   mappings.put(CacheType.CAFFEINE, CaffeineCacheConfiguration.class);
   mappings.put(CacheType.SIMPLE, SimpleCacheConfiguration.class);
   mappings.put(CacheType.NONE, NoOpCacheConfiguration.class);
   MAPPINGS = Collections.unmodifiableMap(mappings);
}

可以看到mappings.put(CacheType.REDIS, RedisCacheConfiguration.class);进入RedisCacheConfiguration类中,

@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory redisConnectionFactory,
      ResourceLoader resourceLoader) {
   RedisCacheManagerBuilder builder = RedisCacheManager.builder(redisConnectionFactory)
         .cacheDefaults(determineConfiguration(resourceLoader.getClassLoader()));
   List<String> cacheNames = this.cacheProperties.getCacheNames();
   if (!cacheNames.isEmpty()) {
      builder.initialCacheNames(new LinkedHashSet<>(cacheNames));
   }
   return this.customizerInvoker.customize(builder.build());
}

RedisCacheManager已经被纳入容器中进行管理了。

image.png 我们首先要指定type类型,在application.properties中添加类型

image.png

三:开始测试

在测试之前,我们先看官方介绍

image.png 主要是这五个注解

  • @Cacheable: 触发将数据保存到缓存的操作
  • @CacheEvict: 触发将数据从缓存删除的操作
  • @CachePut: 会把方法的返回值put到缓存里面缓存起来,不影响方法执行更新缓存
  • @Caching:组合以上多个操作
  • @CacheConfig: 在类级别共享缓存的相关配置

3.1@Cacheable注解使用

3.1.1:开启缓存注解

在启动类上加上该注解

//开启使用缓存注解
@EnableCaching
3.1.2:使用@Cacheable

注意:value值也就是cacheNames是必传的,是一个list数组(可以理解成缓存分区)

@Cacheable(value={"category"})
public List<CategoryEntity> listWithTree1() {
    //两种方式查询,一种是根据cat_level=1值查询,另一种是根据parent_cid=0查询
    System.out.println("执行了listWithTree1()方法");
    List<CategoryEntity> categoryEntities=this.baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("cat_level", ProductConstant.CatelogLevelEnum.FIRST_LEVEL.getCode()));
    return categoryEntities;
}
3.1.3启动服务测试

查看redis image.png 可以注意到几个点

  • 1)TTL为-1,也就是默认不过期
  • 2)key的名字:category::SimpleKey [],(默认生成的key)
  • 3)value值:默认使用jdk序列化机制,将序列化后的数据存到缓存中
  • 4)如果缓存中已经有数据,则该方法不会执行

image.png

  • 1)TTL为-1,也就是默认不过期 解决办法:在配置文件中设置过期时间
#统一设置过期时间,单位是毫秒
spring.cache.redis.time-to-live=36000

直接看效果 image.png

  • 2)key的名字:category::SimpleKey [],(默认生成的key) 直接通过指定key来使用自己指定的key名称,注意这里的key使用的是spel表达式,官方文档对此有这样一种介绍
Available Caching SpEL Evaluation Context

image.png 方式一:

/**
 * 查找所有一级分类
 * @return
 */
@Cacheable(value={"category"},key = "'listWithTree1'")
public List<CategoryEntity> listWithTree1() {
    //两种方式查询,一种是根据cat_level=1值查询,另一种是根据parent_cid=0查询
    System.out.println("执行了listWithTree1()方法");
    List<CategoryEntity> categoryEntities=this.baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("cat_level", ProductConstant.CatelogLevelEnum.FIRST_LEVEL.getCode()));
    return categoryEntities;
}

方式二:

//key使用的是spel表达式,如果指定固定名称,需要"'test'",s
@Cacheable(value={"category"},key ="#root.method.name")
public List<CategoryEntity> listWithTree1() {
    //两种方式查询,一种是根据cat_level=1值查询,另一种是根据parent_cid=0查询
    System.out.println("执行了listWithTree1()方法");
    List<CategoryEntity> categoryEntities=this.baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("cat_level", ProductConstant.CatelogLevelEnum.FIRST_LEVEL.getCode()));
    return categoryEntities;
}

image.png

  • 3)value值:默认使用jdk序列化机制,将序列化后的数据存到缓存中 最后要解决的就是让保存的value以json格式进行保存 其中RedisCacheConfiguration类有这样一段关键代码
public static RedisCacheConfiguration defaultCacheConfig(@Nullable ClassLoader classLoader) {

   DefaultFormattingConversionService conversionService = new DefaultFormattingConversionService();

   registerDefaultConverters(conversionService);

   return new RedisCacheConfiguration(Duration.ZERO, true, true, CacheKeyPrefix.simple(),
         SerializationPair.fromSerializer(RedisSerializer.string()),
         SerializationPair.fromSerializer(RedisSerializer.java(classLoader)), conversionService);
}
private org.springframework.data.redis.cache.RedisCacheConfiguration determineConfiguration(
      ClassLoader classLoader) {
   if (this.redisCacheConfiguration != null) {
      return this.redisCacheConfiguration;
   }
   Redis redisProperties = this.cacheProperties.getRedis();
   org.springframework.data.redis.cache.RedisCacheConfiguration config = org.springframework.data.redis.cache.RedisCacheConfiguration
         .defaultCacheConfig();
   config = config.serializeValuesWith(
         SerializationPair.fromSerializer(new JdkSerializationRedisSerializer(classLoader)));
   if (redisProperties.getTimeToLive() != null) {
      config = config.entryTtl(redisProperties.getTimeToLive());
   }
   if (redisProperties.getKeyPrefix() != null) {
      config = config.prefixKeysWith(redisProperties.getKeyPrefix());
   }
   if (!redisProperties.isCacheNullValues()) {
      config = config.disableCachingNullValues();
   }
   if (!redisProperties.isUseKeyPrefix()) {
      config = config.disableKeyPrefix();
   }
   return config;
}

其中可以看到数据存入缓存中,key用的是RedisSerializer.string(),而value值用的是RedisSerializer.java(classLoader),如果我们想让value值用json序列化,应该怎么做呢? 编写配置类如下

/**
 * 关于缓存的配置类
 */
@Configuration
@EnableCaching
public class MyCacheConfig {
    @Bean
     RedisCacheConfiguration myCacheconfig(){
        RedisCacheConfiguration config=RedisCacheConfiguration.defaultCacheConfig();
        config = config.serializeValuesWith(
                RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
        return config;
    }
}

测试如下

image.png 可以看到value值已经变成json格式了,但是又带来了新的问题,为什么我们在配置文件中配置得过期时间没有生效呢 原因还是看上面的代码 image.png 如果我们没配置RedisCacheConfiguration,它会读取配置文件的参数,但是如果我们写了这个RedisCacheConfiguration,底层就会直接返回RedisCacheConfiguration里的配置信息,所以配置文件看起来失效了,解决办法 直接看代码:

/**
 * 关于缓存的配置类
 */
@Configuration
@EnableCaching
@EnableConfigurationProperties(CacheProperties.class)
public class MyCacheConfig {
    @Bean
     RedisCacheConfiguration myCacheconfig(CacheProperties cacheProperties){
        RedisCacheConfiguration config=RedisCacheConfiguration.defaultCacheConfig();
        config = config.serializeValuesWith(
                RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
        CacheProperties.Redis redisProperties=cacheProperties.getRedis();//获取配置文件中类型为redis的所有配置
        if (redisProperties.getTimeToLive() != null) {
            config = config.entryTtl(redisProperties.getTimeToLive());
        }
        if (redisProperties.getKeyPrefix() != null) {
            config = config.prefixKeysWith(redisProperties.getKeyPrefix());
        }
        if (!redisProperties.isCacheNullValues()) {
            config = config.disableCachingNullValues();
        }
        if (!redisProperties.isUseKeyPrefix()) {
            config = config.disableKeyPrefix();
        }
        return config;
    }
}

其实就两步,首先加上@EnableConfigurationProperties(CacheProperties.class)注解,然后仿照源码编写即可,至此关于@Cacheable的使用初步结束,接下来我们开始使用注解CacheEvict

3.2@CacheEvict注解使用

@CacheEvict:触发将数据从缓存删除的操作

3.2.1主要代码如下
/**
 * 更新分类名称,同步更新关联关系表
 */
@Transactional
@CacheEvict(value = {"category"},key = "'listWithTree1'")//删除缓存
public void updatedetail(CategoryEntity category) {
    this.updateById(category);
    Long catId=category.getCatId();
    String name=category.getName();
    categoryBrandRelationService.updateCategory(catId,name);

}

启动服务进行测试,发现当调用了updatedetail方法以后,category分区中的listWithTree1就会被删除,符合预期

3.2.2删除一个分区所有缓存
/**
 * 更新分类名称,同步更新关联关系表
 */
@Transactional
@CacheEvict(value = {"category"},allEntries = true)//删除指定分区下所有缓存(注意redis并没有分区这个概念,分区只是spring往redis写入缓存的标识)
public void updatedetail(CategoryEntity category) {
    //TODO 前端商品系统--》分类管理--》点击edit,数据不回显
    this.updateById(category);
    Long catId=category.getCatId();
    String name=category.getName();
    categoryBrandRelationService.updateCategory(catId,name);

}

3.3@Caching注解使用

当想使用注解一次执行多个缓存操作时,springcache也提供了这样的功能,需求是当我在后台修改数据时,我想删除listWithTree1和getCatalogJson2的缓存,应该怎么实现呢,主要代码如下:

/**
 * 更新分类名称,同步更新关联关系表
 */
@Transactional
//@CacheEvict(value = {"category"},key = "'listWithTree1'")//更新的同时,删除缓存
@Caching(evict ={@CacheEvict(value = {"category"},key = "'listWithTree1'"),
                 @CacheEvict(value = {"category"},key = "'getCatalogJson'")})          
public void updatedetail(CategoryEntity category) {
    //TODO 前端商品系统--》分类管理--》点击edit,数据不回显
    this.updateById(category);
    Long catId=category.getCatId();
    String name=category.getName();
    categoryBrandRelationService.updateCategory(catId,name);

}

四:使用spring cache重构之前的方法

未使用spring cache之前的代码

 public Map<String, List<Catelog2Vo>> getCatalogJson2() {
        String category = redisTemplate.opsForValue().get("category");
        Map<String, List<Catelog2Vo>> map=null;
        if(StringUtils.isEmpty(category)){
            System.out.println("未命中缓存---》");
            //查询数据库
            map= getCatalogJsonWithRedissonLock();
        }else{
            System.out.println("缓存命中了---》");
            map= JSON.parseObject(category, new TypeReference<Map<String, List<Catelog2Vo>>>(){
            }); //内部类
        }
        return map;
    }

改造后的代码

/**
 * 使用spring cache改造getCatalogJson方法
 * @return
 */
@Cacheable(value = {"category"},key ="#root.methodName" )
public Map<String,List<Catelog2Vo>> getCatalogJson(){
    System.out.println("开始查询数据库---》");
    //查询所有分类,用来减少数据库io
    List<CategoryEntity> selectList = this.baseMapper.selectList(null);//不传查询条件查所有
    //List<CategoryEntity> level1 = this.listWithTree1();
    //查询所有一级分类,避免直接在数据库查询
    List<CategoryEntity> level1 = getParentCid(selectList, 0L);
    Map<String, List<Catelog2Vo>> map = level1.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {
        //查询当前一级分类对应的所有二级分类
        List<CategoryEntity> categoryEntities = getParentCid(selectList, v.getCatId());
        log.info("当前一级分类id是:{},对应的所有二级分类是:{}", v.getCatId(), categoryEntities);
        //封装得到的结果
        List<Catelog2Vo> catelog2Vos = null;
        if (categoryEntities != null) {
            catelog2Vos = categoryEntities.stream().map(l2 -> {
                Catelog2Vo catelog2Vo = new Catelog2Vo(v.getCatId().toString(), l2.getCatId().toString(), l2.getName(), null);
                //根据当前二级分类找到所有的三级分类再分装成vo
                List<CategoryEntity> level3Catelog = getParentCid(selectList, l2.getCatId());
                log.info("当前二级分类id是:{},对应的所有三级分类是:{}", l2.getCatId(), level3Catelog);
                if (level3Catelog != null) {
                    List<Catelog2Vo.Category3Vo> collect = level3Catelog.stream().map(l3 -> {
                        Catelog2Vo.Category3Vo category3Vo = new Catelog2Vo.Category3Vo(l2.getCatId().toString(), l3.getCatId().toString(), l3.getName());
                        return category3Vo;
                    }).collect(Collectors.toList());
                    catelog2Vo.setCatalog3List(collect);
                }
                return catelog2Vo;
            }).collect(Collectors.toList());
        }
        return catelog2Vos;
    }));
    return map;
}

启动服务测试,符合预期(以前的加锁,写入缓存等等都不用再自己写了,我们只需要关注业务逻辑,关于缓存的操作全部使用注解来操作)

image.png

五:springcache的不足

5.1读模式场景

  • 缓存穿透:查询一个null数据,
    • 解决办法:缓存空数据
#设置生成null缓存,解决缓存穿透
spring.cache.redis.cache-null-values=true
  • 缓存击穿:大量并发进来同时查询一个正好过期的key
    • 解决办法:加锁
@Cacheable(value={"category"},key ="#root.method.name",sync = true)

Cacheable注解有个属性sync ,如果设置为true,则会在读的时候加上本地锁,RedisCache 相关源码如下(可以打断点来调试sync的值为false和true区别)

public synchronized <T> T get(Object key, Callable<T> valueLoader) {

   ValueWrapper result = get(key);

   if (result != null) {
      return (T) result.get();
   }

   T value = valueFromLoader(key, valueLoader);
   put(key, value);
   return value;
}
  • 缓存雪崩:大量的key同时过期
    • 解决办法:给key设置过期时间
#统一设置过期时间,单位是毫秒
spring.cache.redis.time-to-live=360000

5.2写模式场景

  • 缓存一致性
    • 解决办法:
  • 1)读写加锁
  • 2)引入canal,实现缓存和数据库的一致性
  • 3)读多写多场景,直接去数据库查询 总结: 常规数据(读多写少,即时性和一致性要求不高的数据):使用springcache(只要有过期时间就可以满足大部分场景需求了) 特殊数据:特殊设计