Part III · Chapter 5 · Cache and Queue · lesson 1
Should you put Redis in front of it?
14 min
Builds on The box labelled cache is a copy that can be wrong and What one request costs you, in cents
Part III · Chapter 5 · Cache and Queue · lesson 1
14 min
Builds on The box labelled cache is a copy that can be wrong and What one request costs you, in cents
The product-detail endpoint reads one row from Postgres, serialises it, and returns it. Averaged across a month it serves about 120 requests a second, and the query comes back in roughly 55ms. A ticket has been opened: put Redis in front of it. The ticket does not say what the hit rate will be. It does carry a managed cache instance at $500 a month.
Nobody in the thread has asked what hit rate makes that instance worth buying.
Predict
Cache economics
Hypothetical model, not a forecast
Effective latency and monthly cost at a given hit rate, and the hit rate below which the cache tier stops paying for itself.
It assumes
It ignores
Leave the hit rate at 90% and find the traffic level, or the origin price, at which the breakeven hit rate drops below 20% — the point at which the cache's cost case stops depending on a guess about hit rates.
Requests arriving each second, averaged across the month.
| Hit rate, percentage points | Saved per month against no cache |
|---|---|
| 1 | -494 |
| 5 | -469 |
| 10 | -438 |
| 20 | -376 |
| 30 | -313 |
| 40 | -251 |
| 50 | -189 |
| 60 | -127 |
| 70 | -64.54 |
| 75 | -33.44 |
| 80 | -2.34 |
| 85 | 28.77 |
| 90 | 59.87 |
| 92 | 72.31 |
| 94 | 84.76 |
| 95 | 90.98 |
| 96 | 97.2 |
| 97 | 103 |
| 98 | 110 |
| 99 | 116 |
| 100 | 122 |
Not there yet — keep moving the controls.
About 483 requests a second, or about $8 per million origin requests. Four times the traffic, and the question stops being interesting: the cache pays for itself at almost any hit rate worth deploying, and the argument moves on to whether the data can be stale.
At 120 requests a second it is interesting, and the numbers are uncomfortable. At a 90% hit rate the cache saves $59.87 a month and takes the endpoint from 55ms to 6.4ms. Two figures, two different arguments, and only one of them is worth $500.
The latency one. Nearly 49ms off a page load, repeatedly, is a real product change. The $59.87 is noise, and if you present the cache as a cost saving to somebody who later reads the bill, you will be explaining yourself.
The hit rate is not a property of the cache. It is a property of the traffic, and three things about the traffic decide it.
Working set against memory. Count the distinct keys requested in a window and multiply by object size. If that exceeds what you are buying, the tail of the distribution is permanently missing and no configuration recovers it.
Reads per write. Every write either invalidates a key or dirties it. A key read fifty times between writes caches beautifully. A key read twice between writes cannot, because you spend most of its life refilling it.
Tolerance for staleness. If the answer is "none", the cache has to be updated on the write path, and correctness now depends on a second system. If the answer is "a few minutes is fine", a time-to-live gets you the easy win.
This is the hour that most cache tickets skip, and it is the highest-value hour in the whole exercise.
Take a day of access logs. Reduce each request to the cache key you intend to use. Replay that sequence through an in-memory LRU of the size you are proposing to buy, and count. Fifty lines of Python, and the output is the number the ticket was guessing at — including the shape of the curve as you vary the size, which tells you whether the next tier up is worth anything.
If the logs do not carry enough to reconstruct a key, that is itself the finding: you do not yet know what you would cache.
Already running a cache and want the real number instead of an estimate? Redis
reports keyspace_hits and keyspace_misses in INFO stats, and the ratio is
the figure the model above wants. Beware that it counts every key in the
instance, so one chatty prefix will flatter or ruin the endpoint you care about.
The database's own cache, first. Postgres has a buffer cache, and a row read every second from a warm 12KB page is not a disk read. Before adding a tier, check whether the 55ms is actually the row fetch or a missing index, a sequential scan, or serialisation in the application. A cache in front of a bad query plan hides the plan and keeps the cost.
An in-process cache, second. Caffeine on the JVM, lru-cache in Node,
functools.lru_cache or cachetools in Python. No network hop, no new
dependency to operate, microsecond hits. It costs you heap, it is cold after
every deploy, and each instance has its own copy — so a hundred instances means
a hundred first misses per key and a hundred independently stale views. That
last part is what rules it out when staleness has to be bounded tightly.
Redis, third, and for a specific reason. Reach for it when the instances must
share one view of the cached value, when the working set is larger than you want
in heap, or when you need the data structures — sorted sets for a leaderboard,
SETNX for a lock, counters for a rate limiter. If the only requirement is
"make this endpoint faster", Redis is a bigger answer than the question.
Memcached, when it is purely a key-value cache with multiple clients and you want the simplest possible operational story. Fewer features, less to go wrong, and no persistence to misconfigure. The reason people pick Redis anyway is that the features arrive later and the migration does not.
A CDN, if the response is a cacheable GET with no per-user content. Cloudflare or Fastly in front of the endpoint moves the hit out of your infrastructure entirely, and the cheapest cache is the one in somebody else's building. It stops being an option the moment the response varies per session.
For this endpoint, with this traffic: fix the query first, then an in-process LRU with a short time-to-live, and Redis only when the fleet grows past the point where per-instance staleness is visible to one user hitting refresh.
Check
Everything above assumes the working set fits in the tier you bought. The moment it stops fitting, the arithmetic changes shape rather than degree.
A cache at its memory limit evicts, and what it evicts next is decided by
policy, not by your model. Redis defaults to noeviction on a plain install,
which means writes start failing rather than old keys disappearing — a surprise
the first time the instance fills. allkeys-lru is what most caches want and
almost nobody sets. Once the working set is larger than memory, the hit rate
stops being the number you measured and becomes a function of how fast the
distribution's tail cycles through.
The symptom is specific and worth recognising: evicted_keys climbing in INFO stats, hit rate drifting down week over week with no code change, and origin
load rising faster than traffic. That is not a tuning problem. It is the model
above telling you that you are buying a cache whose job is now to churn.
Two honest responses. Buy more memory, and re-run the breakeven with the new tier price, because the breakeven moves with it. Or cut the working set: cache fewer fields, cache the expensive derived value rather than the whole row, drop the long tail deliberately by not caching keys whose popularity you can see is one-shot. The second is nearly always the better engineering and always the harder sell.
Caches are mostly bought for latency and justified with cost, because cost is the argument that fits in a ticket.
The problem is that the cost case is the one that collapses first. It depends on a per-request origin price that is itself an attribution choice — the database instance was already running, and it will carry on running when the cache absorbs 90% of the reads. The saving only becomes real if you then buy a smaller instance, which nobody does, because the instance is also serving the write path and four other endpoints.
So the honest version of the ticket says: this takes roughly 49ms off a page a user hits constantly, it costs $500 a month, and it introduces a second thing that can serve wrong answers. All three of those are true, and a reviewer can weigh them. The version that promises a cost saving is making a claim that the finance report will quietly contradict in a quarter.