87.SpringCache+Redis应用

220 阅读6分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第23天,点击查看活动详情

简介

Spring Cache 是Spring 提供的一整套的缓存解决方案,它不是具体的缓存实现,它只提供一整套的接口和代码规范、配置、注解等,用于整合各种缓存方案。主流缓存都有对应的实现,这里介绍如何集成redis

每次调用需要缓存功能的方法时,Spring 会检查指定参数的指定的目标方法是否已缓存过执行结果;如果有就直接从缓存中获取方法调用后的结果,如果没有就调用方法并缓存结果后返回给用户,下次调用直接从缓存中获取。

配置

依赖

需要引入spring-boot-starter-cache和redis、jedis相关jar包,因为common工程中已经引入,因此只需要依赖common工程即可

<dependency>
  <groupId>com.anchu.common</groupId>
  <artifactId>wsnb-mall-common</artifactId>
  <version>1.0-SNAPSHOT</version>
</dependency>

nacos配置

spring
	cache
		type:redis
		redis
			time-to-live:3600000  #设置存活时间毫秒
			key-prefix:goods:	#缓存默认前缀
			use-key-prefix:true		#是否启用前缀

CacheManager

缓存管理器CacheManager配置,用于配置缓存的基本行为和属性,配置类位于common工程的com.anchu.mallcommon.config.RedisConfig

@Configuration
@EnableCaching//开启缓存
@EnableConfigurationProperties(CacheProperties.class)
public class RedisConfig extends CachingConfigurerSupport {

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory connectionFactory, CacheProperties cacheProperties) {
        //初始化一个RedisCacheWriter
        RedisCacheWriter redisCacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory);
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
        // 设置缓存key的序列化方式
        config =
            config.serializeKeysWith(
                RedisSerializationContext.SerializationPair.fromSerializer(
                    new StringRedisSerializer()));
        // 设置缓存value的序列化方式(JSON格式)
        ObjectMapper objectMapper = new ObjectMapper();
        // 设置序列化对象类名方式
        objectMapper.activateDefaultTyping(objectMapper.getPolymorphicTypeValidator(), ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
        // 不序列化对象的null属性
        objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        // 忽略字段映射异常
        objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

        config =
            config.serializeValuesWith(
                RedisSerializationContext.SerializationPair.fromSerializer(
                    new GenericJackson2JsonRedisSerializer(objectMapper)));
        // 读取spring.cache下的配置
        CacheProperties.Redis redisProperties = cacheProperties.getRedis();
        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 new CustomRedisCacheManager(redisCacheWriter, config);
    }

    …………
}

自定义过期时间

SpringCache本身是一个顶级接口,而过期时间并不是所有缓存框架都支持,所以集成redis后,如果想给每个接口缓存设置不同的过期时间,需要通过重写RedisCacheManager类的createRedisCache方法实现

public class CustomRedisCacheManager extends RedisCacheManager {

    public CustomRedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration) {
        super(cacheWriter, defaultCacheConfiguration);
    }

    /**
     * 重写createRedisCache方法
     * @param name 原来的name只是作为redis存储键名
     *             重写的name可通过"#"拼接过期时间:
     *             1. 如果没有"#"则使用默认过期时间
     *             2. 拼接的第一个"#"后面为过期时间,第二个"#"后面为时间单位
     *             3. 时间单位的表示使用: d(天)、h(小时)、m(分钟)、s(秒), 默认为毫秒
     * @param cacheConfig
     * @return
     */
    @Override
    protected RedisCache createRedisCache(String name, RedisCacheConfiguration cacheConfig) {
        // 解析name,设置过期时间
        if (StringUtils.isNotEmpty(name) && name.contains("#")) {
            String[] split = name.split("#");

            // 缓存键名
            String cacheName = split[0];
            // "#"后第一位是时间
            int expire = Integer.parseInt(split[1]);
            // 过期时间,默认为ms
            Duration duration = Duration.ofMillis(expire);
            // 根据"#"后第二位字符判断过期时间的单位,设置相应的过期时间,默认时间单位是ms
            if (split.length == 3) {
                switch (split[2]){
                    case "s":
                        duration = Duration.ofSeconds(expire);
                        break;
                    case "m":
                        duration = Duration.ofMinutes(expire);
                        break;
                    case "h":
                        duration = Duration.ofHours(expire);
                        break;
                    case "d":
                        duration = Duration.ofDays(expire);
                        break;
                    default:
                        duration = Duration.ofMillis(expire);
                }
            }
            return super.createRedisCache(cacheName, cacheConfig.entryTtl(duration));
        }
        return super.createRedisCache(name, cacheConfig);
    }
}

异常处理

先看下SpringCache默认的异常处理机制

public interface CachingConfigurer {

    /**
    * Return the {@link CacheErrorHandler} to use to handle cache-related errors.
    * <p>By default,{@link org.springframework.cache.interceptor.SimpleCacheErrorHandler}
    * is used and simply throws the exception back at the client.
    …………
    */
    @Nullable
    default CacheErrorHandler errorHandler() {
        return null;
    }
}

注解上说明了默认使用SimpleCacheErrorHandler,会直接将异常抛回给客户端,所以假如redis连接出现问题或人为修改缓存导致的序列化失败,都会导致接口异常。通过继承CachingConfigurerSupport并重写errorHandler()方法,可以自定义异常处理机制,目前如果SpringCache出现异常,会打印日志并忽略异常,继续执行方法,当然,这样的日志需要重点监控,因为这意味着请求都没有走缓存

