Skip to main content
surviving the spike

Spring Cache Abstraction with Redis

10 min read Chapter 17 of 66

Spring Cache Abstraction with Redis

You have a LettuceConnectionFactory and a RedisCacheManager. Now you need to make decisions that will determine whether your caching layer helps or hurts. The wrong TTL means serving stale fares. The wrong key strategy means cache misses on every request. The wrong serializer means doubling your Redis memory bill. This section covers the configuration details that separate a caching layer that works from one that causes incidents.

RedisCacheConfiguration: The Details That Matter

The RedisCacheConfiguration object controls four things per cache region: TTL, key prefix, null value handling, and serialization. Getting any of these wrong creates problems that only surface under production load.

TTL Per Cache Name

A single global TTL is the first mistake teams make. Fare estimates, driver availability, trip history, and user profiles have fundamentally different staleness tolerances. A 5-minute TTL that works for trip history will serve stale driver locations for 4 minutes and 50 seconds too long.

// SCALED: Per-cache TTL with business reasoning
@Bean
public RedisCacheManager cacheManager(LettuceConnectionFactory connectionFactory) {
    RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
        .entryTtl(Duration.ofMinutes(5))
        .computePrefixWith(cacheName -> cacheName + "::")
        .serializeKeysWith(
            RedisSerializationContext.SerializationPair.fromSerializer(
                new StringRedisSerializer()))
        .serializeValuesWith(
            RedisSerializationContext.SerializationPair.fromSerializer(
                new GenericJackson2JsonRedisSerializer()));

    Map<String, RedisCacheConfiguration> perCacheConfig = new HashMap<>();

    // Fare estimates: recalculated every pricing cycle
    perCacheConfig.put("fareEstimates",
        defaultConfig.entryTtl(Duration.ofSeconds(60)));

    // Driver availability: must reflect real-time state
    perCacheConfig.put("driverAvailability",
        defaultConfig.entryTtl(Duration.ofSeconds(10)));

    // Trip history: historical data, rarely changes
    perCacheConfig.put("tripHistory",
        defaultConfig.entryTtl(Duration.ofMinutes(5)));

    // User profiles: change infrequently
    perCacheConfig.put("userProfiles",
        defaultConfig.entryTtl(Duration.ofHours(1)));

    // Surge multipliers: volatile during peak
    perCacheConfig.put("surgeMultipliers",
        defaultConfig.entryTtl(Duration.ofSeconds(15)));

    return RedisCacheManager.builder(connectionFactory)
        .cacheDefaults(defaultConfig)
        .withInitialCacheConfigurations(perCacheConfig)
        .transactionAware()
        .build();
}

Key Prefix Strategy

The computePrefixWith call controls how Redis keys are structured. The default prefix is cacheName::. This matters for two reasons. First, you need to inspect cache entries in production. Running redis-cli KEYS tripHistory::* gives you every cached trip history entry. With a bad prefix, you are guessing. Second, you need to flush a single cache region without affecting others. redis-cli --scan --pattern 'fareEstimates::*' | xargs redis-cli DEL clears fare estimates while leaving trip history intact.

Null Value Caching

// BOTTLENECK: Caching null values
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
    .entryTtl(Duration.ofMinutes(5));
    // cacheNullValues is true by default

By default, Spring Cache stores null values. If fareService.calculate() returns null for an invalid zone pair, that null gets cached for 5 minutes. Every subsequent request for the same invalid zone pair returns the cached null immediately. This sounds like an optimization, but it creates a subtle bug: if the zone pair becomes valid (a new service area launches), the null response persists until the TTL expires.

// SCALED: Disable null caching for caches where absence is temporary
perCacheConfig.put("fareEstimates",
    defaultConfig
        .entryTtl(Duration.ofSeconds(60))
        .disableCachingNullValues());

For trip history, null values are fine. A rider with no trips should not generate database queries on every page load. For fare estimates, disabling null caching is correct because zone availability changes.

@Cacheable on Fare Estimates: Cache Key Design

The fare estimate endpoint receives a pickup location and a dropoff location as latitude/longitude pairs. Caching by exact coordinates is useless. Two requests from the same block will have slightly different coordinates and produce separate cache entries. The cache hit rate approaches zero.

The Symptom

// BOTTLENECK: Cache key on exact coordinates, near-zero hit rate
@Cacheable(value = "fareEstimates",
           key = "#pickup.lat + ',' + #pickup.lng + ':' + #dropoff.lat + ',' + #dropoff.lng")
