A cache is a copy with a deadline. It earns its place when the copy is cheaper than the query, the deadline is written down, and a write knows which copies to drop.
Name the key so a write can find it
A useful key says what the value is and which version of the shape it uses. quote:v1:SCOM is a quote for one symbol in the first response shape. user:42:dashboard is a different object, and it must not share a key with anyone else’s dashboard. If you change the fields in the payload, bump the version. Old keys then expire on their own instead of serving a shape the client no longer understands.
Include only the parts that change the result. A symbol and a currency belong in the key. The current time does not, or you will never hit. A user’s id belongs in the key when the payload contains their prices, their permissions, or their draft. Leave it out when the payload is the same public page for everyone.
Write the key in one function and call it from the reader and from the writer. A second, slightly different string in the invalidation path is how a stale page survives a publish. The function is the documentation.
Set the deadline from the cost of being wrong
A marketing page can live for minutes. A stock price cannot. A permission bit should usually not be cached at all. Ask what a wrong answer costs for the whole time-to-live, then pick the shortest TTL you can afford operationally. Fifteen seconds is plenty to absorb a burst of refreshes on a quote. Fifteen minutes is plenty for a navigation menu that an editor changes a few times a week.
Store the time you loaded the value inside the payload, not only in the cache server’s expiry. When you display the quote, you can say when it was true. When the provider is down, you can decide whether a thirty-second-old price is still honest enough to show with a stale flag, or whether you should fail the request.
Do not cache an error as if it were a value, unless you mean to. A five-millisecond cache of “provider failed” can save a stampede, and a five-minute cache of that same error will take the feature down after the provider has recovered. If you cache failures, give them their own short TTL and a different key suffix.
One writer, then drop the copy
The database remains the record. The cache is a shortcut back to it. After a successful save, delete the keys that were derived from that row. Delete is safer than overwrite: a concurrent reader will miss, load the new row, and store it. An overwrite that races the save can put the old row back.
If many requests miss at once, they will all run the expensive load. A short lock, taken with an atomic add, lets one request do the work. The others wait once and read the filled key, or they load themselves if the lock holder failed. The lock needs its own short TTL so a crashed worker does not hold it.
Measure hit rate on the keys you meant to save, not on the cache process as a whole. A high hit rate on a key you could have computed in memory is not a win. A miss on every quote, because the key includes the current second, is a bug.
A read-through with a single loader
This is the shape we use when a Django view needs a public value that is expensive to build and safe to share. cache.add is atomic on the backends we run, so only one worker fills the key. The lock timeout is shorter than the value timeout, and it is cleared when the load finishes.
The loader is injected so a test can pass a fake instead of Redis and a network. The view does not know whether the number came from cache.
Python · quotes/cache.py
from django.core.cache import cache
def quote_key(symbol: str) -> str:
"""Public quote payload, version 1. Bump the version when the fields change."""
return f"quote:v1:{symbol.upper()}"
def get_or_load(key: str, loader, ttl: int):
cached = cache.get(key)
if cached is not None:
return cached
lock = f"{key}:lock"
if cache.add(lock, "1", timeout=5):
try:
value = loader()
cache.set(key, value, timeout=ttl)
return value
finally:
cache.delete(lock)
cached = cache.get(key)
return cached if cached is not None else loader()
def invalidate_quote(symbol: str) -> None:
cache.delete(quote_key(symbol))
What not to put in a shared key
A shared key is safe for a symbol’s last price. It is not safe for “the page this user is allowed to see.” If the payload includes a name, an email, a portfolio, or a permission, the key must include that user’s id, and the view must check the permission again on the way out of the cache. The cache is not an access-control list.
Also leave out anything you would be unwilling to log. Cache servers get copied, inspected, and occasionally left open on a private network that is less private than you think.
When you add the next cached value
- Write one function that builds the key, and use it for reads and deletes.
- Put the version and the TTL next to that function.
- Delete the key in the same request that commits the write.