Back to Caching: Spring Cache & Redis
Q1Question 1 of 1

What is Spring Cache with Redis?

Key Concepts
  • @Cacheable implements cache-aside for reads: check cache first, on miss execute the method and store the result
  • @CacheEvict removes stale entries when data changes; @CachePut always executes and updates the cache
  • Redis is the standard distributed cache: in-memory, sub-millisecond, shared across all service instances
  • Cache keys must include ALL inputs that change the response, including tenantId, accountId, and pagination
  • Every cached entry needs a TTL; different data types need different TTLs based on volatility
  • Cache stampede occurs when many requests miss the same expired key simultaneously; fix with distributed lock and TTL jitter
  • When Redis is unavailable, the circuit breaker trips and the service falls back to direct database queries

Definition

@Cacheable checks the cache before executing a method and stores the result on a miss, @CacheEvict removes stale entries when data changes, and Redis provides a distributed cache shared across all service instances with sub-millisecond access.
  • @Cacheable – implements the cache-aside pattern for reads: if the cache has the value, return it immediately without executing the method; if not, execute the method, store the result, and return it.
  • @CachePut – always executes the method and writes the result into the cache, keeping the cache in sync after writes.
  • @CacheEvict – removes one or all entries from a cache region when data changes, so the next read fetches the fresh value.
  • Redis – an in-memory data store used as a distributed cache: sub-millisecond latency, shared across all service instances, supports strings, hashes, lists, sets, and sorted sets.
  • Cache stampede – when many requests simultaneously miss the same expired key and all hit the database at once.
  • TTL (Time To Live) – the maximum duration a cached entry is valid before automatic expiration.

The National Bank of India keeps a copy of frequently requested exchange rates at the front desk (cache) instead of calling the forex desk (database) every time. @Cacheable means check the front desk copy first; if it is not there, call the forex desk and bring a copy back. @CacheEvict means the forex desk just updated a rate, so throw away the front desk copy so the next customer gets the fresh one. Cache stampede: 100 customers ask for the USD rate at the exact moment the front desk copy expires, and all 100 rush to the forex desk simultaneously.

Understanding the Concepts

What is @Cacheable and how does it work?

The core annotation is @Cacheable, and it implements the cache-aside pattern for reads. When you put @Cacheable("exchangeRates") on a method, Spring wraps that method in a proxy. On every call, the proxy first builds a cache key from the method arguments and asks the cache whether it already holds a value for that key. If the cache has it (cache hit), the proxy returns the cached value immediately without ever running your method body. If the cache does not have it (cache miss), the proxy runs your real method, takes whatever it returns, stores it in the cache under that key, and then returns it.

JAVA
package com.bank.service;

import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;

@Service
public class ExchangeRateService {

    private final ExchangeRateRepository rateRepository;

    public ExchangeRateService(ExchangeRateRepository rateRepository) {
        this.rateRepository = rateRepository;
    }

    @Cacheable(value = "exchangeRates", key = "#fromCurrency + '-' + #toCurrency")
    public BigDecimal getRate(String fromCurrency, String toCurrency) {
        // This method body runs ONLY on a cache miss
        // On a cache hit, the proxy returns the cached value directly
        return rateRepository.findRate(fromCurrency, toCurrency);
    }
}
TEXT
First call:  getRate("USD", "INR") -> cache MISS -> query DB -> store in cache -> return 83.50
Second call: getRate("USD", "INR") -> cache HIT  -> return 83.50 (DB not touched)
Third call:  getRate("EUR", "INR") -> cache MISS -> query DB -> store in cache -> return 90.25

How do @CacheEvict and @CachePut work?

@CacheEvict is the write-side partner of @Cacheable. When data changes, the cached copy is stale and must be removed so the next read fetches the fresh value. @CachePut always runs your method and writes the result into the cache, keeping it in sync.

JAVA
package com.bank.service;

import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;

@Service
public class AccountService {

    private final AccountRepository accountRepository;

    public AccountService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    // READ: cache the balance
    @Cacheable(value = "balances", key = "#accountId")
    public BigDecimal getBalance(String accountId) {
        return accountRepository.findBalance(accountId);
    }

    // WRITE: evict the stale cached balance after a transfer
    @CacheEvict(value = "balances", key = "#accountId")
    public void debit(String accountId, BigDecimal amount) {
        accountRepository.debit(accountId, amount);
        // After this method, the cache entry for this account is removed
        // Next getBalance() call will miss and fetch the new balance
    }

    // UPDATE: always execute and refresh the cache
    @CachePut(value = "balances", key = "#accountId")
    public BigDecimal deposit(String accountId, BigDecimal amount) {
        accountRepository.credit(accountId, amount);
        return accountRepository.findBalance(accountId);
        // Method ALWAYS executes, result ALWAYS stored in cache
    }

