SpringBoot Cache中Redis实现的使用

前言:为什么使用缓存

众所周知,缓存一般在内存中存储,cpu对内存的调度效率要优于硬盘(时间快)

我们通常使用空间换取时间,达到某种程度的时空平衡

于是一般来讲使用缓存可以提高效率—>提升性能


我这次使用缓存是目的是将数据库字典类查询的结果放入缓存,以便于提高字典类使用时查询的效率

(注:我所用到的字典类一般不会更新,数据量也不大,占用内存不多,大多用于查询的操作,适合用做缓存,可以大大减少数据库的压力,在一定程度上提升性能)

SpringBoot Cache 和 Mybatis 缓存的对比

为什么要使用SpringBoot Cache 而不是使用Mybatis缓存

首先,Mybatis 缓存分为一级和二级缓存

一级缓存默认开启,是SqlSession级别的(一般是指程序对数据库的一次会话,与@Transactional对应),默认在一个会话中有效

一般来讲,一个service服务中连续查询两次相同的sql情况很少,故不常用到。

二级缓存默认关闭,是SqlSessionFactory级别的,多个SqlSession会话共享,缓存是以namespace为单位的


但是通常使用会存在一些问题,不同的namespace完全是有可能操作同一张表的,那么会导致一个namespace的数据修改了一张表,但是另一个namespace的那张表的数据缓存没被修改,这样就导致了缓存和数据库不一致的问题,如果是集群的状态下,则产生的问题会更多,故也不常用到。

所以使用缓存一般会用到缓存中间件如Redis来处理,SpringBoot 很早就为我们提供好了Cache的操作规范,使用简洁方便,故简单使用的话一般不用自己重复造轮子。

什么是Spring Boot Cache

SpringBoot Cache 是 SpringBoot为了简化缓存的的开发,提供的一整套的缓存解决方案。

提供一整套的接口和代码规范、配置、注解等,用于整合各种缓存方案。

主要是定义了统一的接口和不同的实现类来统一使用不同的缓存技术。

官方文档:https://springdoc.cn/spring-boot/io.html#io

功能简介:Cache是作用于方法上的,方法配置了缓存,则第一次执行完方法后会将结果数据存入缓存,第二次执行则直接从缓存读取结果返回,不会执行方法

其中主要包含接口:Cache、CacheManager

  • Cache

    缓存的接口,用来存储缓存的key和value,如有Redis的实现,RedisCache

    主要方法

    方法名描述
    getName()获取到缓存的名称
    get(key)获取这个缓存中某个 key映射的值
    put(key,value)保存或者更新这个缓存中某个key 映射的值
    evict(key)从这个缓存中删除某个key ,即删除缓存中的某个条目
    clear() 方法清空缓存中的所有条目
  • CacheManager

    缓存管理的接口,用来管理多个Cache对象

    主要方法

    方法名描述
    getCache(String)Cache 根据缓存的名称得到缓存对象
    getCacheNames()Collection 获取管理器管理范围内的所有cache名称

其中主要包含注解如下:

Annotation作用
@Cacheable查询常用。将方法的结果缓存起来,下一次方法执行参数相同时,将不执行方法,返回缓存中的结果。@Cacheable 会进行缓存检查
@CacheEvict删除常用。移除指定缓存,如果向让cache 中所有的 key-value 都失效,即清空cache中所有的数据,可以使用 allEntries=true
@CachePut更新常用。标记该注解的方法总会执行,根据注解的配置将结果缓存。一般用于更新数据的时候,方法更新了数据,就将更新后的数据返回,如果有这个Annotation,那么这个数据就立即被缓存了。
@Caching可以组合使用@Cacheable,@CacheEvict,@CachePut
@CacheConfig类级别注解,可以设置一些共通的配置,@CacheConfig(cacheNames=“user”), 代表该类下的方法均使用这个cacheNames

这些缓存操作Annotation 中常用属性的解释:

  • cacheNames/Value: 缓存名字,可以指定多个
  • key: 缓存数据时使用的key,默认空字符串。key可以使用spEL表达式
  • keyGenerator: key的生成器。自己编写一个key生成器,并注册到Spring容器中,keyGenerator指定bean的名称即可,这样就会自动调用生成器来生成 key
  • cacheManager: 指定缓存管理器。 即缓存管理器在Spring容器中的bean的名称
  • cacheResolver:Cache 解析器
  • condition: 符合条件的才会被缓存,支持 spEL表达式
  • unless: 否定缓存。当unless指定的条件为true时,方法的返回值不会被缓存。支持spEL
  • sync: 是否使用异步模式

