Backend Development 10 min read

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

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

2.2 Multi‑Level Cache Aspect

<code>@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
}
</code>

2.3 Business Code and Test Interface

<code>@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));
}
</code>
<code>@GetMapping("/{id}")
public ResponseEntity<Product> query(@PathVariable Long id) {
    return ResponseEntity.ok(productService.query(id));
}
</code>

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

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

Cache‑Clear Aspect

<code>@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
}
</code>

Example usage:

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

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

JavaCacheAOPRedisSpring BootAnnotations
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

login 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.