Facebook
Twitter
Google+
Kommentare
0

High Performance: Caching (reloaded) mit PHP

Gastartikel von Oliver Sperke.

Ich bin 35 Jahre alt und seit 10 Jahren selbständiger Webentwickler. Mein Fokus liegt dabei auf der Erstellung, Beratung und Optimierung in den Bereichen High Performance, Usability und Sicherheit in den gängigsten Internetsprachen: PHP, HTML, Javascript und CSS.

Nach langem Arbeiten an einem Projekt fängt der ambitionierte Entwickler an, zu testen, wie sich seine dynamische Internetseite unter Last verhält. Da ja jeder von uns von Millionen Besuchern träumt, will man natürlich auch wissen, wie sich Millionen von Besucher anfühlen und ob unser „kleines Kunstwerk“ davon genau so begeistert wäre wie wir. Dynamische Webseiten sind toll, allerdings hat der gemeine Internetserver ein großes Problem damit. Die Erzeugung ist meist sehr aufwendig. Daten müssen aus Datenbanken geholt werden, Berechnungen wollen berechnet werden und Blogeinträge müssen wie Blogeinträge aussehen.

Seit Jahren hat sich eine simple Technik etabliert, die diese gequälten Webserver entlastet. Jeder fortgeschrittene Entwickler kennt und liebt sie, weil sie so schön einfach und universal einsetzbar ist: *trommelwirbel* Das Caching *tusch*. Da aber Caching an sich ein uralter Hut ist, will ich Euch zeigen, wie Ihr evtl. Eure Performance mit minimalen Änderungen mehr als verdoppeln könnt.

Am Anfang war der Benchmark

Nehmen wir als Beispiel eine ganz normale WordPress Installation mit dem üblichem Inhalt. WordPress ist umfangreich, es ist komfortabel, es ist leicht verständlich und es ist langsam. Da aber in den wenigstens Blogs sekündlich neue Beiträge und/oder Kommentare geschrieben werden, dafür aber häufige Aufrufe nicht unüblich sind, drängt sich uns hier das Caching geradezu auf. Natürlich gibt es einige gute Plugins dafür, die die verschiedenen Möglichkeiten des Cachings wunderbar abdecken. Aber um die soll es heute zur Abwechslung einmal nicht gehen. Ich will Euch ja nicht mit den „ollen Kamellen“ langweilen.

Um die Geschwindigkeit zu testen brauchen wir natürlich Hilfe in Form eines wunderbaren Programm Namens Apachebench. Dieses ist ein sehr einfach gehaltenes, aber mächtiges Kommandozeilenprogramm. Es macht im Prinzip nichts anderes als eine vorgegebene Seite immer wieder und wieder abzurufen. Dabei merkt es sich Start und Endzeitpunkt und berechnet daraus die Geschwindigkeit, mit der unsere Anfragen beantwortet wurden. Da wir immer noch mit dynamischen Daten arbeiten, starten wir einen ersten vorsichtigen Test. Wir rufen die index.php unserer WordPress Installation zunächst 1000 Mal (-n1000) auf mit je 4 parallelen Zugriffen (-c4). Ich habe 4 gewählt, weil mein Testsystem vier Prozessorkerne hat. So ist ständig jeder Prozessorkern beschäftigt. Die gesamten Daten des Testsystems findet Ihr am Ende des Artikels. Ich gebe bei der Ausgabe von Apachebench immer nur die wichtigen Daten an. Die gesamte Ausgabe ist natürlich umfangreicher.

ab -n1000 -c4 http://phpgangsta.x-blogs.org/
Server Software:        Cherokee
Server Hostname:        phpgangsta.x-blogs.org
Server Port:            80

Document Path:          /
Document Length:        18612 bytes