@Configuration
@EnableCaching//开启缓存
@EnableConfigurationProperties(CacheProperties.class)
public class RedisConfig extends CachingConfigurerSupport {
/**  忽略 **/
	@Override
    public CacheErrorHandler errorHandler() {
        return new CacheErrorHandler() {
            @Override
            public void handleCachePutError(RuntimeException exception, Cache cache,
                                            Object key, Object value) {
                ignoreRedisException(key, exception);
            }

            @Override
            public void handleCacheGetError(RuntimeException exception, Cache cache,
                                            Object key) {
                ignoreRedisException(key, exception);
            }

            @Override
            public void handleCacheEvictError(RuntimeException exception, Cache cache,
                                              Object key) {
                ignoreRedisException(key, exception);
            }

            @Override
            public void handleCacheClearError(RuntimeException exception, Cache cache) {
                ignoreRedisException(null, exception);
            }

            //打印错误日志,忽略异常继续执行
            private void ignoreRedisException(Object key, Exception e){
                LogHelper.error(String.format("redis operate error key:%s", key), e);
            }
        };
    }
/**  忽略 **/
}

使用示例

设置缓存

以原本的商品详情方法为例,原代码如下

public static final String GOOD_DETAIL_INFO = "GOOD_DETAIL_INFO_%s";

@Override
public TradeGoodsDetailAppDto goodsBaseInfo(GoodsDetailDto goodsDetailDto) {
    String key = String.format(GoodInfoRedisConstants.GOOD_DETAIL_INFO,goodsDetailDto.getId());
    Object o = redisService.get(key);
    if (o != null) {
        return JSONObject.parseObject(o.toString(),TradeGoodsDetailAppDto.class);
    }

    TradeGoodsDetailAppDto goodsDetail = null;
    try {
        //商品基本信息查询
        goodsDetail = buildGoodsBaseDetail(goodsDetailDto);
        //商品详情数据查询
        goodsQueryService.getGoodsBaseDetail(goodsDetail);
        //组装返回参数
        buildGoodsDetail(goodsDetail, goodsDetailDto.getIsWholesale());

    } catch (Exception e) {
        LogHelper.error("查询商详内容异常id:{}" + goodsDetailDto.getId(), e);
    }
    if (ObjectUtil.isNotNull(goodsDetail)) {
        redisService.set(key, com.alibaba.fastjson.JSON.toJSONString(goodsDetail),GoodInfoRedisConstants.GOOD_DETAIL_INFO_TIME, TimeUnit.SECONDS);
    }
    return goodsDetail;
}

使用SpringCache注解后

public static final String GOOD_DETAIL_INFO = "'GOOD_DETAIL_INFO_'";

@Override
@Cacheable(value = GoodInfoRedisConstants.GOOD_DETAIL_INFO + "#1#m", key = GoodInfoRedisConstants.GOOD_DETAIL_INFO + "+#goodsDetailDto.id")
public TradeGoodsDetailAppDto goodsBaseInfo(GoodsDetailDto goodsDetailDto) {
    TradeGoodsDetailAppDto goodsDetail = null;
    try {
        //商品基本信息查询
        goodsDetail = buildGoodsBaseDetail(goodsDetailDto);
        //商品详情数据查询
        goodsQueryService.getGoodsBaseDetail(goodsDetail);
        //组装返回参数
        buildGoodsDetail(goodsDetail, goodsDetailDto.getIsWholesale());

    } catch (Exception e) {
        LogHelper.error("查询商详内容异常id:{}" + goodsDetailDto.getId(), e);
    }
    return goodsDetail;
}

@Cacheabl注解中的value代表的是在spring缓存管理中存储的键值,因为需要支持自定义过期时间,所以对原本的value进行了扩展,第一个#后表示时间,第二个#后表示时间单位,具体可参考上面的自定义过期时间配置,如果value中没有设置过期时间,默认读取配置spring.cache.redis.time-to-live设置的过期时间,单位是ms

key的值是存储到redis中的键值,key是通过spel表达式拼接的,所以当使用常量时,需要把常量值用单引号包裹,否则会被当成变量名进行解析

清除缓存

当商品编辑/删除/审核时,需要清除对应商品详情的缓存,只需要在对应的方法上加上@CacheEvict注解即可

@Override
@Transactional(rollbackFor = {Exception.class})
@CacheEvict(value = GoodInfoRedisConstants.GOOD_DETAIL_INFO, key = GoodInfoRedisConstants.GOOD_DETAIL_INFO + "+#tradeGoodsForAddDto.tradeGoodsDto.id")
public Long editNew(TradeGoodsForAddDto tradeGoodsForAddDto) {
	// 内容忽略
}

注意value的值需要和@Cacheable注解中的value相同,如果需要删除对应value的全部缓存,则不需要指定key,设置属性allEntries=true即可,默认在方法执行完之后删除缓存,如果需要在方法执行前就删除缓存,设置属性beforeInvocation=true即可

常见缓存问题解决方案

缓存穿透

SpringCache只支持简单的缓存null值的方式解决缓存穿透,通过配置spring.cache.redis.cache-null-values=true,当接口返回为null时也会回种到缓存。目前这个值默认设置为true,如果不想把null值设置到缓存中,可以设置@Cacheable注解中的属性 unless = "#result==null"

缓存雪崩

目前没有处理,如果有大批量同时设置缓存的场景,还是一样需要通过重写RedisCacheManager类的createRedisCache方法,在原过期时间上追加随机值实现

缓存击穿

在@Cacheable注解中可以设置属性sync=true,这样如果缓存失效时有多个线程请求执行方法,只会有一个线程对方法进行执行,其他线程会被阻塞直到缓存可用。当然这是对单机的限流,以目前的集群规模设置这个属性就可以了,如果想对整个集群限流,可以扩展CacheInterceptor类,在里面加个分布式锁