Caching Layer Three: Computed Aggregates, Invalidation, and What Goes Wrong
Caching Layer Three: Computed Aggregates, Invalidation, and What Goes Wrong
You cached trip history in CH6. Static-ish data, clear ownership, simple TTL. Now you need to cache data that changes every second: how many drivers are available in zone-47, what the current surge multiplier is for downtown, the average fare on the airport route in the last 15 minutes. These are computed aggregates. They are expensive to calculate, required on every ride request, and wrong the moment they are stored.
Computed aggregates sit in a brutal middle ground. Cache them too aggressively and riders see stale surge pricing, pay $12 when the real price is $28, and your revenue tanks. Cache them too loosely and you are back to hammering PostgreSQL with GROUP BY queries 8,000 times per minute during Friday night peak.
This chapter covers how to cache surge multipliers and driver counts in Redis, when to use TTL vs event-driven invalidation, how to prevent cache stampedes with the XFetch algorithm, and the five failure modes that will break your caching layer if you do not plan for them.
The diagram contrasts what happens when a popular cache key expires under high load. Without protection (left), all 1,000 concurrent requests see the cache miss simultaneously and stampede the database with identical queries, driving CPU to 100% and producing a 34% error rate. With a mutex lock (right), only one request acquires a distributed lock via Redis SETNX, queries the database, and repopulates the cache. The remaining 999 requests wait briefly on the lock and then serve from the freshly populated cache. The result: one database query instead of a thousand, and zero errors instead of hundreds of timeouts.
Computed Aggregates in the Ride-Hailing Platform
The Symptom
Friday 6:45 PM. The surge pricing endpoint handles 8,400 requests per minute. Each request calculates the surge multiplier for a zone by counting active ride requests, counting available drivers, and dividing demand by supply. The computation queries three tables with a 15-minute sliding window. PostgreSQL CPU is at 84%. P95 latency on the surge endpoint is 620ms. Riders are seeing a loading spinner where the fare estimate should be.
The Cause
The surge multiplier computation is expensive and identical for every rider in the same zone during the same time window. One thousand riders requesting a ride in zone-47 within the same second all trigger the same GROUP BY query against the same data. There is no caching. Every request computes from scratch.
// BOTTLENECK: Raw computation on every surge pricing request
@GetMapping("/api/v1/surge/{zoneId}")
public Mono<SurgeResponse> getSurgeMultiplier(@PathVariable String zoneId) {
return rideRequestRepository.countActiveInZone(zoneId, Instant.now().minus(15, ChronoUnit.MINUTES))
.zipWith(driverRepository.countAvailableInZone(zoneId))
.map(tuple -> {
long demand = tuple.getT1();
long supply = Math.max(tuple.getT2(), 1);
double multiplier = Math.min(calculateMultiplier(demand, supply), 5.0);
return new SurgeResponse(zoneId, multiplier, Instant.now());
});
}
Two database queries per request. Under peak load, this endpoint alone consumes 35% of available database connections.
The Baseline
Before caching, peak-hour metrics for the surge pricing endpoint:
| Metric | Value |
|---|---|
| Throughput | 8,400 req/min |
| p50 latency | 180ms |
| p95 latency | 620ms |
| PostgreSQL CPU | 84% |
| DB connection pool utilization | 78% |
| Error rate | 1.8% (connection timeouts) |
The Fix: Surge Multiplier in a Redis Hash
Store the surge multiplier per zone in a Redis Hash. The hash key is surge:{zoneId}. Fields are multiplier, computedAt, demand, and supply. This gives you atomic reads of the full surge state and lets monitoring tools inspect individual zones.
// SCALED: Surge multiplier cached in Redis Hash with TTL safety net
@Service
public class SurgePricingService {
private final ReactiveRedisTemplate<String, String> redisTemplate;
private final SurgeComputationService computationService;
private final MeterRegistry meterRegistry;
private static final Duration SURGE_TTL = Duration.ofSeconds(30);
public Mono<SurgeResponse> getSurgeMultiplier(String zoneId) {
String key = "surge:" + zoneId;
return redisTemplate.opsForHash().entries(key)
.collectMap(e -> (String) e.getKey(), e -> (String) e.getValue())
.flatMap(cached -> {
if (!cached.isEmpty()) {
meterRegistry.counter("surge.cache", "result", "hit").increment();
return Mono.just(fromCache(zoneId, cached));
}
meterRegistry.counter("surge.cache", "result", "miss").increment();
return computeAndCache(zoneId, key);
});
}
private Mono<SurgeResponse> computeAndCache(String zoneId, String key) {
return computationService.compute(zoneId)
.flatMap(surge -> {
Map<String, String> fields = Map.of(
"multiplier", String.valueOf(surge.multiplier()),
"computedAt", surge.computedAt().toString(),
"demand", String.valueOf(surge.demand()),
"supply", String.valueOf(surge.supply())
);
return redisTemplate.opsForHash().putAll(key, fields)
.then(redisTemplate.expire(key, SURGE_TTL))
.thenReturn(surge);
});
}
}
Driver counts go into a Redis Sorted Set keyed by zone. The score is the last heartbeat timestamp. ZRANGEBYSCORE returns only drivers with heartbeats in the last 60 seconds.
// SCALED: Driver availability as a Sorted Set with timestamp scores
public Mono<Long> getAvailableDriverCount(String zoneId) {
String key = "drivers:available:" + zoneId;
double cutoff = Instant.now().minus(60, ChronoUnit.SECONDS).toEpochMilli();
return redisTemplate.opsForZSet()
.count(key, Range.closed(cutoff, Double.MAX_VALUE));
}
Invalidation: TTL vs Event-Driven vs Hybrid
TTL-based invalidation is simple: the surge multiplier expires after 30 seconds. The problem is that 30 seconds is an eternity during a demand spike. At 6:50 PM a concert lets out and 3,000 people open the app in zone-12. The surge should jump from 1.0x to 3.2x within seconds. With TTL-only, riders see the stale 1.0x price for up to 30 seconds. You are giving away rides at a loss.
Event-driven invalidation reacts to state changes. A Kafka consumer listens for ride-requested and driver-status-changed events and updates the Redis cache immediately. Latency drops from 30 seconds to under 500ms. The problem is that Kafka consumer rebalances during deployments create gaps where events are not processed.
The hybrid approach uses events as the primary invalidation path and TTL as the safety net. Track which fires first with a metric.
// SCALED: Hybrid invalidation with metrics tracking
@KafkaListener(topics = "ride-events", groupId = "surge-cache-invalidator")
public void onRideEvent(RideEvent event) {
String key = "surge:" + event.zoneId();
computationService.compute(event.zoneId())
.flatMap(surge -> {
Map<String, String> fields = Map.of(
"multiplier", String.valueOf(surge.multiplier()),
"computedAt", surge.computedAt().toString(),
"invalidatedBy", "event"
);
return redisTemplate.opsForHash().putAll(key, fields)
.then(redisTemplate.expire(key, SURGE_TTL));
})
.doOnSuccess(v -> meterRegistry.counter("surge.invalidation",
"trigger", "event").increment())
.subscribe();
}
When the TTL fires before an event arrives, that is a signal your event pipeline is lagging. Alert on it.
Cache Stampede Prevention with XFetch
The Symptom
The surge cache for zone-47 expires at 18:52:30.000. At that exact moment, 847 concurrent requests arrive. All 847 see a cache miss. All 847 call computationService.compute("zone-47"). PostgreSQL gets 847 identical queries in the same 200ms window. CPU spikes to 96%. Response times degrade across every endpoint.
The Cause
Classic thundering herd. The cache expires atomically. Every request racing through the miss window triggers a recompute. With a 30-second TTL and 8,400 requests per minute, you get 140 requests per second. A 200ms recompute window means 28 concurrent recomputes on every expiration cycle.
The Fix: Probabilistic Early Recomputation (XFetch)
The XFetch algorithm recomputes the cache before it expires. Each request generates a random number and checks if it should trigger an early recompute based on how close the TTL is to expiration. The closer the expiration, the higher the probability of recomputing. In practice, exactly one request triggers the recompute while the rest still serve the cached value.
// SCALED: XFetch probabilistic early recomputation
public Mono<SurgeResponse> getSurgeWithXFetch(String zoneId) {
String key = "surge:" + zoneId;
return redisTemplate.opsForHash().entries(key)
.collectMap(e -> (String) e.getKey(), e -> (String) e.getValue())
.zipWith(redisTemplate.getExpire(key))
.flatMap(tuple -> {
Map<String, String> cached = tuple.getT1();
Duration ttlRemaining = tuple.getT2();
if (cached.isEmpty()) {
return computeWithLock(zoneId, key);
}
double delta = computationTimeMs; // measured avg recompute time
double beta = 1.0; // tuning parameter
double now = System.currentTimeMillis();
double expiry = now + ttlRemaining.toMillis();
double xfetchThreshold = delta * beta * Math.log(Math.random());
if (now - (expiry - SURGE_TTL.toMillis()) >= SURGE_TTL.toMillis() + xfetchThreshold) {
// Probabilistic early recompute, non-blocking
computeAndCache(zoneId, key).subscribe();
}
return Mono.just(fromCache(zoneId, cached));
});
}
For complete cache misses, add a SETNX-based distributed lock so only one request computes:
// SCALED: Distributed lock for cold cache miss
private Mono<SurgeResponse> computeWithLock(String zoneId, String key) {
String lockKey = "lock:surge:" + zoneId;
return redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(5))
.flatMap(acquired -> {
if (Boolean.TRUE.equals(acquired)) {
return computeAndCache(zoneId, key)
.doFinally(sig -> redisTemplate.delete(lockKey).subscribe());
}
// Another request is computing. Wait and retry from cache.
return Mono.delay(Duration.ofMillis(50))
.then(getSurgeMultiplier(zoneId));
});
}
The Five Cache Failure Modes
Each failure mode has a specific Redis configuration that causes it and a specific configuration that prevents it. CH7-S2 covers each in full detail with reproduction steps and Locust tests.
1. Thundering Herd. Every request hits the origin when the cache expires. Caused by fixed TTL without stampede protection. Fixed with XFetch and SETNX locks as shown above.
2. Stale Reads. Driver counts show 15 available when only 2 remain. Caused by event-driven invalidation lag during Kafka consumer rebalance. Fixed with read-repair: if the cached computedAt timestamp is older than a threshold, trigger a background recompute and serve the stale value.
3. Cache Poisoning. A fare calculation bug writes a negative multiplier to the cache. Every surge request serves $-4.20 for 30 seconds. Fixed with validation before cache writes. Never cache a value without checking invariants.
4. Memory Pressure. Redis hits maxmemory and starts evicting surge pricing entries because allkeys-lru treats all keys equally. Your $2M/hour revenue-critical surge cache gets evicted to make room for debug logs someone forgot to remove. Fixed with volatile-lfu: only keys with explicit TTLs are eviction candidates, and frequency-based eviction keeps hot keys alive.
5. Eviction Policy Mismatch. allkeys-random evicts the hot driver location cache 40% of the time while keeping cold analytics keys. Fixed by benchmarking allkeys-lru, allkeys-lfu, volatile-lru, and volatile-lfu against your actual access patterns.
The Proof: Locust Test for Surge Pricing Under Demand Spike
# locustfile_surge.py
from locust import HttpUser, task, between, events
import random
import time
class SurgePricingUser(HttpUser):
wait_time = between(0.1, 0.5)
zones = [f"zone-{i}" for i in range(1, 51)]
@task(8)
def get_surge_pricing(self):
zone = random.choice(self.zones)
with self.client.get(
f"/api/v1/surge/{zone}",
name="/api/v1/surge/[zoneId]",
catch_response=True
) as response:
if response.status_code == 200:
data = response.json()
if data["multiplier"] < 0:
response.failure("Negative surge multiplier (cache poisoning)")
elif data["multiplier"] > 5.0:
response.failure("Surge multiplier exceeds cap")
else:
response.failure(f"Status {response.status_code}")
@task(2)
def simulate_demand_spike(self):
"""Flood a single zone to trigger surge recalculation"""
zone = "zone-12"
self.client.post(
f"/api/v1/ride/request",
json={"zoneId": zone, "riderId": f"rider-{random.randint(1, 10000)}"},
name="/api/v1/ride/request [spike]"
)
Run with: locust -f locustfile_surge.py --users 2000 --spawn-rate 200 --run-time 3m
Before and After Metrics
| Metric | No Cache | TTL-Only | Hybrid + XFetch |
|---|---|---|---|
| p50 latency | 180ms | 3ms | 2ms |
| p95 latency | 620ms | 45ms (stampede) | 8ms |
| PostgreSQL CPU (peak) | 84% | 31% | 12% |
| DB queries/min | 8,400 | 280 | 45 |
| Surge accuracy lag | 0s | 0-30s | 0-2s |
| Stampede queries/expiry | N/A | 28 | 1 |
| Error rate | 1.8% | 0.4% | 0.05% |
The hybrid approach with XFetch reduces database queries by 99.5% while keeping surge pricing accuracy within 2 seconds of real-time. CH7-S1 covers the implementation details. CH7-S2 covers how to reproduce and survive each failure mode.