Skip to content
Laviyx Learn

Try

System Design course menu

Caching in System Design: Strategies and Eviction

Speed up systems with caching: cache-aside, write-through and write-back, LRU eviction, TTLs, invalidation, hit ratio, Redis, and the cache stampede problem.

Intermediate8 min readUpdated
On this page

A cache stores a copy of data somewhere fast, so that later requests avoid slow work. Memory is thousands of times faster than disk, and a small amount of data often gets most of the traffic.

Where caches live#

Text
Browser --> CDN --> Load balancer --> App cache --> Redis --> Database
 cache                                 (memory)              (own cache)

The closer to the user, the faster. Most design discussions focus on a shared cache such as Redis or Memcached, in front of the database.

Cache-aside#

This is the most common pattern. The app checks the cache, and on a miss, it reads the database and fills the cache:

Python
cache = {}
database = {"user:1": "Asha", "user:2": "Ravi"}
db_reads = 0


def get(key):
    global db_reads
    if key in cache:
        return cache[key]
    db_reads += 1
    value = database[key]
    cache[key] = value
    return value


for key in ["user:1", "user:1", "user:2", "user:1"]:
    print(key, "->", get(key))
print("Database reads:", db_reads)
Output
user:1 -> Asha
user:1 -> Asha
user:2 -> Ravi
user:1 -> Asha
Database reads: 2

Four requests caused only two database reads.

Write strategies#

StrategyOn a writeProsCons
Cache-asideUpdate the database, delete the cache entrySimple; only caches what is usedFirst read after a change is slow
Write-throughWrite to cache and database togetherCache stays freshSlower writes
Write-backWrite to cache, and to the database laterVery fast writesData lost if the cache dies
Write-aroundWrite only to the databaseAvoids filling the cacheNext read misses

Eviction#

Memory is limited, so a cache removes something when full. The most common rule is LRU, which removes the least recently used item:

Python
from collections import OrderedDict


class LRUCache:
    def __init__(self, capacity):
        self.items = OrderedDict()
        self.capacity = capacity

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

    def put(self, key, value):
        self.items[key] = value
        self.items.move_to_end(key)
        if len(self.items) > self.capacity:
            removed, _ = self.items.popitem(last=False)
            print("Evicted", removed)


lru = LRUCache(2)
lru.put("a", 1)
lru.put("b", 2)
lru.get("a")
lru.put("c", 3)
print(list(lru.items))
Output
Evicted b
['a', 'c']

b was removed, since a had just been used. Others are LFU (least frequently used), and FIFO (first in, first out).

Why hit ratio matters#

The hit ratio is the share of requests found in the cache. A small change in it can mean a big change in load:

Python
requests = 100_000
for hit in (0.80, 0.90, 0.99):
    to_db = requests * (1 - hit)
    avg_ms = hit * 1 + (1 - hit) * 20
    print(f"Hit ratio {hit:.0%}: {to_db:>6,.0f} database reads, average {avg_ms:.1f} ms")
Output
Hit ratio 80%: 20,000 database reads, average 4.8 ms
Hit ratio 90%: 10,000 database reads, average 2.9 ms
Hit ratio 99%:  1,000 database reads, average 1.2 ms

Going from 90% to 99% cuts database load by ten times.

Keeping data fresh#

There is an old saying that cache invalidation is one of the hard problems in computing. The main tools are:

  • TTL (time to live): entries expire after a set time. Simple, but data can be stale until then.
  • Delete on write: remove the entry when data changes.
  • Versioned keys: use a new key, such as user:1:v7, after a change.

Most systems use delete on write, with a TTL as a safety net.

Common cache problems#

ProblemWhat happensFix
StampedeA popular entry expires, and thousands of requests hit the databaseA lock, so that one request refills; refresh early
PenetrationRequests for data that does not exist always missCache the "not found" result; Bloom filter
AvalancheMany entries expire togetherAdd randomness to TTLs
Hot keyOne key overloads a cache nodeLocal caching; copies of the key

This shows how a little randomness spreads expiry times:

Python
import random

random.seed(3)
ttl = 600
expiry = sorted(ttl + random.randint(0, 60) for _ in range(5))
print(expiry)
Output
[608, 615, 623, 634, 637]

When not to cache#

Do not cache data that must be exact, such as a bank balance at the moment of payment, or data that is rarely read twice. Each cache adds complexity, and a chance of stale data.

Common mistakes#

Practice#

Practice exercise

Add a hit counter to a dictionary cache, run these keys through it, and print the hit ratio: a b a c a b d a.

Show solution
Python
seen = set()
hits = 0
keys = "a b a c a b d a".split()
for key in keys:
    if key in seen:
        hits += 1
    seen.add(key)
print(f"{hits}/{len(keys)} = {hits / len(keys):.0%}")
Output
4/8 = 50%

Key takeaway#

Next: CDNs and Object Storage.

Finished this lesson?

Some links are affiliate links. If you buy through them, Laviyx Learn may earn a commission at no extra cost to you. As an Amazon Associate, we earn from qualifying purchases.