Redis Cache Versus Read Replica for Hot Read Paths
Problem statement: one read path is hot enough to matter, and you have to decide whether to put a cache in front of it or add another replica behind it — knowing the two solve different problems.
The decision is usually made by preference rather than measurement, and the measurement is cheap. This page identifies what a read path can be bound by, shows how to tell which applies, and gives the rule that follows. The staleness consequences of adding a cache are covered in caching and materialized reads for stale-tolerant workloads.
Symptom Identification
The read path is a candidate for either treatment when:
- One endpoint accounts for a disproportionate share of database read time.
- The replicas’ CPU is dominated by a small number of statement digests.
- p99 latency for that endpoint is well above p50, suggesting queueing rather than execution.
- The endpoint is called far more often than the data behind it changes.
What distinguishes the two treatments is which of these is the binding constraint:
| Observation | Bound by | Treatment |
|---|---|---|
| Query executes in under 1 ms but the call takes 4 ms | round trip | cache |
| Query executes in 20 ms and is identical every time | repeated computation | materialized view, then maybe cache |
| Query executes in 2 ms but p99 is 200 ms | queue wait / connection pressure | replica, or pool sizing |
| Every call reads a different key | nothing a cache can help | replica |
The third row is worth calling out: a path bound by queue wait is not helped by a cache unless the cache removes enough calls to shorten the queue — and if the queue is caused by pool sizing rather than by database capacity, neither a cache nor a replica is the right answer. Check connection pool architecture for read replicas before adding hardware.
Root Cause Analysis
A cache removes round trips and repeated work for keys that are read repeatedly. Its benefit is proportional to reuse: how many times the same key is read within one TTL. With a reuse ratio of ten, ninety percent of reads never reach the database. With a ratio of 1.2, most reads are misses that now pay a cache round trip before the database round trip — strictly worse than not having the cache.
A replica adds capacity for all reads, cacheable or not. Its benefit is proportional to the share of read work that can be moved off the constrained node. It requires no new consistency reasoning beyond the replication lag you already have, which is why it is usually the cheaper option in engineering terms even when it is the more expensive one in infrastructure terms.
The two address different bottlenecks and are frequently confused because both “make reads faster”. The distinguishing measurement is the gap between client-observed latency and server-side execution time. If a query executes in 0.8 ms and the client sees 4 ms, 3.2 ms is round trip and overhead — a cache eliminates it. If the query executes in 20 ms, the round trip is noise and a cache saves almost nothing per call; what would help is not executing the query, which is a materialized view.
Staleness composes and is the hidden cost. A cache entry populated from a replica two seconds behind and held for a thirty-second TTL can be thirty-two seconds old. Adding a replica adds no staleness beyond what already exists. This asymmetry is frequently the deciding factor for read paths with tight freshness requirements.
Step-by-Step Resolution
Step 1 — Split client latency into its components
-- Server-side execution time for the candidate digest.
SELECT queryid, calls,
round(mean_exec_time::numeric, 2) AS mean_exec_ms,
round(mean_plan_time::numeric, 2) AS mean_plan_ms
FROM pg_stat_statements
WHERE queryid = 8172635471029384756;# Client-observed latency for the same call path.
histogram_quantile(0.50, sum by (le) (rate(db_query_duration_ms_bucket{path="profile_get"}[5m])))The difference between the two is round trip plus driver overhead plus queue wait. Separate the queue component with a checkout-time span if you have one, as described in tracing and SLO reporting for replica reads.
Inline verification: the three components sum to approximately the client-observed p50, within a millisecond.
Step 2 — Measure key reuse in a candidate window
-- How many times is the same key read within a 60-second window?
-- Requires request-level logging of the key; sample rather than logging all.
SELECT round(count(*)::numeric / count(DISTINCT cache_key), 2) AS reads_per_key
FROM request_log
WHERE path = 'profile_get'
AND logged_at > now() - interval '60 seconds';# Without request logging, approximate from an access log.
awk '/profile_get/ {print $7}' /var/log/app/access.log \
| tail -100000 | sort | uniq -c \
| awk '{t+=$1; k++} END {printf "reads per key: %.2f over %d keys\n", t/k, k}'Inline verification: you have a single ratio. Below about 2, a cache will mostly miss; above about 5, it will mostly hit.
Step 3 — Apply the rule
| Reuse ratio | Bound by | Choose |
|---|---|---|
| High (> 5) | round trip | cache — removes most calls entirely |
| High (> 5) | execution of an identical aggregate | materialized view, optionally cached |
| Low (< 2) | anything | replica — a cache cannot help what it cannot hit |
| Any | queue wait | fix pool sizing first, then re-measure |
Inline verification: the chosen option addresses the component that dominated in step 1. If it does not, the decision was made on preference rather than on the measurement.
Step 4 — Budget the staleness before building the cache
# If the answer is 'cache', check the composed staleness fits the budget
# BEFORE writing the code. This calculation frequently changes the decision.
REPLICA_LAG_P99_S = 2.0 # measured, not assumed
CACHE_TTL_S = 30.0
BUDGET_S = 10.0 # what the read path actually promises
worst_case = REPLICA_LAG_P99_S + CACHE_TTL_S
assert worst_case <= BUDGET_S, (
f"composed staleness {worst_case}s exceeds the {BUDGET_S}s budget — "
f"TTL must be <= {BUDGET_S - REPLICA_LAG_P99_S}s, which may not be "
f"long enough for the cache to be worth having"
)Running this before building often reveals that the TTL required to stay inside the budget is so short that the reuse ratio within it collapses — which turns the answer back into a replica.
Inline verification: the assertion passes with the TTL you intend to use, and the reuse ratio from step 2 was measured over that same TTL rather than a longer one.
Step 5 — Verify against the original metric
# The thing to check is the bottleneck you started from, not the hit rate.
histogram_quantile(0.99, sum by (le) (rate(db_query_duration_ms_bucket{path="profile_get"}[5m])))
# And the load actually removed from the database.
sum(rate(pg_stat_statements_calls{queryid="8172635471029384756"}[5m]))Inline verification: database call rate for that digest falls by roughly 1 − 1/reuse_ratio, and p99 falls by roughly the round-trip component you measured in step 1.
Configuration Snippet
# read_path.py — a cache in front of a replica, written so the staleness is
# explicit and the metrics answer the question you started with.
import time
from prometheus_client import Histogram, Counter
READ_AGE = Histogram("db_read_served_age_ms", "Age of served data", ["path"])
SOURCE = Counter("db_read_source_total", "Where the read was served from",
["path", "source"])
BUDGET_S = 10.0
def get_profile(uid: int):
key = f"profile:{uid}"
if (entry := cache.get(key)) is not None:
age = time.time() - entry["as_of"] # age since COMMIT, not since SET
if age <= BUDGET_S:
READ_AGE.labels("profile_get").observe(age * 1000)
SOURCE.labels("profile_get", "cache").inc()
return entry["value"]
cache.delete(key) # over budget: do not serve it
row, lag_s = replica.query_with_lag(
"SELECT * FROM profiles WHERE id = %s", uid)
# TTL is the REMAINING budget, not a fixed number. A read from a replica
# that is already 2 s behind gets an 8 s TTL, not a 30 s one.
ttl = max(BUDGET_S - lag_s, 1.0)
cache.set(key, {"value": row, "as_of": time.time() - lag_s}, ttl=ttl)
READ_AGE.labels("profile_get").observe(lag_s * 1000)
SOURCE.labels("profile_get", "replica").inc()
return rowTwo details carry most of the correctness. as_of records the commit time the value reflects rather than the moment it was cached, so the age check is against real staleness. And the TTL is computed from the remaining budget after the replica’s lag is subtracted, which is what stops the two staleness sources compounding past the promise. A fixed TTL is the common shortcut and it is exactly the bug described in the parent guide.
Verification and Rollback
Confirm the bottleneck moved:
# Before/after on the metric that motivated the change.
histogram_quantile(0.99, sum by (le) (rate(db_query_duration_ms_bucket{path="profile_get"}[5m])))
# And the source split — this should match the predicted reuse ratio.
sum by (source) (rate(db_read_source_total{path="profile_get"}[5m]))If the cache-versus-replica split does not match the reuse ratio you measured, the cache window in production differs from the one you sampled — usually because the TTL had to be shortened to fit the staleness budget.
Confirm the staleness stayed inside the budget:
# The composed age of served data. This is the number that must not drift.
histogram_quantile(0.99, sum by (le) (rate(db_read_served_age_ms_bucket{path="profile_get"}[5m])))Rollback. A cache is straightforward to remove because it sits in front of a path that still works without it:
# Feature-flag the cache read so removal is a config change, not a deploy.
if not flags.enabled("profile_cache"):
return replica.query("SELECT * FROM profiles WHERE id = %s", uid)[0]Flush the keys after disabling so that re-enabling later does not serve entries populated under different assumptions — particularly if the TTL or the as_of semantics changed in between. Removing a replica is the harder rollback of the two: instances can be deleted quickly but re-creating one requires a fresh base backup, so scale the replica tier down more cautiously than you scale a cache down.
Edge Cases and Gotchas
A cache stampede converts a cache benefit into a database spike
When a hot key expires, every concurrent request for it misses simultaneously and hits the database at once. On a key read a thousand times a second, expiry produces a thousand simultaneous identical queries. Mitigate with a short randomised TTL jitter and a single-flight lock so only one request repopulates while the others wait:
ttl = base_ttl * random.uniform(0.85, 1.0) # jitter: spread expiriesCache memory pressure evicts unpredictably
Redis under maxmemory with an LRU policy evicts keys you did not choose, so the effective TTL of a cold-ish key is shorter than configured and the reuse ratio you measured no longer holds. Monitor evicted_keys; a non-zero and rising value means your cache is smaller than its working set and the hit rate you designed for is not achievable.
The replica option has a floor you may already be at
Adding a replica helps only if reads can actually move onto it. If a large share of reads is pinned to the primary by transaction wrappers, a new replica sits idle and the primary stays hot — the situation described in detecting accidental primary reads in production traffic. Check the replica share of reads before concluding that more replica capacity is what you need.
FAQ
Is a cache always faster than a read replica?
For a single key lookup, yes by roughly an order of magnitude — a Redis GET is typically well under a millisecond while a database round trip plus planning and execution is a few milliseconds. But that gap only matters if your read path is bound by that latency. A path bound by a twenty-millisecond aggregate is not helped by a faster round trip; it is helped by not running the aggregate, which is a materialized view.
What key reuse rate justifies a cache?
As a rule of thumb, a cache pays for itself when the same key is read several times within one TTL. Below about a two-to-one read-per-key ratio in the window, most requests are misses that pay the cache round trip plus the database round trip, so the cache adds latency and complexity while removing very little load. Measure the ratio from real access logs rather than assuming a hot-key distribution.
Can a cache replace a read replica entirely?
Only for the subset of reads that are cacheable, which is rarely all of them. Every cache miss still needs a database, and the misses are precisely the reads a cache cannot smooth — cold keys, new keys, and the burst after an invalidation. Treat a cache as a load reducer in front of a read tier, and size the replica tier for the miss traffic rather than for the total.
Which option is cheaper to operate?
A replica, in most teams’ experience, because it introduces no new consistency reasoning. A cache adds an invalidation problem, a second staleness source that composes with replication lag, and a failure mode where a stale entry survives an invalidation. The infrastructure bill often favours the cache; the engineering and incident cost usually favours the replica. Weigh both.
Related
← Back to Caching & Materialized Reads for Stale-Tolerant Workloads
- Cache Invalidation That Survives Replication Lag — the correctness problem you take on by choosing the cache.
- Serving Bounded-Staleness Reads with an Explicit Freshness Budget — turning the budget in step 4 into something the code enforces.
- Read Scaling Trade-offs in High-Traffic Applications — where adding replicas stops helping, and why.