Stampede with and without protection
Simulate a modelNeeds Python
Problem
When a popular cache entry expires, every request that arrives before it is refilled misses and recomputes it. By Little's Law, the number of duplicate recomputes in progress is L = λ_k · W: the key's request rate times the time one recompute takes. At 1,000 requests per second and a 2-second query, that is 2,000 identical queries at once, and they slow each other down, which makes W longer and the stampede worse. Two protections exist. Coalescing lets one request recompute while the others wait for its result. Early probabilistic expiry lets a random request refresh the entry shortly before it expires, while everyone else is still served the old value.
Write a small Python program with a fake "database" function that sleeps 2 seconds and counts how many times it is called. Put a dictionary cache with a TTL in front of it. Start 200 threads that all ask for the same key at the moment it expires. First run it with no protection and record the number of database calls (expect about 200, the value of L = λ_k · W). Then add per-key coalescing (one thread computes, the others wait on an event) and record the calls again (expect 1). Finally replace coalescing with early probabilistic expiry: refresh when now - delta * beta * math.log(random.random()) >= expiry, with delta equal to the measured recompute time and beta = 1. Send steady traffic for many TTL periods and count recomputes per period (expect slightly more than 1) and the longest time any request waited (expect close to 0).
The script uses a 120-second TTL for the steady-traffic part, refreshes in the background, and runs for three periods.
The question. How many database calls does one expiry cause with no protection and with coalescing, and how many recomputes per TTL period does early expiry need?
Predict
About 200 calls, then 1 call, then roughly 1 to 1.2 recomputes per period with no waiting.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
stampede-with-and-without-protection.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Stampede with and without protection. A fake database call that sleeps and counts its calls, behind a TTL cache. 200 threads ask for the same key the moment it expires: first unprotected, then with per-key coalescing; then steady traffic with early probabilistic expiry. """ import argparse, json, math, random, threading, time p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--threads", type=int, default=200, help="Threads that ask for the key at the moment it expires.") p.add_argument("--recompute", type=float, default=2.0, help="Seconds one database call takes (W).") p.add_argument("--ttl", type=float, default=120.0, help="TTL of the entry, in seconds, for the steady-traffic part.") p.add_argument("--periods", type=int, default=3, help="TTL periods of steady traffic.") p.add_argument("--clients", type=int, default=50, help="Steady-traffic clients, each asking every 10 ms.") p.add_argument("--beta", type=float, default=1.0, help="beta of early probabilistic expiry.") p.add_argument("--seed", type=int, default=0, help="Random seed.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() random.seed(args.seed) calls = 0 call_lock = threading.Lock() def db(): global calls with call_lock: calls += 1 time.sleep(args.recompute); return "page" def burst(get): """All threads ask at once for a key that has just expired; returns database calls.""" global calls calls, cache = 0, {} start = threading.Barrier(args.threads) def worker(): start.wait(); get("home", cache) ts = [threading.Thread(target=worker) for _ in range(args.threads)] for t in ts: t.start() for t in ts: t.join() return calls # 1. No protection: every thread that misses calls the database def get_plain(key, cache): v = cache.get(key) if v: return v cache[key] = db(); return cache[key] # 2. Per-key coalescing: one thread computes, the others wait on an event lock, events = threading.Lock(), {} def get_coalesced(key, cache): v = cache.get(key) if v: return v with lock: ev = events.get(key) leader = ev is None if leader: events[key] = ev = threading.Event() if leader: cache[key] = db(); ev.set() with lock: events.pop(key) else: ev.wait() return cache[key] plain = burst(get_plain) coalesced = burst(get_coalesced) print(f"{args.threads} threads at expiry, no protection: {plain} database calls " f"(L = lambda_k * W: every request in the window recomputes)") print(f"{args.threads} threads at expiry, coalesced: {coalesced} database call(s)") # 3. Early probabilistic expiry under steady traffic: a request that draws # now - delta*beta*ln(rand) >= expiry refreshes in the background; everyone is served the current value calls = 0 entry = {"value": db(), "expiry": time.time() + args.ttl, "refreshing": False} delta = args.recompute # the measured recompute time refresh_log, waits, stop = [], [], threading.Event() entry_lock = threading.Lock() t0 = time.time() def refresh(): v = db() with entry_lock: entry.update(value=v, expiry=time.time() + args.ttl, refreshing=False) def client(): while not stop.is_set(): t = time.time() with entry_lock: early = t - delta * args.beta * math.log(random.random()) >= entry["expiry"] if early and not entry["refreshing"]: entry["refreshing"] = True refresh_log.append(t - t0) threading.Thread(target=refresh).start() _ = entry["value"] waits.append(time.time() - t) time.sleep(0.01) calls = 0 cs = [threading.Thread(target=client) for _ in range(args.clients)] for c in cs: c.start() time.sleep(args.ttl * args.periods) stop.set() for c in cs: c.join() per_period = [sum(1 for r in refresh_log if i * args.ttl <= r < (i + 1) * args.ttl) for i in range(args.periods)] rate = len(waits) / (args.ttl * args.periods) lead = delta * args.beta * math.log(rate * delta * args.beta) # where lambda * delta * e^(-x/delta) = 1 print(f"steady traffic {rate:,.0f} requests/s: early expiry should fire about delta*beta*ln(lambda*delta) = {lead:.1f} s " f"before expiry, so about {args.ttl / (args.ttl - lead + delta):.2f} recomputes per {args.ttl:g} s period") print(f"early expiry, {args.periods} TTL periods of {args.ttl:g} s, {args.clients} clients: recomputes per period {per_period} " f"(mean {sum(per_period) / len(per_period):.2f}); longest wait {max(waits) * 1000:.1f} ms") if args.json: with open(args.json, "w") as f: json.dump({"tables": { "periods": [{"period": i + 1, "recomputes": n} for i, n in enumerate(per_period)], "summary": [{"plain_calls": plain, "coalesced_calls": coalesced, "mean_recomputes": sum(per_period) / len(per_period), "max_wait_ms": max(waits) * 1000, "rate": rate, "lead_s": lead, "predicted": args.ttl / (args.ttl - lead + delta), "threads": args.threads}]}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run stampede-with-and-without-protection.py uv run stampede-with-and-without-protection.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --threads200 | Threads that ask for the key at the moment it expires. |
| --recompute2 | Seconds one database call takes (W). |
| --ttl120 | TTL of the entry, in seconds, for the steady-traffic part. |
| --periods3 | TTL periods of steady traffic. |
| --clients50 | Steady-traffic clients, each asking every 10 ms. |
| --beta1 | beta of early probabilistic expiry. |
| --seed0 | Random seed. |
Our run
Data table
| TTL period | Recomputes |
|---|---|
| 1 | 1 |
| 2 | 1 |
| 3 | 1 |
200 threads at expiry, no protection: 200 database calls (L = lambda_k * W: every request in the window recomputes)
200 threads at expiry, coalesced: 1 database call(s)
steady traffic 4,211 requests/s: early expiry should fire about delta*beta*ln(lambda*delta) = 18.1 s before expiry, so about 1.15 recomputes per 120 s period
early expiry, 3 TTL periods of 120 s, 50 clients: recomputes per period [1, 1, 1] (mean 1.00); longest wait 13.5 msWhat we found
With no protection, the 200 threads made 200 database calls: every one of them missed and recomputed. With coalescing they made 1.
Early expiry recomputed 1 times per period on average and no request waited longer than 13.5 ms, as expected. How early it fires depends on the traffic: at 4,210 requests per second and a 2-second recompute, the refresh is expected about 18.1 s before expiry, so a period sees about 1.15 recomputes. With a TTL much shorter than that lead (try --ttl 10), the entry is refreshed again almost as soon as the last refresh lands, and recomputes per period climb well above 1.
Read it
What makes a stampede a disaster and not just waste is feedback: the duplicate queries slow the database, which lengthens W, which raises L. If λ_k · W exceeds what the database can serve, the key never fills again, even after the trigger is gone.
Coalescing removes the stampede inside one process, but across a fleet of 50 servers it still leaves 50 recomputes unless the lock is distributed. Early probabilistic expiry needs no lock at all and works across the fleet: the probability of refreshing rises as expiry nears, scaled by the measured recompute time, so expensive entries start trying earlier. Its price is slightly more than one recompute per period, and its condition is a TTL comfortably longer than the refresh lead δ·β·ln(λ·δ).