Database Caching Strategies
Keep expensive queries out of the database, and know which ones.
A Cache Is a Second Copy of Your Data
Caching is usually sold as a speed trick, and this lesson does measure large speedups: around 100x on a search that scans 200,000 rows. It also measures a case where the cache is no faster at all, which is the more useful result, because a cache is not free. It is a second, asynchronous copy of your data, and everything hard about caching (staleness, invalidation, stampedes, memory limits) follows from that. This lesson picks up where Performance & Optimization left off, with runnable Redis patterns and the failure modes each one buys you.
- Facebook: the NSDI 2013 paper Scaling Memcache at Facebook describes a memcached deployment processing over a billion requests per second and holding trillions of items. Loading one popular page fetched an average of 521 distinct keys, which is why the paper spends most of its length on fan-out and incast congestion rather than on cache hit rates.
- That 521-key figure is the useful one to remember: at scale the hard problem is rarely "is it cached", it is the number of round trips a single request fans out into, and what happens when one of them is slow.
Try It Locally
Every example on this page was run against the two containers below (Redis 8.8 and PostgreSQL 16), and every output shown is copied from that run. Each Python example is a complete script: it opens its own connections, so you can save any one of them to a file and run it on its own with python script.py.
# Two throwaway containers: the cache and the database it protects. docker run -d --name redis-demo -p 6379:6379 redis:8-alpine docker run -d --name pg-cache-demo -p 5499:5432 \ -e POSTGRES_USER=shop -e POSTGRES_PASSWORD=shop -e POSTGRES_DB=shop \ postgres:16-alpine # Port 5499 keeps this container clear of any PostgreSQL already on 5432. # A shell inside the cache, for poking at keys by hand: docker exec -it redis-demo redis-cli # The Python examples need a virtualenv: on Debian/Ubuntu a bare # "pip install" is refused (PEP 668: externally-managed-environment). python3 -m venv .venv && source .venv/bin/activate pip install "redis>=6" "psycopg[binary]>=3.2" # Clean up when you are done: # docker rm -f redis-demo pg-cache-demo
The Schema
Three tables: a handful of users for the single-record examples, and 200,000 products so the query-caching section has something genuinely expensive to cache.
# Three tables the Python examples read from. Run this once.
docker exec -i pg-cache-demo psql -U shop -d shop -q -v ON_ERROR_STOP=1 \
--pset linestyle=unicode --pset border=2 <<'SQL'
DROP TABLE IF EXISTS products, categories, users;
CREATE TABLE users (
id INT PRIMARY KEY,
username TEXT NOT NULL,
email TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO users (id, username, email) VALUES
(123, 'alice', 'alice@example.com'),
(456, 'bob', 'bob@example.com'),
(789, 'carol', 'carol@example.com');
CREATE TABLE categories (
id INT PRIMARY KEY,
name TEXT NOT NULL
);
INSERT INTO categories VALUES (1, 'Electronics'), (2, 'Books');
CREATE TABLE products (
id INT PRIMARY KEY,
name TEXT NOT NULL,
price NUMERIC(10, 2) NOT NULL,
category_id INT NOT NULL REFERENCES categories(id)
);
-- 200,000 rows, so the search example later has real work to do.
INSERT INTO products (id, name, price, category_id)
SELECT g,
CASE WHEN g % 1000 = 0 THEN 'Laptop model ' || g ELSE 'Gadget ' || g END,
(g % 2000) + 0.99,
CASE WHEN g % 2 = 0 THEN 1 ELSE 2 END
FROM generate_series(1, 200000) AS g;
ANALYZE;
SELECT (SELECT count(*) FROM users) AS users,
(SELECT count(*) FROM categories) AS categories,
(SELECT count(*) FROM products) AS products;
SQLExpected Output:
┌───────┬────────────┬──────────┐ │ users │ categories │ products │ ├───────┼────────────┼──────────┤ │ 3 │ 2 │ 200000 │ └───────┴────────────┴──────────┘ (1 row)
Why Caching Works
Databases optimize for durability: a committed write has to survive a power cut, which means touching disk. Caches optimize for speed: they keep everything in RAM and accept that a restart loses it. The orders of magnitude below are what that trade-off buys, and they are also what tells you when caching is pointless: you can only ever save the difference between two rows of this table.
| Operation | Typical Latency | Rough Throughput | Use Case |
|---|---|---|---|
| L1 CPU cache | ~1 ns | ~1,000,000,000 ops/sec per core | Compiler and CPU territory, not yours |
| RAM access | ~100 ns | ~10,000,000 ops/sec per core | In-process caches (a dict, an LRU) |
| Redis over loopback | 70 - 150 μs round trip | ~230,000 ops/sec measured (see below) | Shared application cache (this lesson) |
| Local database, page in RAM | ~0.1 ms for an indexed lookup | Thousands of queries/sec per connection | The case where caching gains nothing |
| NVMe SSD read | 50 μs - 1 ms | 10,000+ IOPS | Database pages not in shared_buffers |
| Network round trip (same region) | 0.5 - 10 ms | Bounded by concurrency, not the wire | Remote database: what caching really saves |
| Cross-region round trip | 60 - 150 ms | Latency dominates everything | Why read replicas and edge caches exist |
Why a Small Cache Goes a Long Way
Access to data is almost never uniform: a small set of rows is read constantly and the long tail is barely read at all. That skew is what makes a cache far smaller than the database still useful, and it is also why hit rate climbs steeply at first and then flattens. The two panels below illustrate the shape; they are not measurements, and your own hit rate is a number to measure rather than to assume.
Skewed Access (Illustrative)
Caching the hot 20% of a catalog serves most of the traffic.
E-commerce example: Total products: 100,000 Hot products: 20,000 Requests per day: 80% (8M) -> the hot 20,000 20% (2M) -> the other 80,000 Cache the hot 20,000 -> ~80% hit rate -> ~80% fewer read queries
Diminishing Returns (Illustrative)
Doubling cache size does not double hit rate: the tail is cold.
Cache size | Hit rate | Read QPS
-----------|----------|----------
0 MB | 0% | 10,000
100 MB | 60% | 4,000
500 MB | 85% | 1,500
1 GB | 92% | 800
2 GB | 95% | 500
The last 5% of hits costs
more RAM than the first 60%.Redis vs Memcached
Both are in-memory key-value stores reached over the network. Memcached is deliberately minimal: opaque values, no persistence, no replication, and a multi-threaded core that scales across cores. Redis executes commands on a single thread but gives you data structures, expiry semantics, persistence and replication. Most applications pick Redis because those extra structures (sorted sets, hashes, sets) remove work from the database entirely, as the leaderboard example later in this lesson shows.
| Feature | Redis | Memcached |
|---|---|---|
| Data structures | Strings, lists, sets, hashes, sorted sets, streams, bitmaps, HyperLogLog | Opaque byte strings only |
| Persistence | Optional: RDB snapshots, AOF log, or both | None: a restart is an empty cache |
| Replication / HA | Primary-replica, Sentinel for failover, Cluster for sharding | None: clients shard, and a lost node is a lost shard |
| Expiration | Per-key TTL, millisecond precision (PEXPIRE) | Per-key TTL, second precision |
| Atomic operations | INCR/DECR, set and list operations, transactions, Lua scripts | INCR/DECR, CAS |
| Pub/Sub & streams | Yes: messaging, notifications, consumer groups | No |
| Threading | Single-threaded command execution (optional I/O threads) | Multi-threaded: scales with cores in one process |
| Max value size | 512 MB per string (but see "avoid this" below) | 1 MB by default |
| Best for | Caching plus sessions, counters, queues, leaderboards, rate limits | Very large, very simple key-value caches |
redis-benchmark -t set,get -n 100000 -c 50 -q reported 227,000 SET/sec and 231,000 GET/sec with a p50 of 0.111 ms, and adding pipelining (-P 16) took GET to 2.17 million/sec. The single thread is rarely the bottleneck; the round trip per command usually is, which is exactly what pipelining removes.Connecting, and the Commands You Actually Need
Almost every cache in production is built from GET, SET, DEL and expiry. This script covers the reads and writes. decode_responses=True is worth setting on day one, because without it every value comes back as bytes and every JSON round trip needs a manual .decode().
"""Redis basics: connect, SET, GET, INCR."""
import redis
# One connection object per process; redis-py pools connections internally.
cache = redis.Redis(
host="localhost",
port=6379,
db=0,
decode_responses=True, # return str instead of bytes
)
cache.flushdb() # start from a clean database so this script is repeatable
# Strings: SET stores the value, GET reads it back.
cache.set("username", "alice")
cache.set("user:1:email", "alice@example.com")
print("username :", cache.get("username"))
print("user:1:email :", cache.get("user:1:email"))
print("missing key :", cache.get("user:2:email"))
# Counters: INCR is atomic and creates the key at 0 if it does not exist.
print("incr #1 :", cache.incr("view_count"))
print("incr #2 :", cache.incr("view_count"))
print("incr by 10 :", cache.incrby("view_count", 10))
# Everything comes back as a string, including numbers.
print("stored value :", repr(cache.get("view_count")))
print("key exists :", cache.exists("view_count") == 1)Expected Output:
username : alice user:1:email : alice@example.com missing key : None incr #1 : 1 incr #2 : 2 incr by 10 : 12 stored value : '12' key exists : True
Two details worth internalising. A missing key returns None, not an error, so "is it cached?" is an is not None check: a cached value that is legitimately empty ("" or 0) looks like a miss if you test truthiness instead. And Redis strings are strings, so view_count comes back as '12' even though INCR did arithmetic on it.
Expiration (TTL)
A TTL is the cheapest correctness mechanism a cache has: it bounds how wrong an entry can be, with no invalidation code at all. Set one on every key you write. The alternative, a key with no expiry, is a permanent copy of data that has moved on.
"""Expiration: TTL, EXPIRE, PERSIST."""
import time
from datetime import timedelta
import redis
cache = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
cache.flushdb()
# SET with an expiry. redis-py marks setex() deprecated in favour of
# set(..., ex=...), and ex accepts an int of seconds or a timedelta.
cache.set("session:abc123", "user_data", ex=timedelta(hours=1))
print("ttl right after set :", cache.ttl("session:abc123"), "seconds")
# SET then EXPIRE is the two-step equivalent (the key is briefly immortal).
cache.set("temp:token", "xyz789")
cache.expire("temp:token", 300)
print("ttl of temp:token :", cache.ttl("temp:token"), "seconds")
# TTL counts down in whole seconds and Redis deletes the key at zero.
cache.set("blink", "gone soon", ex=2)
print("blink now :", cache.get("blink"), "| ttl", cache.ttl("blink"))
time.sleep(2.1)
print("blink after 2.1s :", cache.get("blink"), "| ttl", cache.ttl("blink"))
# Two sentinel values worth knowing: -1 = key exists but never expires,
# -2 = key does not exist at all.
cache.persist("session:abc123")
print("ttl after persist :", cache.ttl("session:abc123"), "(-1 = no expiry)")
print("ttl of unknown key :", cache.ttl("nope"), "(-2 = key missing)")Expected Output:
ttl right after set : 3600 seconds ttl of temp:token : 300 seconds blink now : gone soon | ttl 2 blink after 2.1s : None | ttl -2 ttl after persist : -1 (-1 = no expiry) ttl of unknown key : -2 (-2 = key missing)
The two negative return values cause bugs when they are not known: -1 means the key exists and will never expire, -2 means the key is gone. Neither is an error, so code that treats "TTL below zero" as a single case will happily leave a key immortal when it meant to refresh it.
The Setting Everyone Forgets: maxmemory
TTLs bound how stale an entry gets. They do not bound how much memory the cache uses: write faster than keys expire and Redis grows until the host runs out of RAM. Out of the box Redis has no memory limit and a noeviction policy, which means it starts rejecting writes rather than making room. For a cache, both defaults are wrong.
"""maxmemory + an eviction policy: the bound that keeps a cache from eating the host."""
import redis
cache = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
cache.flushall()
# Redis ships with no memory bound and refuses writes once the host is full.
print("default maxmemory :", cache.config_get("maxmemory")["maxmemory"])
print("default maxmemory-policy:", cache.config_get("maxmemory-policy")["maxmemory-policy"])
# For a cache, both of these are the wrong defaults. Cap the memory and let
# Redis drop the least recently used keys instead of failing writes.
cache.config_set("maxmemory", "4mb")
cache.config_set("maxmemory-policy", "allkeys-lru")
# evicted_keys counts from server start, so record it before the write.
evicted_before = cache.info("stats")["evicted_keys"]
# Write ~20 MB into a 4 MB cache: 20,000 values of 1 KB.
payload = "x" * 1024
pipe = cache.pipeline(transaction=False)
for i in range(20_000):
pipe.set(f"pad:{i}", payload)
if i % 1000 == 999:
pipe.execute()
pipe.execute()
evicted = cache.info("stats")["evicted_keys"] - evicted_before
print("keys written : 20000")
print("keys still resident :", cache.dbsize())
print("keys evicted :", evicted)
print("used memory :", cache.info("memory")["used_memory_human"])
print("oldest key still there :", cache.get("pad:0") is not None)
print("newest key still there :", cache.get("pad:19999") is not None)
cache.config_set("maxmemory", "0")
cache.config_set("maxmemory-policy", "noeviction")
cache.flushall()Expected Output:
default maxmemory : 0 default maxmemory-policy: noeviction keys written : 20000 keys still resident : 1571 keys evicted : 18429 used memory : 4.00M oldest key still there : False newest key still there : True
20,000 keys went in, roughly 1,570 stayed, and Redis evicted the rest to hold the 4 MB ceiling: the oldest key is gone, the newest is still there. That is what allkeys-lru buys, and it is the behaviour you want from a cache. Use volatile-lru instead when the same Redis also holds keys that must never be evicted (sessions, locks, queues), because that policy only evicts keys carrying a TTL. Either way, alert on evicted_keys: a sudden climb means the working set has outgrown the cache and the hit rate is about to fall.
Cache-Aside Pattern (Lazy Loading)
Cache-aside is the default pattern, and the one to reach for unless you have a reason not to. The application owns the logic: check the cache, and on a miss query the database and write the result back. Nothing is cached until someone asks for it, which is why it is also called lazy loading. (Read-through is the related pattern where the cache library itself loads from the database, so the application only ever talks to the cache.)
Flow Diagram
Cache-Aside: Miss Then Hit
Figure 1: On a miss the application loads from the database and populates the cache; subsequent reads are served straight from the cache until the TTL expires.
Implementation
Three steps, in this order: read the cache, fall back to the database, backfill. The counter in the script exists to make the saving measurable rather than asserted.
"""Cache-aside (lazy loading): read cache, fall back to the database, backfill."""
import json
from datetime import timedelta
import psycopg
import redis
cache = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
db = psycopg.connect("host=localhost port=5499 dbname=shop user=shop password=shop")
cache.flushdb()
db_queries = 0 # so the run below can prove the database was skipped
def get_user(user_id):
"""Return one user, using Redis as a look-aside cache."""
global db_queries
cache_key = f"user:{user_id}"
# 1. Try the cache first.
cached = cache.get(cache_key)
if cached is not None:
print(f" cache HIT {cache_key}")
return json.loads(cached)
# 2. Miss: the database is the source of truth.
print(f" cache MISS {cache_key} -> querying PostgreSQL")
db_queries += 1
with db.cursor() as cur:
cur.execute(
"SELECT id, username, email FROM users WHERE id = %s", (user_id,)
)
row = cur.fetchone()
if row is None:
return None # nothing cached: every request for a missing id hits the DB
user = {"id": row[0], "username": row[1], "email": row[2]}
# 3. Backfill the cache so the next reader skips step 2.
cache.set(cache_key, json.dumps(user), ex=timedelta(hours=1))
return user
print("first read:")
print(" ->", get_user(123))
print("second read:")
print(" ->", get_user(123))
print("third read:")
print(" ->", get_user(123))
print(f"database queries for 3 reads: {db_queries}")
db.close()Expected Output:
first read:
cache MISS user:123 -> querying PostgreSQL
-> {'id': 123, 'username': 'alice', 'email': 'alice@example.com'}
second read:
cache HIT user:123
-> {'id': 123, 'username': 'alice', 'email': 'alice@example.com'}
third read:
cache HIT user:123
-> {'id': 123, 'username': 'alice', 'email': 'alice@example.com'}
database queries for 3 reads: 1Three reads, one database query. Note the if row is None branch: a missing row caches nothing, so every request for an id that does not exist reaches the database. That is cache penetration, and a loop asking for random ids can use it to bypass the cache entirely. The fix is to cache the negative result too, under a short TTL (30 to 60 seconds), so a nonexistent id costs one query per minute rather than one per request.
Does It Actually Help? Measure First
Caching gets added reflexively, so it is worth timing the thing you are about to cache. This compares the exact operation the previous script cached, an indexed primary-key lookup, against the Redis GET that replaces it.
"""Is the cache actually faster? Measure, do not assume.
Compares one indexed primary-key lookup against one Redis GET, 5 repetitions
of 30 runs each, reporting the median of each repetition.
"""
import json
import statistics
import time
import psycopg
import redis
cache = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
db = psycopg.connect("host=localhost port=5499 dbname=shop user=shop password=shop")
cache.set("user:123", json.dumps({"id": 123, "username": "alice"}))
cur = db.cursor()
def timed(fn, runs=30):
samples = []
for _ in range(runs):
start = time.perf_counter()
fn()
samples.append((time.perf_counter() - start) * 1000) # ms
return statistics.median(samples)
def db_read():
cur.execute("SELECT id, username, email FROM users WHERE id = 123")
cur.fetchone()
def cache_read():
json.loads(cache.get("user:123"))
timed(db_read, 50) # warm up: connection, plan cache, shared buffers
timed(cache_read, 50)
print("rep | db (ms) | redis (ms) | ratio")
for rep in range(1, 6):
d = timed(db_read)
c = timed(cache_read)
print(f" {rep} | {d:6.3f} | {c:6.3f} | {d / c:4.1f}x")
cur.close()
db.close()Expected Output:
rep | db (ms) | redis (ms) | ratio 1 | 0.108 | 0.118 | 0.9x 2 | 0.095 | 0.069 | 1.4x 3 | 0.093 | 0.110 | 0.8x 4 | 0.124 | 0.130 | 1.0x 5 | 0.115 | 0.073 | 1.6x
The cache is not faster. Both operations land around 0.1 ms and the ratio wanders on either side of 1x from repetition to repetition (0.5x to 1.6x across several runs of this script). That is not a broken measurement, it is the honest result: both sides are dominated by a loopback TCP round trip and process scheduling, not by the work being done. PostgreSQL answers a primary-key lookup out of shared_buffers in microseconds, so there is nothing left for a cache to save.
Caching that lookup can still be right, for a reason this benchmark cannot show: a production database is not on loopback, and it is shared. Moving the query to a cache removes several milliseconds of network latency per call and frees a database connection for work only the database can do. But when the uncached query is already cheap, the reason to cache is capacity, not latency, and you should expect the latency graph to stay flat. Where the two coincide is expensive queries: the same style of measurement on this lesson's search query shows a consistent 68x to 109x.
Handling Cache Stampede
A hot key expires. Every request that was being served from it misses at the same instant, and all of them run the same expensive query. This is a cache stampede (or "dog-piling"), and it is at its worst exactly when traffic is highest. The defence is a short-lived lock, so one caller rebuilds the entry while the rest wait for it.
"""Cache stampede: what 20 simultaneous misses cost, with and without a lock."""
import json
import threading
import time
from datetime import timedelta
import psycopg
import redis
cache = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
DSN = "host=localhost port=5499 dbname=shop user=shop password=shop"
db_calls = 0
db_calls_lock = threading.Lock()
def rebuild_from_db(user_id):
"""The expensive read the cache exists to avoid (pg_sleep fakes the cost)."""
global db_calls
with db_calls_lock:
db_calls += 1
with psycopg.connect(DSN) as conn, conn.cursor() as cur:
cur.execute(
"SELECT id, username, email, pg_sleep(0.2) FROM users WHERE id = %s",
(user_id,),
)
row = cur.fetchone()
return {"id": row[0], "username": row[1], "email": row[2]}
def get_user_naive(user_id):
"""Cache-aside with no stampede protection."""
key = f"user:{user_id}"
cached = cache.get(key)
if cached is not None:
return json.loads(cached)
user = rebuild_from_db(user_id)
cache.set(key, json.dumps(user), ex=timedelta(hours=1))
return user
def get_user_locked(user_id, attempts=50):
"""Cache-aside where exactly one caller is allowed to rebuild the entry."""
key = f"user:{user_id}"
lock_key = f"lock:{key}"
for _ in range(attempts):
cached = cache.get(key)
if cached is not None:
return json.loads(cached)
# SET NX is atomic: exactly one caller gets True. EX is mandatory,
# otherwise a crash between here and DELETE strands the lock forever.
if cache.set(lock_key, "1", nx=True, ex=5):
try:
user = rebuild_from_db(user_id)
cache.set(key, json.dumps(user), ex=timedelta(hours=1))
return user
finally:
cache.delete(lock_key)
time.sleep(0.05) # someone else is rebuilding: wait, then re-check
# Lock holder died and the entry is still cold: serve from the database
# rather than failing the request.
return rebuild_from_db(user_id)
def storm(fn, threads=20):
global db_calls
cache.flushdb()
db_calls = 0
workers = [threading.Thread(target=fn, args=(123,)) for _ in range(threads)]
start = time.perf_counter()
for w in workers:
w.start()
for w in workers:
w.join()
elapsed = (time.perf_counter() - start) * 1000
return db_calls, elapsed
calls, ms = storm(get_user_naive)
print(f"no lock : {calls:2d} database queries for 20 concurrent misses ({ms:.0f} ms)")
calls, ms = storm(get_user_locked)
print(f"with lock: {calls:2d} database query for 20 concurrent misses ({ms:.0f} ms)")Expected Output:
no lock : 20 database queries for 20 concurrent misses (237 ms) with lock: 1 database query for 20 concurrent misses (260 ms)
20 database queries become 1. Read the wall-clock numbers carefully: the locked version is slightly slower (260 ms vs 237 ms) because the waiters poll. The lock does not make this request faster, it stops 20 requests doing the same work, and at 2,000 concurrent requests instead of 20 that difference is the database staying up. Two details matter in the implementation: the lock needs its own TTL, since a process that dies holding it would otherwise block rebuilds forever, and the waiting path needs a bounded retry with a fallback, so a lost lock degrades into extra database load rather than a hung request. Two related defences are worth knowing: give TTLs a random jitter so keys written together do not expire together, and refresh hot entries slightly before they expire rather than after.
Write Patterns: Around, Through, Back
Cache-aside describes reads. Writes are the other half, and they are where cache and database drift apart. Three strategies, in increasing order of risk: delete the entry (write-around), refresh it (write-through), or write only to the cache and flush later (write-back).
Write-Around
Write the database, delete the cache entry.
1. Write -> database
2. Commit
3. DEL cache key
4. Next read -> miss, reload
App -> DB (write)
-> Cache (delete)Pros:
- Simplest to get right
- Deleting is idempotent
Cons:
- Next read pays the miss
Write-Through
Write the database, then refresh the entry.
1. Write -> database
2. Commit
3. SET cache key = new row
4. Next read -> hit
App -> DB (write)
-> Cache (update)Pros:
- Cache stays warm
- No post-write miss
Cons:
- Two writes per update
- Concurrent writers can reorder
Write-Back
Write the cache, flush to the database later.
1. Write -> cache
2. Return success
3. Background job -> DB
(batched)
App -> Cache (write)
| async
v
DB (batch write)Pros:
- Fastest writes
- Absorbs write bursts
Cons:
- Cache loss is data loss
- Needs durable queueing
Write-Around (Start Here)
Delete rather than update, and delete after the commit. A delete is idempotent and carries no assumption about what the new value is, which is what makes this the pattern that survives concurrent writers.
"""Write-around: write the database, delete the cache entry."""
import json
from datetime import timedelta
import psycopg
import redis
cache = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
db = psycopg.connect("host=localhost port=5499 dbname=shop user=shop password=shop")
cache.flushdb()
def get_user(user_id):
"""Cache-aside read (same function as the previous section)."""
key = f"user:{user_id}"
cached = cache.get(key)
if cached is not None:
return "HIT ", json.loads(cached)
with db.cursor() as cur:
cur.execute("SELECT id, username, email FROM users WHERE id = %s", (user_id,))
row = cur.fetchone()
user = {"id": row[0], "username": row[1], "email": row[2]}
cache.set(key, json.dumps(user), ex=timedelta(hours=1))
return "MISS", user
def update_user_email(user_id, email):
"""Write to the database, then invalidate the cached copy."""
with db.cursor() as cur:
cur.execute(
"UPDATE users SET email = %s, updated_at = now() WHERE id = %s",
(email, user_id),
)
db.commit() # delete only after the write is durable, never before
# DEL is idempotent: it returns how many keys it removed, and removing a
# key that was never cached is not an error.
removed = cache.delete(f"user:{user_id}")
print(f"invalidated user:{user_id} (keys removed: {removed})")
print("read 1:", get_user(123))
print("read 2:", get_user(123))
update_user_email(123, "alice.new@example.com")
print("read 3:", get_user(123))
print("read 4:", get_user(123))
# Reset the row so the script can be re-run.
with db.cursor() as cur:
cur.execute("UPDATE users SET email = 'alice@example.com' WHERE id = 123")
db.commit()
db.close()Expected Output:
read 1: ('MISS', {'id': 123, 'username': 'alice', 'email': 'alice@example.com'})
read 2: ('HIT ', {'id': 123, 'username': 'alice', 'email': 'alice@example.com'})
invalidated user:123 (keys removed: 1)
read 3: ('MISS', {'id': 123, 'username': 'alice', 'email': 'alice.new@example.com'})
read 4: ('HIT ', {'id': 123, 'username': 'alice', 'email': 'alice.new@example.com'})Read 3 is a miss, which is the cost of this pattern, and it returns the new email, which is the point. The ordering rule deserves stating explicitly: commit, then delete. Delete first and a concurrent reader can miss, read the old row (the update has not committed yet) and repopulate the cache with exactly the value you were removing. Even in the right order, a delete that fails after a successful commit leaves a stale entry, which is the second reason every key still needs a TTL.
Write-Through Flow
Write-Through: Update Both, Synchronously
Figure 2: The write updates the database and the cache in the same request, so the next read is a hit. The trade-off is a second write per update, and a race if two writers interleave.
Write-Through Implementation
The detail that makes this safe is RETURNING: the values written to the cache come from the row the database actually stored, not from the parameters the application sent. Defaults, triggers and constraints have all had their say by then.
"""Write-through: write the database, then refresh the cache with the new row."""
import json
from datetime import timedelta
import psycopg
import redis
cache = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
db = psycopg.connect("host=localhost port=5499 dbname=shop user=shop password=shop")
cache.flushdb()
def update_user_email(user_id, email):
"""Update the row and write the returned values straight into the cache."""
with db.cursor() as cur:
# RETURNING names the columns explicitly: RETURNING * would make the
# row unpacking below depend on physical column order.
cur.execute(
"""
UPDATE users SET email = %s, updated_at = now()
WHERE id = %s
RETURNING id, username, email
""",
(email, user_id),
)
row = cur.fetchone()
db.commit() # refresh the cache only once the write is durable
user = {"id": row[0], "username": row[1], "email": row[2]}
cache.set(f"user:{user_id}", json.dumps(user), ex=timedelta(hours=1))
return user
def get_user(user_id):
cached = cache.get(f"user:{user_id}")
if cached is not None:
return "HIT ", json.loads(cached)
return "MISS", None
print("before update:", get_user(123))
print("updated :", update_user_email(123, "alice.new@example.com"))
print("after update :", get_user(123))
with db.cursor() as cur:
cur.execute("UPDATE users SET email = 'alice@example.com' WHERE id = 123")
db.commit()
db.close()Expected Output:
before update: ('MISS', None)
updated : {'id': 123, 'username': 'alice', 'email': 'alice.new@example.com'}
after update : ('HIT ', {'id': 123, 'username': 'alice', 'email': 'alice.new@example.com'})The read after the update is a hit, with no database round trip: worthwhile for data read constantly and written occasionally, such as a user profile. The race to know about: two concurrent updates can commit in one order and write the cache in the other, leaving the cache holding the loser's value indefinitely. Write-around does not have that problem, because a delete cannot be stale. If you need write-through under concurrency, keep a version or updated_at inside the cached value, refuse to overwrite a newer one, and keep the TTL as a backstop.
INCR and flush aggregates to the database periodically, accepting the loss of the last interval.Cache Invalidation Patterns
Phil Karlton's line, that the two hard things in computer science are cache invalidation and naming things, is quoted so often that the actual difficulty gets lost. It is this: the cache does not know when the database changed, so something has to tell it, and that something has to enumerate every derived entry the change touched. There are three answers, and real systems use all three at once.
Pattern 1: Let the TTL Expire It
The simplest answer is to do nothing and accept bounded staleness. No invalidation code means no invalidation bugs, and for a product listing or a dashboard tile a few minutes of lag is invisible. The script below makes the staleness explicit rather than pretending it does not exist.
"""Invalidation pattern 1: let the TTL do it."""
import json
import time
import psycopg
import redis
cache = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
db = psycopg.connect("host=localhost port=5499 dbname=shop user=shop password=shop")
cache.flushdb()
with db.cursor() as cur:
cur.execute("SELECT id, name, price FROM products WHERE id = 1000")
row = cur.fetchone()
# price is NUMERIC, which psycopg returns as Decimal, and json.dumps() cannot
# serialise Decimal. Cast at the boundary.
product = {"id": row[0], "name": row[1], "price": float(row[2])}
# A 3-second TTL so the effect is visible in one script run; a product page
# in production is closer to 5-15 minutes.
cache.set("product:1000", json.dumps(product), ex=3)
print("cached :", cache.get("product:1000"))
print("ttl :", cache.ttl("product:1000"), "seconds")
# The row changes underneath the cache. No invalidation code runs.
with db.cursor() as cur:
cur.execute("UPDATE products SET price = 1499.99 WHERE id = 1000")
db.commit()
print("db price now: 1499.99")
print("cache still :", json.loads(cache.get("product:1000"))["price"], "(stale, by design)")
time.sleep(3.1)
print("after ttl :", cache.get("product:1000"), "(expired, next read repopulates)")
with db.cursor() as cur:
cur.execute("UPDATE products SET price = 1000.99 WHERE id = 1000")
db.commit()
db.close()Expected Output:
cached : {"id": 1000, "name": "Laptop model 1000", "price": 1000.99}
ttl : 3 seconds
db price now: 1499.99
cache still : 1000.99 (stale, by design)
after ttl : None (expired, next read repopulates)For three seconds the application served a price the database no longer had. That is the whole trade: you choose the window. Sensible starting points, to be shortened when a product owner objects: sessions 30 minutes to 24 hours, product catalog 5 to 15 minutes, user profiles 1 to 6 hours, mostly-static content a day or more, and prices or inventory counts either seconds or not cached at all.
Pattern 2: Invalidate on Write
When staleness is not acceptable, the writer deletes what it invalidated. The difficulty is not the delete, it is the enumeration: one product price appears on the product page, the category listing and the homepage, and forgetting one leaves a stale entry that no test will catch.
"""Invalidation pattern 2: delete every key the write touched."""
import json
import psycopg
import redis
cache = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
db = psycopg.connect("host=localhost port=5499 dbname=shop user=shop password=shop")
cache.flushdb()
# Pretend these four entries were built by earlier page renders.
cache.set("product:1000", json.dumps({"id": 1000, "price": 1000.99}), ex=900)
cache.set("product:1000:details", json.dumps({"reviews": 12}), ex=900)
cache.set("category:1:products", json.dumps([1000, 1002, 1004]), ex=900)
cache.set("homepage:featured", json.dumps([1000]), ex=900)
def update_price(product_id, new_price):
"""Update a product and drop every cached view that contains it."""
with db.cursor() as cur:
# RETURNING gives the category without a second round trip: the
# invalidation needs it and the row is already locked here.
cur.execute(
"UPDATE products SET price = %s WHERE id = %s RETURNING category_id",
(new_price, product_id),
)
(category_id,) = cur.fetchone()
db.commit()
keys = [
f"product:{product_id}", # the product itself
f"product:{product_id}:details", # its detail page
f"category:{category_id}:products", # the listing it appears in
"homepage:featured", # any page that embeds it
]
removed = cache.delete(*keys) # variadic DEL: one round trip, not four
print(f"deleted {removed} of {len(keys)} candidate keys: {keys}")
# KEYS scans the whole keyspace and blocks the server: fine for this demo,
# never on a production instance. Use SCAN there.
print("before:", sorted(cache.keys("*")))
update_price(1000, 1499.99)
print("after :", sorted(cache.keys("*")))
with db.cursor() as cur:
cur.execute("UPDATE products SET price = 1000.99 WHERE id = 1000")
db.commit()
db.close()Expected Output:
before: ['category:1:products', 'homepage:featured', 'product:1000', 'product:1000:details'] deleted 4 of 4 candidate keys: ['product:1000', 'product:1000:details', 'category:1:products', 'homepage:featured'] after : []
Two things make this maintainable. Build the key list in one place next to the write (a function per entity), so adding a cached view means editing one list rather than hunting through the codebase. And pass all the keys to a single DEL: it is one round trip, and it is atomic, so no reader can catch the invalidation half-finished.
Pattern 3: Tag-Based Invalidation
Enumeration stops scaling once a single change can invalidate an unknown number of entries ("every cached page showing an Electronics product"). Tags invert the bookkeeping: each entry registers itself in a Redis set as it is written, and invalidation deletes that set's members.
"""Invalidation pattern 3: group keys under tags, then delete a whole tag."""
import json
import redis
cache = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
cache.flushdb()
def cache_with_tags(key, data, tags, ttl=900):
"""Store a value and register it under each tag (a Redis SET)."""
pipe = cache.pipeline() # one round trip for the value and all tag writes
pipe.set(key, json.dumps(data), ex=ttl)
for tag in tags:
pipe.sadd(f"tag:{tag}", key)
# Tag sets need their own TTL, or they outlive every value they
# point at. Give them a margin over the data TTL.
pipe.expire(f"tag:{tag}", ttl * 2)
pipe.execute()
def invalidate_by_tag(tag):
"""Delete every value registered under a tag, plus the tag set itself."""
tag_key = f"tag:{tag}"
keys = cache.smembers(tag_key)
if not keys:
print(f"tag '{tag}': nothing to invalidate")
return 0
# Members may already have expired on their own: DEL reports how many
# keys it actually removed, which is the honest number to log.
removed = cache.delete(*keys)
cache.delete(tag_key)
print(f"tag '{tag}': {len(keys)} member(s), {removed} live key(s) deleted")
return removed
cache_with_tags(
"product:1000",
{"id": 1000, "name": "Laptop model 1000"},
tags=["product", "category:electronics", "brand:apple"],
)
cache_with_tags(
"product:2000",
{"id": 2000, "name": "Laptop model 2000"},
tags=["product", "category:electronics", "brand:samsung"],
)
cache_with_tags(
"product:3000",
{"id": 3000, "name": "Laptop model 3000"},
tags=["product", "category:books"],
)
print("electronics members:", sorted(cache.smembers("tag:category:electronics")))
invalidate_by_tag("category:electronics")
print("product:1000 ->", cache.get("product:1000"))
print("product:2000 ->", cache.get("product:2000"))
print("product:3000 ->", cache.get("product:3000"))
# The "product" tag still lists the two keys just deleted: tag sets are not
# updated when a value expires or is deleted through another tag.
print("product tag members:", sorted(cache.smembers("tag:product")))
invalidate_by_tag("product")Expected Output:
electronics members: ['product:1000', 'product:2000']
tag 'category:electronics': 2 member(s), 2 live key(s) deleted
product:1000 -> None
product:2000 -> None
product:3000 -> {"id": 3000, "name": "Laptop model 3000"}
product tag members: ['product:1000', 'product:2000', 'product:3000']
tag 'product': 3 member(s), 1 live key(s) deletedOne deletion cleared both electronics products and left the books one alone. The last two lines show the pattern's cost: tag:product still lists the two keys that were deleted through the electronics tag, and the second invalidation reports 3 members but only 1 live key. Tag sets accumulate dead members, because nothing removes a key from its tags when it expires. Give tag sets a TTL of their own (as here), treat the member count as an upper bound rather than a fact, and keep tags coarse: a tag with a million members turns one invalidation into a million-key DEL on a single-threaded server.
Query Result Caching
Caching whole result sets, rather than single rows, is where the measured wins live: searches, aggregations and multi-table joins cost real CPU, so skipping them saves real time. The catch is the key. It has to encode every parameter that changed the result, or two different searches will collide and serve each other's rows.
Caching Search Results
This is a deliberately expensive query: a leading-wildcard ILIKE over 200,000 rows, which no b-tree index can serve, joined to categories. Exactly the sort of query users repeat all day with identical parameters.
"""Caching a whole query result: an unindexed search over 200,000 rows."""
import hashlib
import json
import statistics
import time
import psycopg
import redis
cache = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
db = psycopg.connect("host=localhost port=5499 dbname=shop user=shop password=shop")
cache.flushdb()
def run_search(term, category=None, min_price=None):
"""The expensive part: ILIKE '%term%' cannot use a b-tree index."""
sql = """
SELECT p.id, p.name, p.price, c.name AS category
FROM products p
JOIN categories c ON c.id = p.category_id
WHERE p.name ILIKE %s
"""
params = [f"%{term}%"]
if category is not None:
sql += " AND c.name = %s"
params.append(category)
if min_price is not None:
sql += " AND p.price >= %s"
params.append(min_price)
# Without ORDER BY the row order is unspecified, so the cached copy and a
# fresh query could disagree. Order by the primary key to make both stable.
sql += " ORDER BY p.id"
with db.cursor() as cur:
cur.execute(sql, params)
rows = cur.fetchall()
# price is NUMERIC -> Decimal, which json.dumps() refuses to serialise.
return [
{"id": r[0], "name": r[1], "price": float(r[2]), "category": r[3]}
for r in rows
]
def search_products(term, category=None, min_price=None, ttl=300):
"""Cache-aside around run_search(), keyed by the search parameters."""
# The key must depend on every parameter, or two different searches
# collide. Hash it so the key stays short and free of user input.
fingerprint = json.dumps([term, category, min_price], sort_keys=True)
key = "search:" + hashlib.sha256(fingerprint.encode()).hexdigest()[:16]
cached = cache.get(key)
if cached is not None:
return "HIT ", json.loads(cached)
results = run_search(term, category, min_price)
cache.set(key, json.dumps(results), ex=ttl)
return "MISS", results
status, rows = search_products("laptop", category="Electronics", min_price=500)
print(f"{status} {len(rows)} rows, first: {rows[0]}")
status, rows = search_products("laptop", category="Electronics", min_price=500)
print(f"{status} {len(rows)} rows, first: {rows[0]}")
status, rows = search_products("laptop", category="Electronics")
print(f"{status} {len(rows)} rows (no min_price -> different key, cold again)")
def median_ms(fn, runs=10):
samples = []
for _ in range(runs):
start = time.perf_counter()
fn()
samples.append((time.perf_counter() - start) * 1000)
return statistics.median(samples)
cached_call = lambda: search_products("laptop", category="Electronics", min_price=500)
uncached_call = lambda: run_search("laptop", category="Electronics", min_price=500)
median_ms(uncached_call, 3) # warm the shared buffers before measuring
print("\nrep | uncached (ms) | cached (ms) | speedup")
for rep in range(1, 6):
u = median_ms(uncached_call)
c = median_ms(cached_call)
print(f" {rep} | {u:9.2f} | {c:9.3f} | {u / c:6.0f}x")
db.close()Expected Output:
MISS 100 rows, first: {'id': 1000, 'name': 'Laptop model 1000', 'price': 1000.99, 'category': 'Electronics'}
HIT 100 rows, first: {'id': 1000, 'name': 'Laptop model 1000', 'price': 1000.99, 'category': 'Electronics'}
MISS 200 rows (no min_price -> different key, cold again)
rep | uncached (ms) | cached (ms) | speedup
1 | 32.10 | 0.295 | 109x
2 | 33.01 | 0.304 | 108x
3 | 32.43 | 0.397 | 82x
4 | 32.97 | 0.483 | 68x
5 | 33.38 | 0.423 | 79xThis is where caching earns its reputation: a steady ~32 ms uncached against 0.3 to 0.5 ms cached, a 68x to 109x speedup that holds up across repetitions because the uncached side is real work rather than round-trip noise. Compare that with the primary-key lookup earlier, which gained nothing: same cache, same database, opposite conclusion. Three implementation details carry over to any query cache:
- Hash the parameters, not the SQL string - a deterministic fingerprint of the arguments keeps the key short, keeps raw user input out of the keyspace, and cannot be thrown off by whitespace changes in the query text.
- Add ORDER BY before you cache - without it the row order is unspecified, so the cached copy and a fresh query can differ. Pagination on top of an unordered result is a bug that only shows up under load.
- Convert at the boundary -
NUMERICcomes back asDecimal, andjson.dumps()raisesTypeErroron it. The same applies todatetimeandUUID: decide on the JSON representation where the rows are read.
Leaderboards: Skipping the Database Entirely
Some workloads do not need a database query to cache. A ranking is a sorted set, which is a native Redis type: ordered by score, updated in O(log N), and read top-down in O(log N + M). Expressed in SQL, the same thing is a window function over the whole table on every read.
"""A leaderboard that never touches the database: Redis sorted sets."""
import redis
cache = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
cache.flushdb()
KEY = "leaderboard:global"
def set_score(user_id, score):
"""ZADD, O(log N). Re-adding an existing member just moves it."""
cache.zadd(KEY, {f"user:{user_id}": score})
def add_points(user_id, points):
"""ZINCRBY is atomic, so concurrent scorers cannot lose updates."""
return cache.zincrby(KEY, points, f"user:{user_id}")
def top(limit=10):
"""ZRANGE ... REV, O(log N + M). ZREVRANGE is deprecated since Redis 6.2."""
rows = cache.zrange(KEY, 0, limit - 1, desc=True, withscores=True)
return [
{"user_id": member.split(":")[1], "score": int(score)}
for member, score in rows
]
def rank_of(user_id):
"""ZREVRANK is 0-based, so add 1 for a human-readable position."""
rank = cache.zrevrank(KEY, f"user:{user_id}")
return None if rank is None else rank + 1
set_score(123, 9500)
set_score(456, 12000)
set_score(789, 8000)
set_score(321, 12000) # deliberate tie with user 456
print("top 3 :", top(3))
print("after +750 :", add_points(789, 750))
print("top 3 :", top(3))
print("rank of 789:", rank_of(789))
print("rank of 999:", rank_of(999), "(not on the board)")
print("board size :", cache.zcard(KEY))
print("score 12000:", sorted(cache.zrangebyscore(KEY, 12000, 12000)))Expected Output:
top 3 : [{'user_id': '456', 'score': 12000}, {'user_id': '321', 'score': 12000}, {'user_id': '123', 'score': 9500}]
after +750 : 8750.0
top 3 : [{'user_id': '456', 'score': 12000}, {'user_id': '321', 'score': 12000}, {'user_id': '123', 'score': 9500}]
rank of 789: 4
rank of 999: None (not on the board)
board size : 4
score 12000: ['user:321', 'user:456']Note the tie: users 456 and 321 both have 12,000, and Redis breaks the tie by member name, in reverse lexicographic order under a descending read. That is deterministic but arbitrary, so if the tie-break matters (prize money, for example) encode it in the score itself, for instance the points plus a small function of the timestamp. Two more practical points: zincrby is atomic, so concurrent scorers cannot lose updates the way a read-modify-write would, and zrevrank returns None rather than raising for a member that is not on the board. Since this data lives only in Redis, enable persistence (AOF) or accept that a restart resets the board.
What the Database Caches By Itself
Before adding a cache, it is worth knowing what the database already does. The short answer for both MySQL and PostgreSQL today: they cache data pages, not query results. A result cache did exist in MySQL and was removed, for reasons that are also the reasons application-level caching is shaped the way it is.
The MySQL Query Cache, and Why It Is Gone
MySQL once cached result sets keyed by the exact text of the SELECT. It was deprecated in 5.7.20 and removed in 8.0, yet blog posts recommending query_cache_size are still easy to find. Ask a current server instead of trusting them.
# The MySQL query cache is the classic example of a database-side result # cache. Do not take its removal on trust: ask a current server. docker run -d --name mysql-demo -e MYSQL_ROOT_PASSWORD=demo mysql:8.4 # MYSQL_PWD avoids the "password on the command line is insecure" warning. docker exec -e MYSQL_PWD=demo mysql-demo mysql -uroot --table \ -e "SELECT VERSION() AS version;" # Every query_cache_* variable is gone. docker exec -e MYSQL_PWD=demo mysql-demo mysql -uroot --table \ -e "SHOW VARIABLES LIKE 'query_cache%';" # have_query_cache survives only to answer "no". docker exec -e MYSQL_PWD=demo mysql-demo mysql -uroot --table \ -e "SHOW VARIABLES LIKE 'have_query_cache';" # Trying to size it is an error, not a no-op. docker exec -e MYSQL_PWD=demo mysql-demo mysql -uroot \ -e "SET GLOBAL query_cache_size = 1073741824;" # Clean up: docker rm -f mysql-demo
Expected Output:
+---------+ | version | +---------+ | 8.4.11 | +---------+ (SHOW VARIABLES LIKE 'query_cache%' prints nothing: the empty set) +------------------+-------+ | Variable_name | Value | +------------------+-------+ | have_query_cache | NO | +------------------+-------+ ERROR 1193 (HY000) at line 1: Unknown system variable 'query_cache_size'
Two failures killed it, and both are worth carrying into your own cache design. Its invalidation was table-granular: any write to a table discarded every cached result that mentioned that table, so on a write-heavy workload the hit rate collapsed while the bookkeeping cost stayed. And it was guarded by a single global mutex, which made it a contention point on exactly the many-core machines it was supposed to help. An application cache avoids both by choosing its own granularity: you decide what a key covers and what invalidates it.
PostgreSQL: shared_buffers
PostgreSQL has never had a result cache. It caches 8 KB pages in shared_buffers, which means a repeated query is still planned and executed, but reads its data from RAM instead of disk. Running the same scan twice shows the difference in one word: read becomes hit.
# PostgreSQL never caches query results: every query is planned and executed # again. What it does cache is 8 KB data pages, in shared_buffers, so the # restart below is what makes the first scan cold. docker restart pg-cache-demo until docker exec pg-cache-demo pg_isready -U shop -q; do sleep 1; done docker exec -i pg-cache-demo psql -U shop -d shop -q \ --pset linestyle=unicode --pset border=2 <<'SQL' -- Serial plan, to keep the output easy to read. SET lasts for one session, -- which here is this single psql invocation. SET max_parallel_workers_per_gather = 0; SELECT pg_stat_reset(); -- throwaway container only: this wipes all statistics SHOW shared_buffers; SHOW effective_cache_size; -- Same scan twice. "read" = the block was not in shared_buffers, "hit" = it was. EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM products WHERE price > 500; EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM products WHERE price > 500; SQL
Expected Output:
pg-cache-demo ┌───────────────┐ │ pg_stat_reset │ ├───────────────┤ │ │ └───────────────┘ (1 row) ┌────────────────┐ │ shared_buffers │ ├────────────────┤ │ 128MB │ └────────────────┘ (1 row) ┌──────────────────────┐ │ effective_cache_size │ ├──────────────────────┤ │ 4GB │ └──────────────────────┘ (1 row) ┌─────────────────────────────────────────────────────────┐ │ QUERY PLAN │ ├─────────────────────────────────────────────────────────┤ │ Aggregate (actual rows=1 loops=1) │ │ Buffers: shared read=1471 │ │ -> Seq Scan on products (actual rows=150000 loops=1) │ │ Filter: (price > '500'::numeric) │ │ Rows Removed by Filter: 50000 │ │ Buffers: shared read=1471 │ │ Planning: │ │ Buffers: shared hit=43 read=19 │ └─────────────────────────────────────────────────────────┘ (8 rows) ┌─────────────────────────────────────────────────────────┐ │ QUERY PLAN │ ├─────────────────────────────────────────────────────────┤ │ Aggregate (actual rows=1 loops=1) │ │ Buffers: shared hit=1471 │ │ -> Seq Scan on products (actual rows=150000 loops=1) │ │ Filter: (price > '500'::numeric) │ │ Rows Removed by Filter: 50000 │ │ Buffers: shared hit=1471 │ └─────────────────────────────────────────────────────────┘ (6 rows)
Identical plans, identical 1,471 blocks, and the only change is where those blocks came from. (The Planning line counts catalog lookups and shifts by a block or two between runs; the scan's 1,471 do not.) The second run is not free, though: it still scans 200,000 rows and evaluates the filter, which is why the search example above costs ~32 ms even when fully cached in shared_buffers. That is the work Redis skips. Note also effective_cache_size: it allocates nothing, it only tells the planner how much memory the OS and PostgreSQL are likely to have between them, so index scans get priced correctly. The usual starting points are 25% of RAM for shared_buffers and 50 to 75% of RAM for effective_cache_size.
Running the Same Thing by Hand
That block pipes its statements in and exits, which is what makes it reproducible. When you want to explore instead, open a psql session inside the container and type them.
# The block above runs and exits. To poke around by hand, open a psql # session inside the container instead: -it allocates the terminal, without # which there is no prompt. docker exec -it pg-cache-demo psql -U shop -d shop # Inside psql, these two meta-commands are what the --pset flags do above. # SET applies to this session only, so run it and both EXPLAINs before \q: # the next session plans the same scan in parallel again.
Expected Output:
psql (16.14) Type "help" for help. shop=# \pset linestyle unicode Line style is unicode. shop=# \pset border 2 Border style is 2. shop=# SET max_parallel_workers_per_gather = 0; SET shop=# SHOW max_parallel_workers_per_gather; ┌─────────────────────────────────┐ │ max_parallel_workers_per_gather │ ├─────────────────────────────────┤ │ 0 │ └─────────────────────────────────┘ (1 row) shop=# \q
One thing changes between the two routes: SET lasts for a session, and interactively the session is however long you stay in psql. So the SET and both EXPLAINs have to happen before \q. Skip it and the same query plans as a Parallel Seq Scan (actual rows=75000 loops=2 rather than rows=150000 loops=1), which is not wrong, just harder to read next to the second run.
The hit ratio for the buffer cache comes from pg_statio_user_tables:
# Buffer cache hit ratio per table. Statistics are reported when a
# transaction ends and the collector can lag by up to a second, so run this
# as its own command, a moment after the queries above. NULLIF guards a table
# that has served no reads at all, where the denominator would be zero.
docker exec pg-cache-demo psql -U shop -d shop \
--pset linestyle=unicode --pset border=2 -c "
SELECT relname,
heap_blks_read AS disk_reads,
heap_blks_hit AS buffer_hits,
round(100 * heap_blks_hit::numeric
/ nullif(heap_blks_hit + heap_blks_read, 0), 2) AS hit_pct
FROM pg_statio_user_tables
ORDER BY relname;"Expected Output:
┌────────────┬────────────┬─────────────┬─────────┐ │ relname │ disk_reads │ buffer_hits │ hit_pct │ ├────────────┼────────────┼─────────────┼─────────┤ │ categories │ 0 │ 0 │ │ │ products │ 1471 │ 1471 │ 50.00 │ │ users │ 0 │ 0 │ │ └────────────┴────────────┴─────────────┴─────────┘ (3 rows)
Exactly 50%: one cold scan and one warm one. On a production database this number should sit above 99%, and a sustained drop usually means the working set has outgrown shared_buffers. Two caveats before acting on it. The counters are cumulative since the last pg_stat_reset(), so a lifetime ratio hides a bad hour: compare snapshots instead. And a "miss" here is a read from the operating system's page cache as often as from a disk, so the number overstates how much real I/O is happening.
Caching Best Practices
Most cache incidents come from a handful of omissions: no TTL, no memory bound, no fallback when the cache is down, and a key naming scheme nobody can reason about.
Do This
- Measure before caching - time the query you intend to cache; a fast indexed lookup gains nothing locally
- Set a TTL on every key - it is the backstop for every invalidation you get wrong
- Set maxmemory and an eviction policy - a cache without a bound is a memory leak with good latency
- Cache expensive queries - joins, aggregations, searches, anything that scans
- Start with cache-aside plus write-around - the simplest pair that stays correct under concurrency
- Fail open - wrap cache calls so a Redis outage degrades to database load, not to errors
- Protect rebuilds of hot keys - a lock, jittered TTLs, or early refresh
- Use structured key names -
user:123,search:<hash>, plus a version prefix so a format change is a rename rather than a migration - Monitor hit rate and evicted_keys together - a falling hit rate with rising evictions means the cache is too small
Avoid This
- Keys with no expiry - they outlive the schema that produced them
- Caching data written more often than it is read - you pay the invalidation cost for a hit rate near zero
- Keys that leave out the user - per-user data under a shared key serves one user's data to another
- Treating a cache error as a request error - the database is still there; catch, log, and query it
- Caching everything - every cached item is another thing to invalidate, and cold entries pay the cost without the benefit
- Pickle for cache values - unpickling attacker-modified bytes executes code; use JSON or MessagePack and validate on read
- Relying on a database result cache - MySQL's was removed in 8.0 and PostgreSQL never had one
- Multi-megabyte values - Redis allows 512 MB per string, but one large value serialises the whole server; cache ids and fetch the pieces
- KEYS and FLUSHALL in production - both block the single command thread; use SCAN and targeted deletes
Caching Strategy Decision Tree
Four questions, in this order. The first one disqualifies more caches than the other three combined.
1. Is the query actually expensive?
- Scans, joins, aggregations, or a remote database - cache it, the win is real and measurable
- Indexed lookup on a nearby database - cache only to save connection capacity, and expect no latency win
2. How stale may the answer be?
- Minutes are fine - TTL only, no invalidation code
- Seconds at most - TTL plus invalidate-on-write
- Never stale - do not cache it, or read it from the primary; a cache is an asynchronous replica
3. What shape is the data?
- Single rows - cache-aside with
entity:idkeys - Result sets - hash the parameters into the key, and add ORDER BY
- Rankings and counters - sorted sets and INCR, with no database round trip at all
- Sessions - Redis with a TTL, plus persistence if losing them logs everyone out
4. How does an entry die?
- Few dependents - delete the enumerated keys on write
- Many or unknown dependents - tag the entries and invalidate by tag
- Under memory pressure - maxmemory plus an LRU policy decides for you, so make sure it decides well
maxmemory with allkeys-lru, and a lock on the rebuild of anything hot. That covers most applications, and every addition beyond it should be justified by a measurement.Key Takeaways
- Cache the expensive thing, not everything - an indexed primary-key lookup measured 0.5x to 1.6x against a Redis GET on the same host, while a 200,000-row search measured 68x to 109x. Same cache, opposite conclusions: time the query before you cache it.
- A cache is an asynchronous replica of your data - staleness, invalidation and stampedes are not implementation bugs, they are the inherent cost of holding a second copy.
- TTL bounds staleness, maxmemory bounds size - you need both. Redis defaults to no memory limit and
noeviction, so an unbounded cache eventually starts rejecting writes instead of making room. - Commit, then delete - write-around is the safe default because a delete carries no assumption about the new value. Deleting before the commit lets a concurrent reader repopulate the stale row.
- Write-through keeps the cache warm and adds a race - two concurrent updates can commit in one order and write the cache in the other. Use it for read-heavy, rarely-written data, with a version check and a TTL.
- A lock on rebuild protects the database, not the request - 20 concurrent misses became 1 query while wall-clock time went slightly up. That trade is what keeps the database alive at 2,000.
- Cache keys must encode every input - hash the search parameters, include the user id for per-user data, and add ORDER BY before caching a result set whose order is otherwise unspecified.
- Tags trade bookkeeping for dead members - the tag set still listed keys that had already been deleted, so its count is an upper bound. Give tag sets their own TTL and keep them coarse.
- Databases cache pages, not results - MySQL's query cache was removed in 8.0 (table-granular invalidation, one global mutex) and PostgreSQL never had one. A repeated scan still costs CPU at a 100% buffer hit ratio.
- Fail open - a cache outage should turn into database load and a slower page, never into a 500. Wrap every cache call accordingly.
What's Next?
- Monitoring and observability - Lesson 28 turns the numbers used here (hit rate,
evicted_keys, buffer hit ratio, query latency) into dashboards and alerts that fire before users notice. - Find the queries worth caching - this lesson's search example was worth caching because it was slow;
pg_stat_statementstells you which of your own queries are the expensive ones, rather than the ones you assume are. - The layer above this one - HTTP and CDN caching apply the same TTL and invalidation questions to whole responses, and they remove the request from your servers entirely rather than just from the database.