    // EVICT ALL: clear entire cache region
    @CacheEvict(value = "balances", allEntries = true)
    public void recalculateAllBalances() {
        // Clears every entry in the "balances" cache
    }
}
TEXT
@Cacheable:  check cache -> hit? return cached : execute + store + return
@CacheEvict: execute method -> delete cache entry
@CachePut:   execute method -> write result to cache

Why use Redis instead of a local cache?

A local in-memory cache (like Caffeine) lives inside one application instance. If you run ten copies of the account service behind a load balancer, you have ten separate caches that can disagree. Evicting one does not evict the others. Redis is a single shared cache that all instances talk to.

| Aspect | Local Cache (Caffeine) | Distributed Cache (Redis) |
|---|---|---|
| Scope | One JVM instance | All instances share |
| Consistency | Each instance independent | Single source of truth |
| Latency | Nanoseconds (heap access) | Sub-millisecond (network) |
| Eviction propagation | Instance-only | All instances see eviction |
| Survives restart | No | Yes (RDB/AOF persistence) |
| Memory limit | JVM heap | Dedicated Redis memory |

YAML
# application.yml: Redis cache configuration
spring:
  cache:
    type: redis
  data:
    redis:
      host: redis-cluster.national-bank.internal
      port: 6379
      timeout: 2000ms

  cache:
    redis:
      time-to-live: 60s           # default TTL for all caches
      key-prefix: "nb:"           # namespace prefix
      use-key-prefix: true
JAVA
package com.bank.config;

import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import java.time.Duration;

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public RedisCacheConfiguration cacheConfiguration() {
        return RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(5))
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair
                    .fromSerializer(new GenericJackson2JsonRedisSerializer()));
        // JSON serialization, not Java serialization
        // Cache entries survive code changes and class renames
    }
}

How do you design cache keys correctly?

Cache keys must include ALL inputs that change the response. A key that is too broad returns wrong data. A key that is too narrow reduces cache hit rates.

JAVA
package com.bank.service;

import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.util.List;

@Service
public class TransactionService {

    // WRONG: missing pagination -- page 1 and page 2 return the same cached data
    @Cacheable(value = "transactions", key = "#accountId")
    public List<Transaction> getTransactions(String accountId, int page, int size) {
        return transactionRepository.findByAccount(accountId, page, size);
    }

    // CORRECT: key includes all inputs that change the result
    @Cacheable(value = "transactions",
               key = "#accountId + ':' + #page + ':' + #size")
    public List<Transaction> getTransactionsCorrect(String accountId, int page, int size) {
        return transactionRepository.findByAccount(accountId, page, size);
    }
}
TEXT
WRONG key: "SAV2001"         -> page 1 and page 2 get same cached result
CORRECT key: "SAV2001:0:20"  -> each page gets its own cache entry

What is cache stampede and how do you prevent it?

Cache stampede (also called thundering herd) happens when a popular cached entry expires and many requests arrive at the same instant. All of them miss the cache at the same time, so all of them query the database simultaneously for the same value. The database, which normally served one query per minute for this value, suddenly gets hundreds of identical queries in one burst.

At National Bank of India, the USD to INR exchange rate is cached with a 60-second TTL. At the exact second it expires, 500 requests for that rate arrive. All 500 miss the cache. All 500 query the database. The database is overwhelmed.

There are two defenses and you use them together:

  1. Distributed lock (single-flight): only one thread fetches from the database; others wait and read from cache.
  2. TTL jitter: add random seconds to the TTL so entries do not all expire at the same instant.
JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.Duration;
import java.util.concurrent.ThreadLocalRandom;

@Service
public class StampedeProtectedRateService {

    private final RedisTemplate<String, BigDecimal> redisTemplate;
    private final ExchangeRateRepository rateRepository;

    public StampedeProtectedRateService(RedisTemplate<String, BigDecimal> redisTemplate,
                                        ExchangeRateRepository rateRepository) {
        this.redisTemplate = redisTemplate;
        this.rateRepository = rateRepository;
    }

    public BigDecimal getRate(String from, String to) {
        String key = "rate:" + from + ":" + to;

        // Check cache first
        BigDecimal cached = redisTemplate.opsForValue().get(key);
        if (cached != null) {
            return cached;
        }

        // Try to acquire lock (single-flight)
        String lockKey = "lock:" + key;
        Boolean acquired = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, BigDecimal.ONE, Duration.ofSeconds(5));

