在我们的后端项目中的性能瓶颈往往就是IO操作了,用户对数据库的查询往往存在许多重复性的查询,因此有许多针对数据库的查询其实是不必要的,我们可以将查询结果缓存起来,下一次用户想查询同样的内容时就不必再去访问数据库了,而是直接从缓存中获取,这样速度就快很多了。

SpringBoot已经自带了缓存机制,默认用的是ConcurrentMapCacheManager,使用ConcurrentMap来缓存数据,不过这样项目一重启数据就没了,并且不支持设置失效时间,为了更有效地管理缓存,我们可以借助第三方,比如JCache、EhCache、Redis等等,SpringBoot支持我们自定义缓存配置来配置合适的缓存管理器,在我做项目中,我选择了Redis来做缓存,当然,相比如EhCache或者默认的ConcurrentMap这种直接在jvm中缓存的机制,速度相比较起来还是比较慢的,但是也比直接访问数据库来得快,下面就来说说我是怎么配置的。

配置参考了这位大佬的文章,做了些完善和修改https://www.jianshu.com/p/b1d6f0c53362

1、需求

对于项目的缓存,我的需求有如下几点:

  • 通过注解,利用AOP实现缓存管理
  • 自定义缓存key的生成策略,为全限定类名+方法名+方法参数,如com.hzhang.service.impl.CommentServiceImpl.findCommentListByBlogId:19,由于使用的Cacheable注解要求指定缓存区块名,所以最终缓存名前面还加了一句自己指定的“cache::”,存储在redis中的缓存如下所示:
  • 在某些方法上通过注解指定批量删除某些缓存

2、代码实现

代码的实现分为五步:

  • 1、引入依赖
  • 2、编写缓存配置类
  • 3、自定义注解
  • 4、缓存存入的实现
  • 5、缓存删除的实现

2.1、引入依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

2.2、编写Redis缓存配置类

需要注入三个Bean,首先是key的生成策略:

public KeyGenerator keyGenerator() {
        return new KeyGenerator() {
            @Override
            public Object generate(Object o, Method method, Object... params) {
                StringBuilder key = new StringBuilder();
                key.append(method.getDeclaringClass().getName()).append(".").append(method.getName()).append(":");  //先将类的全限定名和方法名拼装在 key 中
                if (params.length == 0) {
                    return key.append("").toString();
                }
                for (Object param : params) {   //通过遍历参数,将参数也拼装在 key 中,保证每次获取key 的唯一性
                    key.append(param.toString());
                }
                return key.toString();
            }
        };
    }

其次是定义redis缓存管理器:

public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
    // 定义缓存配置    
    RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()	// 使用默认配置
                .entryTtl(Duration.ofHours(12));	// 缓存过期时间
        return RedisCacheManager
                .builder(RedisCacheWriter.nonLockingRedisCacheWriter(redisConnectionFactory))
                .cacheDefaults(redisCacheConfiguration).build();
    }

其中,默认配置的规则如下:

缓存过时时间为永不过时,允许缓存空值,使用实际缓存名称做前缀(比如我之后在@Cacheable中指定的前缀cache::),key的序列化采用StringRedisSerializer(只允许序列化字符串),值的序列化使用JdkSerializationRedisSerializer

如此我们就可以将结果序列化之后缓存到Redis中去了,我们读取的话则需要做反序列化(所有Bean都应该实现Serializable接口),Spring中操作Redis用的是RedisTemplate类,如果我们不自己指定序列化规则,这个类默认采用的序列化器将是JdkSerializationRedisSerializer,到时读出来的数据会包含一些奇怪的字符,因此需要配置一下序列化规则,我使用的阿里巴巴的jackson(听说有安全漏洞?github一直提醒我换了。。。也可以使用谷歌的Gson),配置规则如下:

public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) throws UnknownHostException {
        RedisTemplate<String, Object> template = new RedisTemplate();
        template.setConnectionFactory(redisConnectionFactory);
//        配置具体序列化方式
        Jackson2JsonRedisSerializer<Object> objectJackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<Object>(Object.class);
//        使用om格式化输出
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        objectJackson2JsonRedisSerializer.setObjectMapper(om);
//        String的序列化
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
//        key采用String的序列化
        template.setKeySerializer(stringRedisSerializer);
//        hash的key也采用String的序列化
        template.setHashKeySerializer(stringRedisSerializer);
//        value采用jackson
        template.setValueSerializer(objectJackson2JsonRedisSerializer);
//        hash的value也采用jackson
        template.setHashValueSerializer(objectJackson2JsonRedisSerializer);
//        执行afterPropertiesSet方法,让配置设置进去
        template.afterPropertiesSet();

        return template;
    }

