Cache ohne Lock ist Thundering Herd: 14 Endpoints, 8 Worker, ein Datenbank-Tod
Cache-Expire ist der einzige Moment, in dem parallele Worker synchron teuer werden. Wer keinen Lock pro Key hat, schickt bei jedem Ablauf die ganze Load auf den langsamsten Pfad.
Die Symptome: PostgreSQL-Primary unter Last-Spikes von 15–17 im 5-Minuten-Takt. Backend-Timeouts. Monitoring zeigte kurze 502-Serien, dann wieder 200. Jedes Mal, wenn der Spike abklang, passierte auf dem Backend nichts Verdächtiges.
Die erste Vermutung: Langlebige Connections. War es nicht. Die zweite: Ein bestimmter Batch-Job. War es auch nicht.
Die Wahrheit lag im Cache.
Was passiert ist
Ein zentraler Monitor-Endpoint lieferte eine aggregierte Zahl — ein COUNT(*) FILTER (... WHERE quality_score >= 80) über 1,4 Mio Rows. Query-Zeit: ca. 450 ms unter Normallast. Cached für 5 Minuten in Redis. Alle 5 Minuten lief der Cache ab.
Das Backend lief mit acht Uvicorn-Workern. In dem Sekundenbruchteil nach Cache-Expire trafen bei allen acht Workern gleichzeitig Requests ein. Jeder Worker prüfte den Cache, fand ihn leer, und feuerte die Aggregation parallel. Acht gleichzeitig laufende COUNT(*) FILTERs auf derselben Tabelle → Buffer-Thrashing, Lock-Kontention, Disk-IO-Stau. PostgreSQL geht in die Knie.
Der erste Worker, der fertig wird, schreibt das Ergebnis in den Cache. Die anderen sieben werfen ihr Ergebnis weg. Acht Queries für eine Antwort.
Warum 14 Endpoints statt einem
Beim Audit haben wir 14 weitere Endpoints gefunden, die dasselbe Pattern hatten. Alle nutzten einen einfachen @cache(ttl=300)-Decorator. Keiner hatte einen Lock.
Die Implementierung sah harmlos aus:
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
Auf Entwicklungssystem mit einem Worker: perfekt. In Produktion mit acht Workern und einem eng getakteten Traffic-Pattern: Katastrophe.
Der Fix: Ein Lock pro Key, zwei Ebenen
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 innerhalb desselben Workers
local = _worker_locks.setdefault(key, asyncio.Lock())
async with local:
# Distributed lock über alle Worker
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:
# Ein anderer Worker baut gerade den Cache neu.
# Warten, dann vom Cache lesen.
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
# Kurz gewartet — jetzt sollte der Cache warm sein
cached = await redis.get("quality_count")
if cached is not None:
return int(cached)
# Fallback: direkte Query, aber mit Timeout-Budget
return await run_expensive_query(timeout=2.0)
Zwei Ebenen sind bewusst:
asyncio.Lockpro Worker — verhindert, dass ein einzelner Worker intern mehrfach dieselbe Query feuert, falls mehrere Coroutines parallel laufen.- Redis-Lock global — verhindert, dass N Worker alle gleichzeitig den Cache neu bauen.
Ergebnis
Nach dem Rollout auf drei kritische Endpoints:
- CPU-Last auf der DB dauerhaft bei 53 % (vorher 91 % im Peak).
- Query-P99 auf dem Monitor-Endpoint von 1,8 s auf 210 ms.
- Zero Backend-Timeouts in der folgenden Woche.
Was wir gelernt haben
- Cache ohne Lock ist eine Zeitbombe, die präzise einmal pro TTL-Intervall explodiert. Je kürzer die TTL, desto häufiger.
- Der Decorator-Ansatz (
@cache(ttl=300)) ist in Single-Worker-Entwicklungsumgebungen unauffällig und versagt nur unter Last. - Audit-Pflicht bei jedem Fix: Wenn ein Pattern auf einem Endpoint falsch ist, ist es vermutlich auf mehreren falsch. Finde alle.
Das Kernprinzip: „Wenn es teuer ist und parallel passieren könnte, braucht es einen Lock pro Schlüssel, nicht pro Request."
Ähnlicher Brand bei dir?
Wir haben vermutlich schon etwas ähnliches gesehen. Sprich mit uns.
Gespräch starten→