聊聊如何基于spring @Cacheable扩展实现缓存自动过期时间以及即将到期自动刷新

语言: CN / TW / HK

前言

用过spring cache的朋友应该会知道,Spring Cache默认是不支持在@Cacheable上添加过期时间的,虽然可以通过配置缓存容器时统一指定。形如

java @Bean public CacheManager cacheManager( @SuppressWarnings("rawtypes") RedisTemplate redisTemplate) { RedisCacheManager cacheManager= new RedisCacheManager(redisTemplate); cacheManager.setDefaultExpiration(60); Map<String,Long> expiresMap = new HashMap<>(); expiresMap.put("customUser",30L); cacheManager.setExpires(expiresMap); return cacheManager; } 但有时候我们会更习惯通过注解指定过期时间。今天我们就来聊一下如何扩展@Cacheable实现缓存自动过期以及缓存即将到期自动刷新

实现注解缓存过期前置知识

SpringCache包含两个顶级接口,Cache和CacheManager,通过CacheManager可以去管理一堆Cache。因此我们要扩展@Cacheable,就脱离不了对Cache和CacheManager进行扩展

其次要实现过期时间,首先是引入的缓存产品,他本身就要支持过期时间,比如引入的缓存为ConcurrentHashMap,他原本就是不支持过期时间,如果要扩展,就要非常耗费精力实现

实现注解缓存过期

方法一、通过自定义cacheNames方式

形如下

java @Cacheable(cacheNames = "customUser#30", key = "#id") 通过#分隔,#后面部分代表过期时间(单位为秒)

实现逻辑步骤为:

1、自定义缓存管理器并继承RedisCacheManager,同时重写createRedisCache方法

示例:

```java public class CustomizedRedisCacheManager extends RedisCacheManager {

public CustomizedRedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration) {
    super(cacheWriter, defaultCacheConfiguration);
}

@Override
protected RedisCache createRedisCache(String name, RedisCacheConfiguration cacheConfig) {
    String[] array = StringUtils.delimitedListToStringArray(name, "#");
    name = array[0];
    if (array.length > 1) {
        long ttl = Long.parseLong(array[1]);
        cacheConfig = cacheConfig.entryTtl(Duration.ofSeconds(ttl)); 
    }
    return super.createRedisCache(name, cacheConfig);
}

}

```

2、将默认的缓存管理器改成我们自定义的缓存管理器

示例:

```java @EnableCaching @Configuration public class CacheConfig {

@Bean
public CacheManager cacheManager() {
    RedisCacheConfiguration defaultCacheConfig = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofDays(1));

    CustomizedRedisCacheManager redisCacheManager = new CustomizedRedisCacheManager(RedisCacheWriter.nonLockingRedisCacheWriter(redisConnectionFactory()), defaultCacheConfig);
    return redisCacheManager;
}

}

``` 通过如上2个步骤,即可实现缓存过期

方法二:通过自定义派生@Cacheable注解

第一种方法的实现是简单,但缺点是语义不直观,因此得做好宣导以及wiki,不然对于新人来说,他可能都不知道cacheName用#分割是代表啥意思

方法二的实现逻辑步骤如下

1、自定义注解LybGeekCacheable

```java @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Inherited @Documented @Cacheable(cacheManager = CacheConstant.CUSTOM_CACHE_MANAGER,keyGenerator = CacheConstant.CUSTOM_CACHE_KEY_GENERATOR) public @interface LybGeekCacheable {

@AliasFor(annotation = Cacheable.class,attribute = "value")
String[] value() default {};


@AliasFor(annotation = Cacheable.class,attribute = "cacheNames")
String[] cacheNames() default {};


@AliasFor(annotation = Cacheable.class,attribute = "key")
String key() default "";


@AliasFor(annotation = Cacheable.class,attribute = "keyGenerator")
String keyGenerator() default "";


@AliasFor(annotation = Cacheable.class,attribute = "cacheResolver")
String cacheResolver() default "";


@AliasFor(annotation = Cacheable.class,attribute = "condition")
String condition() default "";


@AliasFor(annotation = Cacheable.class,attribute = "unless")
String unless() default "";


@AliasFor(annotation = Cacheable.class,attribute = "sync")
boolean sync() default false;


 long expiredTimeSecond() default 0;


 long preLoadTimeSecond() default 0;

} ```

大部分注解和@Cacheable保持一致,新增expiredTimeSecond缓存过期时间以及缓存自动刷新时间 preLoadTimeSecond

