Webprojekte: Caching

Aus beruflichen Gründen (seto-social) habe ich mich in den letzten Monaten immer mal wieder damit befasst wie man bei stark interaktiven Webprojekten am günstigsten Cachen, also bereits generierte Daten zwischenspeichern, könnte.

Die Standardversion ist wohl bei den meisten Frameworks bereits generierte Teile einer Seite oder gar komplette Seiten als statischen Content auf der Festplatte zwischenzulagern und so PHP-Ausführungs-Aufwand drastisch zu verringern.
Bei relativ statischen Seiten funktioniert dies auch sehr schön, ein Blog kann z.B. den kompletten Artikel als HTML ablegen und nur bei Änderungen oder Kommentaren aktualisieren.
Doch was macht man bei Dingen die sich permanent aktualisieren oder gar für jeden Nutzer anders aussehen?

Bei diesem Problem hilft es dann auch nichts dass z.B. Symfony für nahezu jede Caching-Engine (sei es Memcached, XCache, APC oder EAccelerator) ein eigene Klasse mitbringt, die alle von sfCache abgeleitet sind und alle relativ leicht austauschbar die selben Funktionen bieten.
Schlimmer noch: Caching-Engines zu verwenden die die komplette Seite über mehrere Requests hinweg aus dem RAM holt ist natürlich extrem schnell... aber alles andere als RAM-sparend.

Günstiger ist da schon die Klasse sfFunctionCache, die darauf ausgelegt ist einzelne Funktionsaufrufe zu Speichern und so auf einer viel kleineren Ebene arbeitet. Doch auch hier ist es wie so oft: Die Idee ist gut, aber die Umsetzung nicht so genial.
sfFunctionCache erwartet als Identifikator jeweils einen String (normalerweise also z.B. den Funktionsnamen) und speichert dann Beliebigen Text in eine Datei mit diesem Namen.

Die erste Variante dieser Vorgehensweise war den String für den Identifikator zu erweitern... Ein MD5-Hash der Parameter macht es z.B. möglich Funktionen auch abhängig von ihrem Eingangswert zu speichern.
Leider sind nun Dateizugriffe nicht gerade das schnellste was man sich so vorstellen kann. Vor allem wenn man versucht auf möglichst kleiner Ebene zu Cachen und so enorme Mengen an Aufrufen hat und dafür nur minimale Datenmengen Speichert.
In extremen Fällen kann dies z.B. dazu führen dass tausende Dateien angelegt werden, alle mit einem serialisiertem Boolean-Wert als einzigem Inhalt. Diese Dateien werden dann bei jedem Zugriff wieder angefasst und treiben die IO-Last des Servers schneller in die Höhe als man wissen will.

Natürlich könnte man hier auch wieder auf alternative Caching-Engines umsteigen, dann ist jedoch die Symfony-Klasse sfFunctionCache wieder ziemlich sinnlos, da diese wieder nur von sfFileCache abgeleitet wurde statt irgendwie von einer generischen Klasse.
Auch könnte man die Caching-Engine direkt ansprechen, es ist ja nicht direkt so als wären die APIs für diese Systeme extrem komplex.

In unserem Fall aber haben wir uns für einen anderen Ansatz entschieden. Anstelle eine weitere Engine zu installieren, die Daten verwaltet, fanden wir es sinnvoller eine bereits installierte Software zu verwenden, die ebenfalls darauf optimiert ist kleine Datenhäppchen zu verwalten, zu suchen und diese möglichst schnell zugreifbar zu machen.
Wir verwendeten MySQL!

Um also unsere Funktionen zu beschleunigen, setzten wir einfach für jeden Funktionsaufruf eine Zeile in eine MySQL-Tabelle, die nur aus dem Primärschlüssel (ein String der aus dem Funktionsnamen und dem Parameterhash besteht), dem Ablaufzeitpunkt und einem Content-Feld mit serialisierten Daten besteht.
Weil wir sehr wenige Daten speichern, diese aber extrem schnell sein sollten, verwenden wir Memory als Tabellentyp.

Um nun eine Funktion aufzurufen, brauchen wir nur noch nachsehen ob diese bereits im Cache liegt (eine Suche via PK, sollte schnell gehen, den Zeitpunkt ignorieren wir hier mal) und wenn dem der Fall ist, so unserialisieren wir die Daten und geben Sie zurück.
Wenn dem nicht der Fall ist, so führen wir die gewünschte Funktion aus (via call_user_func) und prüfen ob die Daten länger sind als die maximale Stringlänge der Datenbank (wir wollen ja weder defekte Daten speichern, noch wegen 1-2 Aufrufen am Tag massiv lange Varchars produzieren).
Wenn die serialisierten Daten also in die Datenbank passen, fügen wir sie ein und geben sie gleichzeitig als Rückgabewert zurück.

