开启掘金成长之旅!这是我参与「掘金日新计划 · 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类,在里面加个分布式锁