Skip to content

Caching

Why cache

Reduce latency and load on databases/downstreams by keeping frequently-read data close.

Patterns

  • Cache-aside (lazy) — app checks cache; on miss, loads from DB and populates. Most common.
  • Read-through — cache library loads from DB on miss.
  • Write-through — write to cache + DB synchronously (consistent, slower writes).
  • Write-behind — write to cache, async flush to DB (fast, risk of loss).

Eviction policies

LRU (least recently used), LFU, FIFO, TTL (time-based). Bound size to avoid memory pressure.

Local vs distributed

Local (in-JVM: EhCache, Caffeine) Distributed (Redis, Memcached)
Speed fastest (no network) network hop
Scope per instance (coherence issue) shared across instances
Capacity heap-bound large, independent
Survives restart no yes

Invalidation ("one of the two hard things")

TTL expiry, explicit eviction on write, or an explicit refresh endpoint. Beware stale data and cache stampede / thundering herd on expiry → mitigate with TTL jitter, locks, or request coalescing.

Redis

In-memory data store; strings/hashes/lists/sets/sorted-sets; TTL; pub/sub; used as distributed cache, session store, rate limiter, leaderboard.

Spring Cache Abstraction

Spring provides unified caching annotations over multiple providers (Caffeine, EhCache, Redis).

Setup

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

<!-- Choose provider -->
<!-- EhCache 3 -->
<dependency>
    <groupId>org.ehcache</groupId>
    <artifactId>ehcache</artifactId>
</dependency>

<!-- OR Caffeine (lightweight, no config) -->
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

<!-- OR Redis -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Enable Caching

@SpringBootApplication
@EnableCaching  // Activate caching
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

Core Annotations

@Service
public class UserService {

    // @Cacheable: Return cached value if exists; otherwise execute & cache result
    @Cacheable(value = "users", key = "#id")
    public User getUser(Long id) {
        return userRepository.findById(id).orElse(null);  // DB hit only on miss
    }

    // @CachePut: Always execute & update cache (write-through)
    @CachePut(value = "users", key = "#user.id")
    public User updateUser(User user) {
        return userRepository.save(user);  // DB updated, cache refreshed
    }

    // @CacheEvict: Remove from cache (invalidation)
    @CacheEvict(value = "users", key = "#id")
    public void deleteUser(Long id) {
        userRepository.deleteById(id);  // DB deleted, cache invalidated
    }

    // @CacheEvict with allEntries: Clear entire cache
    @CacheEvict(value = "users", allEntries = true)
    public void clearCache() {
        // Invalidate all users cache
    }

    // Conditional caching (cache only if condition true)
    @Cacheable(value = "users", key = "#id", condition = "#id > 0")
    public User getUserIfValid(Long id) {
        return userRepository.findById(id).orElse(null);
    }

    // Unless: Cache all except when condition true
    @Cacheable(value = "users", key = "#id", unless = "#result == null")
    public User getUserNonNull(Long id) {
        return userRepository.findById(id).orElse(null);  // Don't cache null
    }
}

Configuration

EhCache Configuration

<!-- ehcache.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<config xmlns="http://www.ehcache.org/v3">
    <cache alias="users">
        <key-type>java.lang.Long</key-type>
        <value-type>com.example.User</value-type>
        <expiry>
            <tti unit="minutes">30</tti>  <!-- Time To Idle: evict if unused for 30 min -->
        </expiry>
        <resources>
            <heap unit="entries">10000</heap>  <!-- Max 10k entries in heap -->
        </resources>
    </cache>

    <cache alias="orders">
        <key-type>java.lang.Long</key-type>
        <value-type>com.example.Order</value-type>
        <expiry>
            <ttl unit="hours">1</ttl>  <!-- Time To Live: expire after 1 hour -->
        </expiry>
        <resources>
            <heap unit="entries">5000</heap>
        </resources>
    </cache>
</config>
# application.yml
spring:
  cache:
    type: ehcache  # Use EhCache provider
    ehcache:
      config: classpath:ehcache.xml

Caffeine Configuration (No XML Needed)

