Cache brez locka je thundering herd: 14 endpointov, 8 workerjev, ena mrtva baza
Cache expire je edini trenutek, ko paralelni workerji vsi postanejo dragi hkrati. Brez locka na ključ vsak refresh pošlje celotno obremenitev po najpočasnejši poti.
Simptomi: PostgreSQL primary z load spikei 15–17 v 5-minutnem taktu. Backend timeouti. Monitoring je kazal kratke serije 502, nato spet 200. Vsakič ko je spike zamrl, na backendu nič sumljivega.
Prvi sum: dolgotrajne povezave. Ne. Drugi: določen batch job. Tudi ne.
Resnica je bila v cacheu.
Kaj se je dogajalo
Centralni monitor endpoint je vračal agregat — COUNT(*) FILTER (... WHERE quality_score >= 80) prek 1,4 M vrstic. Čas querya: približno 450 ms pri normalni obremenitvi. Cached 5 minut v Redisu. Vsakih 5 minut je cache potekel.
Backend je tekel z osmimi uvicorn workerji. V drobnem trenutku po cache expireu so zahteve istočasno udarile vse osem workerjev. Vsak worker je preveril cache, ga našel praznega in sprožil agregacijo paralelno. Osem istočasnih COUNT(*) FILTER queryjev nad isto tabelo → buffer thrashing, lock contention, zastoj na disk-I/O. PostgreSQL pade.
Prvi worker, ki konča, zapiše rezultat v cache. Ostalih sedem svojega zavrže. Osem queryjev za en odgovor.
Zakaj 14 endpointov namesto enega
V auditu smo našli še 14 endpointov z istim vzorcem. Vsi so uporabljali preprost @cache(ttl=300) decorator. Nihče ni imel locka.
Implementacija je bila videti neškodljiva:
async def get_quality_count():
cached = await redis.get("quality_count")
if cached:
return int(cached)
# Cache miss → ALL workers race to fill
count = await run_expensive_query()
await redis.set("quality_count", count, ex=300)
return count
Na dev stroju z enim workerjem: popolno. V produkciji z osmimi workerji in tesno taktiranim prometnim patternom: katastrofa.
Fix: en lock na ključ, dva nivoja
from redis.asyncio import Redis
import asyncio
from contextlib import asynccontextmanager
_worker_locks: dict[str, asyncio.Lock] = {}
@asynccontextmanager
async def cache_lock(key: str, redis: Redis, ttl: int = 30):
# Short-circuit znotraj istega workerja
local = _worker_locks.setdefault(key, asyncio.Lock())
async with local:
# Distributed lock prek vseh workerjev
got = await redis.set(f"lock:{key}", "1", ex=ttl, nx=True)
if got:
try:
yield True
finally:
await redis.delete(f"lock:{key}")
else:
# Drug worker pravkar gradi cache. Kratek počitek,
# nato preberi.
await asyncio.sleep(0.1)
yield False
async def get_quality_count():
cached = await redis.get("quality_count")
if cached is not None:
return int(cached)
async with cache_lock("quality_count", redis) as have_lock:
if have_lock:
count = await run_expensive_query()
await redis.set("quality_count", count, ex=300)
return count
cached = await redis.get("quality_count")
if cached is not None:
return int(cached)
return await run_expensive_query(timeout=2.0)
Dva nivoja sta namerna:
asyncio.Lockna workerja — prepreči, da en worker interno sproži isti query večkrat, če teče več korutin vzporedno.- Redis lock globalno — prepreči, da N workerjev hkrati gradi cache.
Rezultat
Po rolloutu na treh kritičnih endpointih:
- CPU load na bazi stabilno na 53 % (prej 91 % v peaku).
- Query P99 na monitor endpointu padel z 1,8 s na 210 ms.
- Nič backend timeoutov v naslednjem tednu.
Kaj smo se naučili
- Cache brez locka je časovna bomba, ki eksplodira natanko enkrat na TTL interval. Krajši TTL, pogosteje.
- Decorator pristop (
@cache(ttl=300)) je v single-worker dev okoljih neviden in odpove šele pod obremenitvijo. - Obvezen audit pri vsakem fixu: če je vzorec na enem endpointu napačen, je verjetno napačen na več. Najdi vse.
Temeljno načelo: »Če je drago in se lahko zgodi paralelno, potrebuješ lock na ključ, ne na zahtevek.«