ZERONE
Nazad na Insights
Infrastruktura2026-04-18 · 6 min čitanja

Keš bez lock-a je thundering herd: 14 endpoint-a, 8 worker-a, jedna mrtva baza

Istek keša je jedini trenutak kada paralelni worker-i istovremeno postaju skupi. Ko nema lock po ključu, pri svakom isteku šalje kompletnu load na najsporiji put.

Simptomi: PostgreSQL primary sa load spike-ovima 15–17 u 5-minutnom taktu. Backend timeout-ovi. Monitoring je pokazivao kratke 502 serije, pa opet 200. Svaki put kad bi spike spao, na backend-u ništa sumnjivo.

Prva pretpostavka: dugotrajne konekcije. Nije. Druga: određeni batch job. Takođe ne.

Istina je bila u kešu.

Šta se dešavalo

Centralni monitor endpoint je vraćao agregiranu cifru — COUNT(*) FILTER (... WHERE quality_score >= 80) preko 1,4 miliona redova. Vreme upita: oko 450 ms pod normalnim opterećenjem. Keširano 5 minuta u Redis-u. Svakih 5 minuta keš je isticao.

Backend je radio sa osam uvicorn worker-a. U deliću sekunde posle cache expire-a, na svih osam worker-a stizale su istovremene konekcije. Svaki worker je proverio keš, našao ga praznim, i pokrenuo agregaciju paralelno. Osam istovremenih COUNT(*) FILTER nad istom tabelom → buffer thrashing, lock contention, disk IO zagušenje. PostgreSQL kolabira.

Prvi worker koji završi upisuje rezultat u keš. Ostalih sedam svoj rezultat baca. Osam upita za jedan odgovor.

Zašto 14 endpoint-a umesto jednog

Tokom audita smo našli još 14 endpoint-a sa istim obrascem. Svi su koristili jednostavan @cache(ttl=300) decorator. Niko 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 development sistemu sa jednim worker-om: savršeno. U produkciji sa osam worker-a i usko taktiranim traffic pattern-om: katastrofa.

Popravka: 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):
    local = _worker_locks.setdefault(key, asyncio.Lock())
    async with local:
        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:
            await asyncio.sleep(0.1)
            yield False

Dva nivoa su namerna:

  1. asyncio.Lock po worker-u — sprečava da jedan worker interno pokrene isti upit više puta.
  2. Redis lock globalno — sprečava da N worker-a istovremeno gradi keš.

Rezultat

Nakon rollout-a na tri kritična endpoint-a:

  • CPU load na bazi stabilno na 53 % (ranije 91 % u peak-u).
  • Query P99 na monitor endpoint-u sa 1,8 s na 210 ms.
  • Nula backend timeout-ova u narednih nedelju dana.

Šta smo naučili

  • Keš bez lock-a je tempirana bomba koja eksplodira tačno jednom po TTL intervalu. Što kraći TTL, češće.
  • Decorator pristup (@cache(ttl=300)) je u single-worker dev okruženjima nevidljiv i otkaže samo pod load-om.
  • Obaveza audita pri svakoj popravci: ako je obrazac na jednom endpoint-u pogrešan, verovatno je pogrešan na više njih. Nađi sve.

Osnovno načelo: „Ako je skupo i može se desiti paralelno, treba lock po ključu, ne po zahtevu."

Sličan požar?

Verovatno smo već videli nešto slično. Javite se.

Započni razgovor