DEV Community

Cover image for Supercharge Your Spring Boot App: Master Distributed Caching for Lightning-Fast Performance
Aarav Joshi
Aarav Joshi

Posted on

Supercharge Your Spring Boot App: Master Distributed Caching for Lightning-Fast Performance

Sure, I'll dive right into the topic of advanced Spring Boot caching strategies with distributed caches.

Caching is a game-changer when it comes to boosting app performance. I've seen it work wonders in many Spring Boot projects. But let's be real, basic caching only gets you so far. When you're dealing with heavy loads and multiple server nodes, you need to step up your game.

That's where distributed caches come in. They're like the superheroes of the caching world. Redis and Hazelcast are two popular options that I've had great success with. They allow you to share cached data across multiple instances of your application, which is crucial for scalability.

Let's start with Redis. It's fast, it's versatile, and it plays nice with Spring Boot. Here's how you can set it up:

First, add the necessary dependencies to your pom.xml:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
Enter fullscreen mode Exit fullscreen mode

Then, configure Redis in your application.properties:

spring.cache.type=redis
spring.redis.host=localhost
spring.redis.port=6379
Enter fullscreen mode Exit fullscreen mode

Now, you're ready to use Redis as your cache. But here's where it gets interesting. Spring Boot provides some nifty annotations that make caching a breeze. Let's look at a practical example:

@Service
public class UserService {
    @Cacheable(value = "users", key = "#id")
    public User getUserById(Long id) {
        // Expensive database operation
        return userRepository.findById(id).orElse(null);
    }

    @CachePut(value = "users", key = "#user.id")
    public User updateUser(User user) {
        // Update user in database
        return userRepository.save(user);
    }

    @CacheEvict(value = "users", key = "#id")
    public void deleteUser(Long id) {
        // Delete user from database
        userRepository.deleteById(id);
    }
}
Enter fullscreen mode Exit fullscreen mode

In this example, @Cacheable stores the result of getUserById in the cache. The next time it's called with the same id, it'll return the cached result instead of hitting the database. @CachePut updates the cache when a user is updated, and @CacheEvict removes a user from the cache when they're deleted.

But what if Redis goes down? Your app shouldn't grind to a halt just because the cache is unavailable. That's why it's crucial to handle cache failures gracefully. You can do this by implementing your own CacheErrorHandler:

@Configuration
public class CacheConfig extends CachingConfigurerSupport {
    @Override
    public CacheErrorHandler errorHandler() {
        return new CacheErrorHandler() {
            @Override
            public void handleCacheGetError(RuntimeException e, Cache cache, Object key) {
                log.error("Cache get error", e);
            }

            @Override
            public void handleCachePutError(RuntimeException e, Cache cache, Object key, Object value) {
                log.error("Cache put error", e);
            }

            @Override
            public void handleCacheEvictError(RuntimeException e, Cache cache, Object key) {
                log.error("Cache evict error", e);
            }

            @Override
            public void handleCacheClearError(RuntimeException e, Cache cache) {
                log.error("Cache clear error", e);
            }
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

This error handler logs cache errors instead of throwing exceptions, allowing your app to continue functioning even if the cache is down.

Now, let's talk about multi-level caching. It's like having a Swiss Army knife in your caching toolkit. The idea is to use a fast, local cache for frequently accessed data, and a distributed cache for less frequently accessed data. Here's how you might implement it:

@Configuration
@EnableCaching
public class MultilevelCacheConfig {
    @Bean
    public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
        SimpleCacheManager cacheManager = new SimpleCacheManager();

        List<Cache> caches = new ArrayList<>();
        caches.add(new ConcurrentMapCache("localCache"));
        caches.add(new RedisCache("distributedCache", redisConnectionFactory));

        cacheManager.setCaches(caches);
        return cacheManager;
    }
}
Enter fullscreen mode Exit fullscreen mode

In this setup, we have a local cache backed by ConcurrentMapCache and a distributed cache backed by RedisCache. You can then use these caches in your service methods:

@Cacheable(cacheNames = {"localCache", "distributedCache"}, key = "#id")
public User getUserById(Long id) {
    // Expensive database operation
    return userRepository.findById(id).orElse(null);
}
Enter fullscreen mode Exit fullscreen mode

This method will first check the local cache, then the distributed cache, and only hit the database if the data isn't found in either cache.

But what about cache coherence? In a distributed system, it's crucial to ensure that all nodes have the same view of the cached data. One way to achieve this is through cache invalidation. When data is updated, you need to invalidate the cache across all nodes.

Spring's @CacheEvict annotation can help with this, but for more complex scenarios, you might need to implement a custom cache resolver. Here's an example:

@Component
public class CustomCacheResolver implements CacheResolver {
    @Autowired
    private CacheManager cacheManager;

    @Override
    public Collection<? extends Cache> resolveCaches(CacheOperationInvocationContext<?> context) {
        String cacheName = determineCacheName(context);
        return Collections.singleton(cacheManager.getCache(cacheName));
    }

    private String determineCacheName(CacheOperationInvocationContext<?> context) {
        // Logic to determine cache name based on method parameters or other factors
    }
}
Enter fullscreen mode Exit fullscreen mode

You can then use this custom resolver in your caching annotations:

@Cacheable(cacheResolver = "customCacheResolver")
public User getUserById(Long id) {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

This gives you fine-grained control over which cache is used for each method call.

Another advanced technique is cache warming. This involves pre-populating your cache with data that you know will be frequently accessed. It can significantly improve performance, especially after a cache clear or application restart. Here's a simple example:

@Component
public class CacheWarmer {
    @Autowired
    private UserService userService;

    @Scheduled(fixedRate = 3600000) // Run every hour
    public void warmCache() {
        List<Long> popularUserIds = getPopularUserIds();
        for (Long id : popularUserIds) {
            userService.getUserById(id); // This will populate the cache
        }
    }

    private List<Long> getPopularUserIds() {
        // Logic to determine popular user IDs
    }
}
Enter fullscreen mode Exit fullscreen mode

This method runs every hour, fetching the most popular users and ensuring they're in the cache.

Time-to-live (TTL) policies are another important aspect of caching. They help ensure that your cache doesn't become stale. With Redis, you can set TTL at the cache level:

@Bean
public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
    RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
        .entryTtl(Duration.ofMinutes(60)); // Set TTL to 60 minutes

    return RedisCacheManager.builder(redisConnectionFactory)
        .cacheDefaults(config)
        .build();
}
Enter fullscreen mode Exit fullscreen mode

You can also set different TTL for different caches:

@Bean
public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
    RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
        .entryTtl(Duration.ofMinutes(10));

    Map<String, RedisCacheConfiguration> configs = new HashMap<>();
    configs.put("users", RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofHours(1)));
    configs.put("posts", RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofMinutes(30)));

    return RedisCacheManager.builder(redisConnectionFactory)
        .cacheDefaults(defaultConfig)
        .withInitialCacheConfigurations(configs)
        .build();
}
Enter fullscreen mode Exit fullscreen mode

This sets a default TTL of 10 minutes, with specific TTLs for the "users" and "posts" caches.

Now, let's talk about Hazelcast. It's another powerful distributed caching solution that integrates well with Spring Boot. Here's how you can set it up:

First, add the Hazelcast dependency:

<dependency>
    <groupId>com.hazelcast</groupId>
    <artifactId>hazelcast-spring</artifactId>
</dependency>
Enter fullscreen mode Exit fullscreen mode

Then, configure Hazelcast:

@Configuration
public class HazelcastConfig {
    @Bean
    public Config hazelCastConfig() {
        return new Config()
            .setInstanceName("hazelcast-instance")
            .addMapConfig(
                new MapConfig()
                    .setName("usersCache")
                    .setEvictionConfig(new EvictionConfig().setEvictionPolicy(EvictionPolicy.LRU))
                    .setTimeToLiveSeconds(2000));
    }
}
Enter fullscreen mode Exit fullscreen mode

This sets up a Hazelcast instance with a map named "usersCache" that uses LRU (Least Recently Used) eviction policy and has a TTL of 2000 seconds.

One of the cool things about Hazelcast is its ability to handle complex data structures. For example, you can cache query results:

@Cacheable(value = "usersCache", key = "#lastName")
public List<User> getUsersByLastName(String lastName) {
    return userRepository.findByLastName(lastName);
}
Enter fullscreen mode Exit fullscreen mode

This caches the entire list of users with a given last name. Hazelcast can efficiently store and retrieve this list.

But what if you need even more control over your caching behavior? That's where custom cache implementations come in. Spring Boot allows you to create your own Cache implementations. Here's a simple example:

public class CustomCache implements Cache {
    private final ConcurrentMap<Object, Object> store = new ConcurrentHashMap<>();
    private final String name;

    public CustomCache(String name) {
        this.name = name;
    }

    @Override
    public String getName() {
        return this.name;
    }

    @Override
    public Object getNativeCache() {
        return this.store;
    }

    @Override
    public ValueWrapper get(Object key) {
        Object value = this.store.get(key);
        return (value != null ? new SimpleValueWrapper(value) : null);
    }

    @Override
    public void put(Object key, Object value) {
        this.store.put(key, value);
    }

    @Override
    public void evict(Object key) {
        this.store.remove(key);
    }

    @Override
    public void clear() {
        this.store.clear();
    }

    // Implement other methods...
}
Enter fullscreen mode Exit fullscreen mode

You can then use this custom cache in your CacheManager:

@Bean
public CacheManager cacheManager() {
    SimpleCacheManager cacheManager = new SimpleCacheManager();
    cacheManager.setCaches(Arrays.asList(
        new CustomCache("cache1"),
        new CustomCache("cache2")
    ));
    return cacheManager;
}
Enter fullscreen mode Exit fullscreen mode

This level of customization allows you to implement complex caching strategies tailored to your specific needs.

In conclusion, advanced caching strategies can significantly boost the performance and scalability of your Spring Boot applications. By leveraging distributed caches like Redis and Hazelcast, implementing multi-level caching, and using Spring's powerful caching abstractions, you can create robust, high-performance applications that can handle heavy loads with ease.

Remember, caching is powerful, but it's not a silver bullet. Always profile your application to ensure that your caching strategy is actually improving performance. And don't forget about cache invalidation - it's one of the hardest problems in computer science, after all!

Happy caching!


Our Creations

Be sure to check out our creations:

Investor Central | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)