Why Caching?
Imagine a librarian who memorizes the locations of the most requested books. Instead of searching the entire library for "Harry Potter" every time, she says "Shelf 3, Row 2" from memory. That's caching!
Database queries are slow (10-100ms). Memory access is fast (0.1ms). Caching stores frequently used data in memory, so you don't hit the database repeatedly.
WITHOUT CACHING: Request → Database → 50ms → Response Request → Database → 50ms → Response (same data!) Request → Database → 50ms → Response (same data again!) WITH CACHING: Request → Database → 50ms → Cache → Response Request → Cache → 0.1ms → Response (from memory!) Request → Cache → 0.1ms → Response (instant!) Result: 500x faster for cached requests!
Reduced Latency
Serve data in milliseconds instead of hundreds of milliseconds. Users notice the difference!
Lower Database Load
Fewer queries mean your database can handle more users without expensive upgrades.
Cost Savings
Less CPU usage, fewer database connections, lower cloud bills.
Better Scalability
Handle traffic spikes easily. Cache absorbs the load.
Spring Cache: Simple Annotations
Spring Cache adds caching with just annotations - no boilerplate code needed.
Setup
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
// Enable caching in your application
@SpringBootApplication
@EnableCaching
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}
Basic Caching with @Cacheable
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
// First call: hits database, stores result in cache
// Subsequent calls: returns from cache, skips database!
@Cacheable("users")
public User findById(Long id) {
System.out.println("Fetching from database...");
return userRepository.findById(id).orElse(null);
}
// Cache with custom key
@Cacheable(value = "users", key = "#email")
public User findByEmail(String email) {
return userRepository.findByEmail(email);
}
// Conditional caching
@Cacheable(value = "users", condition = "#id > 0")
public User findByIdIfPositive(Long id) {
return userRepository.findById(id).orElse(null);
}
}
Updating Cache with @CachePut
// Always executes the method AND updates the cache
@CachePut(value = "users", key = "#user.id")
public User updateUser(User user) {
return userRepository.save(user);
}
Removing from Cache with @CacheEvict
// Remove specific entry from cache
@CacheEvict(value = "users", key = "#id")
public void deleteUser(Long id) {
userRepository.deleteById(id);
}
// Clear entire cache
@CacheEvict(value = "users", allEntries = true)
public void clearUserCache() {
// Cache cleared!
}
// Evict before method execution
@CacheEvict(value = "users", key = "#id", beforeInvocation = true)
public void deleteUserSafely(Long id) {
userRepository.deleteById(id);
}
Multiple Cache Operations
@Caching(
put = { @CachePut(value = "users", key = "#user.id") },
evict = { @CacheEvict(value = "userList", allEntries = true) }
)
public User saveUser(User user) {
return userRepository.save(user);
}
In-Memory Cache with Caffeine
Caffeine is a high-performance in-memory cache. Great for single-server applications.
<!-- pom.xml -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
# application.properties spring.cache.type=caffeine spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=10m
Custom Cache Configuration
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCaffeine(Caffeine.newBuilder()
.maximumSize(1000) // Max entries
.expireAfterWrite(10, TimeUnit.MINUTES) // TTL
.recordStats()); // Enable stats
return cacheManager;
}
// Different settings for different caches
@Bean
public CacheManager customCacheManager() {
SimpleCacheManager cacheManager = new SimpleCacheManager();
cacheManager.setCaches(Arrays.asList(
buildCache("users", 100, 30), // 100 entries, 30 min
buildCache("products", 500, 60), // 500 entries, 60 min
buildCache("config", 50, 120) // 50 entries, 2 hours
));
return cacheManager;
}
private CaffeineCache buildCache(String name, int size, int minutes) {
return new CaffeineCache(name, Caffeine.newBuilder()
.maximumSize(size)
.expireAfterWrite(minutes, TimeUnit.MINUTES)
.build());
}
}
Distributed Cache with Redis
Redis is a distributed cache that works across multiple servers. Essential for microservices and scaled applications.
Setup
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
# application.properties spring.cache.type=redis spring.data.redis.host=localhost spring.data.redis.port=6379 spring.data.redis.password=yourpassword # if needed spring.cache.redis.time-to-live=3600000 # 1 hour in ms
Redis Configuration
@Configuration
@EnableCaching
public class RedisConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
// Default config
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()));
// Custom config per cache
Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();
cacheConfigs.put("users", defaultConfig.entryTtl(Duration.ofHours(1)));
cacheConfigs.put("products", defaultConfig.entryTtl(Duration.ofMinutes(15)));
cacheConfigs.put("sessions", defaultConfig.entryTtl(Duration.ofHours(24)));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(cacheConfigs)
.build();
}
}
Using RedisTemplate Directly
@Service
public class CacheService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
// Store value
public void set(String key, Object value, long timeoutMinutes) {
redisTemplate.opsForValue().set(key, value, timeoutMinutes, TimeUnit.MINUTES);
}
// Get value
public Object get(String key) {
return redisTemplate.opsForValue().get(key);
}
// Delete value
public void delete(String key) {
redisTemplate.delete(key);
}
// Check if exists
public boolean exists(String key) {
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
}
// Set expiration
public void expire(String key, long timeoutMinutes) {
redisTemplate.expire(key, timeoutMinutes, TimeUnit.MINUTES);
}
// Store hash (like a Map)
public void setHash(String key, String field, Object value) {
redisTemplate.opsForHash().put(key, field, value);
}
public Object getHash(String key, String field) {
return redisTemplate.opsForHash().get(key, field);
}
}
Caching Patterns
Cache-Aside (Lazy Loading)
Most common pattern. Application manages the cache.
public User getUser(Long id) {
// 1. Check cache
User user = cache.get("user:" + id);
if (user != null) {
return user; // Cache hit
}
// 2. Cache miss - load from database
user = userRepository.findById(id).orElse(null);
// 3. Store in cache for next time
if (user != null) {
cache.set("user:" + id, user, 30); // 30 min TTL
}
return user;
}
// With @Cacheable, Spring does this automatically!
@Cacheable("users")
public User getUser(Long id) {
return userRepository.findById(id).orElse(null);
}
Write-Through
Update cache immediately when data changes.
@CachePut(value = "users", key = "#user.id")
public User saveUser(User user) {
return userRepository.save(user); // DB and cache updated together
}
Write-Behind (Write-Back)
Update cache immediately, database later (async).
public User saveUser(User user) {
// Update cache immediately
cache.set("user:" + user.getId(), user, 60);
// Queue database update for later
asyncDatabaseService.saveAsync(user);
return user;
}
Cache Stampede Prevention
Prevent multiple requests from hitting the database when cache expires.
@Service
public class UserService {
private final LoadingCache<Long, User> userCache;
public UserService(UserRepository userRepository) {
this.userCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(30, TimeUnit.MINUTES)
.build(userId -> {
// Only ONE thread loads data, others wait
return userRepository.findById(userId).orElse(null);
});
}
public User getUser(Long id) {
return userCache.get(id); // Automatically prevents stampede
}
}
Real-World Examples
API Rate Limiting
@Service
public class RateLimiter {
@Autowired
private RedisTemplate<String, Integer> redisTemplate;
public boolean isAllowed(String clientId, int maxRequests, int windowSeconds) {
String key = "rate:" + clientId;
Long count = redisTemplate.opsForValue().increment(key);
if (count == 1) {
redisTemplate.expire(key, windowSeconds, TimeUnit.SECONDS);
}
return count <= maxRequests;
}
}
// Usage in controller
@GetMapping("/api/data")
public ResponseEntity<?> getData(@RequestHeader("X-Client-Id") String clientId) {
if (!rateLimiter.isAllowed(clientId, 100, 60)) { // 100 req/min
return ResponseEntity.status(429).body("Too many requests");
}
return ResponseEntity.ok(fetchData());
}
Session Storage
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
# application.properties spring.session.store-type=redis spring.session.redis.namespace=myapp:sessions server.servlet.session.timeout=30m
Caching Database Queries
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
@Cacheable("products")
Optional<Product> findById(Long id);
@Cacheable(value = "productsByCategory", key = "#category")
List<Product> findByCategory(String category);
@CacheEvict(value = {"products", "productsByCategory"}, allEntries = true)
@Override
<S extends Product> S save(S product);
}
Caching Best Practices
1. Cache What Makes Sense
Good Candidates for Caching:
- Read-heavy data (read 100x more than written)
- Expensive computations
- Data that doesn't change often
- Reference data (countries, categories)
Poor Candidates:
- Rapidly changing data
- User-specific data that varies widely
- Security-sensitive data
- Data that must always be fresh
2. Set Appropriate TTLs
// Too short: cache isn't helpful @Cacheable(value = "users", ttl = 10) // 10 seconds - why bother? // Too long: stale data problems @Cacheable(value = "users", ttl = 86400000) // 1 day - risky! // Just right: balance freshness and performance @Cacheable(value = "users", ttl = 1800000) // 30 minutes
3. Handle Cache Failures Gracefully
@Cacheable(value = "users", unless = "#result == null")
public User findUser(Long id) {
try {
return userRepository.findById(id).orElse(null);
} catch (Exception e) {
logger.error("Failed to fetch user", e);
return null; // Don't cache errors
}
}
4. Use Meaningful Cache Keys
// BAD: Generic keys
@Cacheable("data")
// GOOD: Descriptive keys
@Cacheable(value = "users", key = "'user:' + #id")
@Cacheable(value = "products", key = "'category:' + #category + ':page:' + #page")
5. Monitor Cache Performance
# Expose cache metrics management.endpoints.web.exposure.include=caches,metrics # Access at: /actuator/caches and /actuator/metrics/cache.gets