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.