Memcached-Slabs und SSR-Seiten ungleicher Größe
Der Vue-SSR-Prozess cachete volles HTML in Memcached. Große Artikelseiten sperrten RAM in dicken Slabs. Kleine Kachelfragmente hatten keinen Platz, die Hit-Rate fiel bei hohem RSS.
Vue SSR auf dieser Site ist ein Node-Prozess hinter nginx, keine SPA. Ich habe gerendertes HTML in Memcached gecacht, Key aus Locale plus Pfad, um Node beim nächsten Request zu überspringen. Abends funktionierte das. Am Morgen stand RSS bei 64 MB auf einer 64-MB-Instanz, die Hit-Rate lag bei 31 Prozent.
Besucher warteten trotzdem auf Node beim Notizenraster. Der Host wirkte voll. Memcached leakte nicht. RAM war in fetten Slab-Klassen fest. Sobald eine Artikelseite mit 80 KB lag, nahm dieses RAM-Stück nur noch Items dieser Klasse. Ein 3-KB-Kachelfragment kam nicht hinein, auch wenn die 80-KB-Seite niemand mehr las, bis dieses Item verdrängt wurde.
Memcached hat keinen Heap im Redis-Sinn. RAM wird in Slab-Klassen geschnitten. Jede Klasse hält Items in einem engen Größenband. Artikel-HTML und kleine Fragmente in einer Instanz zu mischen heißt: die Hit-Rate fällt, RSS bleibt an der Decke.
Was wir wirklich speicherten
Das Notizenraster nutzt fünf Kachelgrößen: sm, md, lg, tall, wide. Die SSR-Hülle der Listenseite lag bei etwa 18 KB. Eine Notiz-Detailseite mit langem Body lag zwischen 70 KB und 95 KB, je nach Locale. Deutsch war etwas länger als Englisch. Russisch lag dazwischen.
Ich habe stats slabs und stats items gezogen. Eine Klasse um 82 KB hielt den Großteil des RAM. Die Klassen unter 8 KB waren fast leer. Evictions liefen in den kleinen Klassen, nicht in der fetten. Das ist die Falle. Der Speicher wirkt belegt. Die Keys, die jeder Listenrequest braucht, können nicht landen.
Der Wachstumsfaktor war der Default 1.25. Für ähnlich große Werte ist das in Ordnung. Schlecht, wenn JSON-Fragment, Listen-Hülle und ganzer Artikel in einem Topf liegen. Die Artikelseiten trugen außerdem Header, Footer und die eingebetteten i18n-Texte der Hülle. Ein ungenutzter Locale-String im Bootstrap-State reichte, um eine Seite in die nächste Slab-Klasse zu schieben.
Zwei Pools, dann keiner
Der erste Schnitt waren zwei Memcached-Instanzen in Docker: memcached-html mit 48 MB für Seiten über 16 KB, memcached-frag mit 16 MB für den Rest. Die Hit-Rate der Fragmente kam zurück. RSS der HTML-Instanz blieb an der Decke, weil Artikelseiten einmal gelesen werden und dann liegen, bis LRU es merkt.
Der bessere Schritt war, volles SSR-HTML in Memcached gar nicht mehr zu cachen. nginx hat bereits einen Microcache im Shared Memory für GETs. Ich setzte 10 Sekunden auf die öffentlichen Notes- und News-Pfade. Das fängt den Burst nach einem Deploy ab, ohne 90-KB-Strings für eine Stunde in Slab-Klassen zu nageln.
In Memcached blieb klein und absichtlich langweilig:
- gerenderte Locale-Kataloge für die Hülle, etwa 4 KB pro Stück
- das gepackte Kachel-Payload für das Notizenraster, ein Key pro Locale, unter 6 KB
- ein Flag, dass der Node-Prozess warm ist, damit nginx einen toten Upstream nicht bei jedem Miss retried
Artikel-HTML wird neu gerendert, wenn der Microcache abläuft. Die Node-CPU stieg etwas. Memcached-RAM fiel etwa auf die Hälfte, die Fragment-Hit-Rate ging über 90 Prozent. Auf einer Box, die auch Redis, MySQL und php-fpm fährt, ist dieser Tausch richtig.
Der Node-Heap ist ein anderes Problem
Als die Slabs ruhig waren, wuchs der Vue-Prozess trotzdem. Das war nicht Memcached. Der SSR-Renderer hielt Component-Caches und einen kompilierten i18n-Nachrichtenbaum pro Request-Locale. Drei Locales auf einem warmen Renderer sind in Ordnung. Ein Leak erscheint, wenn ein Request die Locale setzt und der Baum in einer globalen Map unter einem neuen String landet, zum Beispiel en-US statt en.
Ich habe die Locale auf en, ru, de in nginx festgezogen, dasselbe Präfix wie die Laravel-API. Die Node-Map hat jetzt drei Einträge. Der Heap nach ein paar tausend Requests bleibt flach genug, dass ein wöchentlicher Neustart in Docker Gewohnheit ist, kein Fix.
Wer trotzdem einen Page-Cache will, legt ihn als gzippten String mit 45 Sekunden TTL nach Redis, neben den API-Cache. Redis verdrängt LRU über alle Größen. Memcached tut das nicht.
Was ich daraus mitnehme
Ich messe stats slabs und die Fragment-Hit-Rate, nicht RSS allein. Eine volle Instanz mit Evictions in den kleinen Klassen ist ein Größenmix, kein fehlender Cache.
Einem Memcached-Pool für 90-KB-HTML und 3-KB-Kacheln traue ich nicht. Der nginx-Microcache mit 10 Sekunden für öffentliche GETs ist der Seiten-Store. Memcached hält Kataloge, Kachel-Payloads und ein Warm-Flag unter 6 KB.
Die Regel, die ich behalte: Locale in nginx auf en, ru, de festziehen, bevor Node sie sieht. Braucht es einen Page-Cache, gzip nach Redis. Artikel-HTML nicht in Slab-Klassen nageln.
