Explain probabilistic early expiration (the XFetch algorithm) as a cache-refresh strategy: what extra data must be stored alongside the cached value, and why does it avoid needing a lock at all?
answer
- Store value + delta (recompute cost) + logical expiry
- now - delta*beta*ln(rand) >= expiry → refresh
- ln(rand) negative → random positive lead time
- Expensive values refresh earlier automatically
- No miss → no lock; refresh async to hide latency
basics
~20 sStore the value with its recompute cost (delta) and a logical expiry time. On each read, refresh early if now - delta * beta * ln(random()) is past the expiry. Probability of refreshing rises near expiry and with cost, so usually one reader refreshes before anyone ever sees a miss.
solid answer
~1 minInstead of preventing a herd at expiry, XFetch makes sure expiry never arrives: readers refresh the entry *slightly early*, at random. You store three things — the value, `delta` (how long the last recompute took), and the logical expiry timestamp — and give the Redis key a physical TTL a bit beyond the logical expiry. Every reader evaluates: ``` if now - delta * beta * ln(rand()) >= expiry: recompute ``` `rand()` is uniform in (0,1), so `ln(rand())` is negative and the subtracted term is a positive random offset. Far from expiry almost nobody triggers; as `now` approaches `expiry` the probability climbs smoothly; and the more expensive the value is to build (larger `delta`), the earlier refreshes start — expensive entries get more lead time. `beta` (default 1) tunes eagerness. Because each reader rolls independently and refreshes happen *before* the key expires, every other caller still gets a valid hit — there is no miss window to coordinate around, hence no lock. The costs: you must store metadata and measure recompute time; a key with no traffic never refreshes early and falls back to the ordinary miss path; and occasionally two readers refresh at once, which is why it pairs well with a cheap `SET NX` guard.
code
text · 10 lines# stored payload (hash) — physical TTL is longer than the logical expiry
HSET dash:42 v "{...}" delta 1.8 expiry 1723640400
EXPIRE dash:42 360 # logical window 300s + 60s grace
# read path (pseudocode)
p = HGETALL dash:42
if p is empty: recompute_and_store(); return
if now() - p.delta * BETA * ln(random()) >= p.expiry:
trigger_refresh_async() # value below is still valid
return p.vgo deeper
Know the idea: refresh a bit early, at random, so the entry never actually expires under load.
State what is stored (value, recompute cost, logical expiry) and how the probability rises as expiry approaches.
Explain why no coordination is needed, tune beta, refresh asynchronously, and know the limits — cold caches, low-traffic keys, occasional duplicate refreshes.
Compare it against stale-while-revalidate and mutexes on freshness, complexity and failure behaviour, and decide which classes of entries justify carrying the extra metadata.
## The insight Mutexes and stale-serving are reactions to a miss. XFetch — from the paper "Optimal Probabilistic Cache Stampede Prevention" — removes the miss instead. If somebody refreshes the entry *just before* it would expire, no request ever sees an empty cache, so there is nothing to stampede on and nothing to serialise. The trick is deciding *who* refreshes early without any coordination. XFetch does it with a per-reader random draw whose probability rises as expiry nears. ## What you store The cached payload carries three fields (in a Redis hash, or serialized into one string): - `value` — the cached data; - `delta` — how long the most recent recompute took, in the same time unit as everything else; - `expiry` — the **logical** freshness deadline as an absolute timestamp. The Redis key gets a **physical** TTL somewhat longer than `expiry` (say expiry + a grace window), so the entry is still present if the early refresh does not happen. Physical TTL is the backstop; logical expiry is the policy. ## The decision rule On every read: ``` now - delta * beta * log(random()) >= expiry -> recompute ``` with `random()` uniform on (0,1) and `log` the natural logarithm. Why this works: - `log(random())` is always negative, so `-delta*beta*log(random())` is a positive random quantity. The reader behaves as if the current time were pushed forward by that amount. - Its distribution is exponential with mean `delta*beta`. Most draws are small; occasionally one is large. - Therefore, when `now` is far below `expiry`, only an unusually large draw triggers a refresh — refreshes are rare. As `now` climbs toward `expiry`, the required draw shrinks and the trigger probability rises smoothly toward certainty. - `delta` scales the whole thing: an entry that takes 2 seconds to rebuild starts drawing meaningfully earlier than one that takes 20 ms. That is the elegant part — the algorithm automatically gives expensive values more lead time, exactly where a stampede would hurt most. - `beta` tunes eagerness. `beta = 1` is the default; `beta > 1` refreshes earlier (more redundant refreshes, more safety margin); `beta < 1` refreshes later (fewer refreshes, higher risk of an actual miss); `beta = 0` disables early refresh entirely. ## Why no lock is needed Each reader decides alone, using only data it already fetched. There is no shared state to contend on, no round trip to acquire anything, and no waiting. Because the winner refreshes *before* the logical expiry and the key is still physically present, every concurrent reader gets a valid value and returns immediately. The rare case of two readers refreshing simultaneously costs one extra recompute — nothing breaks. Contrast with a mutex, which activates *after* a miss: someone has already found an empty cache, so somebody must wait, serve stale, or degrade. XFetch's window of vulnerability is smaller by construction. ## The real costs and limits - **Metadata and measurement.** You must time the recompute and store `delta` and `expiry` with the value. Retro-fitting this onto an existing cache means changing the serialization format for every cached entry. - **Traffic dependence.** The dice are rolled on reads. A key read once a minute is unlikely to roll a trigger before expiry, so it just expires and takes the ordinary miss path. XFetch protects *hot* entries, which is exactly the population that stampedes — but do not expect it to make cold-key misses disappear. - **Refresh latency lands on a real request.** The reader that triggers pays the recompute time. Under low-latency SLOs, do the refresh **asynchronously**: return the still-valid cached value to the caller immediately and rebuild on a background task or worker. Then the algorithm decides *when* to refresh and the request path never slows down at all. - **Not a defence against a cold cache.** After a flush or restart there is no value, so there is no `delta` and no `expiry` to reason about; you are back to plain misses and need bounded recompute concurrency. - **Duplicate refreshes are possible.** Rare, but real. If duplicates are expensive, wrap the recompute in a cheap `SET lock:<key> <token> NX PX <ttl>` — the combination is common: XFetch decides *when*, the mutex ensures *one*. - **`delta` must be honest.** Store the measured duration of the last successful recompute, not a hard-coded guess. If the source of truth degrades, `delta` rises automatically and refreshes start earlier — a pleasant self-tuning property, but it also means an anomalous slow rebuild makes the entry refresh aggressively for a while. ## When to reach for it Good fit: a bounded set of expensive, frequently-read entries where you control the serialization format and can afford background refresh — dashboards, aggregates, recommendation payloads, rendered fragments. Poor fit: a huge keyspace of cheap entries (the metadata overhead and complexity are not worth it), or systems where the miss path must be safe for cold-cache scenarios anyway (then invest in stale-serving plus admission control first). Many teams reach for stale-while-revalidate instead, which achieves a similar "nobody ever waits" property with simpler bookkeeping. The distinguishing benefit of XFetch is that callers never receive stale data at all — the refresh happens while the value is still fresh.
- What role does the `beta` parameter play, and when would you raise it?`beta` scales the random lead time, so it controls how eagerly readers refresh ahead of the logical expiry. `beta = 1` is the neutral default; raising it above 1 makes refreshes start earlier, trading extra recomputes for a smaller chance that the entry actually expires; lowering it does the reverse. Raise it for entries where an actual miss would be very expensive or where `delta` is highly variable.
- Why does XFetch protect hot keys well but do little for rarely-read ones?The refresh decision is evaluated only when someone reads the key, so the number of chances to trigger an early refresh is proportional to the read rate. A key read thousands of times per second will almost certainly roll a trigger before expiry; a key read once a minute probably will not, and simply expires into the normal miss path. That is an acceptable asymmetry, since only hot keys stampede.
Nobody schedules a passport renewal for the exact day it expires. The more painful the renewal (delta), the earlier you start thinking about it — and each person picks their own day, so the office is never swamped on the last day.
saying these in an interview costs you the question
- Thinking the algorithm needs a distributed lock to pick the refresher — the whole point is that each reader decides independently
- Storing a hard-coded constant for `delta` instead of the measured recompute duration
- Setting the Redis TTL exactly at the logical expiry, so there is no grace window left when the early refresh does not fire
- Expecting it to help after a flush or restart, when there is no cached metadata to reason about
- Doing the triggered recompute synchronously on the request path when latency SLOs are tight