Master Multi‑Level Caching in Spring Boot 3 with Custom Annotations

Learn how to boost Spring Boot 3 application performance by implementing a multi‑level cache that combines local and Redis storage, using custom @MultiLevelCache annotations and AOP aspects, complete with cache clearing mechanisms, code examples, and testing instructions.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Master Multi‑Level Caching in Spring Boot 3 with Custom Annotations

1. Introduction

Cache mechanism is essential for improving system performance and reducing database load. This article demonstrates a multi‑level cache that combines local in‑memory cache and Redis, simplified by a custom @MultiLevelCache annotation.

2. Practical Example

2.1 Custom Annotation

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MultiLevelCache {
    // cache key expression (SpEL)
    String key() default "";
    // local cache TTL (seconds)
    long localTtl() default 30;
    // Redis cache TTL (seconds)
    long redisTtl() default 300;
    // whether to cache null values
    boolean cacheNull() default true;
    // null value cache TTL (seconds)
    long nullTtl() default 60;
}

2.2 Multi‑Level Cache Aspect

@Aspect
@Component
public class MultiLevelCacheAspect {
    private static final Logger logger = LoggerFactory.getLogger(MultiLevelCache.class);
    private final Cache<String, Object> localCache;
    private final StringRedisTemplate redisTemplate;
    private final RedissonClient redissonClient;
    private final ObjectMapper objectMapper;
    private static final String NULL_VALUE = "";

    public MultiLevelCacheAspect(Cache<String, Object> localCache,
                                StringRedisTemplate redisTemplate,
                                RedissonClient redissonClient,
                                ObjectMapper objectMapper) {
        this.localCache = localCache;
        this.redisTemplate = redisTemplate;
        this.redissonClient = redissonClient;
        this.objectMapper = objectMapper;
    }

    @Around("@annotation(multiLevelCache)")
    public Object cacheAround(ProceedingJoinPoint pjp, MultiLevelCache multiLevelCache) throws Throwable {
        MethodSignature signature = (MethodSignature) pjp.getSignature();
        String cacheKey = generateCacheKey(signature, pjp.getArgs(), multiLevelCache.key());

        // 1. Local cache
        Object result = getFromLocalCache(cacheKey);
        if (result != null) {
            logger.info("Local cache hit: {}", cacheKey);
            return convertNullValue(result);
        }

        // 2. Redis cache
        result = getFromRedis(cacheKey);
        if (result != null) {
            logger.info("Redis cache hit: {}", cacheKey);
            if (NULL_VALUE.equals(result)) {
                return null;
            }
            result = objectMapper.readValue(result.toString(), signature.getReturnType());
            localCache.put(cacheKey, result);
            return convertNullValue(result);
        }

        // 3. Distributed lock
        RLock lock = redissonClient.getLock(cacheKey + ":lock");
        try {
            if (lock.tryLock()) {
                // double‑check cache
                Object localFallback = localCache.getIfPresent(cacheKey);
                if (localFallback != null) {
                    return convertNullValue(localFallback);
                }
                result = getFromRedis(cacheKey);
                if (result != null) {
                    return convertNullValue(result);
                }

                // 4. Execute target method (DB query)
                logger.info("Querying database");
                result = pjp.proceed();

                // 5. Update caches
                cacheResult(cacheKey, result, multiLevelCache);
                return result;
            } else {
                return getFallbackValue(signature.getReturnType());
            }
        } finally {
            lock.unlock();
        }
    }

    // Helper methods (generateCacheKey, getFromLocalCache, getFromRedis,
    // cacheResult, convertNullValue, getFallbackValue) omitted for brevity
}

2.3 Business Code and Test Interface

@MultiLevelCache(key = "#id", localTtl = 10, redisTtl = 60, cacheNull = true, nullTtl = 30)
public Product query(Long id) {
    int t = new Random().nextInt(10000);
    return new Product(id, "Spring Boot3 Practical Cache Example - " + t,
                       BigDecimal.valueOf(t));
}
@GetMapping("/{id}")
public ResponseEntity<Product> query(@PathVariable Long id) {
    return ResponseEntity.ok(productService.query(id));
}

Running the above endpoints shows that data is first retrieved from the local cache, then from Redis after the local entry expires.

2.4 Cache Clearing

Custom Annotation

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CacheClear {
    // cache key expression (SpEL)
    String key() default "";
}

Cache‑Clear Aspect

@Aspect
@Component
@Order(Ordered.LOWEST_PRECEDENCE)
public class CacheClearAspect {
    private static final Logger logger = LoggerFactory.getLogger(CacheClearAspect.class);
    private final Cache<String, Object> localCache;
    private final StringRedisTemplate redisTemplate;

    public CacheClearAspect(Cache<String, Object> localCache,
                           StringRedisTemplate redisTemplate) {
        this.localCache = localCache;
        this.redisTemplate = redisTemplate;
    }

    @Around("@annotation(cacheClear)")
    public Object cacheClearAround(ProceedingJoinPoint pjp, CacheClear cacheClear) throws Throwable {
        MethodSignature signature = (MethodSignature) pjp.getSignature();
        String cacheKey = generateCacheKey(signature, pjp.getArgs(), cacheClear.key());
        Object ret = pjp.proceed();
        if (TransactionSynchronizationManager.isActualTransactionActive()) {
            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
                @Override
                public void afterCommit() {
                    clearCache(cacheKey);
                }
            });
        } else {
            clearCache(cacheKey);
        }
        return ret;
    }

    private void clearCache(String cacheKey) {
        try {
            localCache.invalidate(cacheKey);
            redisTemplate.delete(cacheKey);
        } catch (Exception e) {
            logger.error("Cache clear failed: {}", cacheKey, e);
        }
    }

    // generateCacheKey method omitted for brevity
}

Example usage:

@Transactional
@CacheClear(key = "#product.id")
public void update(Product product) {
    // update logic
}
@GetMapping("/update/{id}")
public ResponseEntity<Object> update(@PathVariable Long id) {
    productService.update(new Product(id));
    return ResponseEntity.ok("success");
}

Console logs confirm that the cache is cleared after the update operation.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaCacheaopredisSpring Boot
Spring Full-Stack Practical Cases
Written by

Spring Full-Stack Practical Cases

Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.