zoukankan      html  css  js  c++  java
  • 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-maven

  • 相关阅读:
    C# 中的本地函数
    C# 9.0 正式发布了(C# 9.0 on the record)
    如何禁用控制台窗口的关闭按钮?
    在 WSL Ubuntu 上使用 .NET 进行跨平台开发新手入门
    C# 中 ConcurrentDictionary 一定线程安全吗?
    Docker 与 Podman 容器管理的比较
    C# 中的数字分隔符 _
    C# 8: 可变结构体中的只读实例成员
    C# 中的只读结构体(readonly struct)
    C# 8: 默认接口方法
  • 原文地址:https://www.cnblogs.com/sanri1993/p/11702753.html
Copyright © 2011-2022 走看看