DEV Community

Mahmoud Nawwar
Mahmoud Nawwar

Posted on

Hybrid Cache Strategy in Spring Boot: A Guide to Redisson and Caffeine Integration

Introduction: The Need for Hybrid Caching

In modern application development, performance and scalability are critical factors that determine the success of a system. Caching plays a pivotal role in improving these aspects by reducing database load, minimizing latency, and ensuring a seamless user experience. However, no single caching solution is perfect for every scenario.

Local caches, like Caffeine, provide extremely fast data retrieval because they operate in memory and are close to the application. These are ideal for reducing response times for frequently accessed data. On the other hand, distributed caches, such as those powered by Redisson with Redis, offer scalability and consistency across multiple instances of an application. Distributed caches ensure all nodes in a distributed system access the same up-to-date data, which is crucial in multi-node environments.

However, relying solely on either local or distributed caching comes with challenges:

  • Local caches can become inconsistent in distributed environments as data updates are not synchronized across nodes.

  • Distributed caches introduce slight network latency, which may not be suitable for ultra-low-latency scenarios.

This is where hybrid caching becomes an effective solution. By combining the strengths of both local and distributed caches using Caffeine and Redisson, you can achieve high performance with the speed of local caching while maintaining consistency and scalability using distributed caching.

This article explores how to implement hybrid caching in a Spring Boot application, ensuring optimal performance and data consistency.

Image description

Implementation

Step 1: Add Dependencies

To begin, add the necessary dependencies to your pom.xml:

        <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>3.2.0</version>
        </dependency>
        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson</artifactId>
            <version>3.43.0</version>
        </dependency>
Enter fullscreen mode Exit fullscreen mode

Step 2: Configure the Cache

Here is the cache configuration:

@Configuration
@EnableCaching
public class CacheConfig implements CachingConfigurer {

  @Value("${cache.server.address}")
  private String cacheAddress;

  @Value("${cache.server.password}")
  private String cachePassword;

  @Value("${cache.server.expirationTime:60}")
  private Long cacheExpirationTime;

  @Bean(destroyMethod = "shutdown")
  RedissonClient redisson() {
    Config config = new Config();
    config.useSingleServer().setAddress(cacheAddress).setPassword(cachePassword.trim());
    config.setLazyInitialization(true);
    return Redisson.create(config);
  }

  @Bean
  @Override
  public CacheManager cacheManager() {
    CaffeineCacheManager cacheManager = new CaffeineCacheManager();
    cacheManager.setCaffeine(
        Caffeine.newBuilder().expireAfterWrite(cacheExpirationTime, TimeUnit.MINUTES));
    return cacheManager;
  }

  @Bean
  public CacheEntryRemovedListener cacheEntryRemovedListener() {
    return new CacheEntryRemovedListener(cacheManager());
  }

