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.
- 1Each layer from the browser to the buffer pool keeps a copy. Know who invalidates each one.
- 2Cache-aside is the default: read the cache, fill on a miss, delete the key after the commit.
- 3When a hot key expires, every reader queries at once. Let one reader fill it.
- 4Add TTL jitter, cache the misses, and use leases so a late fill cannot write a stale value.
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.
| pattern | who fills the cache | stale window | use it when | status |
|---|---|---|---|---|
| Cache-aside | Service | Until the delete, or the TTL | Most reads. The cache can fail and the app still works. | Default |
| Read-through | Cache library | Same as cache-aside | A library or proxy owns the loading code for many callers. | Approved |
| Write-through | Service | Short; two writes can still cross | Data is read right after it is written. | Read after write |
| Write-behind | Redis | The database is behind the cache | Counters and metrics, where a crash may lose a few writes. | Loss is acceptable |
| Write-around | Service | Until the next read fills | Data is written often and read rarely. | Approved |
| Refresh-ahead | Service | Up to one refresh period | A few hot keys that must never miss. | Known hot keys |
| Update the cache on every write | Service | Until the TTL, if writers cross | Never. 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.
| policy | evicts | status |
|---|---|---|
| LRU | The key unused for the longest time. | Default choice |
| LFU | The key with the fewest recent uses. A one-off scan does not push out hot keys. | Skewed reads |
| TTL only | Nothing until a key expires. | Memory is ample |
| Random | Any key. | Uniform access |
| No eviction | Nothing. 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.
| method | stale for | cost | status |
|---|---|---|---|
| TTL only | Up to the TTL | None | Staleness is acceptable |
| Delete on write, after the commit | Milliseconds; a slow reader can refill an old value | One DEL per write | Default |
| Delete before the commit | Until the TTL | One DEL | Not approved |
| Versioned keys: product:42:v7 | None for new readers; old keys age out | Readers must know the version | Immutable data |
| Change data capture from the WAL | Replication lag, often under a second | A consumer to run | Many writers |
| Leases on fill | None from a late fill | Two scripts | Hot, 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.
| tool | capability | what it gives this design | also used for |
|---|---|---|---|
| Redis | GET, and SET with EX or PX | Read a key, or store it with a time to live in one command. | Sessions, rate-limit windows |
| Redis | SET with NX and PX | One reader in the cluster wins the fill lock. The lock expires if its holder dies. | Idempotency keys, holds |
| Redis | Server-side scripts (EVAL) | Check a lease and fill in one step. Release a lock only if you own it. | Token buckets, inventory holds |
| Redis | DEL with several keys | Delete the value and its lease in one command. | Cleanup of related keys |
| Redis | Cluster 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 |
| Redis | maxmemory-policy: allkeys-lru, allkeys-lfu | Redis evicts by itself when memory is full. | Session stores |
| Redis | Asynchronous replication | Limit A replica can still serve a key that the primary just deleted. | |
| Service | In-process LRU with a TTL | A hit in about 150 ns with no network trip. The first stop for hot keys. | Config, feature flags |
| Service | Singleflight | Callers in one process share one query per key. | Any expensive call, such as token refresh |
| Postgres | Buffer pool (shared_buffers) | A warm primary-key read costs about 0.15 ms. Cache only work that costs more. | Every query |
| Postgres | MVCC: readers see committed rows | A reader during an open write gets the old row. This is why the delete waits for the commit. | Consistent reads without locks |
| Postgres | Logical decoding (change data capture) | A stream of committed changes. A consumer deletes keys from it. | Search sync, outbox relay |
| HTTP | Cache-Control, ETag | Browsers 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.
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- 1Hash tag. The value, its lock and its lease land in one cluster slot.
- 2Negative caching: a missing row is cached as a tombstone, so repeated bad ids cost one query.
- 3A short TTL for the tombstone, so a row created later shows up soon.
- 4Always set a TTL. It bounds the damage of any missed invalidation.
- 5The delete comes after the commit. Before it, a reader could refill the old row.
- 6Delete, do not update. The next reader fills from the committed row.
Tested source Go: read, fill, negative cache · Go: write, then delete
// 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)
}
// 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.
// 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- 1A map of calls in flight, guarded by a mutex. The first caller runs the query; the rest wait for its result.
- 2Set only if absent, with an expiry. If the holder dies, the lock frees itself after 2 s.
- 3The last holder may have filled the key just before this caller took the lock.
- 4Compare and delete in one script. A slow holder never deletes the next holder’s lock.
- 5The holder died without filling. A waiter takes over.
- 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
// 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
}
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)
}
-- 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
// 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.
- 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.
| failure | what happens | fix | fixed 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 |
| Penetration | Requests 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 |
| Avalanche | Many 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 key | One 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 write | The 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 down | Every read goes to Postgres at once. | Time out fast, keep the local L1, and limit the rate of database fallbacks. | Circuit breaker |
| Cold start | After 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 |
Each writer updates the row, then writes the new value into the cache. The two cache writes arrive out of order.
| # | writer 1 | writer 2 | Postgres row | Redis cache |
|---|---|---|---|---|
| 0 | start | 10.00, v1 | 10.00, v1 | |
| 1 | UPDATE price to 11.00, COMMIT: 11.00, v2 | 11.00, v2 | 10.00, v1 ✕ | |
| 2 | UPDATE price to 12.00, COMMIT: 12.00, v3 | 12.00, v3 | 10.00, v1 ✕ | |
| 3 | SET the cache to 12.00, v3 | 12.00, v3 | 12.00, v3 | |
| 4 | SET the cache to 11.00, v2 (it ran late) | 12.00, v3 | 11.00, v2 ✕ | |
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.
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- 1A script: return the value, or give the lease to the first caller only.
- 2The write deleted the lease, so a fill that read the old row stores nothing.
- 3One command removes the value and cancels any open lease.
- 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
-- 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}-- 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
// 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.
| step | add | it handles | move up when you see |
|---|---|---|---|
| 1 | Postgres 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. |
| 2 | An 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. |
| 3 | Redis, 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. |
| 4 | Redis 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. |
| 5 | A 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.
0 of 9 known
Why delete the key on a write instead of writing the new value into the cache?
Why delete after the commit and not before it?
Delete after commit. Can the cache still go stale?
100 readers in 4 processes read one hot key. It expires. How many queries reach Postgres with singleflight, and with a Redis lock?
A batch job writes 1 million keys with TTL 1 hour. What happens one hour later, and what is the fix?
Bots request product ids that do not exist. Each request misses the cache. What do you do?
One key gets more reads than one Redis node can serve. What are your options?
You set max-age to one year on app.js. You ship a fix. Why do some users still run the old file?
A primary-key read takes about 0.15 ms. A Redis GET takes about the same. When is the cache worth it?
- 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.