Leveraging Spring Cache Abstraction for Efficient Data Caching

When architecting educational products, a common requirement is to enhance data retrieval efficiency. A suggestion arose to integrate our team's encapsulated Redis client with Spring Cache, significantly simplifying caching mechanisms.

This exploration dives into the design principles and practical application of Spring Cache, demonstrating its elegance and utility.

Manual Caching vs. Spring Cache Abstraction

Traditional Hardcoded Caching

Previously, caching was often implemented through hardcoded logic. For instance, to optimize user information retrieval, a typical approach might look like this:

@Autowired
private UserMapper userMapper;
@Autowired
private StringCommand stringCommand; // Assuming this is a Redis client wrapper

public User getUserById(Long userId) {
    String cacheKey = "userId_" + userId;
    User user = (User) stringCommand.get(cacheKey);
    if (user != null) {
        return user;
    }

    user = userMapper.getUserById(userId);
    if (user != null) {
        stringCommand.set(cacheKey, user);
        return user;
    }
    return null;
}

public void updateUser(User user) {
    userMapper.updateUser(user);
    String cacheKey = "userId_" + user.getId();
    stringCommand.set(cacheKey, user); // Update cache
}

public void deleteUserById(Long userId) {
    userMapper.deleteUserById(userId);
    String cacheKey = "userId_" + userId;
    stringCommand.del(cacheKey); // Remove from cache
}

While straightforward, this method suffers from:

  1. Code Redundancy: Caching logic (key generation, cache calls for get, set, delete) is repeated across multiple operations, leading to verbose code.
  2. Tight Coupling: Caching logic is intertwined with business logic, making it intrusive. This complicates development (e.g., temporarily disabling caching) and increases the cost of switching caching implementations.

Spring Cache Abstraction

Spring Cache provides an abstraction layer over caching, decoupling caching concerns from business logic. It leverages Spring AOP (Aspect-Oriented Programming) to achieve this.

Spring AOP and Proxies

Spring AOP uses proxy-based design. Instead of directly calling a method on an object, calls are intercepted by a dynamic proxy. This proxy can execute logic before and after the method invocation, enabling caching operations without modifying the core business method.

Cache Annotations

Spring Cache offers several annotations to declare caching behavior:

  • @Cacheable: Caches the result of a method. Subsequent calls with the same arguments retrieve the result from the cache.
  • @CachePut: Updates the cache with the method's result. The method is always executed.
  • @CacheEvict: Removes entries from the cache.
  • @Caching: Allows grouping multiple cache annotations.
  • @CacheConfig: Defines class-level cache configurations.

@Cacheable Example:

@Cacheable(value = "userCache", key = "#userId", unless = "#result == null")
public User getUserById(Long userId) {
    return userMapper.getUserById(userId);
}

This annotation associates the getUserById method with the userCache. If the returned User is not null, it's cached. Subsequent calls with the same userId will hit the cache.

Cache Key Generation:

The key attribute in cache annotations allows defining cache keys using SpEL (Spring Expression Language). If key is not specified, Spring's default KeyGenerator creates a key based on method parameters. Custom KeyGenerator implementations can be provided for specific key generation strategies.

Caching Conditions:

  • condition: A SpEL expression that determines if the result should be cached before method execution. If the condition evaluates to false, the method is not cached.
    @Cacheable(cacheNames = "bookCache", condition = "#name.length() < 32")
    public Book findBook(String name) { ... }
    
  • unless: A SpEL expression that determines if the result should be cached after method execution. If the expression evaluates to true, the result is not cached.
    @Cacheable(value = "userCache", key = "#userId", unless = "#result == null")
    public User getUserById(Long userId) { ... }
    

@CachePut Example:

Used for updating cache entries. The annotated method is always executed, and its result is put into the cache.

@CachePut(value = "userCache", key = "#user.id", unless = "#result == null")
public User updateUser(User user) {
    userMapper.updateUser(user);
    return user;
}

@CacheEvict Example:

Removes entries from the cache.

@CacheEvict(value = "userCache", key = "#userId")
public void deleteUserById(Long userId) {
    userMapper.deleteUserById(userId);
}

Cache Configuration

Spring Cache is an abstraction; concrete implementations require a CacheManager. Spring Boot auto-configures a CacheManager based on available dependencies. For example, ConcurrentMapCacheManager uses in-memory maps.