  @Bean
  @Override
  public CacheResolver cacheResolver() {
    return new LocalCacheResolver(cacheManager(), redisson(), cacheEntryRemovedListener());
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Use the Spring cache abstraction annotations in your code


    @Cacheable(value = "products", key = "#id")
    public Product getProductById(Long id) {
        // Simulates an expensive database call
        return productRepository.findById(id);
    }

    @CacheEvict(value = "products", key = "#id")
    public void deleteProductById(Long id) {
        productRepository.deleteById(id);
    } 
Enter fullscreen mode Exit fullscreen mode

Explaining the Key Components

1. Cache Manager

The CacheManager is responsible for managing the lifecycle of caches and providing access to the appropriate cache implementation (e.g., local or distributed). In this case, we use CaffeineCacheManager to enable in-memory caching with an expiration policy configured via Caffeine.

2. Cache Resolver

The CacheResolver determines which cache(s) to use for a specific operation dynamically. Here, the LocalCacheResolver bridges the local (Caffeine) and distributed (Redisson) caches, ensuring that the hybrid strategy is applied effectively.

@Component
public class LocalCacheResolver implements CacheResolver {

  private final CacheManager cacheManager;
  private final RedissonClient redisson;
  private final CacheEntryRemovedListener cacheEntryRemovedListener;

  @Value("${cache.server.expirationTime:60}")
  private Long expirationTime;

  private final Map<String, LocalCache> cacheMap = new ConcurrentHashMap<>();

  public LocalCacheResolver(
      CacheManager cacheManager,
      RedissonClient redisson,
      CacheEntryRemovedListener cacheEntryRemovedListener) {
    this.cacheManager = cacheManager;
    this.redisson = redisson;
    this.cacheEntryRemovedListener = cacheEntryRemovedListener;
  }

  @Override
  @Nonnull
  public Collection<? extends Cache> resolveCaches(
      @Nonnull CacheOperationInvocationContext<?> context) {
    Collection<Cache> caches = getCaches(cacheManager, context);
    return caches.stream().map(this::getOrCreateLocalCache).toList();
  }

  private Collection<Cache> getCaches(
      CacheManager cacheManager, CacheOperationInvocationContext<?> context) {
    return context.getOperation().getCacheNames().stream()
        .map(cacheManager::getCache)
        .filter(Objects::nonNull)
        .toList();
  }

  private LocalCache getOrCreateLocalCache(Cache cache) {
    return cacheMap.computeIfAbsent(
        cache.getName(),
        cacheName -> new LocalCache(cache, redisson, expirationTime, cacheEntryRemovedListener));
  }
}
Enter fullscreen mode Exit fullscreen mode
public class LocalCache implements Cache {

  private final Cache cache;
  private final Long expirationTime;
  private final RMapCache<Object, Object> distributedCache;

  public LocalCache(
      Cache cache,
      RedissonClient redisson,
      Long expirationTime,
      CacheEntryRemovedListener cacheEntryRemovedListener) {
    this.cache = cache;
    this.expirationTime = expirationTime;
    this.distributedCache = redisson.getMapCache(getName());
    this.distributedCache.addListener(cacheEntryRemovedListener);
  }

  @Override
  @Nonnull
  public String getName() {
    return cache.getName();
  }

  @Override
  @Nonnull
  public Object getNativeCache() {
    return cache.getNativeCache();
  }

  @Override
  public ValueWrapper get(@Nonnull Object key) {
    Object value = cache.get(key);

    if (value == null && (value = distributedCache.get(key)) != null) {
      cache.put(key, value);
    }

    return toValueWrapper(value);
  }

  private ValueWrapper toValueWrapper(Object value) {
    if (value == null) return null;
    return value instanceof ValueWrapper ? (ValueWrapper) value : new SimpleValueWrapper(value);
  }

  @Override
  public <T> T get(@Nonnull Object key, Class<T> type) {
    return cache.get(key, type);
  }

  @Override
  public <T> T get(@Nonnull Object key, @Nonnull Callable<T> valueLoader) {
    return cache.get(key, valueLoader);
  }

  @Override
  public void put(@Nonnull Object key, Object value) {
    distributedCache.put(key, value, expirationTime, TimeUnit.MINUTES);
    cache.put(key, value);
  }

  @Override
  public void evict(@Nonnull Object key) {
    distributedCache.remove(key);
  }

  @Override
  public void clear() {
    cache.clear();
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Cache Entry Removed Listener

The CacheEntryRemovedListener listens for entries removed from the distributed cache (Redis) and ensures they are also removed from the local caches across nodes, maintaining consistency.

@RequiredArgsConstructor
@Component
public class CacheEntryRemovedListener implements EntryRemovedListener<Object, Object> {

  private final CacheManager cacheManager;

  @Override
  public void onRemoved(EntryEvent event) {
    Cache cache = cacheManager.getCache(event.getSource().getName());
    if (cache != null) {
      cache.evict(event.getKey());
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Hybrid Caching Workflow

Cache Entry Put

When a method annotated with @Cacheable is executed, the put method is invoked. This stores the data in both the local cache (Caffeine) and the distributed cache (Redis):

javaCopyEdit@Override
public void put(@Nonnull Object key, Object value) {
    distributedCache.put(key, value, expirationTime, TimeUnit.MINUTES);
    cache.put(key, value);
}
Enter fullscreen mode Exit fullscreen mode

Cache Entry Get

To retrieve data, the system first checks the local cache for the key. If the key is not found, it queries the distributed cache. If the value exists in the distributed cache, it is also added to the local cache for faster subsequent access:

@Override
public ValueWrapper get(@Nonnull Object key) {
    Object value = cache.get(key);

    if (value == null && (value = distributedCache.get(key)) != null) {
      cache.put(key, value);
    }

    return toValueWrapper(value);
}
Enter fullscreen mode Exit fullscreen mode

Cache Entry Eviction

When a cache eviction occurs (e.g., through a @CacheEvict annotation), the key is removed from the distributed cache. Other nodes' local caches are notified via the CacheEntryRemovedListener to remove the same key:

@Override
public void evict(@Nonnull Object key) {
    distributedCache.remove(key);
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Hybrid caching combines the speed of local in-memory caches with the scalability and consistency of distributed caches. This approach addresses the limitations of using either local or distributed caching alone. By integrating Caffeine and Redisson in a Spring Boot application, you can achieve significant performance improvements while ensuring consistent data across application nodes.

Using the CacheEntryRemovedListener and CacheResolver ensures that cache entries stay synchronized across all cache layers, providing an efficient and reliable caching strategy for modern, scalable applications. This hybrid approach is especially valuable in distributed systems where both performance and consistency are paramount.

Top comments (0)