Inhalt
- Warum 50ms? Die Zahl hinter dem Ziel
- 1. OPcache richtig konfigurieren — der größte Einzelgewinn
- Die optimale php.ini Konfiguration
- OPcache-Status prüfen
- 2. Query-Profiling mit EXPLAIN — DB-Abfragen die heimlich alles verlangsamen
- Schritt 1: Slow Query Log aktivieren
- Schritt 2: Eigenes Query-Timing in PDO
- Schritt 3: EXPLAIN auf langsame Queries anwenden
- 3. Output Buffering — kontrollieren wann PHP ausgibt
- ob_start() und ob_end_flush() richtig einsetzen
- Kombination mit gzip-Komprimierung
- Output Buffering für partielles Caching
- 4. HTTP/2 Server Push — Assets schicken bevor der Browser fragt
- In PHP per Header implementieren
- Oder direkt im HTML mit <link rel="preload">
- Critical CSS inlinen — der mächtigste LCP-Boost
- 5. Lazy Loading für DB-Verbindungen — Verbindungen nur bei Bedarf aufbauen
- Das Problem: Eager Connection
- Die Lösung: Lazy PDO Wrapper
- Persistent Connections — mit Vorsicht
- 6. Bonus: PHP-Profiling mit Xdebug oder Tideways
- Die vollständige Optimierungs-Checkliste
- Fazit: 50ms sind kein Mythos
PHP-Seiten auf unter 50ms bringen — ohne Framework-Wechsel
Deine Seite ist technisch korrekt, sicher und gut designed — aber der Server braucht 300ms, bevor der erste Byte beim Browser ankommt. Dieser Guide zeigt dir, wie du das auf unter 50ms drückst. Mit echten Messwerten. Ohne Laravel, ohne Symfony, ohne Neubau.
Warum 50ms? Die Zahl hinter dem Ziel
Google misst Time to First Byte (TTFB) als eigenständiges Ranking-Signal. Ein TTFB unter 200ms gilt als "gut", unter 100ms als "sehr gut". Unter 50ms — das ist das Niveau, das Nutzer schlicht nicht mehr wahrnehmen.
Der Browser wartet auf den ersten Byte, bevor er überhaupt mit dem Parsen beginnt. Alles, was danach kommt — CSS, JavaScript, Bilder — ist in diesem Moment irrelevant. Der Server ist dein erster Flaschenhals, und er wird am häufigsten ignoriert.
Hier sind die realen Ausgangswerte einer typischen PHP-Seite ohne Optimierung auf einem Shared-Hoster:
Diese Werte stammen aus einer echten PHP 8.3 Anwendung auf einem VPS mit 2 vCores und 4 GB RAM. Kein CDN-Trick, kein Caching-Proxy davor — nur sauber optimiertes PHP.
1. OPcache richtig konfigurieren — der größte Einzelgewinn
OPcache ist PHPs eingebauter Bytecode-Cache. Ohne ihn kompiliert PHP bei jedem einzelnen Request deine .php-Dateien neu von Quellcode zu Bytecode. Mit OPcache passiert das genau einmal — danach liegt der Bytecode im Shared Memory und wird wiederverwendet.
Das Problem: OPcache ist zwar standardmäßig aktiviert, aber mit suboptimalen Defaults.
Die optimale php.ini Konfiguration
; OPcache aktivieren (sollte bereits 1 sein)
opcache.enable=1
opcache.enable_cli=0
; Speicher für den Bytecode-Cache (in MB)
; Für kleine bis mittelgroße Projekte: 128MB
; Für größere Projekte: 256-512MB
opcache.memory_consumption=128
; Maximale Anzahl gecachter Dateien
; Immer etwas mehr als deine tatsächliche Dateianzahl
opcache.max_accelerated_files=10000
; Interned Strings Cache (für wiederholte String-Literale)
opcache.interned_strings_buffer=16
; KRITISCH: Deaktiviert die Prüfung ob sich eine Datei geändert hat
; Auf Produktion immer 0! Auf Entwicklung: 1 oder 2
opcache.validate_timestamps=0
; Wie oft OPcache auf Dateiänderungen prüft (in Sekunden)
; Nur relevant wenn validate_timestamps=1
opcache.revalidate_freq=0
; Preloading (PHP 7.4+): Lädt kritische Dateien beim Server-Start in den Cache
; opcache.preload=/var/www/html/preload.php
; opcache.preload_user=www-data
; Optimierungslevel (0-3, Standard ist 1)
opcache.optimization_level=0x7FFEBFFF
⚠️ Wichtig:
opcache.validate_timestamps=0bedeutet, dass Dateiänderungen nicht mehr automatisch erkannt werden. Nach einem Deployment musst du OPcache manuell leeren:opcache_reset()oder einen PHP-FPM-Reload auslösen.
OPcache-Status prüfen
<?php
// opcache_status.php — nur intern zugänglich machen!
$status = opcache_get_status();
echo "Gecachte Dateien: " . $status['opcache_statistics']['num_cached_scripts'] . "\n";
echo "Cache-Auslastung: " . round($status['memory_usage']['used_memory'] / 1024 / 1024, 2) . " MB\n";
echo "Hit-Rate: " . round($status['opcache_statistics']['opcache_hit_rate'], 2) . "%\n";
Eine Hit-Rate unter 95% zeigt, dass dein Cache zu klein ist oder validate_timestamps noch aktiv ist.
Gemessener Gewinn: Allein durch die korrekte OPcache-Konfiguration fällt die PHP-Ausführungszeit in unserem Test von 280ms auf ~90ms. Der größte Einzelhebel.
2. Query-Profiling mit EXPLAIN — DB-Abfragen die heimlich alles verlangsamen
Der häufigste Performance-Killer in PHP-Anwendungen sind nicht die PHP-Skripte selbst, sondern Datenbankabfragen ohne Index oder mit unnötigen Full Table Scans.
Schritt 1: Slow Query Log aktivieren
In deiner my.cnf / my.ini:
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 0.1
log_queries_not_using_indexes = 1
Alles über 100ms landet jetzt im Log. Aber noch besser: eigene Query-Zeitmessung direkt in PHP.
Schritt 2: Eigenes Query-Timing in PDO
<?php
class ProfiledPDO extends PDO {
private array $queryLog = [];
public function query(string $query, ...$args): PDOStatement|false {
$start = microtime(true);
$result = parent::query($query, ...$args);
$this->queryLog[] = [
'sql' => $query,
'time' => round((microtime(true) - $start) * 1000, 2) . 'ms'
];
return $result;
}
public function getQueryLog(): array {
return $this->queryLog;
}
}
// Verwendung:
$pdo = new ProfiledPDO('mysql:host=localhost;dbname=mydb', $user, $pass);
// ... deine normalen Abfragen ...
// Am Ende der Seite:
foreach ($pdo->getQueryLog() as $entry) {
error_log("[Query] " . $entry['time'] . " — " . substr($entry['sql'], 0, 100));
}
Schritt 3: EXPLAIN auf langsame Queries anwenden
Sobald du eine langsame Query identifiziert hast, führst du sie mit EXPLAIN aus:
EXPLAIN SELECT p.*, u.username
FROM posts p
JOIN users u ON p.author_id = u.id
WHERE p.status = 'published'
ORDER BY p.published_at DESC
LIMIT 10;
Worauf du achtest:
+----+-------------+-------+------+---------------+------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+---------------+------+---------+------+------+-------------+
| 1 | SIMPLE | p | ALL | NULL | NULL | NULL | NULL | 8432 | Using where |
+----+-------------+-------+------+---------------+------+---------+------+------+-------------+
type: ALL und rows: 8432 ist das Alarmsignal — MySQL scannt hier jeden einzelnen der 8.432 Datensätze.
Die Lösung: ein zusammengesetzter Index auf die häufig kombinierten Spalten:
-- Index auf (status, published_at) — deckt beide WHERE und ORDER BY ab
ALTER TABLE posts ADD INDEX idx_status_published (status, published_at DESC);
-- Ergebnis nach dem Index:
-- type: range | key: idx_status_published | rows: 12 ✓
Von 8.432 gescannten Zeilen auf 12. Das sind Größenordnungen, keine Prozentpunkte.
Gemessener Gewinn: DB-Queries von ~180ms auf ~12ms. Kombination aus Index-Optimierung und Reduzierung von 8 auf 3 Queries pro Seitenaufruf.
3. Output Buffering — kontrollieren wann PHP ausgibt
Standardmäßig sendet PHP HTML-Fragmente an den Browser, sobald sie entstehen. Das klingt gut, hat aber einen Nachteil: Header können nicht mehr gesetzt werden, sobald die erste Ausgabe stattgefunden hat. Außerdem verhindert es Komprimierung auf Inhaltsebene.
ob_start() und ob_end_flush() richtig einsetzen
<?php
// Am Anfang deines Bootstrap/index.php
ob_start();
// ... dein gesamtes PHP ...
// Hier kannst du noch Header setzen, auch wenn schon HTML generiert wurde
// header('Cache-Control: public, max-age=3600');
// Am Ende: alles auf einmal ausgeben
ob_end_flush();
Kombination mit gzip-Komprimierung
<?php
ob_start('ob_gzhandler'); // Komprimiert den Buffer automatisch mit gzip
// ... dein HTML ...
ob_end_flush();
Achtung:
ob_gzhandlerund Apache'smod_deflategleichzeitig aktiv zu haben führt zu doppelter Komprimierung und kaputten Seiten. Entweder das eine oder das andere.
Output Buffering für partielles Caching
Der mächtigere Einsatz ist das Fragment-Caching — teure HTML-Blöcke auf Disk zwischenspeichern:
<?php
function cachedFragment(string $key, int $ttl, callable $generator): string {
$cacheFile = sys_get_temp_dir() . '/cache_' . md5($key) . '.html';
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $ttl) {
return file_get_contents($cacheFile);
}
ob_start();
$generator();
$html = ob_get_clean();
file_put_contents($cacheFile, $html, LOCK_EX);
return $html;
}
// Verwendung: Die Navigation wird für 5 Minuten gecacht
echo cachedFragment('main-nav', 300, function() use ($pdo) {
// Teure DB-Abfragen für die Navigation
$categories = $pdo->query("SELECT * FROM categories")->fetchAll();
include 'partials/navigation.php';
});
Gemessener Gewinn: Für Seiten mit teuren, aber sich selten ändernden Blöcken (Navigation, Sidebar, Footer-Widgets): 40–60ms Einsparung.
4. HTTP/2 Server Push — Assets schicken bevor der Browser fragt
Bei HTTP/1.1 läuft der Prozess so ab:
- Browser lädt HTML
- Browser parst HTML, findet
<link rel="stylesheet" href="style.css"> - Browser fragt nach
style.css - Server liefert
style.css
Drei Roundtrips, bevor CSS lädt. Mit HTTP/2 Server Push schickst du kritische Assets direkt mit dem ersten HTML-Response mit — Schritt 3 entfällt.
In PHP per Header implementieren
<?php
// Kritische Assets pushen, bevor HTML ausgegeben wird
header('Link: </css/critical.css>; rel=preload; as=style', false);
header('Link: </js/alpine.min.js>; rel=preload; as=script', false);
header('Link: </fonts/inter.woff2>; rel=preload; as=font; crossorigin', false);
Oder direkt im HTML mit <link rel="preload">
Wenn dein Hoster HTTP/2 Push nicht unterstützt (prüfen mit curl -I --http2 https://deine-domain.de), ist preload die Fallback-Lösung:
<head>
<!-- Browser lädt diese Assets parallel zum HTML-Parsing -->
<link rel="preload" href="/css/critical.css" as="style">
<link rel="preload" href="/fonts/inter-variable.woff2" as="font" type="font/woff2" crossorigin>
<!-- Dann erst einbinden -->
<link rel="stylesheet" href="/css/critical.css">
</head>
Critical CSS inlinen — der mächtigste LCP-Boost
Statt die komplette CSS-Datei zu laden, inline nur das CSS, das für den sichtbaren Bereich (above the fold) gebraucht wird:
<?php
// critical.css enthält nur Styles für Header, Hero, erste Sektion
$criticalCss = file_get_contents(__DIR__ . '/css/critical.min.css');
?>
<head>
<style><?= $criticalCss ?></style>
<link rel="stylesheet" href="/css/main.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="/css/main.css"></noscript>
</head>
Das Haupt-CSS wird zunächst als print-Stylesheet geladen (blockiert nicht das Rendering), dann per JavaScript auf all gesetzt.
Gemessener Gewinn: LCP-Wert von 2.8s auf 0.9s. Der Unterschied ist für Nutzer sofort spürbar.
5. Lazy Loading für DB-Verbindungen — Verbindungen nur bei Bedarf aufbauen
Ein häufig übersehener Performance-Killer: PHP baut die Datenbankverbindung auf, auch auf Seiten, die gar keine DB-Abfragen brauchen. Auf einem VPS dauert der Verbindungsaufbau zu MySQL typischerweise 2–8ms — klingt wenig, macht aber bei 50 Requests pro Sekunde spürbar was aus.
Das Problem: Eager Connection
<?php
// bootstrap.php — wird auf JEDER Seite eingebunden
$pdo = new PDO('mysql:host=localhost;dbname=mydb', $user, $pass);
// Verbindung aufgebaut — auch auf der Kontaktseite ohne DB-Abfragen
Die Lösung: Lazy PDO Wrapper
<?php
class LazyPDO {
private ?PDO $pdo = null;
private string $dsn;
private string $user;
private string $pass;
private array $options;
public function __construct(string $dsn, string $user, string $pass, array $options = []) {
$this->dsn = $dsn;
$this->user = $user;
$this->pass = $pass;
$this->options = $options;
}
private function connect(): PDO {
if ($this->pdo === null) {
$this->pdo = new PDO($this->dsn, $this->user, $this->pass, $this->options);
}
return $this->pdo;
}
public function prepare(string $sql, array $options = []): PDOStatement {
return $this->connect()->prepare($sql, $options);
}
public function query(string $sql, ...$args): PDOStatement|false {
return $this->connect()->query($sql, ...$args);
}
public function isConnected(): bool {
return $this->pdo !== null;
}
}
// Verwendung — Verbindung wird erst aufgebaut wenn prepare/query aufgerufen wird:
$db = new LazyPDO('mysql:host=localhost;dbname=mydb;charset=utf8mb4', $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
Persistent Connections — mit Vorsicht
Eine weitere Option sind persistente PDO-Verbindungen, die zwischen Requests wiederverwendet werden:
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_PERSISTENT => true,
// ...
]);
⚠️ Vorsicht bei Persistent Connections: Offene Transaktionen und Locks aus einem vorherigen Request können bestehen bleiben. Nur sinnvoll wenn du weißt was du tust und keine langen Transaktionen verwendest.
Gemessener Gewinn: Auf Seiten ohne DB-Abfragen: 5–8ms Einsparung. Klingt wenig — summiert sich bei hohem Traffic zu echten Kosten.
6. Bonus: PHP-Profiling mit Xdebug oder Tideways
Wenn du nach der Implementierung aller bisherigen Maßnahmen noch Potenzial suchst, hilft echtes Profiling.
# Xdebug installieren (Debian/Ubuntu)
pecl install xdebug
# php.ini Ergänzung für Profiling
xdebug.mode=profile
xdebug.output_dir=/tmp/xdebug-profiles
xdebug.profiler_output_name=cachegrind.out.%p.%r
Öffne die generierten cachegrind.out.* Dateien in KCacheGrind (Linux) oder QCacheGrind (macOS/Windows). Du siehst auf Funktionsebene, wo PHP wie viel Zeit verbringt.
Typische Überraschungen die man dabei findet:
str_replace()in einer Schleife mit 10.000 Iterationenfile_exists()Aufrufe die auf NFS-Mounts per Netzwerk gehen- Autoloader der bei jedem Request hunderte Dateien prüft
Die vollständige Optimierungs-Checkliste
✅ OPcache aktiviert und korrekt konfiguriert (validate_timestamps=0)
✅ EXPLAIN auf alle Queries mit mehr als 10ms Laufzeit angewendet
✅ Zusammengesetzte Indizes für häufige WHERE + ORDER BY Kombinationen
✅ Anzahl der DB-Queries pro Request auf das Minimum reduziert
✅ Output Buffering aktiv, keine vorzeitige Ausgabe
✅ Fragment-Caching für teure, statische HTML-Blöcke
✅ Critical CSS inline, Haupt-CSS non-blocking geladen
✅ HTTP/2 aktiviert (prüfen: curl -I --http2 https://deine-domain.de)
✅ Preload-Hints für kritische Fonts und Scripts
✅ LazyPDO — Verbindungsaufbau nur bei tatsächlichem Bedarf
✅ Slow Query Log aktiv in der Produktionsumgebung
Fazit: 50ms sind kein Mythos
Der Weg von 300ms auf unter 50ms TTFB ist keine Magie und erfordert keinen Rewrite. Es sind fünf konkrete, unabhängige Maßnahmen — jede für sich messbar, alle zusammen transformativ.
Die wichtigste Erkenntnis: Performance ist kein Feature das man am Ende hinzufügt. Es ist eine Denkweise die man in jeden Query, jeden Include und jeden Response-Header einbaut.
Wenn du diese Optimierungen in einer bestehenden PHP-Anwendung umsetzt, starte mit OPcache — der Gewinn ist sofort, ohne jede Codeänderung. Dann EXPLAIN auf deine drei meistgenutzten Seiten. Der Rest folgt.
*Hast du konkrete Messwerte von deiner eigenen PHP-Anwendung? Teile sie in den Kommentaren — ich bin gespannt wie die Ausgangswerte bei euch aussehen.
Kommentare (0)
Diskutiere mit!
Logge dich ein oder erstelle kostenlos ein Konto, um an der Unterhaltung teilzunehmen und dein Wissen zu teilen.
Noch keine Kommentare vorhanden. Sei der Erste!