# application.yml
spring:
  cache:
    type: caffeine
    caffeine:
      spec: maximumSize=10000,expireAfterWrite=10m  # Config as string
// Or programmatically
@Configuration
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager cacheManager = new CaffeineCacheManager("users", "orders");
        cacheManager.setCaffeine(
            Caffeine.newBuilder()
                .maximumSize(10000)
                .expireAfterWrite(10, TimeUnit.MINUTES)
                .recordStats()  // Enable statistics
        );
        return cacheManager;
    }
}

Redis Configuration

# application.yml
spring:
  cache:
    type: redis
    redis:
      time-to-live: 600000  # 10 minutes in milliseconds
      key-prefix: "cache:"
      use-key-prefix: true

  redis:
    host: localhost
    port: 6379

Advanced: Custom Key Generation

// By default: key="#id" uses method parameter name
@Cacheable(value = "users", key = "#id")
public User getUser(Long id) { ... }

// Custom key with multiple params
@Cacheable(value = "users", key = "#userId + ':' + #includeOrders")
public User getUser(Long userId, boolean includeOrders) { ... }

// SpEL expressions
@Cacheable(value = "users", key = "T(java.lang.String).format('user:%d', #id)")
public User getUser(Long id) { ... }

// Custom key generator class
@Configuration
public class CacheConfig {
    @Bean
    public KeyGenerator customKeyGenerator() {
        return (target, method, params) -> {
            String methodName = method.getName();
            return methodName + ":" + Arrays.toString(params);
        };
    }
}

@Cacheable(value = "users", keyGenerator = "customKeyGenerator")
public User getUser(Long id) { ... }

Monitoring & Statistics

@Component
public class CacheStats {

    @Autowired
    private CacheManager cacheManager;

    @Scheduled(fixedRate = 60000)  // Every minute
    public void printCacheStats() {
        for (String cacheName : cacheManager.getCacheNames()) {
            Cache cache = cacheManager.getCache(cacheName);

            // Get native cache implementation (Caffeine)
            if (cache.getNativeCache() instanceof com.github.benmanes.caffeine.cache.Cache) {
                com.github.benmanes.caffeine.cache.Cache<?> caffeineCache = 
                    (com.github.benmanes.caffeine.cache.Cache<?>) cache.getNativeCache();

                System.out.println("Cache: " + cacheName);
                System.out.println("  Size: " + caffeineCache.estimatedSize());
                System.out.println("  Stats: " + caffeineCache.stats());
            }
        }
    }
}

EhCache: Local In-JVM Cache

EhCache — Popular, production-grade local caching library for Java.

POM Dependency

<dependency>
    <groupId>org.ehcache</groupId>
    <artifactId>ehcache</artifactId>
    <version>3.10.8</version>
</dependency>

Programmatic Configuration

@Configuration
public class EhCacheConfig {

    @Bean
    public CacheManager cacheManager() {
        CacheConfiguration<Long, User> userCacheConfig = CacheConfigurationBuilder
            .newCacheConfigurationBuilder(Long.class, User.class,
                ResourcePoolsBuilder.heap(10000))  // 10k entries
            .withExpiry(ExpiryPolicyBuilder.timeToIdleExpiration(
                Duration.ofMinutes(30)))  // Evict if unused for 30 min
            .withEvictionPriority(EvictionPriority.FIFO)
            .build();

        CacheConfiguration<Long, Order> orderCacheConfig = CacheConfigurationBuilder
            .newCacheConfigurationBuilder(Long.class, Order.class,
                ResourcePoolsBuilder.heap(5000))
            .withExpiry(ExpiryPolicyBuilder.timeToLiveExpiration(
                Duration.ofHours(1)))  // Expire after 1 hour
            .build();

        CacheManager cacheManager = CacheManagerBuilder.newCacheManagerBuilder()
            .withCache("users", userCacheConfig)
            .withCache("orders", orderCacheConfig)
            .build(true);  // init on creation

        return new org.springframework.cache.ehcache.EhCacheCacheManager(cacheManager);
    }
}

Direct API Usage (Without Spring)