2、自定义缓存管理器并继承RedisCacheManager并重写loadCaches和createRedisCache

```java public class CustomizedRedisCacheManager extends RedisCacheManager implements BeanFactoryAware {

private Map<String, RedisCacheConfiguration> initialCacheConfigurations;

private RedisTemplate cacheRedisTemplate;

private RedisCacheWriter cacheWriter;

private DefaultListableBeanFactory beanFactory;

private RedisCacheConfiguration defaultCacheConfiguration;

protected CachedInvocation cachedInvocation;


public CustomizedRedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration, Map<String, RedisCacheConfiguration> initialCacheConfigurations,RedisTemplate cacheRedisTemplate) {
    super(cacheWriter, defaultCacheConfiguration, initialCacheConfigurations);
    this.initialCacheConfigurations = initialCacheConfigurations;
    this.cacheRedisTemplate = cacheRedisTemplate;
    this.cacheWriter = cacheWriter;
    this.defaultCacheConfiguration = defaultCacheConfiguration;
    //采用spring事件驱动亦可
    //EventBusHelper.register(this);
}

public Map<String, RedisCacheConfiguration> getInitialCacheConfigurations() {
    return initialCacheConfigurations;
}

@Override
protected Collection<RedisCache> loadCaches() {
    List<RedisCache> caches = new LinkedList<>();

    for (Map.Entry<String, RedisCacheConfiguration> entry : getInitialCacheConfigurations().entrySet()) {
        caches.add(createRedisCache(entry.getKey(), entry.getValue()));
    }
    return caches;
}

@Override
public RedisCache createRedisCache(String name, @Nullable RedisCacheConfiguration cacheConfig) {
   CustomizedRedisCache customizedRedisCache = new CustomizedRedisCache(name, cacheWriter, cacheConfig != null ? cacheConfig : defaultCacheConfiguration);
   return customizedRedisCache;
}

} ```

3、在spring bean初始化完成后,设置缓存过期时间,并重新初始化缓存

```java Component @Slf4j public class CacheExpireTimeInit implements SmartInitializingSingleton, BeanFactoryAware {

private DefaultListableBeanFactory beanFactory;

@Override
public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
    this.beanFactory = (DefaultListableBeanFactory)beanFactory;
}

@Override
public void afterSingletonsInstantiated() {
    Map<String, Object> beansWithAnnotation = beanFactory.getBeansWithAnnotation(Component.class);
    if(MapUtil.isNotEmpty(beansWithAnnotation)){
        for (Object cacheValue : beansWithAnnotation.values()) {
            ReflectionUtils.doWithMethods(cacheValue.getClass(), method -> {
                ReflectionUtils.makeAccessible(method);
                boolean cacheAnnotationPresent = method.isAnnotationPresent(LybGeekCacheable.class);
                if(cacheAnnotationPresent){
                     LybGeekCacheable lybGeekCacheable = method.getAnnotation(LybGeekCacheable.class);
                      CacheHelper.initExpireTime(lybGeekCacheable);
                }

            });
        }
        CacheHelper.initializeCaches();
    }
}

```

注: 为啥要重新初始化缓存,主要是为了一开始默认的是没设置缓存过期,重新初始化是为了设置过期时间。为啥调用initializeCaches()这个方法,看下官方描述就知道了

```java /* * Initialize the static configuration of caches. *

Triggered on startup through {@link #afterPropertiesSet()}; * can also be called to re-initialize at runtime. * @since 4.2.2 * @see #loadCaches() / public void initializeCaches() { Collection<? extends Cache> caches = loadCaches();

    synchronized (this.cacheMap) {
        this.cacheNames = Collections.emptySet();
        this.cacheMap.clear();
        Set<String> cacheNames = new LinkedHashSet<>(caches.size());
        for (Cache cache : caches) {
            String name = cache.getName();
            this.cacheMap.put(name, decorateCache(cache));
            cacheNames.add(name);
        }
        this.cacheNames = Collections.unmodifiableSet(cacheNames);
    }
}

``` 他就是在运行的时候,可以重新初始化缓存

4、将默认的缓存管理器改成我们自定义的缓存管理器

```java

@Bean(CacheConstant.CUSTOM_CACHE_MANAGER)
public CacheManager cacheManager(RedisConnectionFactory connectionFactory,RedisTemplate cacheRedisTemplate) {

    RedisCacheWriter redisCacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory);

    RedisCacheConfiguration defaultCacheConfig = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofDays(1));

    Map<String, RedisCacheConfiguration> initialCacheConfiguration = new HashMap<>();

    return new CustomizedRedisCacheManager(redisCacheWriter,defaultCacheConfig,initialCacheConfiguration,cacheRedisTemplate);
}

```