完整代码如下:

package com.hzhang.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.interceptor.KeyGenerator;
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.cache.RedisCacheWriter;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.lang.reflect.Method;
import java.net.UnknownHostException;
import java.time.Duration;

/**
 * @author :Hzhang
 * @date :Created in 2020/6/1 16:07
 * @description:redis配置
 * @modified By:
 * @version: $
 */
@Configuration
@EnableCaching
public class RedisConfig extends CachingConfigurerSupport {
    /**
     * 自定义key的生成策略:包名+方法名+方法参数
     *
     * @return
     */
    @Override
    @Bean
    public KeyGenerator keyGenerator() {
        return new KeyGenerator() {
            @Override
            public Object generate(Object o, Method method, Object... params) {
                StringBuilder key = new StringBuilder();
                key.append(method.getDeclaringClass().getName()).append(".").append(method.getName()).append(":");  //先将类的全限定名和方法名拼装在 key 中
                if (params.length == 0) {
                    return key.append("").toString();
                }
                for (Object param : params) {   //通过遍历参数,将参数也拼装在 key 中,保证每次获取key 的唯一性
                    key.append(param.toString());
                }
                return key.toString();
            }
        };
    }

    /**
     * 配置缓存过期时间
     *
     * @param redisConnectionFactory
     * @return
     */
    @Bean
    public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofHours(12));
        return RedisCacheManager
                .builder(RedisCacheWriter.nonLockingRedisCacheWriter(redisConnectionFactory))
                .cacheDefaults(redisCacheConfiguration).build();
    }

    /**
     * 定义序列化方式
     *
     * @param redisConnectionFactory
     * @return
     * @throws UnknownHostException
     */
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) throws UnknownHostException {
        RedisTemplate<String, Object> template = new RedisTemplate();
        template.setConnectionFactory(redisConnectionFactory);
//        配置具体序列化方式
        Jackson2JsonRedisSerializer<Object> objectJackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<Object>(Object.class);
//        使用om格式化输出
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        objectJackson2JsonRedisSerializer.setObjectMapper(om);
//        String的序列化
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
//        key采用String的序列化
        template.setKeySerializer(stringRedisSerializer);
//        hash的key也采用String的序列化
        template.setHashKeySerializer(stringRedisSerializer);
//        value采用jackson
        template.setValueSerializer(objectJackson2JsonRedisSerializer);
//        hash的value也采用jackson
        template.setHashValueSerializer(objectJackson2JsonRedisSerializer);
//        执行afterPropertiesSet方法,让配置设置进去
        template.afterPropertiesSet();

        return template;
    }
}

2.3、自定义注解

接下来定义一个ClearRedisCache注解用以在增删改方法执行后对某些缓存进行删除,由于数据表关联关系的存在,因此还需要指定需要级联删除的缓存:

package com.hzhang.annotation;

import java.lang.annotation.*;

/**
 * @author :Hzhang
 * @date :Created in 2020/6/1 22:13
 * @description:调用方法前清空Redis中对应的缓存
 * @modified By:
 * @version: $
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ClearRedisCache {
    /**
     * 需要级联删除的缓存
     * @return
     */
    Class<?>[] cascade() default {};
}

删除的缓存将为前缀为所在的全限定类名对应的key,以及指定的级联删除的key。

2.4、缓存存入的实现

这里使用的是Spring框架的Cacheable注解,该注解的作用是执行方法前先判断缓存中有没有数据,如果有的话就直接从缓存中取出结果并返回,没有的话再去执行对应的方法,除了这个注解外,还有CachePut、Caching、CacheEvict、CacheConfig这几个常用的缓存相关的注解,到网上搜可以看到很多介绍。

/**
     * 查询首页博客分页列表
     * @param currentPage
     * @param pageSize
     * @return
     */
@Cacheable(cacheNames = "cache")
PageInfo<Blog> findHomeBlogList(Integer currentPage, Integer pageSize);