Practical Integration Examples

Integrating Caffeine (In-Memory Cache)

Add dependencies:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>2.7.0</version>
</dependency>

Configure Caffeine:

@Configuration
@EnableCaching
public class MyCacheConfig {

    @Bean
    public Caffeine caffeineConfig() {
        return Caffeine.newBuilder()
                .maximumSize(10000)
                .expireAfterWrite(60, TimeUnit.MINUTES);
    }

    @Bean
    public CacheManager cacheManager(Caffeine caffeine) {
        CaffeineCacheManager caffeineCacheManager = new CaffeineCacheManager();
        caffeineCacheManager.setCaffeine(caffeine);
        return caffeineCacheManager;
    }
}

Apply annotations to business logic:

@Cacheable(value = "userCache", unless = "#result == null")
public User getUserById(Long id) {
    return userMapper.getUserById(id);
}

@CachePut(value = "userCache", key = "#user.id", unless = "#result == null")
public User updateUser(User user) {
    userMapper.updateUser(user);
    return user;
}

@CacheEvict(value = "userCache", key = "#id")
public void deleteUserById(Long id) {
    userMapper.deleteUserById(id);
}

Monitoring database logs confirms that the first call to getUserById hits the database, while subsequent calls are served from the cache.

Integrating Redisson (Distributde Cache)

Add Redisson dependency:

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.12.0</version>
</dependency>

Configure Redisson as CacheManager:

@Configuration
@EnableCaching
public class RedissonCacheConfig {

    @Bean(destroyMethod = "shutdown")
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer()
              .setAddress("redis://127.0.0.1:6201")
              .setPassword("your_password");
        // Optional: Use JsonJacksonCodec for better readability
        // config.setCodec(new JsonJacksonCodec());
        return Redisson.create(config);
    }

    @Bean
    CacheManager cacheManager(RedissonClient redissonClient) {
        Map<String, CacheConfig> cacheConfigs = new HashMap<>();
        // Configure 'userCache' with TTL and maxIdleTime
        cacheConfigs.put("userCache", 
                         new CacheConfig(24 * 60 * 1000, 12 * 60 * 1000));
        return new RedissonSpringCacheManager(redissonClient, cacheConfigs);
    }
}

Switching to Redisson only requires changing the CacheManager configuration; the business logic using cache annotations remains the same. Data can be verified in Redis Desktop Manager.

Understanding List Caching

When caching lists, Spring Cache typically caches the entire collection as a single entry. For example:

@Cacheable(value = "userCache")
public List<User> getUserList(List<Long> idList) {
    return userMapper.getUserByIds(idList);
}

This caches the whole list under a single key. If finer-grained control or item-level caching is needed, extensions or custom solutions might be required, as Spring Cache prioritizes simplicity in its abstraction.

Custom Multi-Level Caching

For high-concurrency scenarios demanding optimized performance, multi-level caching (e.g., in-memory + distributed) offers significant benefits.

Design

A custom solution can involve:

  • MultiLevelCacheManager: Manages multiple cache levels.
  • MultiLevelCache: Implements org.springframework.cache.Cache, delegating to different cache providers.
  • First Level Cache: In-memory (e.g., Caffeine).
  • Second Level Cache: Distributed (e.g., Redisson).

MultiLevelCache Logic

Get Operation:

  1. Attempt to retrieve data from the first-level (local) cache.
  2. If not found, query the second-level (distributed) cache.
  3. If found in the second level, populate the first level and return the data.
  4. If not found in either, proceed to fetch from the data source (triggering the annotated method).

Put Operation:

  1. Write the data to both the first-level and second-level caches.

This approach ensures faster access for frequently used data while leveraging distributed caches for persistence and wider availability.

When to Choose Spring Cache

Spring Cache is ideal for scenarios where stringent real-time data requirements are not critical, such as caching portal home pages, static display pages, or ledaerboards. It significantly boosts developer productivity by simplifying caching logic.

However, for high-throughput, large-volume applications requiring fine-grained cache control (e.g., multi-level caching, specific list caching strategies, cache event listeners), extending Spring Cache or using specialized caching libraries might be necessary.

Tags: Spring Cache Caching Redis Caffeine aop

Posted on Sat, 07 Nov 2026 16:08:02 +0000 by tasistro