Redis Expiration and Eviction: TTL, Lazy Deletion and maxmemory Policies

· tech

#redis#cache

📑 Contents

Use Redis as a cache and sooner or later you hit two questions: how does a key with a TTL get cleared once it expires? and what happens when memory is full? The two are often lumped together, but they’re different things — the former is expiration: “this key’s own time is up, it should go”; the latter is eviction: “there’s no room left, someone has to be asked to leave”. Separating the two is the foundation of using Redis well as a cache.

Expiration: a key’s time is up; how does it get cleared

You’d think that the moment a key’s TTL is reached, Redis deletes it and frees the memory? It doesn’t. Redis uses two mechanisms together to clear expired keys, and neither guarantees “immediately”:

How expired keys get cleared: lazy + periodic, two prongs key whose TTL has expiredstill occupies memory (not cleared yet) ① lazy deletion (passive)someone GETs it →found expired → deleted on the spot, returns nil ② periodic deletion (active)background samples ~10×/s →random batch from keys with TTL, delete expired so "expired" ≠ "freed" — untouched and not yet sampled, it just lies there
Lazy deletion saves CPU (no proactive scanning), but an expired key nobody accesses keeps occupying memory; periodic deletion plugs that hole — a background task runs a few times a second and randomly samples a batch from "keys with a TTL" to delete (sampling more rounds if the expired ratio is high). Note it samples rather than scanning everything, precisely to avoid an O(N) scan blocking the single thread. Together they balance CPU against memory — at the cost that "expiration" isn't precisely immediate

The counter-intuitive point here is worth remembering: a key’s TTL being reached doesn’t mean it vanishes from memory at once. If nobody accesses it (lazy deletion isn’t triggered) and the background sampling hasn’t picked it yet, it lies in memory for a while — a GET returns nil (logically expired), but the physical memory hasn’t been freed. Incidentally, a replica doesn’t delete expired keys on its own; it waits for the DEL the master sends after expiring the key — to keep master and replica consistent, the master is the authority on expiration.

Eviction: memory is full; who gets kicked out

Expiration is a key leaving on its own schedule; eviction is something else — when memory usage hits the maxmemory ceiling, Redis has to actively kick out some keys to make room. Which ones? The eviction policy decides, and there are 8 of them (plus one “don’t kick anyone”):

maxmemory hit — evict whom? The 8 policies LRUleast recently used LFUleast frequently used randomrandom ttlclosest to expiry allkeysall keys allkeys-lru allkeys-lfu allkeys-random — (none) volatileonly keys with TTL volatile-lru volatile-lfu volatile-random volatile-ttl noeviction (default): evict nothing; memory full → writes return errors LRU = longest untouched · LFU = least used (resists "occasional full scan" cache pollution) pure cache → allkeys-lru / lfu; data you can't lose → volatile-* or noeviction
A policy combines two dimensions: scope (allkeys picks from all keys / volatile only from keys with a TTL) × strategy (LRU longest untouched / LFU least used / random / ttl closest to expiry). LFU resists better than LRU the cache pollution caused by "some batch job occasionally scanning all the cold data". And Redis's LRU/LFU are actually approximate (sampled estimates; no full access list is maintained) — once again "trade a little precision for memory"

The key to choosing a policy is one question: is this Redis a pure cache, or does it also hold things that can’t be lost?

  • Pure cache (every key can be rebuilt from the database behind it): use allkeys-lru or allkeys-lfu, let Redis freely kick out the least useful and keep the memory for hot data.
  • Mixed with important data: use volatile-* — evict only “disposable data with a TTL set”, protecting the important keys without a TTL; or use noeviction and manage capacity yourself.
  • Never leave the default noeviction in a “used as a cache” scenario — the moment memory fills, every write starts failing, while you may think Redis is just “clearing old data automatically”.

redis-cli: TTL and maxmemory operations

Expiration and eviction each have a set of commands, matching “leaving on its own” and “no room, kick whom”:

# expiration (TTL)
SET session:1 "..." EX 3600     # set the value with a 1-hour TTL in one go (most common)
EXPIRE user:1 60; TTL user:1    # add a TTL afterwards / check seconds left (-1 = permanent, -2 = doesn't exist)
PERSIST user:1                  # remove the TTL, back to permanent
# eviction (memory ceiling and policy)
CONFIG SET maxmemory 2gb
CONFIG SET maxmemory-policy allkeys-lru   # one of the 8 policies (see the matrix above)
INFO stats                      # check evicted_keys: non-zero and climbing = scale up or change policy
OBJECT FREQ hotkey              # under an LFU policy, see a key's access frequency

Watch two numbers and you have the essentials: TTL tells you how long a key has left to live, and evicted_keys climbing in INFO stats is the first alarm that “memory hit the ceiling and eviction has started” — the watershed between adding memory and changing the eviction policy.

Reflections

Expiration vs eviction: separate “leaving on its own” from “no room, asked to leave”

When I started with Redis I treated expiration and eviction as the same thing, and got the maxmemory configuration completely backwards. Then it clicked: expiration is a key’s personal schedule (TTL reached, it should go); eviction is the system’s space pressure (memory full, someone has to be asked to leave) — independent of each other; a key without a TTL never “expires” but can still be “evicted”. The distinction looks like a detail, but it directly decides your design: which keys get a TTL (to expire naturally), what the memory ceiling is, whom to evict on hitting it. Think of “time’s up” and “no room” as two separate pressures, and a lot of Redis capacity questions become clear.

Approximate LRU: another “trade a little precision for memory”

Redis’s LRU isn’t “evict exactly the least recently used one” — maintaining a full LRU list costs too much memory. It uses sampled approximation instead: pick a few keys at random, evict the least recently used among them. It’s Redis’s consistent philosophy again, the same thinking as HyperLogLog estimating hundreds of millions in 12KB and fork COW copying only modified pages — between “fully exact” and “save resources”, cleverly choose less exact. And it’s usually good enough: cache eviction never needed “mathematically optimal”; approximate LRU’s effect on hit rate is negligible while it saves the heavy cost of maintaining an exact structure. I increasingly think that telling “where exactness is needed from where approximate is fine” is one of the clearest gaps between senior engineers and beginners.

The wrong eviction policy is a hidden bomb: “fine normally, explodes when full”

The eviction policy is the kind of setting that “bites you with its default if you don’t actively think about it”. The default noeviction is a disaster for a “used as a cache” scenario — calm while memory isn’t full, and then once data grows to the ceiling, rather than clearing old data automatically, every write starts failing, and the whole service goes down with it. I’ve seen this incident more than once: everyone assumed Redis would “clear the old stuff itself” when in fact it was refusing the new. It taught me a more general lesson: for any resource with a “ceiling”, actively think through “what happens when we hit it” — automatic reclamation, refusing service, or outright crash? That “boundary behaviour” is invisible day to day, yet it’s usually the star of the 3am incident. Thinking through every ceiling’s hit-the-top behaviour before launch is a very worthwhile paranoia.