Reposted spring @Cacheable extension to implement automatic cache expiration time and automatic refresh

Reprinted from: Let’s talk about how to implement automatic cache expiration time and automatic refresh based on spring @Cacheable extension-Tencent Cloud Developer Community-Tencent Cloud (tencent.com)

Foreword

Friends who have used spring cache will know that Spring Cache does not support adding expiration time to @Cacheable by default, although it can be specified uniformly when configuring the cache container. Shaped like

@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;
}

copy

But sometimes we are more accustomed to specifying the expiration time through annotations. Today we will talk about how to extend @Cacheable to realize automatic cache expiration and automatic refresh of cache when it is about to expire.

2

Implementing annotation cache expiration pre-knowledge

SpringCache contains two top-level interfaces, Cache and CacheManager. CacheManager can manage a bunch of Cache. Therefore, if we want to extend @Cacheable, we cannot do without extending Cache and CacheManager.

Secondly, the expiration time must be implemented. First, the cache product introduced must support expiration time. For example, the cache introduced is ConcurrentHashMap, which does not originally support expiration time. If it is to be expanded, it will take a lot of effort to implement.

3

Implementing annotation cache expiration

01

Method 1: By customizing cacheNames

The shape is as follows

@Cacheable(cacheNames = "customUser#30", key = "#id")

copy

Separated by #, the part after # represents the expiration time (in seconds)

The logical steps to implement are:

1. Customize the cache manager and inherit RedisCacheManager, while overriding the createRedisCache method

Example:

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);
    }
}

copy

2. Change the default cache manager to our custom cache manager

Example:

@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;
    }

}

copy

Cache expiration can be achieved through the above 2 steps.

02

Method 2: Derive the @Cacheable annotation through customization

The implementation of the first method is simple, but the disadvantage is that the semantics are not intuitive, so publicity and wiki must be done well, otherwise for newcomers, they may not know what it means to separate the cacheName with #

The logical steps to implement method 2 are as follows

1. Custom annotation LybGeekCacheable

@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;


}

copy

Most annotations are consistent with @Cacheable, with the addition of expiredTimeSecond cache expiration time and cache automatic refresh time preLoadTimeSecond

2. Customize the cache manager and inherit RedisCacheManager and override loadCaches and createRedisCache

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;
        //You can also use spring event driver
        //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;
    }

}

copy

3. After the spring bean initialization is completed, set the cache expiration time and re-initialize the cache.

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();
        }
    }

copy

Note: The reason why the cache needs to be re-initialized is mainly because the default cache expiration is not set at the beginning, and re-initialization is to set the expiration time. Why is the initializeCaches() method called? Just read the official description.

/**
   * Initialize the static configuration of caches.
   * <p>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);
    }
  }

copy

It can reinitialize the cache when it is running.

4. Change the default cache manager to our custom cache manager

@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);
    }

copy

5. Test

@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();

    }

copy

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

    }

copy

The above are the main ways to implement extended cache expiration. Next, let’s talk about automatic cache refresh.

4

Automatic cache refresh

Generally speaking, when the cache fails, the request will be hit to the back-end database, which may cause cache breakdown. Therefore, we actively refresh the cache when the cache is about to expire to improve the cache hit rate and thereby improve performance.

Spring4.3’s @Cacheable provides a sync attribute. When the cache becomes invalid, in order to prevent multiple requests from hitting the database, the system performs a concurrency control optimization. At the same time, only one thread will go to the database to retrieve data and other threads will be blocked.

5

The cache is about to expire and automatically refresh

1. Encapsulate cache annotation object CachedInvocation

/**
 * @description: Method class information marked with cache annotations, used to call the original method to load data when actively refreshing the cache
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public final class CachedInvocation {

    private CacheMetaData metaData;
    privateObject 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();
    }


}

copy

2. Write an aspect to obtain the parameters of the upcoming expiration time, and publish the event to call the object CachedInvocation.

@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);
    }

copy

3. Customize the cache manager and receive CachedInvocation

Example

public class CustomizedRedisCacheManager extends RedisCacheManager implements BeanFactoryAware {

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

copy

4. Customize the cache and rewrite the get method

@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) & amp; & amp; preLoadTimeSecond > 0){
            String cacheKey = createCacheKey(key);
            RedisTemplate cacheRedisTemplate = CacheHelper.getCacheManager().getCacheRedisTemplate();
            Long ttl = cacheRedisTemplate.getExpire(cacheKey, TimeUnit.SECONDS);
            if(ObjectUtil.isNotEmpty(ttl) & amp; & amp; 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;
    }




}

copy

5. How to proactively refresh the cache when the cache is about to expire

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);
                    }
            }
        }

    }

copy

6. Test

@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();

    }

copy

@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"));

    }

copy

6

Summary

This article mainly introduces how to implement the automatic expiration time of the cache and the automatic refresh of the cache when it is about to expire based on the spring @Cacheable extension.

I don’t know if any friends will have questions about why @Cacheable does not provide a ttl attribute. After all, it is not difficult. In my opinion, spring provides more general specifications and standards. If the cache defined does not support ttl, it is inappropriate for you to configure ttl in @Cacheable. Sometimes when implementing a component or framework, consider Whether it can be realized or not, but whether it is necessary to realize it, is more of a trade-off and trade-off.