其中cacheNames指定的就是缓存的前缀名。

2.5、缓存删除的实现

缓存的删除在org.springframework.cache.annotation中有一个CacheEvict就是用来删除指定缓存的,不过只能删除一个确定的key,我们需要的是批量的删除,因此才定义了一个ClearRedisCache注解,将这个注解加在增删改方法上,同时指定需要级联删除的缓存,如下:

/**
     * 根据id删除博客
     * @param id
     */
@ClearRedisCache(cascade = {TagServiceImpl.class, TypeServiceImpl.class})
void deleteBlog(Long id);

在执行deleteBlog方法之后,我们需要清空关于这个BlogService相关的缓存,同时由于Blog与Tag及Type都有关联关系,因此需要做级联删除,这一步通过AOP来实现,实际执行的是***Impl类中的方法,因此需要指定的类也应该是实现类。AOP代码的实现如下:

package com.hzhang.aspect;

import com.hzhang.annotation.ClearRedisCache;
import com.hzhang.utils.RedisUtil;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.stream.Stream;

/**
 * @author :Hzhang
 * @date :Created in 2020/6/1 22:00
 * @description:调用Service层方法前清空缓存
 * @modified By:
 * @version: $
 */
@Component
@Aspect
public class ClearCacheAspect {
    @Autowired
    private RedisUtil redisUtil;

    @Pointcut("execution(* com.hzhang.service.*.*(..))")
    public void clearCache() {
    }

    @Before("clearCache()")
    public void doBefore(JoinPoint joinPoint) {
//        找到被调用的方法并判断是否加上了清空缓存的注解
        Class targetClass = joinPoint.getTarget().getClass();
        boolean isClear = false;
        Method[] methods = targetClass.getMethods();
        Class<?>[] cascade = null;
        for (Method method : methods) {
            if (method.getName().equals(joinPoint.getSignature().getName())) {
                if (AnnotationUtils.findAnnotation(method, ClearRedisCache.class) != null) {
                    ClearRedisCache annotation = AnnotationUtils.findAnnotation(method, ClearRedisCache.class);
                    cascade = annotation.cascade();
                    isClear = true;
                    break;
                }
            }
        }
//        如果需要清空缓存
        if (isClear) {
//            获取key
            String[] key = {"cache::" + joinPoint.getSignature().getDeclaringTypeName() + "*"};
//            需要级联删除的key
            String[] cascadeKeys = Arrays.stream(cascade)
                    .map(cls -> "cache::" + cls.getName() + "*")
                    .toArray(len -> new String[len]);
//            模糊删除对应的key
            Stream.of(key, cascadeKeys)
                    .flatMap(Arrays::stream)
                    .forEach(k -> {
                        String[] keys = redisUtil.keys(k);
                        if (keys != null && keys.length > 0) {
                            redisUtil.del(keys);
                        }
                    });
        }
    }
}

逻辑就是对com.hzhang.service包中的所有方法做切面,在方法执行前先判断有没有加上ClearRedisCache的注解,如果有的话就设置isClear为true,同时获取注解中的casche参数。

接下需要按照key的生成策略构造key,先获取方法本身对应的key,再去获取需要级联删除的key数组,利用flatMap将两个数组对应的stream流拼接成一个stream流,然后从redis中模糊查询出需要删除的key列表,然后进行批量的删除。

这里用到的redisUtil是自定义的对redisTemplate做一层封装的工具类,差不多就是把对redis的操作方法都换成redis的操作命令同名的方法,网上有许多类似的实现,可以再根据自己的需要加入一些命令的封装。

好了,到这里SpringBoot整合Redis缓存的工作就完成啦,我们只需要在需要缓存的方法上加上@Cacheable注解,在需要删除缓存的方法上加上@ClearRedisCache注解就可以了,一般都是在Service层上做。

3、题外话

这里插一个题外话,就是持久层框架使用的是Mybatis,开启了懒加载,然后Redis的序列化就出问题,会报类似这样的错误:

解决方法有两个,第一个就是关闭懒加载,第二个就是在所有实体类上面加上下面这个注解,忽略掉Json序列化的错误。我采用的是加注解的方法,对结果没有什么影响。

@JsonIgnoreProperties(value = {"handler"})