How to Choose Between TTL and Explicit Invalidation
You are staring at a cache that occasionally serves stale data, and you have to decide whether to let keys expire on a timer or purge them the instant the source of truth changes. This page gives you a repeatable procedure for making that call per key family: measure the data's volatility and read/write ratio, quantify your tolerance for a stale read, then wire up TTL expiration, explicit invalidation, or a hybrid that uses a short TTL as a safety net behind explicit purges. Every step below is independently runnable against a live Redis 7.x instance so you can validate the decision with real numbers instead of guessing. For the underlying mechanics of how Redis stores and evicts keys, start from Redis caching architecture and invalidation fundamentals.
Prerequisites
- Redis 6.2+ or 7.x, reachable via
redis-cliand from your application host. redis-py5.x on Python 3.10+ (pip install "redis>=5,<6" tenacity). The examples use theredis.asyncioclient.- Permission to read
INFO,SLOWLOG, andCONFIG GET(noCONFIG SETrequired for measurement). - A representative key family to profile — e.g.
session:*,product:*, orleaderboard:*— not the whole keyspace at once. - An understanding of your consistency SLA: the maximum wall-clock time a stale read is acceptable for this data.
Step-by-Step Decision Procedure
1. Classify the key family's write volatility
Sample how often the underlying record actually changes, because a key rewritten every few seconds is a different problem from one rewritten daily.
# Count writes to a key family over a 60s window using keyspace notifications.
redis-cli CONFIG SET notify-keyspace-events KEA
timeout 60 redis-cli --csv PSUBSCRIBE '__keyevent@0__:set' | grep -c 'product:'
2. Measure the read/write ratio
Pull hit/miss and command stats so you know whether the family is read-heavy (favours TTL amortization) or write-heavy (favours explicit purges).
redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses|expired_keys|evicted_keys"
3. Check whether expiration is keeping up
Confirm the active-expiration cycle is not CPU-starved before you rely on TTLs; a stagnant expired_keys counter with a growing key count means expiry is lagging.
redis-cli CONFIG GET hz # background task frequency, default 10
redis-cli SLOWLOG GET 10 # long commands can block the expire cycle
The hz directive governs how many times per second Redis runs background jobs, including active expiration. Raising it from 10 to 20 tightens the gap between a key's nominal expiry and its removal when thousands of keys expire per second, at a proportional CPU cost. If expiry lags because memory fills first, keys leave via LRU eviction under your maxmemory-policy rather than on schedule — a different failure with different symptoms.
4. Implement TTL expiration with jitter
If the family is read-heavy and tolerates a bounded staleness window, use TTLs — but jitter them so a batch written together does not expire in the same instant and stampede your database.
import random
def jittered_ttl(base_ttl: int, jitter_fraction: float = 0.1) -> int:
"""Spread expiry by ±jitter_fraction of base_ttl to avoid synchronized mass expiry."""
jitter = int(base_ttl * jitter_fraction * (2 * random.random() - 1))
return max(1, base_ttl + jitter)
5. Apply the jittered TTL on write
Set the value and its randomized expiry atomically so no key is ever written without a bound.
import redis.asyncio as redis
client = redis.Redis(host="localhost", port=6379, decode_responses=True)
async def cache_product(product_id: str, payload: str, base_ttl: int = 300) -> None:
# SET with EX applies the expiry in the same round trip as the write.
await client.set(f"product:{product_id}", payload, ex=jittered_ttl(base_ttl))
6. Implement explicit invalidation on mutation
If the family is write-heavy or the staleness SLA is near zero, purge the key the moment the source of truth changes. Prefer UNLINK over DEL so memory is reclaimed on a background thread instead of blocking the event loop.
from tenacity import (
retry, stop_after_attempt, wait_exponential, retry_if_exception_type,
)
import redis.exceptions
@retry(
retry=retry_if_exception_type(
(redis.exceptions.ConnectionError, redis.exceptions.TimeoutError)
),
wait=wait_exponential(multiplier=0.1, max=2),
stop=stop_after_attempt(4),
reraise=True,
)
async def invalidate_product(product_id: str) -> None:
# UNLINK removes the key from the keyspace synchronously but frees
# memory asynchronously — safe for large values on the hot path.
await client.unlink(f"product:{product_id}")
7. Add a short-TTL safety net behind explicit purges
Because an explicit invalidation can be lost to a network partition or consumer lag, keep a conservative TTL on the same keys so a missed purge self-heals within a bounded window instead of serving stale data indefinitely. This hybrid is the default choice for most write-heavy families and pairs naturally with pub/sub invalidation for cross-service fan-out.
async def cache_product_hybrid(product_id: str, payload: str) -> None:
# Explicit UNLINK is primary; the 120s TTL bounds the blast radius
# of any invalidation message that never arrives.
await client.set(f"product:{product_id}", payload, ex=jittered_ttl(120))
8. Gate the strategy in CI
Enforce the decision in the pipeline so a future change cannot silently ship an unbounded key or a TTL that violates your SLA.
- name: Validate cache strategy compliance
run: python -m pytest tests/cache/test_invalidation_patterns.py -v
Failure Modes
Synchronized mass expiry (cache stampede). A batch of keys written in the same request all carry an identical TTL and expire together, dumping concurrent misses on the database. Diagnose by correlating a spike in keyspace_misses with a periodic sawtooth in key count:
redis-cli INFO stats | grep keyspace_misses
Fix by applying jittered_ttl (Step 4) so expiries are spread across a window. Read-through families that recompute on miss benefit further from probabilistic early recomputation.
Missed explicit invalidation. A purge dispatched after a database commit is lost to a broker outage or consumer lag, so the cache serves the pre-mutation value until the next write. Diagnose by comparing the last-modified timestamp in your database against the cached payload for a sampled key. Fix with the Step 7 hybrid: the short TTL caps how long any missed purge can persist, and idempotent retries (the tenacity decorator in Step 6) absorb transient broker failures.
Expiry starved by long commands. A blocking KEYS, large SORT, or slow Lua script monopolizes the single-threaded event loop, so the active-expiration cycle never runs and TTL-bearing keys pile up past their nominal expiry. Diagnose:
redis-cli SLOWLOG GET 10
redis-cli INFO stats | grep expired_keys # stagnant while key count climbs
Fix by removing the blocking command (replace KEYS with SCAN) and, if expiry still lags under legitimate load, raise hz.
Verification
Confirm every cached key in the family carries a bound — a -1 result means an unbounded key slipped past your policy:
redis-cli TTL product:12345 # expect a positive integer, never -1
Assert that expiry is the dominant removal path over a load test, and that eviction is not silently doing the work of expiration:
redis-cli INFO stats | grep -E "expired_keys|evicted_keys"
# expired_keys should climb steadily; a large evicted_keys delta means
# maxmemory-policy is intervening — revisit sizing or eviction policy.
Verify an explicit purge actually removed the key and did not merely rewrite it:
redis-cli UNLINK product:12345
redis-cli EXISTS product:12345 # expect 0
FAQ
Can I use TTL and explicit invalidation on the same key?
Yes — that is the recommended hybrid (Step 7). Explicit UNLINK is the primary mechanism for freshness, and a short TTL is the safety net that bounds staleness if a purge is ever lost. Almost every write-heavy family should use both rather than betting on either alone.
Does a very short TTL make explicit invalidation unnecessary?
Only if your staleness SLA is larger than the TTL. A one-second TTL approximates strong consistency but multiplies database load and can expire mid-transaction, exposing partial state. If the SLA is truly near-zero, use explicit invalidation with a modest TTL behind it instead of shrinking the TTL toward zero.
Why prefer UNLINK over DEL for invalidation?
DEL frees the value's memory synchronously on the main thread, so purging a large hash or set can stall every other command for milliseconds. UNLINK removes the key from the keyspace immediately but reclaims memory on a background thread, keeping the hot path responsive. Use UNLINK for anything but trivially small values.
How do I invalidate many related keys at once?
Do not scan the whole keyspace on every write. Group related keys under a shared tag and purge the tag set, as covered in key tagging strategies. For asynchronous or high-volume fan-out, offload the purge to a queue via asynchronous invalidation queues with Celery.
My expired_keys counter is flat but keys still disappear — what is happening?
They are being evicted, not expired. When memory reaches maxmemory, Redis removes keys under its eviction policy before their TTL fires, so evicted_keys rises while expired_keys stays flat. Confirm the policy with redis-cli CONFIG GET maxmemory-policy and review LRU vs LFU eviction policies to right-size the instance.
Up: TTL vs Explicit Invalidation