Skip to content
ZERONE
Nazaj na vpoglede
Infrastruktura2026-04-18 · 6 min branjaIz Case 04

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:

  1. asyncio.Lock na workerja — prepreči, da en worker interno sproži isti query večkrat, če teče več korutin vzporedno.
  2. 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.«

Podoben izziv tudi pri vas?

Verjetno smo že videli kaj podobnega. Pogovorimo se.

Začnimo pogovor