        if (Boolean.TRUE.equals(acquired)) {
            try {
                // Only this thread queries the database
                BigDecimal rate = rateRepository.findRate(from, to);
                // TTL with jitter: 60 + random(0-5) seconds
                long ttlSeconds = 60 + ThreadLocalRandom.current().nextInt(6);
                redisTemplate.opsForValue().set(key, rate, Duration.ofSeconds(ttlSeconds));
                return rate;
            } finally {
                redisTemplate.delete(lockKey);
            }
        } else {
            // Another thread is fetching -- wait and retry from cache
            try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            BigDecimal retryValue = redisTemplate.opsForValue().get(key);
            return retryValue != null ? retryValue : rateRepository.findRate(from, to);
        }
    }
}
TEXT
Without stampede protection:
  500 requests -> 500 cache misses -> 500 DB queries (burst!)

With stampede protection:
  500 requests -> 500 cache misses -> 1 acquires lock -> 1 DB query
  -> stores in cache -> 499 read from cache (or wait + retry)

What TTL strategy should you use?

Every cached entry needs a TTL. Without it, the cache drifts stale forever. Different data types have different volatility and need different TTLs.

| Data Type | TTL | Reason |
|---|---|---|
| Exchange rates | 30 to 60 seconds | Changes frequently |
| Account balance | 5 to 10 seconds | Changes on every transaction |
| Customer profile | 5 to 15 minutes | Rarely changes |
| Reference data (branch list) | 1 to 24 hours | Almost static |
| Configuration | 1 hour | Changes during deployments |

JAVA
package com.bank.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import java.time.Duration;
import java.util.Map;

@Configuration
public class CacheTtlConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(5));

        // Different TTLs for different cache regions
        Map<String, RedisCacheConfiguration> cacheConfigs = Map.of(
            "exchangeRates", defaultConfig.entryTtl(Duration.ofSeconds(60)),
            "balances",      defaultConfig.entryTtl(Duration.ofSeconds(10)),
            "customers",     defaultConfig.entryTtl(Duration.ofMinutes(15)),
            "branches",      defaultConfig.entryTtl(Duration.ofHours(24))
        );

        return RedisCacheManager.builder(factory)
            .cacheDefaults(defaultConfig)
            .withInitialCacheConfigurations(cacheConfigs)
            .build();
    }
}

What happens when Redis is unavailable?

A cache outage should degrade performance, never cause a total outage. When Redis is unreachable, the service should fall back to direct database queries. The response will be slower, but Priya will still see her account balance.

JAVA
package com.bank.service;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;

@Service
public class ResilientAccountService {

    private static final Logger log = LoggerFactory.getLogger(ResilientAccountService.class);
    private final AccountRepository accountRepository;

    public ResilientAccountService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    @Cacheable(value = "balances", key = "#accountId",
               unless = "#result == null")
    public BigDecimal getBalance(String accountId) {
        return accountRepository.findBalance(accountId);
    }
}
JAVA
// Custom CacheErrorHandler: log Redis errors but do not fail the request
package com.bank.config;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cache.Cache;
import org.springframework.cache.interceptor.CacheErrorHandler;

public class GracefulCacheErrorHandler implements CacheErrorHandler {

    private static final Logger log = LoggerFactory.getLogger(GracefulCacheErrorHandler.class);

    @Override
    public void handleCacheGetError(RuntimeException e, Cache cache, Object key) {
        log.warn("Cache GET failed for key {} in {}: {}", key, cache.getName(), e.getMessage());
        // Do NOT re-throw: fall back to executing the method directly
    }

    @Override
    public void handleCachePutError(RuntimeException e, Cache cache, Object key, Object value) {
        log.warn("Cache PUT failed for key {} in {}: {}", key, cache.getName(), e.getMessage());
    }

    @Override
    public void handleCacheEvictError(RuntimeException e, Cache cache, Object key) {
        log.warn("Cache EVICT failed for key {} in {}: {}", key, cache.getName(), e.getMessage());
    }

    @Override
    public void handleCacheClearError(RuntimeException e, Cache cache) {
        log.warn("Cache CLEAR failed for {}: {}", cache.getName(), e.getMessage());
    }
}
TEXT
Redis available:   getBalance("SAV2001") -> cache HIT -> 1ms response
Redis unavailable: getBalance("SAV2001") -> cache error (logged) -> DB query -> 50ms response
Priya sees her balance either way. Only the speed changes.

What are common mistakes with caching?

The most common mistake is caching mutable objects. If the caller modifies the returned object, the cached copy is also modified because Java returns a reference, not a copy.

JAVA
// WRONG: caching a mutable object
@Cacheable("accounts")
public Account getAccount(String accountId) {
    return accountRepository.findById(accountId);
}
// Caller does: account.setBalance(newBalance)
// This modifies the CACHED object! Next caller sees wrong balance.

// CORRECT: return an immutable DTO or use Redis (serialization creates a copy)
@Cacheable("accounts")
public AccountDTO getAccount(String accountId) {
    Account account = accountRepository.findById(accountId);
    return new AccountDTO(account.getId(), account.getBalance()); // immutable record
}

