Skip to content
ZERONE
Natrag na uvide
Infrastruktura2026-04-18 · 6 min čitanjaIz Case 04

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:

  1. asyncio.Lock po workeru — sprečava da jedan worker interno ispali isti query više puta ako više koroutina ide paralelno.
  2. 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."

Sličan izazov i kod vas?

Vjerojatno smo već vidjeli nešto slično. Razgovarajmo.

Započnite razgovor