System Design
B4

Caching: layers, patterns and what breaks

A cache keeps a copy of data closer to the reader. It trades freshness for speed and for load taken off the database. This sheet shows where copies live, how they get filled and removed, and how they fail.

Not startedSaved in this browser only.
  1. 1Each layer from the browser to the buffer pool keeps a copy. Know who invalidates each one.
  2. 2Cache-aside is the default: read the cache, fill on a miss, delete the key after the commit.
  3. 3When a hot key expires, every reader queries at once. Let one reader fill it.
  4. 4Add TTL jitter, cache the misses, and use leases so a late fill cannot write a stale value.
B4
    A

    Where caches sit

    from the user to the disk
    layer · a miss goes downholdshit latency, log scaleinvalidated byBrowser cacheon the user's deviceStatic files, images,cacheable API repliesno network tripOnly max-age. You cannotpurge it: change the URLCDN edgenear the userStatic files, video segments,public pages5 to 50 ms, trip to the edges-maxage, or a purgecall to the CDNReverse proxyin front of the appWhole HTTP responsesabout 0.5 ms, one hopTTL, or a purge requestIn-process cacheapp memory, per processHot keys, config,small lookup tablesabout 150 ns, measuredA short TTL. Each processholds its own copyDistributed cacheRedis or MemcachedObjects, sessions,results of expensive queries0.1 to 1 ms, network tripThe app: delete the keyon write, plus a TTLBuffer poolPostgres shared_buffersTable and index pagesabout 0.15 ms, measuredThe database itself.It is never stale100 ns10 µs1 ms100 ms

    Hit times are orders of magnitude. In-process and buffer-pool reads were measured in the lab. A data-centre hop is about 0.5 ms (Jeff Dean's latency table). Light in fiber covers about 200 km per ms, so an edge 500 km away is a 5 ms round trip.

    I name each layer, what it holds and who invalidates it. A browser cache cannot be purged, so long TTLs go only on versioned file names.

    B

    Read and write patterns

    numbered in request order
    Cache-asideread1 32AppCacheDatabase1 GET, miss · 2 SELECT · 3 SETThe app reads the cache. On a missit reads the database and fills the key.Read-throughread12AppCacheDatabase1 get · 2 the cache loads a missThe cache layer loads a missing keyitself. The app sees one call.Refresh-aheadread12AppCacheDatabase1 get, a hit · 2 reload before expiryHot keys reload before they expire,so readers do not see the miss.Write-throughwrite12AppCacheDatabase1 write · 2 write, same requestEach write goes to the cache and thedatabase before the reply.Write-behindwrite12AppCacheDatabase1 write · 2 later, in batchesThe reply comes from the cache. A crashloses the writes still in the queue.Write-aroundwrite12AppCacheDatabase1 UPDATE, COMMIT · 2 DEL the keyWrite the database only. The nextread fills the cache.
    patternwho fills the cachestale windowuse it whenstatus
    Cache-asideServiceUntil the delete, or the TTLMost reads. The cache can fail and the app still works.Default
    Read-throughCache librarySame as cache-asideA library or proxy owns the loading code for many callers.Approved
    Write-throughServiceShort; two writes can still crossData is read right after it is written.Read after write
    Write-behindRedisThe database is behind the cacheCounters and metrics, where a crash may lose a few writes.Loss is acceptable
    Write-aroundServiceUntil the next read fillsData is written often and read rarely.Approved
    Refresh-aheadServiceUp to one refresh periodA few hot keys that must never miss.Known hot keys
    Update the cache on every writeServiceUntil the TTL, if writers crossNever. Panel J shows the race.Not approved

    I use cache-aside with delete-on-write by default. I move to write-through or refresh-ahead only for a named reason.

    C

    Eviction

    when memory is full
    most recentleast recentstartDCBAGET BBDCAa hit: B to the frontSET EEBDCAevicted
    policyevictsstatus
    LRUThe key unused for the longest time.Default choice
    LFUThe key with the fewest recent uses. A one-off scan does not push out hot keys.Skewed reads
    TTL onlyNothing until a key expires.Memory is ample
    RandomAny key.Uniform access
    No evictionNothing. Writes fail with an out-of-memory error.Not for a cache

    Redis approximates LRU and LFU by sampling keys (maxmemory-samples, 5 by default). The lab server reports maxmemory-policy noeviction, the default.

    For a cache I set allkeys-lfu or allkeys-lru. The Redis default refuses writes when memory is full.

    D

    Invalidation

    how a stale copy goes away
    methodstale forcoststatus
    TTL onlyUp to the TTLNoneStaleness is acceptable
    Delete on write, after the commitMilliseconds; a slow reader can refill an old valueOne DEL per writeDefault
    Delete before the commitUntil the TTLOne DELNot approved
    Versioned keys: product:42:v7None for new readers; old keys age outReaders must know the versionImmutable data
    Change data capture from the WALReplication lag, often under a secondA consumer to runMany writers
    Leases on fillNone from a late fillTwo scriptsHot, changing keys
    • Always keep a TTL. It bounds every bug you did not find.
    • Change data capture reads committed changes only, so it deletes after the commit by design.
    • Versioned keys suit content that never changes after it is written.

    I delete the key after the commit and keep a TTL as the safety net. When many services write one table, I use change data capture.

    E

    Capabilities used

    what each tool gives you
    toolcapabilitywhat it gives this designalso used for
    RedisGET, and SET with EX or PXRead a key, or store it with a time to live in one command.Sessions, rate-limit windows
    RedisSET with NX and PXOne reader in the cluster wins the fill lock. The lock expires if its holder dies.Idempotency keys, holds
    RedisServer-side scripts (EVAL)Check a lease and fill in one step. Release a lock only if you own it.Token buckets, inventory holds
    RedisDEL with several keysDelete the value and its lease in one command.Cleanup of related keys
    RedisCluster hash tags: product:{42}The value, its lock and its lease share one slot, so one script can use all three.Any multi-key script in a cluster
    Redismaxmemory-policy: allkeys-lru, allkeys-lfuRedis evicts by itself when memory is full.Session stores
    RedisAsynchronous replicationLimit A replica can still serve a key that the primary just deleted.
    ServiceIn-process LRU with a TTLA hit in about 150 ns with no network trip. The first stop for hot keys.Config, feature flags
    ServiceSingleflightCallers in one process share one query per key.Any expensive call, such as token refresh
    PostgresBuffer pool (shared_buffers)A warm primary-key read costs about 0.15 ms. Cache only work that costs more.Every query
    PostgresMVCC: readers see committed rowsA reader during an open write gets the old row. This is why the delete waits for the commit.Consistent reads without locks
    PostgresLogical decoding (change data capture)A stream of committed changes. A consumer deletes keys from it.Search sync, outbox relay
    HTTPCache-Control, ETagBrowsers and CDNs cache public responses and revalidate them cheaply.Sheet B5

    Redis gives me atomic fill locks, scripts for leases and per-key TTLs. Postgres gives me the committed truth and a change stream to invalidate from.

    F

    Cache-aside, read and write

    pseudo code
    read and write pathpseudo code
    get(id):
      v = GET "product:{42}1"                   // Redis
      IF v is a hit: RETURN v                  // "not found" can be a hit too2
      row = SELECT ... WHERE id = 42           // Postgres
      IF row is missing:
        SET key = "-" EX 30 s3                  // cache the miss
        RETURN not found
      SET key = row EX ttl4
      RETURN row
    
    update(id, price):
      UPDATE products SET price, version + 1
      COMMIT5
      DEL key, lease6                           // after COMMIT, never before
    1. 1Hash tag. The value, its lock and its lease land in one cluster slot.
    2. 2Negative caching: a missing row is cached as a tombstone, so repeated bad ids cost one query.
    3. 3A short TTL for the tombstone, so a row created later shows up soon.
    4. 4Always set a TTL. It bounds the damage of any missed invalidation.
    5. 5The delete comes after the commit. Before it, a reader could refill the old row.
    6. 6Delete, do not update. The next reader fills from the committed row.
    Tested source Go: read, fill, negative cache · Go: write, then delete
    Go: read, fill, negative cachego
    
    // Get returns a product, from Redis when it is there and from Postgres otherwise.
    func (c *Cache) Get(ctx context.Context, id int64) (Product, error) {
      if p, hit, err := c.Peek(ctx, id); hit || err != nil {
        return p, err // a cached "not found" returns ErrNotFound here
      }
      switch c.strategy {
      case CacheAside:
        return c.fill(ctx, id)
      case Singleflight:
        p, err, _ := c.flights.Do(Key(id), func() (Product, error) { return c.fill(ctx, id) })
        return p, err
      case Lock:
        return c.fillUnderLock(ctx, id)
      }
      return Product{}, fmt.Errorf("unknown strategy %q", c.strategy)
    }
    
    // fill reads Postgres and stores the result, or a tombstone when the row does not exist.
    func (c *Cache) fill(ctx context.Context, id int64) (Product, error) {
      p, err := c.store.Load(ctx, id)
      if errors.Is(err, ErrNotFound) {
        if err := c.rdb.Set(ctx, Key(id), tombstone, c.NegativeTTL).Err(); err != nil {
          return Product{}, fmt.Errorf("cache tombstone %d: %w", id, err)
        }
        return Product{}, ErrNotFound
      }
      if err != nil {
        return Product{}, err
      }
      return p, c.Put(ctx, p, c.TTL)
    }
    
    Go: write, then deletego
    
    // UpdatePrice is the write path: change the row, commit, and only then delete the cache key and
    // its lease. The next reader fills the cache from the committed row.
    func (c *Cache) UpdatePrice(ctx context.Context, db *pgxpool.Pool, id, price int64) (version int64, err error) {
      err = db.QueryRow(ctx, sqls["update_price"], id, price).Scan(&version) // commits on its own
      if errors.Is(err, pgx.ErrNoRows) {
        return 0, ErrNotFound
      }
      if err != nil {
        return 0, fmt.Errorf("update product %d: %w", id, err)
      }
      return version, c.Invalidate(ctx, id)
    }
    
    // Invalidate deletes the cached value and any open lease, in one command.
    func (c *Cache) Invalidate(ctx context.Context, id int64) error {
      if err := c.rdb.Del(ctx, Key(id), leaseKey(id)).Err(); err != nil {
        return fmt.Errorf("invalidate %d: %w", id, err)
      }
      return nil
    }
    

    On a miss I read Postgres and fill with a TTL. On a write I commit first, then delete the key.

    G

    Fill a missing key once

    pseudo code
    stampede protectionpseudo code
    // one query per key, per process
    singleflight.Do(key, load)                 // others in this process wait1
    
    // one query per key, in the whole cluster
    IF SET "product:{42}:lock" = token NX PX 20002:
      v = GET key                              // check again3
      IF v is a miss: v = load(); SET key = v
      DEL lock IF lock == token4                // a script
      RETURN v
    REPEAT every 10 ms:
      IF GET key is a hit: RETURN it
      IF the lock is gone5: try to take it
    
    // spread expiries of keys written together
    ttl = base + random(0, spread)6
    1. 1A map of calls in flight, guarded by a mutex. The first caller runs the query; the rest wait for its result.
    2. 2Set only if absent, with an expiry. If the holder dies, the lock frees itself after 2 s.
    3. 3The last holder may have filled the key just before this caller took the lock.
    4. 4Compare and delete in one script. A slow holder never deletes the next holder’s lock.
    5. 5The holder died without filling. A waiter takes over.
    6. 6TTL jitter. Panel H shows 100 keys spread over 500 ms instead of one spike.
    Tested source Go: singleflight · Go: fill under a lock · Redis script: release the lock · Go: TTL jitter
    Go: singleflightgo
    
    // Do runs fn for key, or waits for the call already running for key. shared is true when the
    // result came from another caller's call.
    func (g *Group[V]) Do(key string, fn func() (V, error)) (v V, err error, shared bool) {
      g.mu.Lock()
      if c, ok := g.calls[key]; ok {
        g.mu.Unlock()
        <-c.done // join the call in flight
        return c.val, c.err, true
      }
      c := &call[V]{done: make(chan struct{})}
      if g.calls == nil {
        g.calls = map[string]*call[V]{}
      }
      g.calls[key] = c
      g.mu.Unlock()
    
      c.val, c.err = fn() // only this caller queries
      g.mu.Lock()
      delete(g.calls, key)
      g.mu.Unlock()
      close(c.done)
      return c.val, c.err, false
    }
    
    Go: fill under a lockgo
    func (c *Cache) fillUnderLock(ctx context.Context, id int64) (Product, error) {
      token := rand.Text()
      for {
        won, err := c.rdb.SetNX(ctx, lockKey(id), token, c.LockTTL).Result() // SET NX PX
        if err != nil {
          return Product{}, fmt.Errorf("take fill lock %d: %w", id, err)
        }
        if won {
          return c.fillAndUnlock(ctx, id, token)
        }
        // Another reader is filling. Wait for the value, or for the lock to vanish.
        for {
          if err := sleep(ctx, c.PollEvery); err != nil {
            return Product{}, err
          }
          if p, hit, err := c.Peek(ctx, id); hit || err != nil {
            return p, err
          }
          n, err := c.rdb.Exists(ctx, lockKey(id)).Result()
          if err != nil {
            return Product{}, fmt.Errorf("check fill lock %d: %w", id, err)
          }
          if n == 0 {
            break // the holder died without filling: try to take the lock
          }
        }
      }
    }
    
    func (c *Cache) fillAndUnlock(ctx context.Context, id int64, token string) (p Product, err error) {
      defer func() {
        if uerr := unlock.Run(ctx, c.rdb, []string{lockKey(id)}, token).Err(); uerr != nil {
          err = errors.Join(err, fmt.Errorf("release fill lock %d: %w", id, uerr))
        }
      }()
      // Check again: the previous holder may have filled the key just before we took the lock.
      if p, hit, err := c.Peek(ctx, id); hit || err != nil {
        return p, err
      }
      return c.fill(ctx, id)
    }
    
    Redis script: release the locklua
    -- Release a lock only if this caller still owns it.
    -- KEYS[1] the lock key, ARGV[1] the caller's token. Returns 1 if released.
    if redis.call('GET', KEYS[1]) == ARGV[1] then
      return redis.call('DEL', KEYS[1])
    end
    return 0
    Go: TTL jittergo
    
    // JitterTTL returns base plus a random extra time in [0, spread), rounded down to a multiple of
    // step. Keys written together then expire at different times. Use step = time.Millisecond in
    // production; the stampede experiment uses a coarser step so each expiry lands well inside one
    // 100 ms bucket.
    func JitterTTL(base, spread, step time.Duration, r *rand.Rand) time.Duration {
      if spread <= 0 || step <= 0 {
        return base
      }
      return base + time.Duration(r.Int64N(int64(spread/step)))*step
    }
    

    Singleflight gives one query per process. A SET NX lock gives one query for the whole cluster. Jitter keeps keys written together from expiring together.

    H

    Try it: hot keys expire

    recorded from Redis and Postgres
    one hot key
    100 keys
    0255075100queries sent to Postgres per 100 ms00010002001003001 key expires0400050006000700ms
    All 100 readers missed within one read interval. Each one sent its own query.
    queries
    100 in all; at most 100 in one 100 ms bucket
    readers
    100 in 4 app processes, one read every 10 ms, all on one key
    query cost
    50 ms, as an expensive query would take

    When a hot key expires, every reader misses in the same few milliseconds. I make one reader fill it, and I add jitter so keys written together expire apart.

    I

    Failure modes

    what breaks, and the fix
    failurewhat happensfixfixed in
    Stampede (thundering herd)A hot key expires. Every reader misses and queries at once.Singleflight, a SET NX fill lock, or early probabilistic refresh.SET NX
    PenetrationRequests for ids that do not exist always miss and always query.Cache the miss with a short TTL. Put a Bloom filter of valid ids in front.Tombstone
    AvalancheMany keys written together expire together. The database takes all the misses at once.Add random jitter to each TTL. Warm the cache before traffic arrives.TTL jitter
    Hot keyOne key gets more reads than one Redis node serves.A local copy in each process for about 1 s, or N copies of the key read at random.Local L1
    Stale read after writeThe cache keeps an old value after the row changed.Delete after the commit. Use leases for the slow-reader race. Keep a TTL.Lease
    Redis is downEvery read goes to Postgres at once.Time out fast, keep the local L1, and limit the rate of database fallbacks.Circuit breaker
    Cold startAfter a deploy or a flush, the hit ratio is 0 and the database takes the full load.Warm the hot keys first, and shift traffic over gradually.Warm-up
    J

    Try it: the stale-cache race

    recorded from Redis and Postgres

    Each writer updates the row, then writes the new value into the cache. The two cache writes arrive out of order.

    #writer 1writer 2Postgres rowRedis cache
    0start10.00, v110.00, v1
    1UPDATE price to 11.00, COMMIT: 11.00, v211.00, v210.00, v1 ✕
    2UPDATE price to 12.00, COMMIT: 12.00, v312.00, v310.00, v1 ✕
    3SET the cache to 12.00, v312.00, v312.00, v3
    4SET the cache to 11.00, v2 (it ran late)12.00, v311.00, v2 ✕
    step 4 of 4
    Stale. The cache holds 11.00, v2 but the row is 12.00, v3. Readers get the old price until the TTL ends.

    Delete after the commit is the default. A slow reader can still fill an old row, so I use a lease on hot keys and always keep a TTL.

    K

    Leases and early refresh

    pseudo code
    lease, then refreshpseudo code
    read(id):
      r = lease_get(key, token)    // ONE atomic step1
      IF r is a hit: RETURN r.value
      IF r is wait: retry in 10 ms
      row = SELECT ...             // the lease is yours
      lease_fill(key, row, token)  // may be refused2
    
    write(id):
      UPDATE ...; COMMIT
      DEL key, lease3
    
    refresh early, on a hit, IF
      now - delta * beta * ln(random())4
        >= expiry
    1. 1A script: return the value, or give the lease to the first caller only.
    2. 2The write deleted the lease, so a fill that read the old row stores nothing.
    3. 3One command removes the value and cancels any open lease.
    4. 4XFetch: the chance to refresh rises near expiry. delta is the reload time; beta above 1 refreshes earlier.
    Tested source Redis script: lease get · Redis script: lease fill · Go: refresh early
    Redis script: lease getlua
    -- Read a cached value, or take the lease to fill it.
    -- KEYS[1] the value key, KEYS[2] its lease key
    -- ARGV[1] the caller's lease token, ARGV[2] lease time to live in milliseconds
    -- Returns {1, value} on a hit, {2} if the caller now holds the lease, {3} if another caller does.
    local v = redis.call('GET', KEYS[1])
    if v then
      return {1, v}
    end
    if redis.call('SET', KEYS[2], ARGV[1], 'NX', 'PX', ARGV[2]) then
      return {2}
    end
    return {3}
    Redis script: lease filllua
    -- Fill the cache only if the caller's lease is still valid.
    -- A write deletes the lease, so a fill that read the database before the write is refused.
    -- KEYS[1] the value key, KEYS[2] its lease key
    -- ARGV[1] lease token, ARGV[2] value, ARGV[3] time to live in milliseconds
    -- Returns 1 if the value was stored, 0 if the lease was gone.
    if redis.call('GET', KEYS[2]) ~= ARGV[1] then
      return 0
    end
    redis.call('SET', KEYS[1], ARGV[2], 'PX', ARGV[3])
    redis.call('DEL', KEYS[2])
    return 1
    Go: refresh earlygo
    
    // RefreshEarly is probabilistic early expiration (the XFetch rule). A reader that hits a key
    // refreshes it before it expires with a probability that rises as expiry nears. delta is how long
    // the refresh takes; beta above 1 refreshes earlier. Few readers refresh, and most refresh just
    // before expiry, so no crowd misses at once.
    func RefreshEarly(now, expiry time.Time, delta time.Duration, beta float64, r *rand.Rand) bool {
      u := 1 - r.Float64() // in (0, 1], so the log is finite
      gap := time.Duration(float64(delta) * beta * -math.Log(u))
      return !now.Add(gap).Before(expiry)
    }
    
    • Leases come from the memcache paper by Nishtala and others (NSDI 2013).
    • XFetch comes from Vattani, Chierichetti and Lowenstein (VLDB 2015).

    A lease lets only one reader fill a key, and a write cancels it. Early refresh reloads a hot key just before it expires.

    L

    Scale ladder

    start simple; climb only on a signal
    Each step adds one component1Postgres2+ in-process3+ Redis4+ cluster, L15+ CDNmore load →
    Capacity against demand1001k10k100kDemand, average: 700 reads per secondDemand, average700Demand, 10× peak: 7,000 reads per secondDemand, 10× peak7,000Postgres, 50 ms query: 1,000 reads per secondPostgres, 50 ms query1,000Postgres, key read: 57,000 reads per secondPostgres, key read57,000Redis, one node: 36,000 reads per secondRedis, one node36,000Redis, 6 primaries: 216,000 reads per secondRedis, 6 primaries216,000reads per second, log scale
    stepaddit handlesmove up when you see
    1Postgres alone. Indexes, and the buffer pool keeps hot pages in memory.Key reads at about 0.15 ms each. At least 57,000 a second on one laptop.An expensive query repeats on every page view.
    2An in-process cache with a short TTL, for config and small lookups.Hits in about 150 ns with no network trip.Many processes each fill their own copy, or writes must reach every copy.
    3Redis, cache-aside, delete after commit, TTL with jitter, one fill per key.A 50 ms query becomes a GET of about 0.2 ms. At least 36,000 GETs a second on one node.One node runs out of memory, or one key gets more reads than one node serves.
    4Redis Cluster plus a local L1 for the hottest keys.Throughput grows with the number of primaries. The L1 absorbs single hot keys.Users are far from the data centre, and most responses are public.
    5A CDN for public responses (sheet B5).The edge serves the hits. The origin sees only the misses.Top of the ladder.

    Demand example: 2 million users a day view 30 products each: 60 million reads a day, 700 a second. The 50 ms query: 50 connections each finish 20 a second. The 6-primary figure is 6 times one node. Measured values are lows across four runs on a busy laptop.

    I start with Postgres alone. I add Redis when an expensive query repeats, and a local L1 only when one key outgrows one Redis node.

    M

    Drill

    predict, then reveal

    0 of 9 known

    1. Why delete the key on a write instead of writing the new value into the cache?

    2. Why delete after the commit and not before it?

    3. Delete after commit. Can the cache still go stale?

    4. 100 readers in 4 processes read one hot key. It expires. How many queries reach Postgres with singleflight, and with a Redis lock?

    5. A batch job writes 1 million keys with TTL 1 hour. What happens one hour later, and what is the fix?

    6. Bots request product ids that do not exist. Each request misses the cache. What do you do?

    7. One key gets more reads than one Redis node can serve. What are your options?

    8. You set max-age to one year on app.js. You ship a fix. Why do some users still run the old file?

    9. A primary-key read takes about 0.15 ms. A Redis GET takes about the same. When is the cache worth it?

    N

    Numbers to say

    measured, derived or cited
    in-process
    About 150 ns for an LRU hit, measured.
    Redis GET
    About 0.2 ms on one machine; 36,000 to 56,000 a second from 32 clients, measured.
    key read
    About 0.1 to 0.2 ms from a warm Postgres; 57,000 to 91,000 a second, measured.
    network
    0.5 ms round trip in one data centre; 150 ms across the Atlantic and back (Jeff Dean's table).
    stampede
    100 readers on one expired key: 100 queries, 4 with singleflight, 1 with a lock, recorded.
    avalanche
    100 keys with one TTL: 100 queries in 100 ms. With jitter: at most 24, recorded.
    hit ratio
    The database sees (1 - hit ratio) of reads. 90% means 10 times fewer, 99% means 100 times fewer.

    Postgres 16 and Redis 8 on an 8-core laptop, loopback, other jobs running. On one machine the round trip dominates, so a GET and a key read cost about the same. Use these as orders of magnitude.