欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

springboot redis-cache 自动刷新缓存

程序员文章站 2023-10-28 22:24:52
这篇文章是对上一篇 "spring data 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 公式
博客地址:
gitee:

使用模板代码 ,从数据库生成代码 ,及一些项目中经常可以用到的小工具
博客地址:
gitee: