Today, it’s nearly impossible to build scalable applications without eventually addressing caching. Most development teams have likely experimented with different caching strategies or tools and eventually crafted a solution that works best for their own needs.
We deal with large volumes of data, and ensuring low-latency data delivery is crucial across projects, whether B2C, B2B, or even desktop applications.
In this article, I’ll explore fundamental caching approaches, tools, and the primary scenarios where they shine. For simplicity, examples are implemented with Java and the Spring Framework, focusing solely on caching technology.
Let’s dive in.
Caching is a crucial optimization technique that allows data to be stored and quickly retrieved, reducing latency, decreasing database load, and improving application responsiveness.
Common scenarios for caching:
When an object hasn’t changed, retrieving it from cache is cheaper than querying the database.
Data is rarely updated, and fast access is essential.
Microservices with potentially redundant data requests across services.
However, caching can also have drawbacks, especially when horizontal scaling and consistency are required across multiple instances.
Let’s examine the primary caching methods, popular cache types, and the benefits and limitations of each approach.
In-memory caching is well-suited to monolithic applications or services with vertical scaling. Popular in-memory caching tools include Caffeine, Ehcache, and Guava.
In-memory caching stores data directly in the JVM, offering low latency by avoiding network calls. This approach is ideal when cached data is non-critical, has a short time-to-live (TTL), and slight inconsistency is acceptable.
Example with Caffeine:
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
return new CaffeineCacheManager();
}
}
YAML configuration:
spring:
cache:
type: caffeine
cache-names: orders
caffeine:
spec: maximumSize=500,expireAfterAccess=600s
Full code here: link.
Benefits:
Drawbacks:
Limited to server memory.
Cache isn’t shared across instances, complicating horizontal scaling.
License: Caffeine, Guava, and Ehcache are free and open-source for commercial use.
Caffeine Link: Caffeine GitHub
If minor delays or eventual consistency are tolerable, using a database for synchronization can be effective. This approach allows services to detect updates by checking a timestamp in the database and clearing the cache if the data has changed.
@EnableScheduling
@Configuration
public class CacheControl {
private final CacheManager cacheManager;
private final CacheRepository cacheRepository;
private final ConcurrentHashMap<String, Instant> cacheMap = new ConcurrentHashMap<>();
private static final Logger logger = LoggerFactory.getLogger(CacheControl.class);
public CacheControl(CacheManager cacheManager, CacheRepository cacheRepository) {
this.cacheManager = cacheManager;
this.cacheRepository = cacheRepository;
}
@PostConstruct
public void init() {
cacheRepository.findAll()
.forEach(c -> cacheMap.put(c.getName(), c.getLastUpdate()));
}
@Scheduled(fixedRate = 60000)
public void resetCache() {
logger.info("Start cache reset");
cacheRepository.findAll().forEach(c -> {
var name = c.getName();
if (cacheMap.get(name).isBefore(c.getLastUpdate())) {
var cache = cacheManager.getCache(name);
if (cache != null) {
cache.clear();
} else {
logger.error("Cache not found for {}", name);
}
}
});
}
}
Code Explanation: At application startup and during bean initialization, we query the database to collect information on the latest cache updates. This information is stored in a ConcurrentHashMap
.
@PostConstruct
public void init() {
cacheRepository.findAll()
.forEach(c -> cacheMap.put(c.getName(), c.getLastUpdate()));
}
In a Scheduled
job, we regularly check the cache validity. If the cache is expired, we clear it.
if (cacheMap.get(name).isBefore(c.getLastUpdate())) {
var cache = cacheManager.getCache(name);
if (cache != null) {
cache.clear();
} else {
logger.error("Could not find cache for {}", name);
}
}
This approach is quite common. However, it can be further improved by using message queues.
At some point, we may want to synchronize our caches not just within a single service, but across all services. Yes, there are cases where we can share cache across multiple services because it serves as a hot storage layer for very fast operations. Or, we may want to avoid calling external services whenever possible to reduce latency, so we store data in each service, duplicating it.
For example, let’s say data from Service A is cached in Services B, C, and D. The task now is to notify all services whenever a product description changes or a user updates their nickname.
A message queue synchronization pattern can help here. In my example, I’m using Kafka.
Example:
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.kafka.annotation.KafkaListener;
import java.util.concurrent.TimeUnit;
public class OrderService {
private final Cache<String, Order> orderCache;
public OrderService() {
this.orderCache = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.maximumSize(1000)
.build();
}
@KafkaListener(topics = "orders")
public void processOrder(String orderId) {
Order order = fetchOrderFromDatabase(orderId);
orderCache.put(orderId, order);
}
public Order getOrder(String orderId) {
return orderCache.getIfPresent(orderId);
}
}
The full listing can be found here: link.
To ensure each service can update its cache, we can configure them slightly to make this possible.
kafka:
bootstrap-servers: localhost:9092
consumer:
group-id: ${KAFKA_CG_NAME:cf-kafka-service-2}
auto-offset-reset: latest
producer:
bootstrap-servers: localhost:9092
We set a unique group-id for each service, allowing each service instance to update its cache.
This solution is somewhat complex, but it provides flexibility for your team to experiment with different modifications and find an optimal setup.
There are also simpler, out-of-the-box solutions if we’re using other patterns...
If you're short on time or don’t want to build everything from scratch, you can rely on ready-made solutions like Redis.
In 90% of Java and Spring projects, I would choose Redis. Often, we don’t need an expensive and complex cluster costing $1000/month—a basic setup is usually sufficient. I’ve seen several projects where even a simple Redis configuration was enough, even for high-load services.
As usual, the Spring team has ensured that integration is simple, so configuring Redis doesn’t feel significantly different from Caffeine, although the config file is slightly longer.
Example:
@Override
@Cacheable(cacheNames = "orders")
public Optional<Order> getOrder(String orderId) {
return orderRepository
.findById(UUID.fromString(orderId))
.map(this::map);
The full listing can be found here: link.
Configuration:
cache:
type: redis
cache-names: orders
redis:
key-prefix: "order_"
cache-null-values: false
data:
redis:
host: localhost
port: 6379
client-type: jedis
client-name: redis-service
Some nuances to keep in mind:
Another caching option, and in my opinion, a more advanced one, is Hazelcast. Hazelcast is a distributed data and compute system that provides powerful tools for Java developers to create and manage microservices.
Hazelcast connects several nodes into a cluster, enabling fast data access and horizontal scalability. The cluster is automatically formed since Hazelcast uses multicast for node discovery or TCP/IP for static networks. One key advantage is that data is distributed across nodes and replicated for fault tolerance. The cluster also scales automatically as nodes are added, with no system downtime.
From the code perspective, not much changes in the service — only the configuration does.
@Override
@Cacheable(cacheNames = "orders")
public Optional<Order> getOrder(String orderId) {
return orderRepository
.findById(UUID.fromString(orderId))
.map(this::map);
The full listing can be found here: link.
Configuration:
cache:
type: hazelcast
cache-names: orders
hazelcast:
config: classpath:hazelcast-client.yaml
Hazelcast also has some downsides, like any solution:
To summarize this approach:
Advantages:
Data consistency across different instances of the application. Suitable for horizontally scalable systems.
Disadvantages:
Higher latency due to network requests. Requires a well-configured infrastructure.
One approach to consider is multi-tier caching.
This method combines in-memory and distributed caching. For example, Caffeine could be used for fast in-memory data access, while Redis serves as a second-level cache for larger data volumes. If you have a few spare weeks and the desire to build it yourself, this approach offers a very flexible solution that can be customized with preferred metrics and fine-tuned to maximize performance.
Quick summary here:
Advantages:
Drawbacks:
More complex architecture.
I'll write another article exploring this approach in more detail.
If your content is static and you want to avoid server costs, CDN caching could be a solution.
Using a CDN, such as Cloudflare, Akamai, or Amazon CloudFront, allows for the rapid delivery of static content by distributing it across servers worldwide.
Advantages:
Drawbacks:
In the project, I tested all the implementations with Locust, which proved quite convenient. You can replicate these tests to see the results for yourself.
Naturally, in-memory caches perform the fastest. If time efficiency is your top priority, these solutions are ideal.
The link is here: click
Using Locust, each service is tested under various loads to analyze response times, hit/miss ratios, and overall behavior. Here are the general results for the caching strategies:
The repository provides five main caching services, each with a unique implementation. Here’s an overview:
github repository is here link.
Since the goal of this article is to introduce caching approaches, let’s summarize some general takeaways for choosing a caching strategy:
2. Latency and Performance Needs:
3. Data Consistency:
4. Budget and Licensing:
Always start by evaluating your metrics and SLAs. If caching can be avoided initially, hold off on implementing it. If a 500ms delay in your service isn’t a concern, then it’s not a problem. Keep in mind that solving data synchronization issues can add complexity and maintenance overhead to your system.
If you do decide to add caching, add it incrementally and monitor where it’s lacking instead of immediately creating a complex solution. In practice, a simple Caffeine cache with low TTL is often sufficient for non-complex fintech or similar applications.
For systems being built from scratch where speed is essential, Hazelcast or Redis are good starting points.
And don’t forget about licenses and paid versions:
Caffeine: Free and commercially available.
Redis: Free Community Edition, with a paid Enterprise version.
Hazelcast: Free Community Edition and paid Enterprise Edition.
Cloudflare and other CDNs: Commercial solutions with usage-based pricing.
In the next article, I’ll cover working with memcached in microservices using Golang, showing how sometimes you don’t need Spring Framework at all and can implement everything with minimal code.
Thanks and take care!