总结:

在支持 Spring Cache 的环境下,对于使用 @Cacheable 标注的方法,Spring 在每次执行前都会检查 Cache 中是否存在相同 key 的缓存元素,如果存在就不再执行该方法,而是直接从缓存中获取结果进行返回,否则才会执行并将返回结果存入指定的缓存中。@CachePut 也可以声明一个方法支持缓存功能。与 @Cacheable 不同的是使用 @CachePut 标注的方法在执行前不会去检查缓存中是否存在之前执行过的结果,而是每次都会执行该方法,并将执行结果以键值对的形式存入指定的缓存中。

SpringBoot Cache Redis实现代码

本文只介绍了@Cacheable的使用,@CacheEvict、@CachePut这两个见名知意,就不介绍了

环境配置

1.依赖引入

SpringBoot项目maven构建情况下pom需要包含spring-boot-starter-cache和spring-boot-starter-data-redis依赖,序列化可以使用fastjson

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version> 1.2.70</version>
</dependency>
2.redis配置
spring:
  redis:
    host: 127.0.0.1
    port: 6379
    password: xxx
    database: 1
# 等等
3.打开配置

在入口处加入 @EnableCaching 打开缓存配置:

@SpringBootApplication
@EnableCaching
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

一般情况下,引入了spring-boot-starter-data-redis 依赖后,就可以直接使用了, 业务类上无需做任何改动。CacheAutoConfiguration会自动判断加载Redis的实现RedisCacheManager等

代码实现

1.业务方法上使用@Cacheable注解
@Cacheable(cacheNames = {"cache:sp_data"},key="#root.methodName")
public List<Data> findAll() {
    TimeInterval timer = DateUtil.timer();
    List<Data> = dataMapper.findAll();
    log.info("数据库访问:findAll方法");
    System.out.println(timer.interval());//花费毫秒数
    return data;
}
  • cacheNames : 可以指定多个cache名称,是一个数组。

  • key : cache中的key,可以使用SpringEL表达式获取当前方法上下文信息,比如方法名称,参数的值等。

    Caching SpringEL Evaluation Context说明:

    属性名称描述示例
    methodName当前方法名#root.methodName
    method当前方法#root.method.name
    target当前被调用的对象#root.target
    targetClass当前被调用的对象的class#root.targetClass
    args当前方法参数组成的数组#root.args[0]
    caches当前被调用的方法使用的Cache#root.caches[0].name

    要使用 root 对象的属性作为 key 时,也可以将“#root”省略,因为 Spring 默认使用的就是 root 对象的属性。

    如果要直接使用方法参数传递的值,可以用 #参数名称 来取出方法调用的时候传递的实参值,比如上面的 #id

2.自定义keyGenerator

如果key的生成规则比较复杂,无法用 SpringEL来生成,可以自定义一个 KeyGenerator, 分为三个步骤来实现:

1.定义一个类,实现 org.springframework.cache.interceptor.KeyGenerator 接口。

2.将自定义的KeyGenerator注册到容器中

3.在@Cacheable 中使用keyGenerator 属性

注:一旦使用了 keyGenerator ,就不要再使用 key属性了。

/**
 * @author xxx
 * @version 1.0
 * @description: 缓存配置类
 * @date 2023/8/26 18:18
 */
@Configuration
public class CacheConfig {

    /**
     * 缓存key生成策略 (cacheNames:方法名:[参数列表])
     * @return KeyGenerator
     */
    @Bean("cacheKeyGenerator")
    public KeyGenerator keyGenerator() {
        return (target, method, params) -> (method.getName() + ":" + Arrays.asList(params));
    }
@Cacheable(cacheNames = {"cache:xxx_data"}, keyGenerator = "cacheKeyGenerator")
3.缓存全局配置

RedisCacheConfiguration的配置:

/**
 * @author xxx
 * @version 1.0
 * @description: 缓存配置类
 * @date 2023/8/26 18:18
 */
@Configuration
public class CacheConfig {
    
    // 设置key过期时间为24小时
    private static final Duration DEAFULT_TTL= Duration.ofHours(24);

