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.
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>
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());
}
}
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);
}
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));
}
}
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();
}
}
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());
}
}
}
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);
}
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);
}
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);
}
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)