// Create cache programmatically
CacheManager cacheManager = CacheManagerBuilder.newCacheManagerBuilder()
    .withCache("users",
        CacheConfigurationBuilder
            .newCacheConfigurationBuilder(Long.class, String.class,
                ResourcePoolsBuilder.heap(100))
            .build())
    .build(true);

Cache<Long, String> cache = cacheManager.getCache("users", Long.class, String.class);

// Put
cache.put(1L, "John");

// Get
String user = cache.get(1L);

// Remove
cache.remove(1L);

// Close
cacheManager.close();

TTL vs TTI

Type Behavior
TTL (Time-To-Live) Expires after set duration, regardless of access
TTI (Time-To-Idle) Expires if not accessed within set duration
<!-- TTL: Entry expires 1 hour after creation -->
<expiry>
    <ttl unit="hours">1</ttl>
</expiry>

<!-- TTI: Entry expires if not accessed for 30 minutes -->
<expiry>
    <tti unit="minutes">30</tti>
</expiry>

Eviction Policies

Policy Eviction
FIFO First-In-First-Out
LRU Least Recently Used
LFU Least Frequently Used
<cache>
    <resources>
        <heap unit="entries">10000</heap>
    </resources>
    <eviction-adviser>
        <!-- Custom eviction logic -->
    </eviction-adviser>
</cache>

Three-Tier Caching (Heap, Offheap, Disk)

<cache>
    <resources>
        <heap unit="entries">1000</heap>      <!-- Fast, in JVM heap -->
        <offheap unit="MB">100</offheap>      <!-- Slower, native memory -->
        <disk unit="MB">1000</disk>           <!-- Persistent, slow -->
    </resources>
</cache>

Data flows: Heap → Offheap → Disk as size increases.


Cache Invalidation Strategies

1. TTL/TTI Expiry (Time-based)

Automatically remove after fixed duration or inactivity:

@Cacheable(value = "users", key = "#id")
public User getUser(Long id) {
    return userRepository.findById(id).orElse(null);
}
// Cache expires after 10 minutes (config)

Pros: Simple, no code needed
Cons: May serve stale data, or lose fresh data

2. Explicit Invalidation (Event-based)

Manually evict on known changes:

@CacheEvict(value = "users", key = "#id")
public void updateUser(User user) {
    userRepository.save(user);
}

@CacheEvict(value = "users", allEntries = true)
public void bulkUpdate() { ... }

Pros: Always fresh
Cons: Must remember to evict

3. Refresh Endpoint

Admin endpoint to clear cache:

@PostMapping("/admin/cache/clear")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Void> clearCache() {
    cacheManager.getCacheNames()
        .forEach(name -> cacheManager.getCache(name).clear());
    return ResponseEntity.noContent().build();
}

4. Cache Stampede Mitigation

When many threads miss cache simultaneously, prevent dogpiling:

// Approach 1: Probabilistic TTL jitter
@Cacheable(value = "users", key = "#id")
public User getUser(Long id) {
    return userRepository.findById(id).orElse(null);
}
// Add jitter in config: TTL ± random(0-10%)

// Approach 2: Lock-based loading
@Service
public class UserService {
    private final Map<Long, Boolean> locks = new ConcurrentHashMap<>();

    public User getUser(Long id) {
        User cached = getFromCache(id);
        if (cached != null) return cached;

        // Only one thread loads; others wait
        synchronized(locks.computeIfAbsent(id, k -> new Object())) {
            cached = getFromCache(id);
            if (cached == null) {
                cached = userRepository.findById(id).orElse(null);
                if (cached != null) putInCache(id, cached);
            }
        }
        return cached;
    }
}

Local vs Distributed Cache Comparison

Aspect Local (EhCache, Caffeine) Distributed (Redis)
Latency Microseconds (in-JVM) Milliseconds (network)
Scope Per instance Shared across all instances
Consistency Can be stale More consistent (single source)
Memory Bounded by JVM heap Separate Redis server
Survives restart No Yes
Serialization Not needed Needed (JSON/binary)
Best for High-traffic local ops Multi-instance, consistency-critical