Concurrency Level:      4
Time taken for tests:   16.623 seconds
Complete requests:      1000
Failed requests:        0
Write errors:           0
Total transferred:      18990000 bytes
HTML transferred:       18612000 bytes
Requests per second:    60.16 [#/sec] (mean)
Time per request:       66.490 [ms] (mean)
Time per request:       16.623 [ms] (mean, across all concurrent requests)
Transfer rate:          1115.65 [Kbytes/sec] received

Was soll man da sagen? Glückwunsch, es ist langsam!

Machen wir es schneller

Wie es schon zu erwarten war, kommen wir mit unserem Standard WordPress nicht weit. Neben der lausigen Geschwindigkeit von 60 Zugriffen pro Sekunde waren während des gesamten Tests auch alle Prozessorkerne bis zum Anschlag ausgelastet. So macht also Bloggen für Millionen Leser keinen Spaß. Einfachste Lösungmöglichkeit – wir besorgen uns eines der o. g. Plugins, doch da lernen wir ja nichts draus, richtig? Wir wollen selber cachen und ausserdem können wir das bestimmt auch viel besser.

Als persistenten Speicher nehme ich APC. Dieses Zusatzmodul ist auf vielen PHP Webservern schon installiert, denn die Hauptaufgabe von APC ist es, PHP Code zu kompiliert und diesen im Arbeitsspeicher zu halten. So werden unsere PHP Scripte dramatisch beschleunigt. Alternativ funktionieren natürlich auch Memcache, alle Datenbanken oder einfache Dateien, in denen wir unsere Daten ablegen. Jede Variante hat seine Vor- und Nachteile. Memcache ist immer dann interessant, wenn man Daten auf mehrere Server verteilen muss, genau so wie Datenbanken, die aber überdimensioniert sind und einen hohen Overhead erzeugen. Dateien funktionieren auch ohne zusätzliche Installationen, sind aber recht langsam. APC ist von den genannten die schnellste Methode.

Da wir hier kein WordPress-Tutorial, sondern ein Performancetutorial machen, tun wir genau das, was man niemals tun sollte. Wir schreiben der Einfachheit halber in die Kerndateien von WordPress, in diesem Fall die index.php. Diese drängt sich geradezu auf, beschleunigt zu werden. Meine Modifizierung ist sehr einfach und stellt quasi ein Modell eines sehr sehr einfachen Caching dar. Keine Funktion, keine Klassen, Caching pur. Für die Praxis ist es damit natürlich nur eingeschränkt tauglich, denn es berücksichtigt ja in keinster Weise Änderungen durch neuen Beiträge oder Kommentare und unterscheidet auch nicht, ob ein Benutzer eingeloggt ist oder nicht. Das ganze System müsste um diese Abhängigkeiten erweitert werden. Trotzdem eignet es sich aber als Basis für Eure eigenen Experimente.

// CacheID
// Der Cache wird neu erstellt durch Änderung der vorstehenden Variable
$cacheid = 'cache' . md5($_SERVER['REQUEST_URI']);

// Sind Daten vorhanden?
if(apc_exists($cacheid) != false)
{
	// dann hole sie und zeige Sie auf der Seite und beende den Vorgang
	$output = apc_fetch($cacheid);
	echo $output;
	exit;
}

// Wir benötigen einen Ausgabepuffer
ob_start();

/** Loads the WordPress Environment and Template */
require('./wp-blog-header.php');

// Wir fangen die Ausgabe ab ...
$output = ob_get_contents();

// ... und vertrauen diese APC an
apc_store($cacheid, $output, 300);

Wiederholen wir den Test von oben. Die Performance sollte sich jetzt wesentlich verbessert haben. Die Daten werden einmal erzeugt und dann in den gemeinsamen Speicher von APC abgelegt. Weitere Aufrufe geben nur noch den Inhaltes des Caches wieder. Testen wir also noch einmal. Ab hier erhöhe ich die Anzahl der Abfragen (-n) auf 100.000, weil damit aussagekräftigere Werte entstehen.

ab -n100000 -c4 http://phpgangsta.x-blogs.org/
Concurrency Level:      4
Time taken for tests:   19.911 seconds
Complete requests:      100000
Requests per second:    5022.40 [#/sec] (mean)
Time per request:       0.796 [ms] (mean)
Time per request:       0.199 [ms] (mean, across all concurrent requests)

Okay, klarer Fall von Makro-Optimierung!

Machen wir es noch schneller

Bis hier hin dürften die meisten von uns schon einmal gekommen sein. Wir haben also die Geschwindigkeit unseres kleinen Blogs mit einfachsten Mitteln um den Faktor ~80 beschleunigt und könnten also Feierabend machen. Tun wir aber nicht, denn ein anderes großes Problem haben wir nämlich noch gar nicht bedacht. Eine Seite schnell zu generieren heißt nicht, dass sie auch schnell bei unserem Besucher ankommt. Dazu kommen noch mehr Faktoren. Eine der wichtigsten ist die Komprimierung des Textes vor der Ausgabe.

Wie man im ersten Test schon sah, ist unsere Startseite ungefähr 18 kb groß. Über GPRS oder unser gutes altes Kabelmodem wäre diese Seite satte 3 bis 4 Sekunden unterwegs. Das ist definitiv so lang, dass ein Benutzer genervt wegschalten könnte. Wenn wir Millionen Besucher haben möchten, dürfen wir aber auf dem Weg niemanden verlieren. Text-, bzw. html-Dateien sind gerade zu dafür erfunden worden, vor dem Versand komprimiert zu werden. Die Trafficersparnis beträgt meistens über 80%, da sich wiederholende Zeichen leicht zusammengepackt lassen. In PHP ist diese Funktion durch den ob_gzhandler auch sehr einfach umzusetzen. Also zurück zu unserer index.php und eine Zeile hinzufügen.

// Sind Daten vorhanden?
if(apc_exists($cacheid) != false)
{
	// Seiten vor der Ausgabe packen
	ob_start("ob_gzhandler");

	// dann hole sie und zeige Sie auf der Seite und beende den Vorgang
	$output = apc_fetch($cacheid);
	echo $output;
	exit;
}

Da Apachebench in der Standardkonfiguration dem Webserver nicht mitteilt, dass es auch mit der gepackten Version arbeiten kann, müssen wir unsere Befehlszeile etwas modifizieren. Mit dem Parameter -H können wir der abgefragten Internetseite zusätzliche Kopfdaten schicken und damit sagen, dass wir auch GZip sprechen und daher doch lieber das kleine Päckchen nehmen. Damit imitieren wir das Verhalten von richtigen Browsern. Diese sagen einer Internetseite mit jedem Aufruf, „Hallo, ich kann auch GZip“. Der ob_gzhandler erkennt dies und packt unsere Daten im Ausgabepuffer vor dem Versand. Die Größe der Datei sinkt – die Übertragung wird schneller. Doch was ist mit unserer Serverperformance?

ab -n 100000 -c 4 -H "Accept-Encoding: GZip" http://phpgangsta.x-blogs.org/
Document Length:        4844 bytes

Concurrency Level:      4
Time taken for tests:   41.272 seconds
Requests per second:    2422.94 [#/sec] (mean)
Time per request:       1.651 [ms] (mean)
Time per request:       0.413 [ms] (mean, across all concurrent requests)

Ein Testergebnis mit 8 Buchstaben: Resultat Verdammt! Positiv fällt uns natürlich sofort auf, dass die Seite auf ca. ein Viertel geschrumpft ist. Statt 3 bis 4 Sekunden würde ein Besucher mit kleiner Leitung nur noch weniger als 1 Sekunde warten. Eine wesentliche Verbesserung und kaum eine Chance, den Browser zu schliessen. Nur unsere wunderbare Performance hat sich dabei halbiert. Nichts mehr mit Millionen Besuchern, Frauen (oder Männern), Geld, Ruhm und noch mehr Frauen (oder Männern).

Die Reihenfolge macht’s

Preisfrage: Was würde passieren, wenn wir nicht die Ausgabe speichern und dann komprimieren, sondern erst komprimieren und dann speichern? An die Editoren! Zunächst einmal brauchen wir eine Funktion, die uns aus einer Textdatei eine GZip Datei macht. Mit der Funktion gzencode ist dies problemlos möglich. Unsere Datei muss dazu nur minimal geändert werden. Wir komprimieren die Ausgabe vor der Speicherung auf dem höchsten Faktor 9. Die Datei soll möglichst klein werden.

// und vertrauen diese APC an
apc_store($cacheid, gzencode($output, 9), 300);

Fehlt uns noch die Ausgabe. Diese bleibt an sich gleich, allerdings ersetzen wir unseren ob_gzhandler gegen den Content-Encoding header, den der Browser benötigt, um GZip Daten zu erkennen und entsprechend zu interpretieren. Ohne diese „Markierung“ würde er uns die Datei einfach im Fenster angezeigt werden, was bei komprimierten Dateien, sagen wir mal, schwierig zu lesen wäre. Wir wollen ja, dass unsere Millionen Leser auch wieder kommen. Denkt bitte auch daran die CacheID zu ändern oder 5 Minuten zu warten, sonst wird der alte Inhalt des Caches ausgegeben.

if(apc_exists($cacheid) != false)
{
	header('Content-Encoding: GZip');
	$output = apc_fetch($cacheid);
	echo $output;
	exit;
}
ob_start();

Alle Browser in freier Wildbahn unterstützen übrigens GZip-komprimierte Daten. Daher sollten auch standardmässig immer gepackte Daten ausgegeben werden. Für die wenigen Ausnahmen, bei denen dem nicht so ist, kann man folgenden Code verwenden. Dies erhöht zwar grundsätzlich die Serverauslastung, aber wie bereits erwähnt, dass ist die absolute Ausnahme.

if(!isset($_SERVER["HTTP_ACCEPT_ENCODING"]) || strpos($_SERVER["HTTP_ACCEPT_ENCODING"], 'gzip') === false)
{
	echo gzdecode($output);
	exit;
}

Unsere Daten werden nun komprimiert gespeichert und dann ohne weitere Bearbeitung, aber mit dem richtigen Kopfdaten an den Browser gereicht. Die Frage ist, wie verhält sich unser kleines Blog jetzt? Probieren wir es aus!

Concurrency Level:      4
Time taken for tests:   17.424 seconds
Complete requests:      100000
Requests per second:    5739.37 [#/sec] (mean)
Time per request:       0.697 [ms] (mean)
Time per request:       0.174 [ms] (mean, across all concurrent requests)
Concurrency Level:      10
Time taken for tests:   16.773 seconds
Complete requests:      100000
Requests per second:    5961.95 [#/sec] (mean)
Time per request:       1.677 [ms] (mean)
Time per request:       0.168 [ms] (mean, across all concurrent requests)
Concurrency Level:      100
Time taken for tests:   19.683 seconds
Complete requests:      100000
Requests per second:    5080.58 [#/sec] (mean)
Time per request:       19.683 [ms] (mean)
Time per request:       0.197 [ms] (mean, across all concurrent requests)
Concurrency Level:      1000
Time taken for tests:   24.064 seconds
Complete requests:      100000
Requests per second:    4155.55 [#/sec] (mean)
Time per request:       240.642 [ms] (mean)
Time per request:       0.241 [ms] (mean, across all concurrent requests)
Concurrency Level:      2000
Time taken for tests:   33.335 seconds
Complete requests:      100000
Requests per second:    2999.87 [#/sec] (mean)
Time per request:       666.696 [ms] (mean)
Time per request:       0.333 [ms] (mean, across all concurrent requests)

Wie man sieht, liegen die Anfragen pro Sekunde bei 2000 (!) parallelen Abfragen immer noch 20% über denen unserer ob_gzhandler Version. Im Normalbereich ca. 10 bis 20 % über denen ohne Komprimierung. Der Prozessor ist dabei übrigens noch weit weg von Totalauslastung. Dieses Verhalten ist auch vollkommen logisch. Kleineren Daten werden wesentlich schneller abgearbeitet. Sie werden schneller aus dem shared memory gelesen und verstopfen nicht unsere Netzwerkverbindung. Auch bei unrealistisch vielen parallelen Anfragen bricht die Performance messbar, aber nicht spürbar ein.

Fazit

Caching ist eine lohnenswerte Technik. Caching von komprimierten Daten ist eine noch viel lohnenswertere(re) Technik, vor allem im High Performancebereich oder auf Webservern, die eigentlich unterdimensioniert sind. Wenn Ihr diese Möglichkeit habt, nutzt sie und baut es in Euer bestehendes System ein.

Testsystem

Hardware:
- Intel Quad Core i5 CPU 750  @ 2.67GHz
- 6 GB Ram
- Software Raid 1

Software:
- Debian 6.0.3
- Cherokee Web Server 1.2.101
- PHP 5.3.8 (php-fpm)
- APC 3.1.9

Ähnliche Artikel:

  1. PHP in_array() die Performance-Bremse
Über den Autor

PHP Gangsta

Der zweitgrößte deutsche, eher praxisorientierte PHP-Blog von Michael Kliewe veröffentlicht seit Mitte 2009 Artikel für Fortgeschrittene.

Link erfolgreich vorgeschlagen.

Vielen Dank, dass du einen Link vorgeschlagen hast. Wir werden ihn sobald wie möglich prüfen. Schließen