5、测试

```java @LybGeekCacheable(cacheNames = "customUser", key = "#id",expiredTimeSecond = 30) public User getUserFromRedisByCustomAnno(String id){ System.out.println("get user with id by custom anno: 【" + id + "】"); Faker faker = Faker.instance(Locale.CHINA); return User.builder().id(id).username(faker.name().username()).build();

}

```

```java @Test public void testCacheExpiredAndPreFreshByCustom() throws Exception{ System.out.println(userService.getUserFromRedisByCustomAnno("1"));

}

``` 以上就是扩展缓存过期的实现主要方式了,接下来我们来聊一下缓存自动刷新

缓存自动刷新

一般来说,当缓存失效时,请求就会打到后端的数据库上,此时可能就会造成缓存击穿现象。因此我们在缓存即将过期时主动刷新缓存,提高缓存的命中率,进而提高性能。

spring4.3的@Cacheable提供了一个sync属性。当缓存失效后,为了避免多个请求打到数据库,系统做了一个并发控制优化,同时只有一个线程会去数据库取数据其它线程会被阻塞

缓存即将到期自动刷新实现步骤

1、封装缓存注解对象CachedInvocation

```java /* * @description: 标记了缓存注解的方法类信息,用于主动刷新缓存时调用原始方法加载数据 / @Data @AllArgsConstructor @NoArgsConstructor @Builder public final class CachedInvocation {

private CacheMetaData metaData;
private Object targetBean;
private Method targetMethod;
private Object[] arguments;


public Object invoke()
        throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
    final MethodInvoker invoker = new MethodInvoker();
    invoker.setTargetObject(this.getTargetBean());
    invoker.setArguments(this.getArguments());
    invoker.setTargetMethod(this.getTargetMethod().getName());
    invoker.prepare();
    return invoker.invoke();
}

} ```

2、编写一个获取即将到期时间参数切面,并进行事件发布调用对象CachedInvocation

```java @Component @Aspect @Slf4j @Order(2) public class LybGeekCacheablePreLoadAspect {

@Autowired
private ApplicationContext applicationContext;


@SneakyThrows
@Around(value = "@annotation(lybGeekCacheable)")
public Object around(ProceedingJoinPoint proceedingJoinPoint,LybGeekCacheable lybGeekCacheable){
    buildCachedInvocationAndPushlish(proceedingJoinPoint,lybGeekCacheable);
    Object result = proceedingJoinPoint.proceed();
    return result;

}

private void buildCachedInvocationAndPushlish(ProceedingJoinPoint proceedingJoinPoint,LybGeekCacheable lybGeekCacheable){
    Method method = this.getSpecificmethod(proceedingJoinPoint);
    String[] cacheNames = getCacheNames(lybGeekCacheable);
    Object targetBean = proceedingJoinPoint.getTarget();
    Object[] arguments = proceedingJoinPoint.getArgs();
    KeyGenerator keyGenerator = SpringUtil.getBean(CacheConstant.CUSTOM_CACHE_KEY_GENERATOR,KeyGenerator.class);
    Object key = keyGenerator.generate(targetBean, method, arguments);
    CachedInvocation cachedInvocation = CachedInvocation.builder()
            .arguments(arguments)
            .targetBean(targetBean)
            .targetMethod(method)
            .metaData(CacheMetaData.builder()
                    .cacheNames(cacheNames)
                    .key(key)
                    .expiredTimeSecond(lybGeekCacheable.expiredTimeSecond())
                    .preLoadTimeSecond(lybGeekCacheable.preLoadTimeSecond())
                    .build()
            )
            .build();
  //  EventBusHelper.post(cachedInvocation);
    applicationContext.publishEvent(cachedInvocation);
}

```

3、自定义缓存管理器,接收CachedInvocation

示例 ```java public class CustomizedRedisCacheManager extends RedisCacheManager implements BeanFactoryAware {

//@Subscribe
@EventListener
private void doWithCachedInvocationEvent(CachedInvocation cachedInvocation){
    this.cachedInvocation = cachedInvocation;
}

```

4、自定义cache并重写get方法

