1. 程式人生 > >springboot redis-cache 自動重新整理快取

springboot redis-cache 自動重新整理快取

這篇文章是對上一篇 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 就可以實現,下面說的是重新整理快取的實現

  1. 攔截 @Cacheable 註解,如果執行的方法是需要重新整理快取的,則註冊一個 MethodInvoker 儲存到 redis ,使用和儲存 key 相同的鍵名再拼接一個字尾
  2. 當取快取的時候,如果 key 的過期時間達到了重新整理閥值,則從 redis 取到當前 cacheKey 的 MethodInvoker 然後執行方法
  3. 將上一步的值儲存進快取,並重置過期時間

引言

本文使用到的 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-ma