TTL and cleanup — lazy, active, and the freer thread
TTL expiration runs on two independent paths — passive on access plus a 10Hz active sampler that pulls 20 random TTL'd keys per tick and resamples while >25% are expired — and the cleanup itself stalls the single loop on a big DEL unless lazyfree-* (or UNLINK) hands the free to the background thread.
Eviction is the memory-pressure path. TTL is the clock path — keys leave because their time is up, not because Redis ran out of room. Same lazyfree lane, different trigger.
Scene 05a
TTL and cleanup — lazy, active, and the freer thread
- Watch
- Try it
- Predict
- Capture
The 10Hz active-expire ticker pulses across the top. Each pulse samples a small random subset of the TTL'd cells (production Redis: 20 against millions of keys; this demo: 4 against 12 — see the pseudocode for the production constant). Expired cells in the sample drain to the right-side counter; expired cells the sampler missed pile up on the EXPIRED-BUT-RESIDENT lag gauge. A passive GET on an expired key deletes it inline. Replicas don't actively expire — they wait for the master's DEL.
Highlighted lines are the ones running in the diagram right now.
def activeExpireCycle(): # runs every 100msfor round in range(max_rounds):sample = random_sample(db.expires, n=20)expired = [k for k in sampleif k.expire_at <= now()]for k in expired:deleteOrLazyfree(k) # lazyfree-lazy-expireratio = len(expired) / len(sample)if ratio <= 0.25: return # under threshold, restif cpu_used() > active_expire_budget: return# else: resample inside the same 100ms tick
def lookupKey(db, key):entry = db.get(key)if entry is None: return Noneif entry.has_ttl and entry.expire_at <= now():deleteOrLazyfree(entry) # passive pathreturn None # GET returns nilreturn entry.value
def deleteOrLazyfree(entry):cost = estimate_free_cost(entry) # ~elements to walkoffload = (cost > LAZYFREE_THRESHOLDor lazyfree_for(entry.callsite))if offload:unlink_from_keyspace(entry) # O(1) on the loopbackground_freer_queue.push(entry)else:walk_and_free(entry) # WHOLE struct, on the loop
Where this sits in Build Redis
Scene 05a of 10, in the Memory act — Persistence, eviction, TTL — what makes RAM disappear.. Passive + 10Hz active sampler expire keys; DEL of a big value freezes the loop unless `lazyfree-*`/UNLINK offloads to the background freer thread. Cache stampede + jitter / SET NX rebuilder lock.
Up next. Replication — one node now has every cleanup mechanic in place. But one node is still a single point of failure. Keeping a copy on another node is the first answer.
Designs that use this
- URL ShortenerShorten a long URL. Read-heavy. Don't collide.
- Distributed Rate LimiterEnforce a per-key request limit across a fleet of enforcers — accurately, in under a millisecond, without becoming the outage.
- Twitter / X TimelinePush or pull? Both. The canonical fanout problem.
- Uber / Lyft — Match Drivers and RidersMatch a rider to the closest acceptable driver in under 3 s. Geohash, S2, surge.