Database Caching at Scale · Cache Consistency Strategies

Two-Tier Caching: In-Process L1 + Redis L2

Microsecond reads from local memory without serving stale data for long: invalidation fan-out with pub/sub.

Scenario

Example app: a pricing API runs on 40 pods. Every request needs the price rules for a product; Redis adds ~0.8 ms per call and the hot set fits easily in memory. The team adds a 60-second in-process cache (L1) in front of Redis (L2).

What you see in production

Latency drops — but after a price change, some pods serve the old price for up to a minute, so customers refreshing the page see the price flip between old and new depending on which pod answers.

The naive code

Cache<Long, PriceRules> l1 = Caffeine.newBuilder().expireAfterWrite(Duration.ofSeconds(60)).build();

public PriceRules rules(long productId) {
    return l1.get(productId, id -> redisOrDb(id));     // each pod keeps its own copy for 60 s
}

public void updateRules(long productId, PriceRules r) {
    repo.save(productId, r);
    redis.delete("rules:" + productId);                 // L2 invalidated; 39 other pods' L1 untouched
}

Reproduce it

Four pods with L1 caches; a price change invalidates L2 only, versus L2 plus a pub/sub broadcast that every pod's L1 subscribes to:

L1 staleness across pods, with and without broadcast invalidationTry it yourself
def run(broadcast):
    db = {"price": 100}
    l2 = {"price": 100}
    l1 = {f"pod{i}": {"price": 100} for i in range(1, 5)}   # all pods have it cached
    db["price"] = 80                                         # price change
    l2.pop("price")                                          # DEL in Redis
    if broadcast:
        for pod in l1.values():                              # PUBLISH invalidate rules:p1 → each pod evicts
            pod.pop("price", None)
    served = {}
    for name, cache in l1.items():
        if "price" not in cache:
            cache["price"] = l2.setdefault("price", db["price"])
        served[name] = cache["price"]
    return served

print("L2 delete only:      ", run(False))
print("L2 delete + pub/sub: ", run(True))

Output

L2 delete only:       {'pod1': 100, 'pod2': 100, 'pod3': 100, 'pod4': 100}
L2 delete + pub/sub:  {'pod1': 80, 'pod2': 80, 'pod3': 80, 'pod4': 80}

Root cause

Each pod's L1 is an independent copy. Deleting the shared L2 entry doesn't reach them, so every pod keeps its old value until its own TTL — and different pods expire at different times, which users experience as values flipping back and forth.

The fix

  • Broadcast invalidations to all instances (Redis pub/sub, a Kafka topic, or Redis client-side caching with tracking), and evict from L1 on receipt.
  • Keep L1 TTLs short (seconds) as the backstop for missed messages (pub/sub is fire-and-forget).
  • Cache in L1 only data that tolerates a few seconds of staleness; read critical values from L2/database.
@Bean
RedisMessageListenerContainer invalidations(RedisConnectionFactory f, Cache<Long, PriceRules> l1) {
    var c = new RedisMessageListenerContainer();
    c.setConnectionFactory(f);
    c.addMessageListener((msg, pattern) -> l1.invalidate(Long.parseLong(new String(msg.getBody()))),
                         new ChannelTopic("invalidate:rules"));
    return c;
}
// on update, after commit:
redis.delete("rules:" + productId);
redis.convertAndSend("invalidate:rules", String.valueOf(productId));

Trade-offs

  • Pub/sub messages are lost if a pod is disconnected — short L1 TTLs and reconnect-time L1 flushes cover that.
  • Every write now fans out to every pod; batch invalidations for bulk updates.
  • L1 multiplies memory by the number of pods and can still disagree for a few milliseconds.

Detect it in production

  • Version/ETag of the served value per pod in responses or logs; alert when pods disagree for longer than expected.
  • Subscriber connection status and invalidation message rates.

Key points

  • L1 caches are per-instance copies; deleting L2 doesn't reach them.
  • Broadcast invalidations (pub/sub, Kafka, client tracking) and keep L1 TTLs short.
  • Use L1 only for data that tolerates brief staleness.

Practice questions

Why do users see a price flip between old and new values after a change in a two-tier cache?

Answer

Each pod's in-process L1 still holds the old value until its own TTL; deleting the shared Redis entry doesn't reach them, so responses depend on which pod answers.

How do you invalidate L1 caches on many instances?

Answer

Publish an invalidation message (Redis pub/sub, a Kafka topic or Redis client-side tracking) that every instance subscribes to and evicts on; keep short L1 TTLs for messages that get lost.

Why keep L1 TTLs short even with broadcast invalidation?

Answer

Pub/sub is fire-and-forget; disconnected instances miss messages. A short TTL bounds how long a missed invalidation can leave stale data.

Open in the app to mark lessons complete, take notes and track your progress.