Redis-Stampede bei einer Liste mit drei Locales
Nach einem Deploy bauten zwanzig PHP-Worker dieselbe Notizenliste dreimal neu. Redis lief voll und verdrängte danach die Keys, die gerade geschrieben waren.
Nach einem Deploy habe ich notes:list:* geleert. In den nächsten zwei Minuten ging die öffentliche Notizenliste von 8 ms auf 400 ms. Besucher warteten auf das Raster. Die Redis-CPU blieb ruhig. MySQL nicht.
Die Liste liegt in Redis. Der Key enthält die Locale, weil Englisch, Russisch und Deutsch unterschiedliches JSON sind. Zwanzig php-fpm-Worker trafen denselben Miss gleichzeitig. Jeder führte die Listenquery aus, serialisierte die Collection und schrieb den Blob. Drei Locales, also sechzig Writes derselben drei Werte. Der letzte Writer gewann. Die anderen siebenundfünfzig waren verbrannte CPU und verbrannter RAM.
Der Host spürte das als InnoDB-Last. Nutzer spürten es als langsamen First Paint nach jedem Release. Redis zeigte den Schaden später, als LRU andere heiße Keys löschte, die gerade geschrieben waren.
Wohin der Speicher ging
Ein unkomprimierter Listen-Payload lag bei etwa 180 KB. Redis hat auf dieser Box maxmemory 256 MB und allkeys-lru. Während des Stampede sprang RSS in wenigen Sekunden um rund 12 MB. Das ist nicht die unangenehme Zahl. Die unangenehme Zahl kam danach.
LRU begann andere heiße Keys zu verdrängen: das User-Snippet, Fragmente des Stack-Katalogs, die String-Kopie, die ein Endpunkt als Memcached-Fallback hält. Diese Misses zogen Arbeit zurück nach PHP. PHP fragte Redis erneut. Redis verdrängte erneut. Die Listenkeys selbst sind groß, also wurden sie leichte Opfer, sobald der Traffic auf Artikelpages wanderte.
Währenddessen habe ich INFO memory und MEMORY STATS gezogen. used_memory_peak klebte an maxmemory. evicted_keys stieg linear. Fragmentierung war in Ordnung. Das war keine Fragmentierung. Das waren zu viele große Strings, auf einmal geschrieben und sofort weggeworfen.
Ein Builder pro Key
Die Lösung ist ein Lock neben dem Cache-Key, nicht ein größeres Redis. Für notes:list:ru heißt der Lock notes:list:ru:lock.
SET notes:list:ru:lock 1 NX EX 10
GET notes:list:ru
GET notes:list:ru:stale
Der Worker, der SET NX gewinnt, baut den Payload, schreibt den Live-Key mit kurzem TTL, kopiert ihn auf einen Stale-Key mit langem TTL und löscht den Lock. Alle anderen warten ein paar Millisekunden und lesen den Live-Key, oder sie liefern die Stale-Kopie, wenn das Wartebudget vorbei ist.
Die Stale-Kopie bleibt absichtlich ein eigener Key. Ein langes TTL auf dem Live-Key würde einen schlechten Payload zu lange verstecken. Der Stale-Key darf zehn Minuten falsch sein. Der Live-Key läuft nach 45 Sekunden ab. Das reicht, um den Stampede zu drücken, und ist kurz genug, dass eine Admin-Publikation beim nächsten Refresh sichtbar wird.
Was wirklich im RAM liegt
Das JSON aus LaraBoom trägt weiterhin alle drei Locales in jeder Zeile. Ein HTTP-Response-Cache pro Locale heißt, Redis speichert dasselbe Body-Feld dreimal, einmal pro Key. Das würde ich als Nächstes ändern, nicht den Lock.
- Das Query-Ergebnis einmal cachen, als PHP-serialisierte Collection ohne Render.
- Die Locale beim Schreiben der HTTP-Antwort wählen, nicht beim Gespräch mit MySQL.
- Den String vor
SETmitgzencodekomprimieren. 180 KB wurden auf dieser Liste 28 KB. Decode-CPU in Redis ist billiger als Eviction.
Ein Hash mit Feldern en, ru, de sieht sauber aus und spart Key-Overhead. Invalidierung wird schlechter. Eine Publikation muss den ganzen Hash löschen oder neu schreiben. Drei String-Keys lassen eine Locale ablaufen, ohne die anderen anzufassen. Das zählt, wenn ein Übersetzer nur Russisch speichert.
In Memcached liegt dieser Cache nicht. Der Lock braucht SET NX mit TTL, und die Stale-Kopie soll neben dem Live-Key liegen. Memcached kann add-if-not-exists. Den Rest bekommt man nur mit extra Roundtrips und hässlichem Expiry.
Woran ich sehe, dass es hält
Der Test, der zählt, ist kein Unit-Test um einen Redis-Fake. Ich leere die drei Listenkeys und schicke 50 parallele GETs auf /api/notes. Accept-Language bleibt ungenutzt, die Locale kommt aus der URL, genau wie im Vue-SSR-Entry.
Vor dem Lock zeigte MySQL etwa 50 Listenqueries. Danach drei, eine pro Locale, plus ein paar Lock-Checks. evicted_keys in Redis blieb flach. Der p99 des Endpunkts fiel unter 20 ms, sobald die Stale-Keys existierten.
Wenn der Lock gehalten wird und Live sowie Stale leer sind, darf der Worker nicht nachlegen. Für diesen einen Request gebe ich die leere Liste zurück, der nächste baut neu. Eine leere Notizenseite für 200 ms nach einem Cold Start ist besser als eine Herde in InnoDB.
Was ich daraus mitnehme
Nach einem Flush messe ich Listenqueries in MySQL und Redis evicted_keys, nicht Redis-CPU. Ruhiges Redis bei heißem InnoDB ist trotzdem ein Stampede.
Einem größeren maxmemory traue ich nicht, sechzig identische Writes zu schlucken. Ein SET NX-Lock pro Locale-Key, 45 Sekunden Live-TTL und eine eigene Stale-Kopie sind die Knöpfe, die gehalten haben.
Die Regel, die ich behalte: ein Builder pro Cache-Key, gzip vor SET. Fehlen Live und Stale nach einem Cold Start, einmal leer liefern. Zwanzig Worker dürfen dasselbe JSON nicht neu bauen.
