LRU vs LFU Eviction Policies in Redis
This guide decides which maxmemory-policy to run when Redis hits its memory ceiling — approximate LRU, LFU, or their volatile-* variants — and how to tune, roll out, and verify that choice under production traffic.
Eviction is not a fallback that only fires when something has gone wrong; it is a deterministic capacity-control layer that runs every time the working set exceeds maxmemory. When proactive lifecycle controls such as TTL and explicit invalidation fail to constrain growth, the eviction algorithm alone decides which keys survive — and that decision directly sets your cache hit ratio, downstream database load, and p99 latency during traffic spikes. The broader context for where eviction sits among the other caching decisions is laid out in Redis Caching Architecture & Invalidation Fundamentals; this page focuses purely on the recency-versus-frequency trade-off.
Architectural Trade-offs: LRU vs LFU
Both policies answer the same question — "which key do I drop to reclaim memory?" — but they read a different signal. LRU (Least Recently Used) evicts by recency of access; LFU (Least Frequently Used) evicts by frequency of access tracked through a decaying counter. The consequence is a divergence in which keys survive a memory-pressure event, and therefore which requests still hit cache afterwards.
| Dimension | Approximate LRU (allkeys-lru) |
LFU (allkeys-lfu) |
|---|---|---|
| Eviction signal | Time since last access (idle time) | Access frequency with logarithmic decay |
| Hit ratio, recency-skewed load | High — recent keys are the hot keys | Moderate — new hot keys start cold and can be evicted early |
| Hit ratio, frequency-skewed load | Lower — a traffic burst can flush long-lived hot keys | High — durable hot set is protected from transient spikes |
| Scan / burst resistance | Weak — a full scan pollutes recency for every key | Strong — one-off accesses barely move the counter |
| Per-key metadata | 24-bit access clock in the object header | 8-bit log counter in the same header (OBJECT FREQ) |
| CPU cost | Sampling only (maxmemory-samples) |
Sampling plus counter increment/decay math |
| Primary tunable | maxmemory-samples |
lfu-log-factor, lfu-decay-time |
| Best-fit workload | API response caches, rendered fragments, uniform key popularity | Sessions, auth tokens, reference data, Zipfian key popularity |
The recurring failure this table predicts: run LRU under a frequency-skewed workload and a single batch job or crawler that touches many cold keys will make them all "recently used," evicting the durable hot set your read path depends on. LFU exists precisely to make that class of access cheap to ignore.
Approach A — Approximate LRU: Sampling Mechanics and Tuning
Redis deliberately avoids strict LRU. A true LRU cache maintains a global doubly-linked list and moves a node to the head on every access, which costs memory per key and serializes the hot path. Instead Redis stores a small access clock in each object header and, at eviction time, samples a configurable number of keys (maxmemory-samples, default 5), then evicts the one with the oldest idle time among that sample. It is an approximation of LRU whose accuracy rises with the sample size.
The default sample of 5 is adequate when key popularity is fairly uniform, but it degrades under skew: with too few samples the chance of catching the genuinely-coldest key in any given eviction drops, so warm keys get evicted alongside cold ones and the hit ratio wobbles. Raising maxmemory-samples to 10–15 tightens the approximation toward true LRU at a small, measurable CPU cost. That precise tuning for write-heavy, high-QPS endpoints is worked through in Configuring LRU Eviction for High-Throughput APIs.
# Runtime adjustment (non-persistent; also edit redis.conf for durability)
redis-cli CONFIG SET maxmemory-policy allkeys-lru
redis-cli CONFIG SET maxmemory-samples 10
# Persistent configuration (redis.conf)
# maxmemory-policy allkeys-lru
# maxmemory-samples 10
Because eviction tuning is an administrative operation rather than a hot-path call, a synchronous client is appropriate here. The routine below is idempotent — it only writes when the running value differs — so it is safe to call from a config-reconciliation loop.
import redis
from redis.exceptions import ResponseError
# redis-py 5.x, synchronous admin client for config reconciliation
client = redis.Redis(host="cache-primary.internal", port=6379, decode_responses=True)
def tune_lru_sampling(target_samples: int = 12) -> None:
"""Align maxmemory-samples with the running config, writing only on drift."""
try:
current = client.config_get("maxmemory-samples")
if int(current["maxmemory-samples"]) != target_samples:
client.config_set("maxmemory-samples", target_samples)
print(f"maxmemory-samples set to {target_samples}")
except ResponseError as exc:
# CONFIG SET is blocked on managed tiers (e.g. some hosted Redis)
print(f"config locked or unsupported: {exc}")
def eviction_velocity() -> int:
"""Cumulative evicted_keys — sample twice and diff to get a rate."""
return client.info("stats").get("evicted_keys", 0)
Approach B — LFU: Logarithmic Counters and Decay
LFU replaces the recency signal with an estimate of access frequency. Every key carries an 8-bit logarithmic counter stored in the same object header field the LRU clock would use; OBJECT FREQ <key> returns its current value (0–255). The counter is not a raw hit count — that would saturate almost immediately and could never distinguish a key accessed a thousand times from one accessed a billion. Instead, on each access the counter increments probabilistically, and the probability of incrementing shrinks as the counter grows, governed by lfu-log-factor. Separately, lfu-decay-time (default: one minute per halving-step) ages the counter downward so a key that was hot last year but idle today loses its protection.
This decay-and-log design is what makes LFU resistant to bursts: a scan or a one-off crawl nudges each touched key's counter by a tiny probabilistic amount, nowhere near enough to displace a genuinely durable hot key. LFU therefore shines in stateful workloads — sessions, auth tokens, shopping carts, hot reference data — where a temporal spike must not flush long-lived, high-value objects. A lower lfu-log-factor (1–5) lets counters climb quickly so hot keys separate from cold ones fast; a higher factor (50–100) delays saturation and preserves finer gradations across a tiered access distribution.
# Enable LFU with a fast-climbing counter and default decay
redis-cli CONFIG SET maxmemory-policy allkeys-lfu
redis-cli CONFIG SET lfu-log-factor 5
redis-cli CONFIG SET lfu-decay-time 1
# Inspect a live key's frequency counter (0-255)
redis-cli OBJECT FREQ session:user:8a3f9c
To validate that LFU is doing its job, sample the counter distribution across your hot prefix. A healthy hot set shows a clear separation between high-counter durable keys and low-counter transient ones; a flat distribution means lfu-log-factor is too high for your access volume and the policy cannot tell hot from cold.
import asyncio
import redis.asyncio as redis
# redis-py 5.x asyncio client — safe to run against a live node under load
async def sample_freq_distribution(prefix: str = "session:", limit: int = 500) -> dict[int, int]:
"""Bucket OBJECT FREQ values across a key prefix to inspect hot/cold separation."""
client = redis.Redis(host="cache-primary.internal", port=6379, decode_responses=True)
buckets: dict[int, int] = {}
scanned = 0
async for key in client.scan_iter(match=f"{prefix}*", count=100):
freq = await client.object("freq", key) # requires maxmemory-policy = *-lfu
bucket = int(freq) // 16 # 16 coarse buckets across the 0-255 range
buckets[bucket] = buckets.get(bucket, 0) + 1
scanned += 1
if scanned >= limit:
break
await client.aclose()
return dict(sorted(buckets.items()))
When to Choose Which
Tie the decision to concrete production signals, not intuition:
- Reach for
allkeys-lruwhen key popularity is roughly uniform or genuinely recency-driven — rendered page fragments, GraphQL/REST response caches, short-lived computed payloads — and whenkeyspace_missesstays low with the default sampling. This is the safe default for a cache whose value is its recency. - Reach for
allkeys-lfuwhen a stable hot set must survive traffic that periodically touches a large cold surface — user sessions, feature flags, product catalog rows read on every request. The tell is a hit ratio that collapses during batch windows or crawler traffic under LRU: that is the durable hot set being flushed, and LFU stops it. - Reach for
volatile-lru/volatile-lfuwhen a single instance mixes evictable cache entries with keys that must never be dropped (durable state, locks, queues). These variants only ever evict keys that carry an explicit TTL, so untagged keys are protected — at the risk of an out-of-memory error if too few keys are evictable. - Reach for
noevictiononly when dropping any key silently is unacceptable and the application enforces its own bounds. Writes then fail with an OOM error oncemaxmemoryis reached, which must be handled explicitly by the client rather than discovered in production.
If you cannot yet characterise the workload, start on allkeys-lru with maxmemory-samples 10, measure for a week, and switch to LFU only if the hit ratio proves burst-sensitive.
Topology-aware consistency
Eviction is enforced per node. In a clustered deployment every primary applies its own maxmemory and evicts against only its local keyspace, so a policy set on one shard does not propagate. Misaligned policies across nodes produce uneven memory pressure and an unpredictable, shard-dependent hit ratio. The mapping of keys to nodes is governed by the hash slot assignment, and a live slot migration can move a hot key onto a shard running a different policy — a subtle source of post-migration miss spikes. Apply policy uniformly across every node, and understand how it fits the wider topology in Understanding Redis Cache Topology.
# Apply one eviction policy uniformly across every primary in the cluster
redis-cli --cluster call <cluster-node-ip>:6379 CONFIG SET maxmemory-policy allkeys-lfu
redis-cli --cluster call <cluster-node-ip>:6379 CONFIG SET lfu-log-factor 5
Failure Modes and Diagnostics
Three eviction-specific failures account for most production incidents in this area.
1. Eviction churn (thrash). maxmemory sits just below the true working-set size, so Redis evicts a key and then immediately re-populates it on the next miss, burning CPU and hammering the origin. The signature is a high evicted_keys rate combined with a high keyspace_misses rate — steady-state eviction of keys that are still wanted.
# High evicted_keys AND high misses = thrash, not healthy capacity control
redis-cli INFO stats | grep -E "evicted_keys|keyspace_hits|keyspace_misses"
redis-cli INFO memory | grep -E "used_memory:|maxmemory:|mem_fragmentation_ratio"
The fix is capacity, not policy: raise maxmemory, shard the keyspace, or shorten TTLs so the working set genuinely fits.
2. Hot-set flush under LRU. A batch job, analytics scan, or crawler reads a large cold surface, marking every touched key "recently used." Under allkeys-lru the durable hot set is now the least recently used and gets evicted, and the hit ratio collapses precisely when the batch finishes. Diagnose by correlating a miss-ratio spike with a known batch window; the fix is allkeys-lfu, whose decaying counter ignores one-off scans.
3. Stale retention under LFU. With lfu-decay-time set too high, keys that were once hot keep an inflated counter long after they go cold, occupying memory that never serves traffic while newer hot keys are evicted for lack of space. Diagnose by sampling OBJECT FREQ on suspected-stale keys and confirming high counters with old idle times; lower lfu-decay-time so counters age faster.
The link between eviction frequency and origin load is non-linear: aggressive eviction on read-heavy endpoints can trigger thundering-herd re-population, while poorly calibrated LFU decay quietly wastes memory. Treat both evicted_keys and the hit ratio as coupled signals, never in isolation.
Verification
Confirm the policy is actually live and behaving on the node — not just present in a config file.
# 1. Confirm the running policy and tunables (not just redis.conf)
redis-cli CONFIG GET maxmemory-policy
redis-cli CONFIG GET maxmemory-samples
redis-cli CONFIG GET 'lfu-*'
# 2. Confirm eviction is happening for the right reason
redis-cli INFO stats | grep evicted_keys
# 3. Compute the live hit ratio (want > 0.90 for most caches)
redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses"
For continuous validation, deploy the Redis Exporter and alert on eviction rate, memory headroom, and hit-ratio degradation:
# Eviction is occurring (correlate with misses before paging)
rate(redis_evicted_keys_total[5m]) > 0
# Memory pressure approaching the ceiling
redis_memory_used_bytes / redis_memory_max_bytes > 0.85
# Hit ratio has dropped below SLO
rate(redis_keyspace_hits_total[5m])
/ (rate(redis_keyspace_hits_total[5m]) + rate(redis_keyspace_misses_total[5m]))
< 0.90
A tested rollout follows the same shape every time: record a 7-day baseline of evicted_keys, keyspace_misses, and used_memory under the current policy; apply the candidate policy to a shadow node replaying production traffic; promote via CONFIG SET on a single replica and watch the hit ratio hold; then roll to primaries, keeping the previous policy in version control so CONFIG SET can revert within the SLO window if p99 regresses.
Parent topic
Up one level: Redis Caching Architecture & Invalidation Fundamentals
Related
- TTL vs Explicit Invalidation — the lifecycle controls that keep the working set below
maxmemoryso eviction stays rare. - Configuring LRU Eviction for High-Throughput APIs — sample-size tuning for write-heavy, high-QPS endpoints.
- Understanding Redis Cache Topology — why eviction policy must be applied uniformly per node.
- Cache-Aside vs Read-Through Patterns — how a miss caused by eviction re-populates the cache.
- Redis Cluster Slot Allocation Basics — the hash-slot mapping that decides which shard's policy governs a key.