    /**
     * redis缓存全局配置
     * @return redisCacheConfiguration
     */
    @Bean
    public RedisCacheConfiguration redisCacheConfiguration(){
        // 使用fastJson来序列化数据
        GenericFastJsonRedisSerializer genericFastJsonRedisSerializer = new GenericFastJsonRedisSerializer();
        // 构造RedisCacheConfiguration
        RedisCacheConfiguration  configuration = RedisCacheConfiguration.defaultCacheConfig();
        configuration = configuration
                // 设置cacheNames和key之间默认的双冒号为单冒号
                .computePrefixWith(name -> name + ":")
                // 禁用缓存空值 禁用后如果返回空值会抛出异常,建议搭配@Cacheable的condition、unless等规则使用
                .disableCachingNullValues()
                // 设置value序列化器
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(genericFastJsonRedisSerializer))
                // 设置 key的过期时间(设置的是全局过期时间)
                .entryTtl(DEAFULT_TTL);
//                // 指定前缀
//                .prefixCacheNameWith(REDIS_CACHE_PREFIX);
        return configuration;
    }

SpringBoot Cache Redis过期时间

之前的全局配置 设置 key的过期时间导致所有的key都是相同的过期时间,在业务上来讲不够灵活

如果要自定义缓存的过期时间,网上有两种实现方案:

1、自定义RedisCacheManager

全局设置通过不同的cacheNames来区分设置不同的过期时间

这种方案需要在自定义缓存管理器中进行判断cacheNames,在设置相应的过期时间,

// 参数1:缓存写入是否开启锁
// 参数2:默认缓存配置
// 参数3:已知缓存名称的映射以及用于这些缓存的配置。
 @Bean
    public CacheManager cacheManager(RedisConnectionFactory factory) {
        return new RedisCacheManager(
                RedisCacheWriter.lockingRedisCacheWriter(factory),
                this.getRedisCacheConfigurationWithTtl(1),
                this.getRedisCacheConfigurationMap()
        );
    }
 
    /**
     * 默认失效时间配置
     *
     * @param days 未设置失效事件的key 默认days失效
     */
    private RedisCacheConfiguration getRedisCacheConfigurationWithTtl(Integer days) {
        Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);
        return RedisCacheConfiguration.defaultCacheConfig().serializeValuesWith(
                RedisSerializationContext
                        .SerializationPair
                        .fromSerializer(jackson2JsonRedisSerializer)).entryTtl(Duration.ofDays(days));
    }
 
    public static final String CACHE_NAME_1 = "cache:name1";
    public static final String CACHE_NAME_2 = "cache:name2";
 
    /**
     * 已知缓存名称的映射以及用于这些缓存的配置
     */
    private Map<String, RedisCacheConfiguration> getRedisCacheConfigurationMap() {
        Map<String, RedisCacheConfiguration> redisCacheConfigurationMap = new HashMap<>();
        // 自定义缓存名称对应的配置
        redisCacheConfigurationMap.put(CACHE_NAME_1, this.getRedisCacheConfigurationWithTtl(5));
        redisCacheConfigurationMap.put(CACHE_NAME_2, this.getRedisCacheConfigurationWithTtl(10));
        return redisCacheConfigurationMap;
    }

写法大致如上,在配置中传入缓存名称和过期时间的对应关系,在灵活一些的话可以自定义规则如从cacheNames中设置名称包含过期时间或自自定义cacheNames与过期时间的动态配置类。

但是不可以细分到设置每个key的过期时间,所以有如下第二种办法

2、自定义CacheResolver

在缓存AOP之前执行缓存的处理,增加自定义注解,从注解中传入过期时间

扩展了CacheResolver后,就相当于拦截了 Cache的解析,即能获取到 Cache对象,又能获取到被拦截的Method,这样就可以通过method 的反射 获取到@CacheExpire对象了。这样就能替换掉 RedisCache中的RedisCacheConfiguration 对象了。

自定义注解CacheExpire

/**
 * @author :xxx
 * @date :Created in 2023/8/26
 * @description: 缓存过期时间注解(结合 cacheResolver = "redisExpireCacheResolver" 使用)
 * @version: 1.0.0
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CacheExpire {

    /**
     * 过期时间,默认是24小时
     */
    public long expire() default 24L;

    /**
     * 单位,默认是小时
     */
    public TimeUnit timeUnit() default TimeUnit.HOURS;

}

自定义缓存解析类RedisExpireCacheResolver

/**
 * @author xxx
 * @version 1.0
 * @description: 自定义缓存解析类
 * @date 2023/8/26 19:05
 */
@Slf4j
public class RedisExpireCacheResolver extends SimpleCacheResolver {

    public RedisExpireCacheResolver(CacheManager cacheManager){
        super(cacheManager);
    }