public Mono<FareEstimate> estimateFare(Location pickup, Location dropoff) {
    return fareCalculator.calculate(pickup, dropoff);
}

With coordinates like 37.7749295 vs 37.7749301, every request is a cache miss. The cache exists but does nothing.

The Fix: Grid Cell Cache Key

Divide the city into grid cells. Each cell is approximately 200 meters square. Map every coordinate to its grid cell ID. Two passengers standing on the same block map to the same grid cell, and their fare estimates come from cache.

// SCALED: Grid cell cache key, 85%+ hit rate in dense areas
@Cacheable(value = "fareEstimates",
           key = "T(com.ridehail.util.GeoGrid).cellId(#pickup, 200) + ':' +
                  T(com.ridehail.util.GeoGrid).cellId(#dropoff, 200)")
public Mono<FareEstimate> estimateFare(Location pickup, Location dropoff) {
    return fareCalculator.calculate(pickup, dropoff);
}

The grid cell utility:

public class GeoGrid {
    private static final double EARTH_RADIUS_METERS = 6_371_000;

    public static String cellId(Location loc, int cellSizeMeters) {
        double latCell = Math.floor(loc.getLat() /
            (cellSizeMeters / EARTH_RADIUS_METERS * (180.0 / Math.PI)));
        double lngCell = Math.floor(loc.getLng() /
            (cellSizeMeters / (EARTH_RADIUS_METERS *
                Math.cos(Math.toRadians(loc.getLat()))) * (180.0 / Math.PI)));
        return (long) latCell + "_" + (long) lngCell;
    }
}

A 200-meter grid cell means the fare estimate could be off by the cost of 200 meters of travel. For a ride-hailing platform where the minimum fare is $5 and the per-meter cost is fractions of a cent, this error is invisible to the rider.

The Proof

MetricExact CoordinatesGrid Cell (200m)
Cache hit rate0.3%87%
Avg cache entries (downtown)142,0001,800
Redis memory usage89 MB1.2 MB
p95 latency310ms14ms

@CachePut for Driver Profile Updates

When a driver updates their profile (name, vehicle, photo), the cache must reflect the change immediately. Waiting for TTL expiration means riders see stale driver information for up to an hour.

@CachePut executes the method body and stores the return value in the cache, overwriting any existing entry. Unlike @Cacheable, it always executes the method.

// SCALED: Write-through with @CachePut
@CachePut(value = "userProfiles", key = "#profile.driverId")
public Mono<DriverProfile> updateDriverProfile(DriverProfile profile) {
    return driverProfileRepository.save(profile)
        .doOnSuccess(saved -> log.info("Driver profile updated and cached: {}",
            saved.getDriverId()));
}

The corresponding read method:

@Cacheable(value = "userProfiles", key = "#driverId")
public Mono<DriverProfile> getDriverProfile(String driverId) {
    return driverProfileRepository.findById(driverId);
}

When updateDriverProfile is called, Spring saves to the database, stores the result in Redis, and all subsequent getDriverProfile calls for that driver return the fresh data from cache. No TTL expiration needed.

The Trap: @CachePut Return Type Mismatch

If updateDriverProfile returns a different type than what getDriverProfile expects, the cache entry is poisoned. @CachePut stores whatever the method returns. If the update method returns a DriverProfileUpdateResponse but the read method expects a DriverProfile, deserialization fails silently or throws at read time.

// BOTTLENECK: Return type mismatch poisons the cache
@CachePut(value = "userProfiles", key = "#command.driverId")
public Mono<DriverProfileUpdateResponse> updateDriverProfile(
        UpdateDriverProfileCommand command) {
    // Returns DriverProfileUpdateResponse, not DriverProfile
    // Cache now contains the wrong type
    return driverProfileRepository.save(toEntity(command))
        .map(this::toUpdateResponse);
}

The fix is to ensure @CachePut and @Cacheable on the same cache return the same type. If they cannot, use @CacheEvict on the write path and let the next read repopulate the cache.

@CacheEvict Patterns and the Conditional Eviction Trap

@CacheEvict removes entries from the cache. Three patterns cover most cases.

Single Key Eviction

// Evict one rider's trip history when a new trip completes
@CacheEvict(value = "tripHistory", key = "#trip.riderId + ':0:20'")
public Mono<Trip> completeTrip(Trip trip) {
    return tripRepository.save(trip);
}

Problem: this only evicts the first page. If the rider viewed pages 2 and 3, those are stale.

All Entries Eviction

// Nuclear option: evict all trip history entries
@CacheEvict(value = "tripHistory", allEntries = true)
public Mono<Trip> completeTrip(Trip trip) {
    return tripRepository.save(trip);
}

This clears every rider’s trip history cache when any trip completes. During peak hours with 200 trip completions per minute, the cache is useless. The hit rate drops to zero.

Pattern-Based Eviction (The Right Answer)

Spring Cache does not natively support pattern-based eviction. You need a custom solution. The pragmatic approach: evict all entries for a specific rider using a custom CacheEvict handler or a manual RedisTemplate call.

// SCALED: Targeted eviction for a single rider's pages
@Service
public class TripCacheEvictionService {

    private final StringRedisTemplate redisTemplate;

    public TripCacheEvictionService(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void evictTripHistoryForRider(String riderId) {
        Set<String> keys = redisTemplate.keys("tripHistory::" + riderId + ":*");
        if (keys != null && !keys.isEmpty()) {
            redisTemplate.delete(keys);
        }
    }
}

Call this from the trip completion handler instead of relying on @CacheEvict.

The Conditional Eviction Trap

// BOTTLENECK: condition evaluated BEFORE method execution
@CacheEvict(value = "tripHistory",
            condition = "#trip.status == T(TripStatus).COMPLETED",
            allEntries = true)
public Mono<Trip> updateTripStatus(Trip trip) {
    trip.setStatus(TripStatus.COMPLETED);
    return tripRepository.save(trip);
}

The condition on @CacheEvict is evaluated before the method executes. If trip.status is IN_PROGRESS when the method is called (and the method sets it to COMPLETED), the condition evaluates to false and the cache is never evicted. The trip completes, the database is updated, but the cache still holds the old trip history.

The fix: use beforeInvocation = false (the default) and check the return value, or restructure so the condition matches the input state.

// SCALED: Evict unconditionally, let the method handle business logic
@CacheEvict(value = "tripHistory", allEntries = true)
public Mono<Trip> completeTrip(Trip trip) {
    // Only called when trip should be completed
    trip.setStatus(TripStatus.COMPLETED);
    trip.setCompletedAt(Instant.now());
    return tripRepository.save(trip);
}

Serialization Comparison: Concrete Numbers

Measured on a TripSummary record with 12 fields, two nested objects (Location with lat/lng, FareBreakdown with 5 fields), serialized and deserialized 100,000 times on JDK 21 with JMH.

MetricJackson JSONJDK SerializationProtobuf
Serialized size847 bytes512 bytes298 bytes
Serialize (avg)42µs28µs11µs
Deserialize (avg)38µs31µs9µs
Total round-trip80µs59µs20µs
Redis memory (1M entries)847 MB512 MB298 MB
Schema evolutionAdd field, old entries still readableserialVersionUID mismatch breaks everythingForward/backward compatible with protobuf rules
Debuggingredis-cli GET shows readable JSONBinary garbageBinary, needs protoc --decode

The Recommendation

Use Jackson JSON (GenericJackson2JsonRedisSerializer) unless you have measured evidence that serialization overhead is your bottleneck. For the ride-hailing platform doing 50,000 cache reads per second, the difference between 80µs and 20µs round-trip is 3 seconds of CPU time per second. At that scale, Protobuf pays for itself. Below that scale, the operational simplicity of JSON is worth the overhead.

// SCALED: Jackson JSON for debuggability
.serializeValuesWith(
    RedisSerializationContext.SerializationPair.fromSerializer(
        new GenericJackson2JsonRedisSerializer()))

One critical detail: GenericJackson2JsonRedisSerializer includes the fully qualified class name in the JSON (@class field). This enables deserialization without knowing the target type at compile time, but it also means renaming or moving a class breaks every cached entry. If you rename com.ridehail.model.TripSummary to com.ridehail.dto.TripSummary, every cached trip history entry fails to deserialize. Plan your package structure before you start caching, or use a custom ObjectMapper with type aliases.

Summary

Spring Cache with Redis requires five configuration decisions: TTL per cache, key prefix format, null value policy, serializer choice, and eviction strategy. Each decision maps directly to a business requirement. The grid cell cache key for fare estimates increased the hit rate from 0.3% to 87%. The per-cache TTL prevents both stale data and unnecessary cache misses. Jackson JSON serialization trades 40% more memory for the ability to debug cache entries in production.

The conditional eviction trap and the return type mismatch on @CachePut are the two most common bugs. Both are silent in development and explosive in production. Test your caching layer with integration tests that verify cache population, eviction, and type safety.