Dies ermöglicht es uns beliebige Funktionen relativ einfach zu den gespeicherten hinzuzufügen, indem wir im entsprechenden Objekt die Funktion umbenennen (idealerweise nach einem festem Schema, aus foo() wird also z.B. fooCache()) und in der __Call-Funktion des Objektes beim Aufruf einer nicht vorhandenen Funktion zu prüfen ob statt der gewünschten Funktion vlt. doch eine existiert deren Namen nach unserem Schema verändert wurde...
Wenn wir also feststellen dass jemand von unserem Objekt die Funktion foo() haben möchte und wir nur eine fooCache() haben, so rufen wir unsere Cache-Lib auf und lassen diese die Arbeit machen (im Cache nachschlagen, Ergebnisse bei Bedarf von fooCache holen und ähnliches).

Diese Vorgehensweise ist sicher noch nicht ideal... aber sie beschleunigt manche Dinge schonmal enorm ohne die vielen Nachteile der vorgegebenen Caching-Varianten zu besitzen.
Ich würde mich über Kommentare mit Verbesserungsvorschlägen oder Dingen die euch aufgefallen sind natürlich wie immer freuen. :-)

Importierte/Alte Kommentare:

#1338: 19.Sep.2009 02:09 von Martin Ringehahn

MySQL für caching? Das will man doch gerade entlasten! Das hier klingt doch wie eine 1a Anwendung für key-value-stores wie memcache. Memcache hat alle benötigten features (key ist ein beliebiger string, ttl kann man setzen und content kann bis zu 1M gross sein). Man spart sich den ganzen kram den MySQL erledigen muss (hier insbesonsere das parsen der SQL query). Jeder thread in MySQL, auch wenn er nur eine MEMORY Anfrage bearbeitet, benötigt hunderte kilobyte RAM. Mal ganz abgesehen vom Thread management, context switches zwischen Threads etc. Einziges Problem wäre, dass man einen leeren cache hat wenn man memcached neustartet. Aber das problem hat man ja bei MEMORY auch in MySQL. Da steigt die Last dann halt etwas an. Wenn der cluster nen caching-neustart nicht aushaelt muss man halt voruebergehend die Anzahl der clients runterfahren und entsprechende Limits haben dass die Webserver nicht den MySQL totfragen bei kaltem cache.

Man kommt dann auch relativ schnell an den Punkt wo man, um MySQL zu entlasten, anfängt den DB server zu replizieren. Da hat man meinetwegen einen Master und mehrere Read-only slaves.
Lesende Zugriffe gehen zu einem Slave, schreibende zum Master. Hier bekommt man das Problem dass Master und Slave nie denselben Zustand haben (Replikationslag). Der Client sieht möglicherweise nach einem UPDATE noch alte daten bei einem darauffolgenden SELECT.

Ein Ansatz ist hier, einen write-through-cache zu fahren, der nicht nur beim lesen von Daten, sondern auch beim schreiben von Daten verwendet wird. Die Datenbank selber wird also nur noch als Persistierungschicht verwendet. Das ganze geht soweit dass man bestimmte listen/views/abfragen komplett in der Cache-ebene spiegelt und entsprechend aktualisiert.

Generell muss man aufpassen dass einem die vielen versteckten API-calls (memcache oder hier MySQL) nicht auf die Füße fallen: Auf Prototypen und Entwicklungssystemen hat man häufig alle Prozesse auf dem selben Host, die latenz von Anfragen ist verschwindend gering. Wenn man dann den Kram deployed kann es sein dass man sich wundert warum die ~100 calls pro pageimpression nun doch etwas laenger brauchen, weil Webserver, Cache layer und DB server nun nicht mehr über loopback in mikrosekunden sondern über Ethernet in millisekunden erreichbar sind. Das summiert sich. Auf localhost fällt es nicht auf, wenn man eine liste seriell aus dem Memcache zieht (for id in ids: cache->getData(id)) ist doch etwas anderes als (cache->getData(ids)). Nur im letzteren fall ist es etwas komplizierter den cache-miss von einzelnen ids abzudecken.

Zu dem function cache und seinen dateien: Das ganze kann unter umständen Sinn machen. Wenn die Menge der zu sichernden calls relativ gering ist und die Rückgabewerte der calls vergleichsweise gross sind kann man sicherlich Netzwerkkommunikation einsparen. Das Dateisystem sollte dann natürlich lokal liegen. Damit begrenzt man sich auf den Host des ausführenden Webservers. Da muss die Caching-strategie auch mit dem Loadbalancing zusammenspielen. Man will ja nicht sitzungsabhaengige Daten über alle Webserver verteilen wenn man round-robin macht.

Hach, caching..

#1339: 13.Oct.2009 07:10 von SirSnookie

Ein passender Artikel für Key->Value Storages (gerade für dynamische Seiten) gibt es bei http://www.metabrew.co...

Bevor man mySQL nimmt, hätte man auch das Filesystem alleine als Filename(Key) Content(Value) Cache nehmen können ;)

#1340: 13.Oct.2009 08:10 von Dr. Azrael Tod

Filesystem ist die Symfony-default-Lösung
ist aber bei richtig vielen Aufrufen und geringen Datenmengen alles... nur nicht schnell

Geschrieben von Dr. Azrael Tod
Later article
Arbeitszeiten