    @Override
    public Collection<? extends Cache> resolveCaches(CacheOperationInvocationContext<?> context) {
        Collection<String> cacheNames = getCacheNames(context);
        if (cacheNames == null) {
            return Collections.emptyList();
        }
        Collection<Cache> result = new ArrayList<>(cacheNames.size());
        for (String cacheName : cacheNames) {
            Cache cache = getCacheManager().getCache(cacheName);
            if (cache == null) {
                throw new IllegalArgumentException("Cannot find cache named '" + cacheName + "' for " + context.getOperation());
            }
            // 获取到缓存对象后,解析并设置过期时间
            parseCacheExpire(cache,context);
            result.add(cache);
        }
        return result;
    }

    /**
     * 解析缓存和上下文对象
     * @param cache
     * @param context
     */
    private void parseCacheExpire(Cache cache,CacheOperationInvocationContext<?> context){
        // 获取方法类
        Method method= context.getMethod();
        // 获取缓存实现类 RedisCache
        RedisCache redisCache=(RedisCache) cache;
        // 方法上是否标注了CacheExpire
        if(AnnotatedElementUtils.isAnnotated(method, CacheExpire.class)){
            // 获取对象
            CacheExpire cacheExpire= AnnotationUtils.getAnnotation(method,CacheExpire.class);
            log.info("redisCache解析,CacheExpire expire:{}, CacheExpire timeUnit:{}",cacheExpire.expire(), cacheExpire.timeUnit());
            Duration duration= Duration.ofMillis(cacheExpire.timeUnit().toMillis(cacheExpire.expire()));
            // 替换RedisCacheConfiguration 对象
            setRedisCacheConfiguration(redisCache,duration);
        } else {
            // 未设置过期时间注解处理
            // ......
        }
    }

    /**
     * 替换RedisCacheConfiguration 对象
     * @param redisCache
     * @param duration
     */
    private void setRedisCacheConfiguration(RedisCache redisCache, Duration duration){
        RedisCacheConfiguration defaultConfiguration=redisCache.getCacheConfiguration();
        RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig();

        // 通过反射查找并修改缓存配置中过期时间的字段
        Field expireField = ReflectionUtils.findField(RedisCacheConfiguration.class, "ttl", Duration.class);
        expireField.setAccessible(true);
        ReflectionUtils.setField(expireField, defaultConfiguration, duration);

    }

}

使用方式

@CacheExpire(expire = 10000L, timeUnit = TimeUnit.MILLISECONDS)
@Cacheable(cacheNames = "cache:xxx_data", keyGenerator = "cacheKeyGenerator", cacheResolver = "redisExpireCacheResolver")

注:存在1个问题,因为RedisCacheConfiguration是全局配置的,通过反射修改会影响所有的过期时间

所以需要都配置cacheResolver、@CacheExpire

3、问题解决

为了方便给每个key设置过期时间,使用第二种方式反射设置RedisCacheConfiguration,如果忘记使用cacheResolver可能就会导致过期时间使用的是上个cacheResolver设置的RedisCacheConfiguration,而非默认值,导致管理混乱。

建议使用第一种自定义RedisCacheManager,规范化让每个cacheName都是相同的过期时间,官方的api的示例也是这样

如果想使用第二方式,则可以针对问题在进行措施解决,如下:

定义切面来进行处理更新RedisCacheConfiguration配置为默认过期时间,这样就算未使用cacheResolver,也会初始化过期时间

/**
 * @author xxx
 * @version 1.0
 * @description: 缓存过期时间切面类
 * @date 2023/8/27 16:00
 */
@Component
@Aspect
@Slf4j
@Order(value = -1)
public class CacheExpireAspect {

    private final RedisCacheConfiguration defaultRedisCacheConfiguration;

    public CacheExpireAspect(RedisCacheConfiguration defaultRedisCacheConfiguration) {
        this.defaultRedisCacheConfiguration = defaultRedisCacheConfiguration;
    }


    @Pointcut("@annotation(com.xxx.common.anno.CacheExpire)")
    private void cacheExpire() {}

    @After("cacheExpire()")
    public void cacheExpireAspectAfter() {
        // 更新RedisCacheConfiguration配置 设置过期时间为24小时
        Field expireField = ReflectionUtils.findField(RedisCacheConfiguration.class, "ttl", Duration.class);
        expireField.setAccessible(true);
        ReflectionUtils.setField(expireField, defaultRedisCacheConfiguration, Duration.ofHours(24));
    }

}

总结

当然,还存在一些问题,比如缓存一致性,缓存双删等策略未实现,后续有完善优化的空间……

Logo

数据库是今天社会发展不可缺少的重要技术,它可以把大量的信息进行有序的存储和管理,为企业的数据处理提供了强大的保障。

更多推荐