這篇文章是對上一篇 spring-data-redis-cache 的使用 的一個補充,上文說到 spring-data-redis-cache 雖然比較強悍,但還是有些不足的,它是一個通用的解決方案,但對於企業級的項目,住住需要解決更多的問題,常見的問題有
- 緩存預熱(項目啟動時加載緩存)
- 緩存穿透(空值直接穿過緩存)
- 緩存雪崩(大量緩存在同一時刻過期)
- 緩存更新(查詢到的數據為舊數據問題)
- 緩存降級
- redis 緩存時,redis 內存用量問題
本文解決的問題
增強 spring-data-redis-cache 的功能,增強的功能如下
- 自定義注解實現配置緩存的過期時間
- 當取緩存數據時檢測是否已經達到刷新數據閥值,如已達到,則主動刷新緩存
- 當檢測到存入的數據為空數據,包含集體空,map 空,空對象,空串,空數組時,設定特定的過期時間
- 可以批量設置過期時間,使用 Kryo 值序列化
- 重寫了 key 生成策略,使用 MD5(target+method+params)
看網上大部分文章都是互相抄襲,而且都是舊版本的,有時還有錯誤,本文提供一個 spring-data-redis-2.0.10.RELEASE.jar 版本的解決方案。本文代碼是經過測試的,但未在線上環境驗證,使用時需注意可能存在 bug 。
實現思路
過期時間的配置很簡單,修改 initialCacheConfiguration
就可以實現,下面說的是刷新緩存的實現
- 攔截
@Cacheable
注解,如果執行的方法是需要刷新緩存的,則注冊一個MethodInvoker
存儲到 redis ,使用和存儲 key 相同的鍵名再拼接一個后綴 - 當取緩存的時候,如果 key 的過期時間達到了刷新閥值,則從 redis 取到當前 cacheKey 的
MethodInvoker
然后執行方法 - 將上一步的值存儲進緩存,並重置過期時間
引言
本文使用到的 spring 的一些方法的說明
// 可以從目標對象獲取到真實的 class 對象,而不是代理 class 類對象
Class<?> targetClass = AopProxyUtils.ultimateTargetClass(target);
Object bean = applicationContext.getBean(targetClass);
// 獲取到真實的對象,而不是代理對象
Object target = AopProxyUtils.getSingletonTarget(bean );
MethodInvoker 是 spring 封裝的一個用於執行方法的工具,在攔截器中,我把它序列化到 redis
MethodInvoker methodInvoker = new MethodInvoker();
methodInvoker.setTargetClass(targetClass);
methodInvoker.setTargetMethod(method.getName());
methodInvoker.setArguments(args);
SpringCacheAnnotationParser 是 Spring 用來解析 cache 相關注解的,我拿來解析 cacheNames ,我就不需要自己來解析 cacheNames 了,畢竟它可以在類上配置,解析還是有點小麻煩。
SpringCacheAnnotationParser annotationParser = new SpringCacheAnnotationParser();
實現部分
自定義注解,配置過期時間和刷新閥值
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD,ElementType.TYPE})
public @interface CacheCustom {
/**
* 緩存失效時間
* 使用 ISO-8601持續時間格式
* Examples:
* <pre>
* "PT20.345S" -- parses as "20.345 seconds"
* "PT15M" -- parses as "15 minutes" (where a minute is 60 seconds)
* "PT10H" -- parses as "10 hours" (where an hour is 3600 seconds)
* "P2D" -- parses as "2 days" (where a day is 24 hours or 86400 seconds)
* "P2DT3H4M" -- parses as "2 days, 3 hours and 4 minutes"
* "P-6H3M" -- parses as "-6 hours and +3 minutes"
* "-P6H3M" -- parses as "-6 hours and -3 minutes"
* "-P-6H+3M" -- parses as "+6 hours and -3 minutes"
* </pre>
* @return
*/
String expire() default "PT60s";
/**
* 刷新時間閥值,不配置將不會進行緩存刷新
* 對於像前端的分頁條件查詢,建議不配置,這將在內存生成一個執行映射,太多的話將會占用太多的內存使用空間
* 此功能適用於像字典那種需要定時刷新緩存的功能
* @return
*/
String threshold() default "";
/**
* 值的序列化方式
* @return
*/
Class<? extends RedisSerializer> valueSerializer() default KryoRedisSerializer.class;
}
創建一個 aop 切面,將執行器存儲到 redis
@Aspect
@Component
public class CacheCustomAspect {
@Autowired
private KeyGenerator keyGenerator;
@Pointcut("@annotation(com.sanri.test.testcache.configs.CacheCustom)")
public void pointCut(){}
public static final String INVOCATION_CACHE_KEY_SUFFIX = ":invocation_cache_key_suffix";
@Autowired
private RedisTemplate redisTemplate;
@Before("pointCut()")
public void registerInvoke(JoinPoint joinPoint){
Object[] args = joinPoint.getArgs();
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Method method = methodSignature.getMethod();
Object target = joinPoint.getTarget();
Object cacheKey = keyGenerator.generate(target, method, args);
String methodInvokeKey = cacheKey + INVOCATION_CACHE_KEY_SUFFIX;
if(redisTemplate.hasKey(methodInvokeKey)){
return ;
}
// 將方法執行器寫入 redis ,然后需要刷新的時候從 redis 獲取執行器,根據 cacheKey ,然后刷新緩存
Class<?> targetClass = AopProxyUtils.ultimateTargetClass(target);
MethodInvoker methodInvoker = new MethodInvoker();
methodInvoker.setTargetClass(targetClass);
methodInvoker.setTargetMethod(method.getName());
methodInvoker.setArguments(args);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new KryoRedisSerializer());
redisTemplate.opsForValue().set(methodInvokeKey,methodInvoker);
}
}
重寫 RedisCache 的 get 方法,在獲取緩存的時候查看它的過期時間,如果小於刷新閥值,則另啟線程進行刷新,這里需要考慮並發問題,目前我是同步刷新的。
@Override
public ValueWrapper get(Object cacheKey) {
if(cacheCustomOperation == null){return super.get(cacheKey);}
Duration threshold = cacheCustomOperation.getThreshold();
if(threshold == null){
// 如果不需要刷新,直接取值
return super.get(cacheKey);
}
//判斷是否需要刷新
Long expire = redisTemplate.getExpire(cacheKey);
if(expire != -2 && expire < threshold.getSeconds()){
log.info("當前剩余過期時間["+expire+"]小於刷新閥值["+threshold.getSeconds()+"],刷新緩存:"+cacheKey+",在 cacheNmae為 :"+this.getName());
synchronized (CustomRedisCache.class) {
refreshCache(cacheKey.toString(), threshold);
}
}
return super.get(cacheKey);
}
/**
* 刷新緩存
* @param cacheKey
* @param threshold
* @return
*/
private void refreshCache(String cacheKey, Duration threshold) {
String methodInvokeKey = cacheKey + CacheCustomAspect.INVOCATION_CACHE_KEY_SUFFIX;
MethodInvoker methodInvoker = (MethodInvoker) redisTemplate.opsForValue().get(methodInvokeKey);
if(methodInvoker != null){
Class<?> targetClass = methodInvoker.getTargetClass();
Object target = AopProxyUtils.getSingletonTarget(applicationContext.getBean(targetClass));
methodInvoker.setTargetObject(target);
try {
methodInvoker.prepare();
Object invoke = methodInvoker.invoke();
//然后設置進緩存和重新設置過期時間
this.put(cacheKey,invoke);
long ttl = threshold.toMillis();
redisTemplate.expire(cacheKey,ttl, TimeUnit.MILLISECONDS);
} catch (InvocationTargetException | IllegalAccessException | ClassNotFoundException | NoSuchMethodException e) {
log.error("刷新緩存失敗:"+e.getMessage(),e);
}
}
}
最后重寫 RedisCacheManager 把自定義的 RedisCache 交由其管理
@Override
public Cache getCache(String cacheName) {
CacheCustomOperation cacheCustomOperation = cacheCustomOperationMap.get(cacheName);
RedisCacheConfiguration redisCacheConfiguration = initialCacheConfiguration.get(cacheName);
if(redisCacheConfiguration == null){redisCacheConfiguration = defaultCacheConfiguration;}
CustomRedisCache customRedisCache = new CustomRedisCache(cacheName,cacheWriter,redisCacheConfiguration, redisTemplate, applicationContext, cacheCustomOperation);
customRedisCache.setEmptyKeyExpire(this.emptyKeyExpire);
return customRedisCache;
}
說明:本文只是截取關鍵部分代碼,完整的代碼在 gitee 上
其它說明
由於 key 使用了 md5 生成,一串亂碼也不知道存儲的什么方法,這里提供一種解決方案,可以對有刷新時間的 key 取到其對應的方法。其實就是我在攔截器中有把當前方法的執行信息存儲進 redis ,是對應那個 key 的,可以進行反序列化解析出執行類和方法信息。
一點小推廣
創作不易,希望可以支持下我的開源軟件,及我的小工具,歡迎來 gitee 點星,fork ,提 bug 。
Excel 通用導入導出,支持 Excel 公式
博客地址:https://blog.csdn.net/sanri1993/article/details/100601578
gitee:https://gitee.com/sanri/sanri-excel-poi
使用模板代碼 ,從數據庫生成代碼 ,及一些項目中經常可以用到的小工具
博客地址:https://blog.csdn.net/sanri1993/article/details/98664034
gitee:https://gitee.com/sanri/sanri-tools-maven