```java @Slf4j public class CustomizedRedisCache extends RedisCache {

private ReentrantLock lock = new ReentrantLock();

public CustomizedRedisCache(String name, RedisCacheWriter cacheWriter, RedisCacheConfiguration cacheConfig) {
    super(name, cacheWriter,cacheConfig);
}

@Override
@Nullable
public ValueWrapper get(Object key) {
    ValueWrapper valueWrapper = super.get(key);
    CachedInvocation cachedInvocation = CacheHelper.getCacheManager().getCachedInvocation();
    long preLoadTimeSecond = cachedInvocation.getMetaData().getPreLoadTimeSecond();
    if(ObjectUtil.isNotEmpty(valueWrapper) && preLoadTimeSecond > 0){
        String cacheKey = createCacheKey(key);
        RedisTemplate cacheRedisTemplate = CacheHelper.getCacheManager().getCacheRedisTemplate();
        Long ttl = cacheRedisTemplate.getExpire(cacheKey, TimeUnit.SECONDS);
        if(ObjectUtil.isNotEmpty(ttl) && ttl <= preLoadTimeSecond){
            log.info(">>>>>>>>>>> cacheKey:{}, ttl: {},preLoadTimeSecond: {}",cacheKey,ttl,preLoadTimeSecond);
            ThreadPoolUtils.execute(()->{
                 lock.lock();
                 try{
                     CacheHelper.refreshCache(super.getName());
                 }catch (Exception e){
                     log.error("{}",e.getMessage(),e);
                 }finally {
                     lock.unlock();
                 }
            });
        }


    }
    return valueWrapper;
}

} ```

5、缓存即将到期主动刷新缓存方法

```java public static void refreshCache(String cacheName){ boolean isMatchCacheName = isMatchCacheName(cacheName); if(isMatchCacheName){ CachedInvocation cachedInvocation = getCacheManager().getCachedInvocation(); boolean invocationSuccess; Object computed = null; try { computed = cachedInvocation.invoke(); invocationSuccess = true; } catch (Exception ex) { invocationSuccess = false; log.error(">>>>>>>>>>>>>>>>> refresh cache fail",ex.getMessage(),ex); }

        if (invocationSuccess) {
                Cache cache = getCacheManager().getCache(cacheName);
                if(ObjectUtil.isNotEmpty(cache)){
                    Object cacheKey = cachedInvocation.getMetaData().getKey();
                    cache.put(cacheKey, computed);
                    log.info(">>>>>>>>>>>>>>>>>>>> refresh cache with cacheName-->【{}】,key--> 【{}】 finished !",cacheName,cacheKey);
                }
        }
    }

}

```

6、测试

```java @LybGeekCacheable(cacheNames = "customUserName", key = "#username",expiredTimeSecond = 20,preLoadTimeSecond = 15) public User getUserFromRedisByCustomAnnoWithUserName(String username){ System.out.println("get user with username by custom anno: 【" + username + "】"); Faker faker = Faker.instance(Locale.CHINA); return User.builder().id(faker.idNumber().valid()).username(username).build();

}

```

```java @Test public void testCacheExpiredAndPreFreshByCustomWithUserName() throws Exception{ System.out.println(userService.getUserFromRedisByCustomAnnoWithUserName("zhangsan"));

    TimeUnit.SECONDS.sleep(5);

    System.out.println("sleep 5 second :" + userService.getUserFromRedisByCustomAnnoWithUserName("zhangsan"));

    TimeUnit.SECONDS.sleep(10);

    System.out.println("sleep 10 second :" + userService.getUserFromRedisByCustomAnnoWithUserName("zhangsan"));

    TimeUnit.SECONDS.sleep(5);

    System.out.println("sleep 5 second :" + userService.getUserFromRedisByCustomAnnoWithUserName("zhangsan"));

}

```

总结

本文主要介绍了如何基于spring @Cacheable扩展实现缓存自动过期时间以及缓存即将到期自动刷新。

不知道有没有朋友会有疑问,为啥@Cacheable不提供一个ttl属性,毕竟也不是很难。在我看来,spring更多提供的是一个通用的规范和标准,如果定义的缓存,本身不支持ttl,你在@Cacheable里面配置ttl就不合适了,有时候实现一个组件或者框架,考虑的是不是能不能实现,而是有没有必要实现,更多是一种权衡和取舍

最后本文的实现的功能, min.jiang 博主他也有实现了一版,博文链接我贴在下方,感兴趣的朋友,可以查看一下

https://www.cnblogs.com/ASPNET2008/p/6511500.html

demo链接

https://github.com/lyb-geek/springboot-learning/tree/master/springboot-cache