Introduction
Modern applications often retrieve the same data repeatedly from databases. Product information, customer profiles, configuration settings, and reference data are commonly accessed by multiple users and services.
Without caching, every request travels through the application layers and eventually reaches the database, increasing response times and placing unnecessary load on backend systems.
Redis provides a highly performant in-memory data store that allows applications to serve frequently requested data in milliseconds while dramatically reducing database load.
In this article, we will explore:
- Redis fundamentals
- Cache design patterns
- Spring Boot caching implementation
- Cache invalidation strategies
- Cache Stampede prevention
- Production best practices
Understanding the Spring Boot Request Flow
A typical Spring Boot application follows the layered architecture below:
Client
|
Controller
|
Service
|
Repository
|
Database
Without caching, every request eventually reaches the database.
Example:
GET /products/1001
Controller
|
ProductService
|
ProductRepository
|
Database
For frequently accessed data, this becomes inefficient.
Redis can be introduced between the Service Layer and Repository Layer.
Client
|
Controller
|
Service
|
Redis Cache
|
Repository
|
Database
Now the service first checks Redis before querying the database.
Why Redis?
Redis is an in-memory key-value data store designed for ultra-low latency access.
Typical response times:
| Operation | Average Latency |
|---|---|
| Redis Read | < 1 ms |
| Database Read | 10-100 ms |
| Remote Service Call | 50-500 ms |
Benefits:
- Faster API responses
- Reduced database load
- Improved scalability
- Better user experience
Common use cases:
- Product catalog caching
- Customer profile caching
- Session storage
- Authentication data
- Application configuration
- Reference data
Redis Data Structures
Redis supports several data types.
Strings
product:1001 -> JSON Object
Hashes
user:1001
name=Rahul
tier=Gold
country=India
Lists
Useful for queues and event processing.
Sets
Store unique values.
Sorted Sets
Ideal for rankings and leaderboards.
Designing Redis Keys
A good naming convention simplifies troubleshooting and cache management.
Recommended format:
<domain>:<entity>:<id>
Examples:
user:profile:1001
product:details:2001
order:summary:5001
Benefits:
- Easy debugging
- Logical grouping
- Easier cache eviction
Time To Live (TTL)
Caches should not live forever.
Example:
SET product:1001 {...} EX 300
The cached data automatically expires after 300 seconds.
Common approaches:
| Strategy | Description |
|---|---|
| Fixed TTL | Expire after a specific duration |
| Sliding TTL | Extend expiration on access |
| Manual Eviction | Explicitly remove data |
Cache Aside Pattern (Most Popular)
The Cache Aside pattern is the most commonly used Redis caching strategy.
Flow:
Client
|
Controller
|
Service
|
Check Redis
|
Cache Hit ?
/ \
Yes No
| |
Return Repository
Data |
|
Database
|
Store in Redis
|
Return
Implementation:
@Service
public class ProductService {
@Autowired
private ProductRepository repository;
@Autowired
private RedisTemplate<String, Product> redisTemplate;
public Product getProduct(String productId) {
String key = "product:" + productId;
Product product =
redisTemplate.opsForValue().get(key);
if(product == null) {
product = repository
.findById(productId)
.orElse(null);
if(product != null) {
redisTemplate.opsForValue()
.set(
key,
product,
Duration.ofMinutes(5)
);
}
}
return product;
}
}
Advantages:
- Simple implementation
- Reduces database traffic
- Easy maintenance
Using Spring Cache Annotations
Spring Boot provides caching abstractions out of the box.
Enable caching:
@EnableCaching
@SpringBootApplication
public class Application {
}
@Cacheable
@Cacheable(
value = "products",
key = "#productId")
public Product getProduct(String productId) {
return repository
.findById(productId)
.orElse(null);
}
If data exists in cache, Spring returns it directly.
@CacheEvict
@CacheEvict(
value = "products",
key = "#product.id")
public void updateProduct(Product product) {
repository.save(product);
}
Removes stale cache after updates.
@CachePut
@CachePut(
value = "products",
key = "#product.id")
public Product save(Product product) {
return repository.save(product);
}
Updates cache immediately after saving.
Cache Invalidation
One of the most important aspects of caching is ensuring stale data is not served.
TTL Based Invalidation
Allow cache entries to expire automatically.
Example:
Product Data = 5 Minutes
Customer Profile = 30 Minutes
Reference Data = 24 Hours
Event Based Invalidation
Whenever data changes, remove cache immediately.
public Product updateProduct(Product product) {
Product updated =
repository.save(product);
redisTemplate.delete(
"product:" + product.getId());
return updated;
}
The next request reloads fresh data from the database.
Understanding Cache Stampede
The Problem
Assume a popular product is requested thousands of times every minute.
product:1001
Its cache expires at exactly 10:00 AM.
Immediately after expiration:
5000 Requests
|
|
Cache Miss
|
|
5000 Database Calls
The database suddenly receives thousands of requests.
This phenomenon is called a Cache Stampede.
It can lead to:
- Database overload
- Increased response times
- Service outages
Preventing Cache Stampede Using Distributed Locks
The goal is to allow only one thread to rebuild the cache.
5000 Requests
|
Cache Miss
|
Acquire Lock
|
One Thread Queries Database
|
Populate Cache
|
Release Lock
|
Remaining Threads Read Cache
Implementation:
public Product getProduct(String productId) {
String cacheKey =
"product:" + productId;
Product product =
redisTemplate.opsForValue()
.get(cacheKey);
if(product != null) {
return product;
}
String lockKey =
"lock:" + productId;
Boolean lockAcquired =
redisTemplate.opsForValue()
.setIfAbsent(
lockKey,
"LOCKED",
Duration.ofSeconds(10));
if(Boolean.TRUE.equals(lockAcquired)) {
try {
product = repository
.findById(productId)
.orElse(null);
if(product != null) {
redisTemplate.opsForValue()
.set(
cacheKey,
product,
Duration.ofMinutes(5));
}
return product;
} finally {
redisTemplate.delete(lockKey);
}
} else {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return redisTemplate.opsForValue()
.get(cacheKey);
}
}
Benefits:
- Only one database call
- Prevents database spikes
- Protects backend systems
Randomized TTL Strategy
Another useful technique is adding randomness to expiration times.
Bad:
TTL = 300 Seconds
All records expire simultaneously.
Good:
TTL = 300 + Random(60)
Example:
int ttl =
300 +
ThreadLocalRandom.current()
.nextInt(60);
redisTemplate.opsForValue()
.set(
key,
value,
Duration.ofSeconds(ttl));
Benefits:
- Spreads cache expirations
- Reduces simultaneous cache misses
- Prevents traffic spikes
Monitoring Redis
Track the following metrics:
| Metric | Target |
|---|---|
| Cache Hit Ratio | > 80% |
| Memory Usage | Monitor Continuously |
| Evictions | Minimal |
| Latency | < 5 ms |
| Active Connections | Capacity Based |
Production Architecture
A typical Spring Boot deployment may look like:
Client
|
Load Balancer
|
Spring Boot Pods
|
Redis Cluster
|
Repository Layer
|
Database
All application instances share the same Redis cluster, ensuring cache consistency across pods.
Best Practices
- Use Cache Aside pattern.
- Use meaningful key names.
- Apply TTL to all cache entries.
- Use Redis Cluster in production.
- Monitor cache hit ratio.
- Avoid storing large objects.
- Implement cache invalidation.
- Protect against Cache Stampede.
Conclusion
Redis is one of the most effective ways to improve application performance in Spring Boot microservices. By introducing Redis between the Service and Repository layers, applications can significantly reduce database traffic, improve response times, and scale efficiently.
When combined with proper TTL strategies, cache invalidation mechanisms, and stampede prevention techniques, Redis becomes a powerful foundation for building resilient and high-performance distributed systems.