Ecommet
Topic 39 of 75

Cache Eviction, Invalidation and Stampedes

A cache is small, so something must leave when it fills up, and stale entries must go when data changes. Learn LRU, LFU and TTL eviction, invalidation approaches, and how to stop a cache stampede from flattening your database.

Intermediate 7 min read 4 quiz questions

Jump to the quiz

In a nutshell

  • Eviction decides what to drop when the cache is full: LRU (least recently used), LFU (least frequently used), or TTL expiry.
  • Invalidation removes entries when the source changes: delete on write, short TTLs, events from the database, or versioned keys.
  • A stampede happens when a hot key expires and thousands of requests rebuild it at once.
  • Stop stampedes with request coalescing (one rebuild, others wait), early refresh and jittered TTLs.

Eviction policies

Redis and Memcached offer approximated LRU and LFU, since tracking exact order for millions of keys is costly.
PolicyDropsGood forWeak spot
LRUThe entry not used for the longest timeMost workloads: recent items are likely to be used againA one-off scan of many keys pushes out the hot ones
LFUThe entry used the fewest timesStable popularity (top products)Old favourites linger after they stop being popular
TTLEntries past their expiry timeBounding stalenessNot a size limit by itself
FIFO / randomOldest inserted / any entrySimplicityIgnores how entries are used

An LRU cache with O(1) operations combines a hash map (key to entry) with a doubly linked list ordered by recency: every access moves the entry to the front, and eviction removes the entry at the back. Both Java and Python have this built in.

LRU cache

Java
import java.util.*;

class LRUCache<K, V> {
    private final int capacity;
    // accessOrder = true: iteration order is least to most recently used
    private final LinkedHashMap<K, V> map;

    LRUCache(int capacity) {
        this.capacity = capacity;
        this.map = new LinkedHashMap<>(16, 0.75f, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
                return size() > LRUCache.this.capacity;
            }
        };
    }

    synchronized V get(K key) {
        return map.get(key); // also marks it most recently used
    }

    synchronized void put(K key, V value) {
        map.put(key, value);
    }

    synchronized Set<K> keys() {
        return new LinkedHashSet<>(map.keySet());
    }
}
Python
from collections import OrderedDict

class LRUCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.data = OrderedDict()  # least to most recently used

    def get(self, key):
        if key not in self.data:
            return None
        self.data.move_to_end(key)
        return self.data[key]

    def put(self, key, value):
        self.data[key] = value
        self.data.move_to_end(key)
        if len(self.data) > self.capacity:
            self.data.popitem(last=False)  # evict the least recently used

    def keys(self):
        return list(self.data)

LFU in O(1) keeps, for each use count, a list of keys in recency order, plus the current minimum count. Eviction removes the least recently used key among those with the lowest count.

LFU cache (ties broken by least recent use)

Java
import java.util.*;

class LFUCache<K, V> {
    private final int capacity;
    private final Map<K, V> values = new HashMap<>();
    private final Map<K, Integer> counts = new HashMap<>();
    private final Map<Integer, LinkedHashSet<K>> byCount = new HashMap<>();
    private int minCount = 0;

    LFUCache(int capacity) {
        this.capacity = capacity;
    }

    private void touch(K key) {
        int c = counts.get(key);
        counts.put(key, c + 1);
        byCount.get(c).remove(key);
        if (c == minCount && byCount.get(c).isEmpty()) {
            minCount++;
        }
        byCount.computeIfAbsent(c + 1, x -> new LinkedHashSet<>()).add(key);
    }

    synchronized V get(K key) {
        if (!values.containsKey(key)) {
            return null;
        }
        touch(key);
        return values.get(key);
    }

    synchronized void put(K key, V value) {
        if (capacity == 0) {
            return;
        }
        if (values.containsKey(key)) {
            values.put(key, value);
            touch(key);
            return;
        }
        if (values.size() == capacity) {
            K victim = byCount.get(minCount).iterator().next();
            byCount.get(minCount).remove(victim);
            values.remove(victim);
            counts.remove(victim);
        }
        values.put(key, value);
        counts.put(key, 1);
        byCount.computeIfAbsent(1, x -> new LinkedHashSet<>()).add(key);
        minCount = 1;
    }
}
Python
from collections import OrderedDict, defaultdict

class LFUCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.values = {}
        self.counts = {}
        self.by_count = defaultdict(OrderedDict)  # count -> keys, least recent first
        self.min_count = 0

    def _touch(self, key):
        c = self.counts[key]
        self.counts[key] = c + 1
        del self.by_count[c][key]
        if c == self.min_count and not self.by_count[c]:
            self.min_count += 1
        self.by_count[c + 1][key] = None

    def get(self, key):
        if key not in self.values:
            return None
        self._touch(key)
        return self.values[key]

    def put(self, key, value):
        if self.capacity == 0:
            return
        if key in self.values:
            self.values[key] = value
            self._touch(key)
            return
        if len(self.values) == self.capacity:
            victim, _ = self.by_count[self.min_count].popitem(last=False)
            del self.values[victim]
            del self.counts[victim]
        self.values[key] = value
        self.counts[key] = 1
        self.by_count[1][key] = None
        self.min_count = 1

Invalidation

Eviction handles space; invalidation handles correctness. The options, from simplest to strongest:

  • TTL only: data is at most N seconds stale. Simple and self-healing; fine when some staleness is acceptable.
  • Delete on write: the code path that writes the database deletes the key. Fast, but every write path must remember to do it.
  • Change data capture: a process reads the database's change log and deletes affected keys, so no write path can forget.
  • Versioned keys: include a version in the key (product:31:v7); bump the version on change and old entries simply stop being read and expire.

Cache stampedes

A hot key (the home page feed, served 10,000 times a second) expires. In the next instant thousands of requests miss together, all run the same expensive query, and the database falls over, which makes rebuilding the cache even slower. This is a stampede (or thundering herd, or dog-pile).

Request coalescing stops a stampede

Request coalescing stops a stampede Request 1 → Cache: miss, gets lock Request 2 → Cache: waits Request 3 → Cache: waits Cache → Database: one query Request 1Request 2Request 3Cachehome:feed expiredDatabasemiss, gets lockwaitswaitsone query

All steps Only one request rebuilds the expired key; the others wait for its result instead of all hitting the database.

Diagram as text
  1. Three requests miss at once. The first takes a short lock for the key; the others wait (or are served the stale value).
  2. Only one query reaches the database. When it returns, the cache is filled and every waiting request gets the result.
  • Request coalescing / locking: the first miss takes a lock (for example Redis SET key:lock 1 NX EX 10) and rebuilds; others wait briefly or get the old value.
  • Serve stale while revalidating: keep the expired value and return it while one background refresh runs.
  • Early, probabilistic refresh: as expiry approaches, each request has a small and growing chance of refreshing early, so one request usually refreshes before the key actually expires.
  • Jittered TTLs: add randomness (10 minutes ± 1) so keys written together do not all expire together.

Common mistakes

  • Giving thousands of keys the same TTL after a cache warm-up, so they all expire in the same second.
  • Relying on delete-on-write alone, with no TTL as a safety net.
  • Letting every miss on a hot key query the database.
  • Using LRU for a workload with large one-off scans, which flush out the hot set.

Interview questions

Try answering out loud before you open each one.

A popular key expires and the database spikes. What do you do?

Prevent concurrent rebuilds: a lock or single-flight so one request recomputes while others wait or get the stale value. Serve stale-while-revalidate for hot keys, refresh them early, and add jitter to TTLs so expiries spread out. Also make sure the database has protection (connection limits, load shedding).

Implement an LRU cache.

A hash map from key to a node in a doubly linked list ordered by recency. get moves the node to the front; put inserts at the front and, if over capacity, removes the tail node and its map entry. Both are O(1). In Java, LinkedHashMap with access order and removeEldestEntry does this; in Python, OrderedDict with move_to_end and popitem(last=False).

Check yourself

1. Capacity 2 LRU: put a, put b, get a, put c. Which key is evicted?
Show the answer

Answer: b. Reading a made it most recent, so b was least recently used.

2. What causes a cache stampede?
Show the answer

Answer: Many requests rebuilding the same expired hot key at once. All misses hit the database together.

3. Why add jitter to TTLs?
Show the answer

Answer: So keys set together do not expire together. Spreading expiries spreads the rebuild load.

4. Why keep a TTL even when you delete keys on every write?
Show the answer

Answer: It limits how long a missed invalidation can serve stale data. Bugs happen; the TTL is the safety net.

Further reading

Your notes

Saved in this browser as you type.

Finished this topic?

Mark it read to track your progress, then carry on with the next one.

Next topic