Caching
Why Caching Matters
Database queries are expensive—even with connection pooling and indexes, queries take milliseconds. Caching stores frequently accessed data in memory, reducing latency from 10-50ms (database) to <1ms (cache). In production systems serving thousands of requests per second, caching reduces database load by 80-95% and improves response times by 10-50x.
Manual Map Caching Baseline
Manual caching with ConcurrentHashMap:
import java.util.concurrent.ConcurrentHashMap;
import java.util.Map;
// => Repository: manual caching with ConcurrentHashMap
@Repository
public class ZakatAccountRepository {
private final JdbcTemplate jdbcTemplate;
// => Manual cache: ConcurrentHashMap for thread-safe caching
// => Key: account ID, Value: cached account
private final Map<Long, ZakatAccount> cache = new ConcurrentHashMap<>();
public ZakatAccountRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
// => Find account with manual caching
public ZakatAccount findById(Long id) {
// => Check cache first: O(1) lookup
// => cache.get(): returns cached value or null
ZakatAccount cached = cache.get(id);
if (cached != null) {
// => Cache hit: return immediately, no database query
// => <1ms response time
return cached;
}
// => Cache miss: query database
// => 10-50ms response time
ZakatAccount account = jdbcTemplate.queryForObject(
"SELECT id, account_number, balance FROM zakat_accounts WHERE id = ?",
(rs, rowNum) -> new ZakatAccount(
rs.getLong("id"),
rs.getString("account_number"),
rs.getBigDecimal("balance")
),
id
);
// => Store in cache: subsequent requests served from memory
// => cache.put(): stores key-value pair
cache.put(id, account);
return account;
}
// => Update account: manual cache invalidation
public void update(ZakatAccount account) {
// => Update database
jdbcTemplate.update(
"UPDATE zakat_accounts SET balance = ? WHERE id = ?",
account.getBalance(),
account.getId()
);
// => Manual cache invalidation: remove stale entry
// => cache.remove(): evicts key from cache
// => Next read will fetch fresh data from database
cache.remove(account.getId());
}
// => Delete account: manual cache invalidation
public void delete(Long id) {
// => Delete from database
jdbcTemplate.update("DELETE FROM zakat_accounts WHERE id = ?", id);
// => Manual cache invalidation
cache.remove(id);
}
// => Clear all cache: manual eviction
public void clearCache() {
// => Remove all entries: cache.clear()
cache.clear();
}
}Limitations:
- No eviction: Cache grows unbounded, causes OutOfMemoryError
- No TTL: Stale data persists forever unless manually invalidated
- Manual invalidation: Easy to forget, causes data inconsistency
- No hit/miss metrics: Can’t measure cache effectiveness
- Single JVM: Can’t share cache across multiple application instances
- Synchronization overhead: Manual thread-safety adds complexity
Spring Cache Abstraction with Caffeine
Spring Cache abstraction with Caffeine provides production-grade in-memory caching:
import org.springframework.cache.annotation.*;
import org.springframework.cache.CacheManager;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.caffeine.CaffeineCacheManager;
// => Configuration: Caffeine cache manager
@Configuration
@EnableCaching // => Activates Spring Cache annotations
public class CacheConfig {
@Bean // => CacheManager: manages multiple named caches
public CacheManager cacheManager() {
// => CaffeineCacheManager: in-memory cache with Caffeine
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
// => Configure Caffeine: cache behavior
cacheManager.setCaffeine(Caffeine.newBuilder()
// => Maximum size: max entries per cache
.maximumSize(1000) // => Max 1000 entries
// => Automatic eviction: removes least recently used (LRU)
// => Prevents OutOfMemoryError
// => Time-based expiration: TTL (time to live)
.expireAfterWrite(Duration.ofMinutes(10)) // => Evict after 10min
// => Automatic invalidation: no manual cache clearing needed
// => Ensures data freshness
// => Metrics: track cache hit/miss rates
.recordStats() // => Enables cache statistics
// => Exposes hit rate, eviction count, load time
);
// => Register cache names: explicit cache definitions
cacheManager.setCacheNames(Arrays.asList("accounts", "nisabRates", "reports"));
// => "accounts": account data cache
// => "nisabRates": zakat threshold rates cache
// => "reports": report data cache
return cacheManager;
}
}
// => Repository: Spring Cache annotations
@Repository
public class ZakatAccountRepository {
private final JdbcTemplate jdbcTemplate;
// => @Cacheable: cache method result
@Cacheable(value = "accounts", key = "#id")
// => value: cache name ("accounts")
// => key: cache key expression (SpEL: #id = method parameter id)
// => Behavior: if key exists in cache, return cached value; else execute method and cache result
public ZakatAccount findById(Long id) {
// => Method body executed only on cache miss
// => Cache hit: method not executed, cached value returned immediately
System.out.println("Querying database for account: " + id); // => Debug: shows cache misses
// => Database query: 10-50ms
return jdbcTemplate.queryForObject(
"SELECT id, account_number, balance FROM zakat_accounts WHERE id = ?",
(rs, rowNum) -> new ZakatAccount(
rs.getLong("id"),
rs.getString("account_number"),
rs.getBigDecimal("balance")
),
id
);
// => Result automatically cached with key = id
// => Subsequent calls with same id return cached value (<1ms)
}
// => @CachePut: always execute method and update cache
@CachePut(value = "accounts", key = "#account.id")
// => Always executes method (no cache check)
// => Updates cache with return value
// => Use for update operations: ensures cache has latest data
public ZakatAccount update(ZakatAccount account) {
// => Update database
jdbcTemplate.update(
"UPDATE zakat_accounts SET balance = ? WHERE id = ?",
account.getBalance(),
account.getId()
);
// => Return updated account: automatically cached
// => Cache entry for account.id updated with fresh data
return account;
}
// => @CacheEvict: remove entry from cache
@CacheEvict(value = "accounts", key = "#id")
// => Removes cache entry with key = id
// => Next findById(id) will query database
public void delete(Long id) {
// => Delete from database
jdbcTemplate.update("DELETE FROM zakat_accounts WHERE id = ?", id);
// => Cache entry automatically evicted after method execution
}
// => @CacheEvict with allEntries: clear entire cache
@CacheEvict(value = "accounts", allEntries = true)
// => allEntries = true: removes all entries from "accounts" cache
// => Use for bulk operations affecting many entries
public void deleteAll() {
jdbcTemplate.update("DELETE FROM zakat_accounts");
// => All cache entries evicted after method execution
}
// => @Caching: multiple cache operations
@Caching(
cacheable = @Cacheable(value = "accounts", key = "#accountNumber"),
// => Cache by accountNumber (primary lookup)
put = @CachePut(value = "accounts", key = "#result.id")
// => Also cache by id (secondary lookup)
// => Single database query populates both cache entries
)
public ZakatAccount findByAccountNumber(String accountNumber) {
// => Query database
return jdbcTemplate.queryForObject(
"SELECT id, account_number, balance FROM zakat_accounts WHERE account_number = ?",
(rs, rowNum) -> new ZakatAccount(
rs.getLong("id"),
rs.getString("account_number"),
rs.getBigDecimal("balance")
),
accountNumber
);
// => Result cached twice: by accountNumber and by id
// => findById() and findByAccountNumber() can both hit cache
}
// => Conditional caching: cache only when condition met
@Cacheable(value = "accounts", key = "#id", condition = "#id > 0")
// => condition: SpEL expression, cache only if true
// => Caches only for positive IDs (skip negative/zero)
public ZakatAccount findByIdConditional(Long id) {
return jdbcTemplate.queryForObject(
"SELECT id, account_number, balance FROM zakat_accounts WHERE id = ?",
(rs, rowNum) -> new ZakatAccount(
rs.getLong("id"),
rs.getString("account_number"),
rs.getBigDecimal("balance")
),
id
);
}
// => Unless: cache unless condition met
@Cacheable(value = "accounts", key = "#id", unless = "#result.balance.compareTo(T(java.math.BigDecimal).ZERO) <= 0")
// => unless: SpEL expression, skip caching if true
// => Don't cache accounts with zero/negative balance
public ZakatAccount findByIdUnless(Long id) {
return jdbcTemplate.queryForObject(
"SELECT id, account_number, balance FROM zakat_accounts WHERE id = ?",
(rs, rowNum) -> new ZakatAccount(
rs.getLong("id"),
rs.getString("account_number"),
rs.getBigDecimal("balance")
),
id
);
}
}
// => Service: cache management API
@Service
public class ZakatAccountService {
private final ZakatAccountRepository accountRepository;
private final CacheManager cacheManager;
// => Programmatic cache access
public void warmCache() {
// => Load frequently accessed data into cache
List<Long> popularAccountIds = Arrays.asList(1L, 2L, 3L, 4L, 5L);
popularAccountIds.forEach(id -> {
// => findById(): populates cache via @Cacheable
accountRepository.findById(id);
});
}
// => Programmatic cache eviction
public void evictAccount(Long accountId) {
// => Get cache by name
Cache cache = cacheManager.getCache("accounts");
if (cache != null) {
// => Evict specific key
cache.evict(accountId);
}
}
// => Programmatic cache clearing
public void clearAllCaches() {
// => Get all cache names
cacheManager.getCacheNames().forEach(cacheName -> {
Cache cache = cacheManager.getCache(cacheName);
if (cache != null) {
// => Clear entire cache
cache.clear();
}
});
}
}Benefits:
- Declarative:
@Cacheableinstead of manual cache.get/put - Automatic eviction: LRU with maximumSize prevents OutOfMemoryError
- TTL: expireAfterWrite automatically invalidates stale data
- Metrics: recordStats() exposes hit/miss rates for monitoring
- Consistent API: Same annotations work with different cache providers
Distributed Caching with Redis
Redis provides shared cache across multiple application instances:
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.*;
// => Configuration: Redis cache manager
@Configuration
@EnableCaching
public class RedisCacheConfig {
@Bean // => RedisCacheManager: distributed cache with Redis
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
// => Redis serialization: how objects stored in Redis
RedisCacheConfiguration cacheConfig = RedisCacheConfiguration.defaultCacheConfig()
// => TTL: time to live for cache entries
.entryTtl(Duration.ofMinutes(10)) // => 10-minute expiration
// => Automatic invalidation: entries removed after TTL
// => Key serialization: String keys
.serializeKeysWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new StringRedisSerializer() // => Keys stored as strings
)
)
// => Value serialization: JSON for human-readable storage
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer() // => Values stored as JSON
)
)
// => Disable null values: prevents caching null results
.disableCachingNullValues();
// => Create RedisCacheManager
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(cacheConfig) // => Default configuration for all caches
.build();
}
}
// => Repository: same annotations, distributed caching
@Repository
public class ZakatAccountRepository {
private final JdbcTemplate jdbcTemplate;
// => @Cacheable: now stores in Redis (shared across instances)
@Cacheable(value = "accounts", key = "#id")
public ZakatAccount findById(Long id) {
// => Cache miss: queries database and stores in Redis
// => Cache hit: retrieves from Redis (shared across all app instances)
return jdbcTemplate.queryForObject(
"SELECT id, account_number, balance FROM zakat_accounts WHERE id = ?",
(rs, rowNum) -> new ZakatAccount(
rs.getLong("id"),
rs.getString("account_number"),
rs.getBigDecimal("balance")
),
id
);
// => Result stored in Redis: available to all app instances
}
// => @CachePut: updates Redis cache
@CachePut(value = "accounts", key = "#account.id")
public ZakatAccount update(ZakatAccount account) {
jdbcTemplate.update(
"UPDATE zakat_accounts SET balance = ? WHERE id = ?",
account.getBalance(),
account.getId()
);
// => Redis cache updated across all instances
return account;
}
// => @CacheEvict: evicts from Redis
@CacheEvict(value = "accounts", key = "#id")
public void delete(Long id) {
jdbcTemplate.update("DELETE FROM zakat_accounts WHERE id = ?", id);
// => Redis cache entry removed across all instances
}
}Benefits:
- Distributed: Cache shared across multiple application instances
- Persistence: Redis persists cache to disk (survives restarts)
- Scalability: Redis cluster supports terabytes of cache
- Pub/Sub: Redis notifies other instances of cache changes
- Flexible TTL: Per-cache or per-entry expiration
Progression Diagram
graph TD
A[Manual Map Cache<br/>ConcurrentHashMap] -->|No Eviction| B[OutOfMemoryError]
A -->|No TTL| C[Stale Data]
A -->|Manual Invalidation| D[Inconsistency Risk]
E[Spring Cache + Caffeine<br/>In-Memory] -->|LRU Eviction| F[Bounded Memory]
E -->|TTL| G[Automatic Freshness]
E -->|Declarative| H[Consistent Invalidation]
E -->|Metrics| I[Observability]
J[Spring Cache + Redis<br/>Distributed] -->|Shared Cache| K[Multi-Instance]
J -->|Persistence| L[Survives Restarts]
J -->|Scalable| M[Terabyte Cache]
style A fill:#DE8F05,stroke:#333,stroke-width:2px,color:#fff
style E fill:#029E73,stroke:#333,stroke-width:2px,color:#fff
style J fill:#0173B2,stroke:#333,stroke-width:2px,color:#fff
Production Patterns
Cache Warming
@Service
public class CacheWarmingService {
private final ZakatAccountRepository accountRepository;
// => Warm cache on application startup
@EventListener(ApplicationReadyEvent.class)
// => Executes after Spring Boot fully started
public void warmCaches() {
// => Preload frequently accessed data
List<Long> popularAccounts = getPopularAccountIds();
popularAccounts.forEach(accountRepository::findById);
// => Populates cache before first user request
// => Eliminates cold-start latency
}
private List<Long> getPopularAccountIds() {
// => Fetch most accessed accounts from analytics
return Arrays.asList(1L, 2L, 3L, 4L, 5L);
}
}Multi-Level Caching
// => L1 cache: Caffeine (in-memory, fast, per-instance)
// => L2 cache: Redis (distributed, slower, shared)
@Configuration
@EnableCaching
public class MultiLevelCacheConfig {
@Bean
@Primary
public CacheManager cacheManager(
CacheManager caffeineCacheManager,
CacheManager redisCacheManager) {
// => CompositeCacheManager: multi-level caching
return new CompositeCacheManager(
caffeineCacheManager, // => L1: check Caffeine first (fast)
redisCacheManager // => L2: fallback to Redis if L1 miss
);
}
@Bean
public CacheManager caffeineCacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCaffeine(Caffeine.newBuilder()
.maximumSize(1000) // => Small L1 cache
.expireAfterWrite(Duration.ofMinutes(5)) // => Short TTL
);
return cacheManager;
}
@Bean
public CacheManager redisCacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30)); // => Longer TTL for L2
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
}
}Cache Monitoring
@Component
public class CacheMetrics {
private final CacheManager cacheManager;
@Scheduled(fixedRate = 60000) // => Every 60 seconds
public void logCacheStats() {
cacheManager.getCacheNames().forEach(cacheName -> {
Cache cache = cacheManager.getCache(cacheName);
if (cache instanceof CaffeineCache) {
// => Caffeine cache: extract statistics
CaffeineCache caffeineCache = (CaffeineCache) cache;
com.github.benmanes.caffeine.cache.Cache<Object, Object> nativeCache =
caffeineCache.getNativeCache();
CacheStats stats = nativeCache.stats();
// => Log cache metrics
long hitCount = stats.hitCount();
long missCount = stats.missCount();
double hitRate = stats.hitRate();
System.out.printf(
"Cache [%s]: hits=%d, misses=%d, hitRate=%.2f%%%n",
cacheName, hitCount, missCount, hitRate * 100
);
}
});
}
}Conditional Caching by Load
@Service
public class ZakatReportService {
private final AtomicInteger requestCount = new AtomicInteger(0);
// => Cache only under high load
@Cacheable(value = "reports", key = "#month", condition = "@zakatReportService.isHighLoad()")
// => condition: calls isHighLoad() method
// => Caches only when load is high
public ZakatReport generateMonthlyReport(String month) {
// => Expensive report generation
return generateReport(month);
}
// => Check if system under high load
public boolean isHighLoad() {
// => Increment request counter
int current = requestCount.incrementAndGet();
// => High load: >100 requests per minute
return current > 100;
}
@Scheduled(fixedRate = 60000) // => Reset every minute
public void resetCounter() {
requestCount.set(0);
}
}Trade-offs and When to Use
| Approach | Eviction | TTL | Invalidation | Metrics | Distribution | Complexity |
|---|---|---|---|---|---|---|
| Manual Map | Manual | Manual | Manual | None | No | Low |
| Spring + Caffeine | Automatic | Automatic | Declarative | Built-in | No | Low |
| Spring + Redis | Automatic | Automatic | Declarative | Built-in | Yes | Medium |
| Multi-Level | Automatic | Automatic | Declarative | Built-in | Hybrid | High |
When to Use Manual Map:
- Learning caching fundamentals
- Simple single-threaded applications
- Debugging cache behavior
When to Use Spring Cache + Caffeine:
- Single-instance applications (no clustering)
- In-memory cache sufficient (<1GB)
- Sub-millisecond cache access required
- Simple deployment (no Redis infrastructure)
When to Use Spring Cache + Redis:
- Multi-instance deployments (horizontal scaling)
- Large cache sizes (>1GB)
- Cache persistence across restarts required
- Shared cache across microservices
- Pub/Sub for cache invalidation
When to Use Multi-Level:
- Best of both worlds: fast L1 + shared L2
- High-throughput applications
- Minimize Redis network latency
- Complex caching requirements
Best Practices
1. Use @Cacheable for Read Operations
Cache frequently accessed data:
@Cacheable(value = "accounts", key = "#id")
public ZakatAccount findById(Long id) {
return jdbcTemplate.queryForObject(sql, rowMapper, id);
}2. Use @CachePut for Updates
Keep cache in sync with database:
@CachePut(value = "accounts", key = "#account.id")
public ZakatAccount update(ZakatAccount account) {
jdbcTemplate.update(sql, account.getBalance(), account.getId());
return account;
}3. Use @CacheEvict for Deletes
Remove stale entries:
@CacheEvict(value = "accounts", key = "#id")
public void delete(Long id) {
jdbcTemplate.update("DELETE FROM zakat_accounts WHERE id = ?", id);
}4. Set Appropriate TTL
Balance freshness vs cache hit rate:
Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(10)) // 10-minute TTL5. Monitor Cache Hit Rate
Track cache effectiveness:
Caffeine.newBuilder().recordStats() // Enable metrics6. Size Cache Appropriately
Prevent OutOfMemoryError:
Caffeine.newBuilder().maximumSize(1000) // Max 1000 entries7. Use Conditional Caching
Cache selectively based on conditions:
@Cacheable(value = "accounts", condition = "#id > 0", unless = "#result == null")See Also
- Spring JDBC - Database operations with caching
- Spring Data JPA - JPA query caching
- Configuration - Cache manager configuration
- Connection Pooling - Reduce database load
- Java Concurrency - Thread-safe caching