Another mistake is using Java serialization for Redis. Java serialization creates binary data tied to the exact class version. If you rename a field or move a class, all cached entries become unreadable. Use JSON serialization instead.

What should a senior engineer know?

  • L1/L2 caching: use Caffeine (local, nanosecond) as L1 and Redis (distributed, sub-millisecond) as L2. Check L1 first, then L2, then database.
  • Redis data structures beyond strings: Hash for storing object fields, List for message queues, Set for unique values, Sorted Set for leaderboards and rate limiting.
  • Redis distributed lock using SET key value NX PX ttl: NX means set only if not exists, PX sets the expiry in milliseconds. This is the foundation of the Redlock algorithm.
  • Serialize with JSON (using GenericJackson2JsonRedisSerializer) so cache entries survive code changes, class renames, and service version upgrades.
  • At National Bank of India, Pankaj configures Redis Sentinel for high availability and Redis Cluster for horizontal scaling across the production environment.
DIAGRAM
L1/L2 Cache Lookup:
  getRate("USD", "INR")
       │
  ┌────┴──────┐
  │ L1 Caffeine│ -> HIT? return (nanoseconds)
  └────┬──────┘
       │ MISS
  ┌────┴──────┐
  │ L2 Redis   │ -> HIT? store in L1 + return (sub-ms)
  └────┬──────┘
       │ MISS
  ┌────┴──────┐
  │ Database   │ -> store in L2 + L1 + return (milliseconds)
  └───────────┘

What are common interview follow-up questions?

  • What is the difference between @Cacheable and @CachePut? @Cacheable may skip the method on a hit. @CachePut always executes and writes the result to cache.
  • Can you use @Cacheable on a void method? No. There is no return value to cache.
  • How do you clear all caches at once? Use @CacheEvict(value = "cacheName", allEntries = true) or inject CacheManager and call cacheManager.getCache("name").clear().
  • What is the difference between Redis and Memcached? Redis supports multiple data structures, persistence, pub/sub, and Lua scripting. Memcached is simpler, supports only key-value strings, and is slightly faster for simple GET/SET operations.

Visual Overview

DIAGRAM
  @Cacheable Flow (Cache-Aside Pattern):

  Method Call
       │
  ┌────┴──────────────┐
  │ Build cache key    │  key = fromCurrency + "-" + toCurrency
  └────┬──────────────┘
       ▼
  ┌──────────────┐
  │ Check cache   │
  └────┬────┬────┘
       │    │
    HIT│    │MISS
       │    ▼
       │  ┌──────────────┐
       │  │ Execute method│  query database
       │  └────┬─────────┘
       │       ▼
       │  ┌──────────────┐
       │  │ Store in cache│  with TTL
       │  └────┬─────────┘
       │       │
       ▼       ▼
  ┌──────────────────┐
  │ Return result     │
  └──────────────────┘


  Cache Stampede Prevention:

  500 requests arrive simultaneously
       │
  ┌────┴──────────────┐
  │ All 500 miss cache │
  └────┬──────────────┘
       ▼
  ┌──────────────────────┐
  │ Try acquire lock      │
  └────┬────────┬────────┘
       │        │
    1 wins    499 wait
       │        │
       ▼        │
  ┌──────────┐  │
  │ Query DB  │  │
  │ (1 query) │  │
  └────┬─────┘  │
       ▼        │
  ┌──────────┐  │
  │ Store in  │  │
  │ cache     │  │
  └────┬─────┘  │
       │        ▼
       │  ┌──────────────┐
       │  │ Read from     │
       │  │ cache (HIT)   │
       │  └──────────────┘
       ▼
  All 500 served, only 1 DB query

Key Takeaway

Spring Cache provides @Cacheable for cache-aside reads (check cache, miss triggers the method and stores the result), @CachePut for updating the cache after a write (always executes and refreshes), and @CacheEvict for invalidating stale entries. Redis is the standard distributed cache at National Bank of India: in-memory sub-millisecond access, shared across all service instances, with JSON serialization so cache entries survive code changes.

Every cached entry needs a TTL. Different data gets different TTLs: exchange rates at 60 seconds, account balances at 10 seconds, customer profiles at 15 minutes, and reference data at 24 hours. For stampede prevention when a hot key expires and 500 requests arrive simultaneously, Rajesh uses a Redis distributed lock (SET NX PX) so only one instance fetches from the database while others wait and read from cache. TTL jitter spreads expirations over time so keys do not all expire at the same instant.

The most important principle is resilience: a cache outage should degrade performance, never cause a total outage. When Redis is unavailable, the GracefulCacheErrorHandler logs the error and the service falls back to direct database queries. Priya sees her balance either way; only the speed changes.

Remember one sentence: @Cacheable checks cache before executing, @CacheEvict removes stale entries on write, Redis shares the cache across all instances, and every entry needs a TTL with jitter to prevent stampede.

---

Share