Caching
Purpose
Add a cache with a clear invalidation story, or find out why the existing one is serving stale data. A cache is a second source of truth; introducing one is a consistency decision, not just a performance one.
When to Use
- A read path is slow and the data is read far more often than it is written.
- An upstream dependency is expensive, rate-limited, or unreliable.
- Debugging stale reads, cache stampedes, or unexplained memory growth.
Capabilities
- Cache placement: client, CDN, application, database.
- Strategies: cache-aside, read-through, write-through, write-behind.
- Invalidation: TTL, explicit, event-driven, versioned keys.
- Stampede protection: locking, early recomputation, request coalescing.
- Negative caching and hot-key mitigation.
Inputs
- The read/write ratio and the cost of the uncached path.
- How stale the data is allowed to be — in seconds, agreed with the product owner.
- Cardinality of the key space and expected memory footprint.
Outputs
- A key schema, a TTL, and an invalidation trigger for each cached entity.
- Stampede protection on any key expensive enough to matter.
- A hit-rate metric, because a cache you do not measure is a cache you do not understand.
Workflow
- Prove the need — Measure the uncached path first. A cache in front of a missing index is a permanent workaround for a five-minute fix.
- Decide the staleness budget — Ask what breaks if a user sees data five seconds old. The answer determines the TTL and whether you need explicit invalidation.
- Design the key — Include everything that varies the result: tenant, locale, permission scope. A key that omits a dimension leaks data between users.
- Choose invalidation — TTL alone for tolerant data. TTL plus explicit deletion on write for data that must be fresh. Versioned keys when deletion is unreliable.
- Protect against stampedes — When a hot key expires, every concurrent request misses simultaneously and hits the origin at once. Use a lock or single-flight.
- Measure — Hit rate, latency at each layer, and eviction rate. Falling hit rate with rising memory means the key space is too large.
Best Practices
- Cache the expensive computation, not the whole response. Response-level caching multiplies the key space by every variant.
- A cache key missing the tenant or the user's permission scope is a data-leak vulnerability, and it will be found by a customer.
- Never cache with an unlimited TTL and rely on invalidation alone. Invalidation fails; the TTL is the safety net.
- Set memory limits and an eviction policy explicitly (
allkeys-lru). A Redis instance with no maxmemory will be killed by the OOM killer.
- Do not cache errors indefinitely, but do cache "not found" briefly — otherwise a bad ID becomes an origin DoS.
- Warm caches before cutting traffic over. A cold cache under full load is an outage.
Examples
Single-flight cache-aside, preventing a stampede:
async def get_pricing(tenant_id: str, sku: str) -> Pricing:
key = f"pricing:v3:{tenant_id}:{sku}"
if (cached := await redis.get(key)) is not None:
return Pricing.model_validate_json(cached)
# Only one caller per key computes; the rest wait for the result.
lock_key = f"{key}:lock"
if await redis.set(lock_key, "1", nx=True, ex=10):
try:
pricing = await compute_pricing(tenant_id, sku) # expensive
await redis.set(key, pricing.model_dump_json(), ex=300)
return pricing
finally:
await redis.delete(lock_key)
# Lost the race: wait briefly for the winner, then fall back to computing.
for _ in range(20):
await asyncio.sleep(0.05)
if (cached := await redis.get(key)) is not None:
return Pricing.model_validate_json(cached)
return await compute_pricing(tenant_id, sku)
Notes
- The
v3 in the key is a schema version. When the cached shape changes, bump it — this invalidates the whole namespace atomically without a scan-and-delete.
- Adding jitter to TTLs (
ex=300 + random.randint(0, 60)) prevents a cohort of keys written together from expiring together.
- A 95% hit rate sounds excellent, but if the 5% of misses are all on the single hottest key, the origin still sees the full load. Look at misses per key, not just the aggregate.
1---2name: caching3description: Use when adding or debugging a cache. Covers cache placement, invalidation strategies, stampede protection, TTL selection, and the consistency you are trading away.4---56# Caching78## Purpose910Add a cache with a clear invalidation story, or find out why the existing one is serving stale data. A cache is a second source of truth; introducing one is a consistency decision, not just a performance one.1112## When to Use1314- A read path is slow and the data is read far more often than it is written.15- An upstream dependency is expensive, rate-limited, or unreliable.16- Debugging stale reads, cache stampedes, or unexplained memory growth.1718## Capabilities1920- Cache placement: client, CDN, application, database.21- Strategies: cache-aside, read-through, write-through, write-behind.22- Invalidation: TTL, explicit, event-driven, versioned keys.23- Stampede protection: locking, early recomputation, request coalescing.24- Negative caching and hot-key mitigation.2526## Inputs2728- The read/write ratio and the cost of the uncached path.29- How stale the data is allowed to be — in seconds, agreed with the product owner.30- Cardinality of the key space and expected memory footprint.3132## Outputs3334- A key schema, a TTL, and an invalidation trigger for each cached entity.35- Stampede protection on any key expensive enough to matter.36- A hit-rate metric, because a cache you do not measure is a cache you do not understand.3738## Workflow39401. **Prove the need** — Measure the uncached path first. A cache in front of a missing index is a permanent workaround for a five-minute fix.412. **Decide the staleness budget** — Ask what breaks if a user sees data five seconds old. The answer determines the TTL and whether you need explicit invalidation.423. **Design the key** — Include everything that varies the result: tenant, locale, permission scope. A key that omits a dimension leaks data between users.434. **Choose invalidation** — TTL alone for tolerant data. TTL plus explicit deletion on write for data that must be fresh. Versioned keys when deletion is unreliable.445. **Protect against stampedes** — When a hot key expires, every concurrent request misses simultaneously and hits the origin at once. Use a lock or single-flight.456. **Measure** — Hit rate, latency at each layer, and eviction rate. Falling hit rate with rising memory means the key space is too large.4647## Best Practices4849- Cache the expensive computation, not the whole response. Response-level caching multiplies the key space by every variant.50- A cache key missing the tenant or the user's permission scope is a data-leak vulnerability, and it will be found by a customer.51- Never cache with an unlimited TTL and rely on invalidation alone. Invalidation fails; the TTL is the safety net.52- Set memory limits and an eviction policy explicitly (`allkeys-lru`). A Redis instance with no `maxmemory` will be killed by the OOM killer.53- Do not cache errors indefinitely, but do cache "not found" briefly — otherwise a bad ID becomes an origin DoS.54- Warm caches before cutting traffic over. A cold cache under full load is an outage.5556## Examples5758**Single-flight cache-aside, preventing a stampede:**5960```python61async def get_pricing(tenant_id: str, sku: str) -> Pricing:62 key = f"pricing:v3:{tenant_id}:{sku}"6364 if (cached := await redis.get(key)) is not None:65 return Pricing.model_validate_json(cached)6667 # Only one caller per key computes; the rest wait for the result.68 lock_key = f"{key}:lock"69 if await redis.set(lock_key, "1", nx=True, ex=10):70 try:71 pricing = await compute_pricing(tenant_id, sku) # expensive72 await redis.set(key, pricing.model_dump_json(), ex=300)73 return pricing74 finally:75 await redis.delete(lock_key)7677 # Lost the race: wait briefly for the winner, then fall back to computing.78 for _ in range(20):79 await asyncio.sleep(0.05)80 if (cached := await redis.get(key)) is not None:81 return Pricing.model_validate_json(cached)8283 return await compute_pricing(tenant_id, sku)84```8586## Notes8788- The `v3` in the key is a schema version. When the cached shape changes, bump it — this invalidates the whole namespace atomically without a scan-and-delete.89- Adding jitter to TTLs (`ex=300 + random.randint(0, 60)`) prevents a cohort of keys written together from expiring together.90- A 95% hit rate sounds excellent, but if the 5% of misses are all on the single hottest key, the origin still sees the full load. Look at misses per key, not just the aggregate.