Cache bez locka je thundering herd: 14 endpointa, 8 workera, jedna mrtva baza
Cache expire jedini je trenutak kad paralelni workeri istovremeno postanu skupi. Bez locka po ključu svaki refresh šalje cijelo opterećenje na najsporiji put.
Simptomi: PostgreSQL primary s load spikevima 15–17 u 5-minutnom taktu. Backend timeouti. Monitoring je pokazivao kratke serije 502, pa opet 200. Svaki put kad bi spike splasnuo, na backendu se nije vidjelo ništa sumnjivo.
Prva pretpostavka: dugotrajne konekcije. Nije. Druga: određeni batch job. Također ne.
Istina je bila u cacheu.
Što se događalo
Središnji monitor endpoint vraćao je agregirani broj — COUNT(*) FILTER (... WHERE quality_score >= 80) preko 1,4 M redaka. Vrijeme querya: oko 450 ms pod normalnim opterećenjem. Cached 5 minuta u Redisu. Svakih 5 minuta cache je isticao.
Backend je radio s osam uvicorn workera. U djeliću sekunde nakon cache expirea, na svih osam workera istovremeno su stizali zahtjevi. Svaki worker provjeri cache, nađe ga praznim i ispali agregaciju paralelno. Osam istovremenih COUNT(*) FILTER queryja nad istom tablicom → buffer thrashing, lock contention, disk-I/O zagušenje. PostgreSQL kolabira.
Prvi worker koji završi upisuje rezultat u cache. Ostalih sedam baca svoj. Osam queryja za jedan odgovor.
Zašto 14 endpointa umjesto jednog
U auditu smo pronašli još 14 endpointa s istim obrascem. Svi su koristili jednostavan @cache(ttl=300) decorator. Nitko nije imao lock.
Implementacija je izgledala bezopasno:
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 s jednim workerom: savršeno. U produkciji s osam workera i usko taktiranim pattern-om prometa: katastrofa.
Fix: jedan lock po ključu, dva nivoa
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 unutar istog workera
local = _worker_locks.setdefault(key, asyncio.Lock())
async with local:
# Distributed lock kroz sve workere
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:
# Drugi worker upravo gradi cache. Kratko čekanje
# pa pročitaj.
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 nivoa su namjerna:
asyncio.Lockpo workeru — sprečava da jedan worker interno ispali isti query više puta ako više koroutina ide paralelno.- Redis lock globalno — sprečava da N workera istovremeno gradi cache.
Rezultat
Nakon rolloura na tri kritična endpointa:
- CPU load na bazi stabilno na 53 % (ranije 91 % u peaku).
- Query P99 na monitor endpointu pao s 1,8 s na 210 ms.
- Nula backend timeouta sljedeći tjedan.
Što smo naučili
- Cache bez locka je tempirana bomba koja eksplodira točno jednom po TTL intervalu. Kraći TTL — češće.
- Decorator pristup (
@cache(ttl=300)) u single-worker dev okruženjima je nevidljiv i otkazuje tek pod opterećenjem. - Obavezni audit pri svakom fixu: ako je obrazac na jednom endpointu pogrešan, vjerojatno je pogrešan na više njih. Nađi sve.
Temeljno načelo: „Ako je skupo i može se dogoditi paralelno, treba lock po ključu, ne po zahtjevu."