PHP mit OPcache, Variablen-Cache & JIT stark beschleunigen (Teil 2: Praxis)

PHP mit OPcache, Variablen-Cache & JIT stark beschleunigen (Teil 2: Praxis)

Wie muss eine PHP-Installation aussehen, um von den zahlreichen Performance-Verbesserungen (v.a. Caching) der letzten Versionen zu profitieren? Dieser Beitrag demonstriert das an einer praktischen Umgebung, welche direkt für eigene Anwendungen oder dem Betreiben von Drittanbieter-Anwendungen genutzt werden kann. Darauf liegt der Fokus: Beispiele, die sich direkt ausprobieren sowie einsetzen lassen. Auf die Funktion & Hintergründe gehe ich an dieser Stelle nur nebenbei sowie rudimentär ein. Wer dies vertiefen möchte, findet mit dem ersten Teil einen kompletten Artikel, der die Details erläutert. Er ist als Grundlage empfehlenswert, um Aufbau & Entwicklung besser nachvollziehen zu können.

Alle aktuellen Versionen profitieren stark

Zum Verfassungszeitpunkt dieses Artikels ist PHP 8.2 die älteste Version, welche noch bis Ende 2026 mit Sicherheitskorrekturen versorgt wird. Alle unterstützten Versionen können somit von den genannten Vorteilen profitieren. Allerdings ist nur der OPcache automatisch eingeschaltet, der Rest nicht. Und selbst das nur in bestimmten Konfigurationen. Ein offizielles Standard PHP 7.4 Docker Abbild beispielsweise lässt den OPcache weiterhin vermissen:

Obwohl er bereits seit Version 5.5 zum Kern von PHP gehört. Ähnliches kann auftreten, wenn von Distributionen angepasste Pakete zum Einsatz kommen. Eine Standard-Installation von PHP besteht bei mir daher im aktivieren aller Module sowie dem Installieren von APCu. Man sollte in der eigenen Umgebung stets zu Beginn prüfen, welche Module installiert & aktiviert sind.

Grundsätzlich kann man sie in jeder Umgebung verwenden. Seit über 10 Jahren laufen meine PHP-Installationen unter Docker. Es ist der flexibelste Weg. Längst existieren offizielle Abbilder, die das Installieren von Erweiterungen mit Hilfsfunktionen wie docker-php-ext-install zum Kinderspiel machen. Daher zeige ich den folgenden Weg anhand der Dockerfiles meiner Projekte. Wer eine direkte Installation bevorzugt, kann die Änderungen auf manuellem Wege an die eigene Umgebung angepasst (z.B. Pfade von Konfigurationsdateien, Aufbau usw.) übertragen.

Nutzung in der Praxis: Aufbau – PHP + OPcache + JIT + APCu auf Docker

Als Basis verwende ich das von PHP offiziell bereitgestellte Apache-Abbild. Man kann hierbei bemängeln, dass mod_php für jede Anfrage einen PHP-Prozess startet. Ein Prozessmanager wie PHP-FPM ist effizienter, diesen setze ich bei den meisten meiner Projekte ein. Hier verzichte ich bewusst darauf, da er die Komplexität erhöht. Der Fokus soll nicht auf dem Konfigurieren (und ggf. Verstehen) von PHP-FPM liegen. Sondern auf PHP mit Opcache, JIT & APCu. Zumal der Vorteil des Prozessmanagers in kleineren Umgebungen gering ist, doch das wäre Stoff für einen eigenen Beitrag. Auch auf weitere Komponenten wie PDO mit einem Datenbanktreiber (meist MySQL/PostgreSQL) gehe ich an dieser Stelle nicht ein.

Wir legen uns also ein Dockerfile an, in dem zunächst APCu installiert werden muss. Darüber hinaus ist nur das Aktivieren per Konfigurationsdatei notwendig. Die php.ini liegt im Apache-Abbild unter /usr/local/etc/php.

FROM php:8.5-apache
# PHP >= 8.5 bundles OpCache, but only for caching bytecode of PHP scripts. APCu allows caching variables 
RUN pecl install APCu \
        && docker-php-ext-enable apcu

Das reicht für eine minimale Installation bereits aus. In php-overrides.ini überschreiben wir Einstellungen aus der standardmäßig vorhandenen php.ini.

opcache.enable=1
; Speicher, der dem OPcache fuer bytecode zur Verfuegung steht
opcache.memory_consumption=64MB
; Speicherlimit fuer JIT
opcache.jit_buffer_size=128M
opcache.jit=tracing

; "Aufwaerm" Skript, welches beim Start bereits geladen + gecached wird
;opcache.preload=/var/www/preload.php
;opcache.preload_user=www-data

; Limit fuer den Objektspeicher APCu
apc.shm_size=64M

; Fehlersuche: 2 = Warnungen, 3 = Info, 4 = Debug
;opcache.log_verbosity_level=4

; Produktiv: revalidate_freq hoch setzen (Standard 2s) oder Pruefung ganz abschalten 
;opcache.validate_timestamps=0
;opcache.revalidate_freq=0

Damit das funktioniert, lassen wir sie in docker-compose.yml als letztes laden. Zur Vereinfachung bei Testzwecken erfolgt das Einhängen der PHP-Dateien als Volumes. Produktiv sauberer wäre es, diese per COPY ins Docker-Abbild zu kopieren. Sowie über develop.watch auf Änderungen zu reagieren. Auch das entfällt hier, um es simpel zu halten.

services:
  opcache-demo:
    build: .
    volumes:
      - ./www:/var/www/html
      - ./preload.php:/var/www/preload.php
      - ./php-overrides.ini:/usr/local/etc/php/conf.d/z-php-overrides.ini
    ports:
      - 80:80

Aufwärmen mit Preload

Wir haben nun zwei robuste Zwischenspeicher. Allerdings mit einem Nachteil: Erst ab der zweiten Abfrage spielen sie ihre Karten aus. Der erste Besucher wird langsamere Ladezeiten abbekommen – insbesondere bei komplexen CMS bzw. Webanwendungen. Das kann durch Aufwärmen gelöst werden. Dabei laden wir wichtige Teile oder die komplette Applikation direkt nach dem Start in den Cache. Dies erledigt ein PHP-Skript, welches wir über die opcache.preload Direktive übergeben. Dies kann theoretisch index.php sein, praktisch rate ich davon ab. In meinen Tests hat selbst die Ausgabe der PHP-Info zum Absturz des Webservers geführt.

[Mon Feb 23 17:14:58.602586 2026] [:emerg] [pid 1:tid 1] AH00020: Configuration Failed, exiting

Stabiler funktioniert ein eigenes PHP-Skript, welches häufig benötigte Klassen/Dateien lädt. Ich platziere es außerhalb des öffentlich zugänglichen www Ordners, also bei Apache in /var/www/preload.php. Darin müssen keine Objekte Erzeugt werden – es genügt, die PHP-Skripte per require_once zu laden.

<?php
require_once __DIR__ . '/html/demo.php';

Dass dies funktioniert, seht ihr mit opcache.log_verbosity_level=3 oder höher. Er lädt über preload.php das Demo-Skript in den OPcache. Beim ersten Aufruf davon erscheint kein Logeintrag zum Caching mehr, weil es bereits drin liegt:

opcache-demo-1  | Mon Feb 23 17:20:12 2026 (17): Message Cached script '$PRELOAD$'
opcache-demo-1  | Mon Feb 23 17:20:12 2026 (17): Message Cached script '/var/www/preload.php'
opcache-demo-1  | Mon Feb 23 17:20:12 2026 (17): Message Cached script '/var/www/html/demo.php'
opcache-demo-1  | 172.26.0.1 - - [23/Feb/2026:17:20:42 +0000] "GET /demo.php HTTP/1.1" 200 380 "-" "Mozilla/5.0 (X11; Linux x86_64; rv:147.0) Gecko/20100101 Firefox/147.0"

Den Objekt-Cache benutzen

Im Gegensatz zum OPcache erfordert der Objekt-Cache Anpassungen am Code der Zielanwendung: Sie muss sinnvoll Daten in den Zwischenspeicher legen. Sowie im besten Falle diese leeren, sobald die gespeicherten Daten nicht mehr aktuell sind. Ein Simples Beispiel demonstriert folgendes Skript:

<?php
$date = new DateTime();
$dt = $date->format('d.m.Y \u\m H:i:s');

$added = (bool)apcu_add('datetime', $dt);
$dtCached = apcu_fetch('datetime');
?>
<h2 style="font-family:Roboto">
    Datum & Uhrzeit: <?=$dt?><br />
    Zeitstempel vom Cache: <?=$dtCached?><br/>
    APCu gespeichert: <?=json_encode($added)?>
</h2>

Es gibt zuerst via $dt den aktuellen Zeitstempel aus, welcher beim Aufruf des Skriptes neu generiert wird. Beim ersten Mal legt es ihn unter dem Schlüssel datetime in APCu ab. Wer die Doku aufmerksam ließt, stellt fest: apcu_add speichert nur, sofern noch nichts im Zwischenspeicher liegt. Dagegen würde apcu_store sämtliche vorhandenen Daten immer überschreiben. Der erste Aufruf schreibt den Zeitstempel in den Cache. Die späteren Anfragen zeigen zwei verschiedene Zeitstempel an: Der Erste ist live generiert, weil normale PHP-Variablen während #2 dem ersten Laden entspricht.

Während das eine gute Demonstration zum Verständnis der Funktion für den ersten Einstieg darstellt, ist es im praktischen Einsatz sinnlos. Dort würde man wie folgt vorgehen: Prüfen, ob der Schlüssel bereits im Cache liegt – wenn ja, ihn von dort laden. Andernfalls müssen die Daten geladen/generiert/aufbereitet werden. Zusätzlich speichern wir sie für den späteren Abruf im Cache. Der Zeitstempel steht hier beispielhaft für umfangreichere Daten, etwa von einer Datenbankabfrage, einem API-Aufruf oder andere komplexere Quellen.

<?php
$key = 'datetime';
if(apcu_exists($key)) {
    $dt = apcu_fetch($key);
}else {
    $date = new DateTime();
    $dt = $date->format('d.m.Y \u\m H:i:s');
    apcu_add($key, $dt);
}
?>
<h2 style="font-family:Roboto">
    Zeitstempel vom Cache: <?=$dt?>
</h2>

Produktiv wäre es am besten, wenn die Anwendung den Zwischenspeicher automatisch leert, um immer aktuelle Daten auszuliefern. Ein Beispiel: Wir speichern die Liste der neuesten Blogbeiträge in APCu. Beim Hinzufügen neuer Einträge ruft die Anwendung apcu_delete() auf. So erscheinen keine älteren Einträge. Ist das nicht möglich, weil die Daten von außen verändert werden, kann alternativ zumindest eine TTL (Time-to-live) angegeben werden. Dafür akzeptiert apcu_add als dritten Parameter die Anzahl an Sekunden. Folgendes Beispiel speichert das Datum im Schlüssel „key“ für maximal 10 Minuten.

apcu_add('date', '23.02.2026', 60*10);

Statistiken: Wie geht es den Caches?

Alle Komponenten stellen PHP-Funktionen bereit, um den Status auslesen zu können. Ich habe mir dazu eine kleine Fußzeile gebaut, welche die interessantesten Metriken darstellt. Zu Beginn von index.php (mein MVC-Router) wird die Zeit festgehalten:

define('SCRIPT_START_TIME', microtime(true));

So lässt sich am Ende die Generierungszeit berechnen. Dies stelle ich zusammen mit weiteren Messwerten (etwa dem RAM-Verbrauch) sowie den Statistiken der Caches in einer kleinen unauffälligen Box dar. Wichtig ist, dass folgender Code am Ende des Skripts aufgerufen wird! Erfolgt der Aufruf früher, sind Werte wie die Ausführungszeit oder der RAM-Verbrauch nicht korrekt. Auch die Cache-Statistik kann fehlerhaft sein, falls danach weitere Zugriffe stattfinden. Interessant ist es, nach einiger Zeit produktiver Nutzung auf die Zahlen zu Blicken. So lässt sich erkennen, ob die reservierten Dimensionen ausreichen, oder angepasst werden können/sollten.

<?php
$totalExecutionTime = round((microtime(true) - SCRIPT_START_TIME) * 1000, 1);
$filesCount = count(get_included_files());
$memory = round(memory_get_peak_usage(real_usage: false) / 1000, 1);
$realMemory = round(memory_get_peak_usage(real_usage: true) / 1000, 1);
$peakMemory = round(memory_get_peak_usage(real_usage: false) / 1000, 1);
$realPeakMemory = round(memory_get_peak_usage(real_usage: true) / 1000, 1);

$opcache = opcache_get_status();

$apcuCache = apcu_sma_info();
$apcuSize = round($apcuCache['seg_size'] / 1000 / 1000, 1);
$apcuAvailable = round($apcuCache['avail_mem'] / 1000 / 1000, 1);

$apcuInfo = apcu_cache_info();
?>
<style>
  #stats-box {
    color: #A9A9A9; 
    text-align: center; 
    margin-top: -10px; 
    padding-bottom: 10px; 
    font-size: 12px;
    font-family: Roboto,Verdana;
  }
</style>
<div id="stats-box">
  Generiert in <?=$totalExecutionTime?>ms mit <?=$filesCount?> Dateien, <?=$memory?> KB Arbeitsspeicher (Real: <?=$realMemory?> KB), Spitze: <?=$peakMemory?> KB (Real: <?=$realPeakMemory?> KB)<br />
  OPCache: <?=$opcache['opcache_statistics']['num_cached_scripts']?> Skripte, <?=$opcache['opcache_statistics']['hits']?> Hits, <?=$opcache['opcache_statistics']['misses']?> Misses, Hit-Rate: <?=round($opcache['opcache_statistics']['opcache_hit_rate'], 1)?>% <br />
  APU: <?=$apcuAvailable?> MB von <?=$apcuSize?> MB verfügbar | <?=$apcuInfo['num_entries']?>/<?=$apcuInfo['num_slots']?> Slots verwendet | <?=$apcuInfo['num_hits']?> Hits, <?=$apcuInfo['num_misses']?> Misses

Hinweis: Jeder apcu_exists() Aufruf wird als Cache-Hit gewertet! Daher generiert obiges Skript pro Ladevorgang zwei Hits.

Übersicht & Analyse per Skript

Mit dem php-cache-dashboard existiert ein simples Skript, welches zur Übersicht über den gesamten Cache ideal ist.1 Es besteht aus einer einzigen PHP-Datei (cache.php), sodass es ohne großartige Installation leicht abgelegt & später wieder entfernt werden kann. Angezeigt wird Opcache, APC (PHP < 5.5)/APCu (ab 5.5) und sogar der Realpath-Zwischenspeicher. Auch Redis als externe, verteilte Lösung wird abgefragt. Die Auslastung ist grafisch dargestellt. Auf Wunsch zeigt das Skript die zwischengespeicherten Dateien als Tabelle mit Meta-Infos (Hits, Größe). Außerdem lassen sich die derzeit abgelegten Schlüssel mit ihren Werten anzeigen sowie durchsuchen. Für Tests können einzelne Schlüssel oder der ganze Cache geleert werden. Damit eignet es sich besonders gut für Anwendungen, in denen man eine Übersicht nicht selbst integrieren kann oder möchte – etwa ein CMS.

Achtung: Da php-cache-dashboard auch das Löschen des Zwischenspeichers ermöglicht, sollte man es möglichst nicht (dauerhaft) ungeschützt öffentlich erreichbar zugänglich machen.

Unabhängig von der Datenquelle sollte man nach einiger Zeit der produktiven Nutzung die Auslastung des Cache prüfen. Je nach Anwendung lässt sich der reservierte Speicher deutlich verkleinern. Oder er ist zu knapp bemessen, wodurch nur ein Teil seines Potenzials nutzbar ist.

Weitere Einstellungen

Ein Blick in die Dokumentation lohnt sich. Dort sind alle OPcache Direktiven mit ihren Standardwerten dokumentiert. Klickt man darauf, folgt weiter unten eine Erklärung.2 Selbiges gilt für APCu.3 Dort finden sich zudem Hilfsfunktionen wie apcu_inc/apcu_dec: Sie können Werte zu Variablen hinzu zählen. Das vereinfacht den Umgang mit Zählern, weil man sich die obige Logik Laden wenn vorhanden/andernfalls abspeichern spart.

Folgende Direktiven können für manche interessant sein:

  • Ist opcache.validate_timestamps aktiv (1), prüft PHP alle opcache.revalidate_freq Sekunden, ob sich die Zwischengespeicherten Skripte geändert haben (standardmäßig alle 2s).
  • opcache.save_comments speichert Kommentare, wenn aktiv (Standard). Wird diese Option abgeschaltet, sinkt der Speicherbedarf des Caches. Allerdings kann es zu Problemen mit bestimmten Frameworks kommen, die Kommentare intern verwenden (z.B. Doctrine oder PHPUnit).
  • Ist ein Pfad für opcache.file_cache gesetzt, legt PHP dort den Cache zusätzlich im Dateisystem an. Oder sollte es zumindest, ich habe es zusammen mit mod_php nicht zum Laufen bekommen. Relevant sollen hierbei opcache.validate_timestamps und opcache.revalidate_freq sein, da sie standardmäßig zur schnellen Löschung führen.

Fehlersuche

Bei der Analyse/Fehlersuche bezüglich des OPcache kann opcache.log_verbosity_level=4 helfen (2 = Warnungen, Informationen = 3, Debug = 4): Es schreibt Informationen ins Log, wenn beispielsweise ein bisher nicht im Cache liegendes Skript (Cache Miss) dort abgelegt wird.

Ebenfalls nützlich ist die Möglichkeit, per php -i sämtliche Konfigurationseinstellungen ausgeben zu können. Der altbekannte Weg über ein Skript mit dem Inhalt <?php phpinfo(); mag eben so funktionieren. Ich finde die Kommandozeile eleganter: Dadurch ist nicht die komplette PHP-Info öffentlich erreichbar, sofern es sich um einen Server im WWW handelt. Außerdem kann sie geschickt mit grep durchsucht werden.

$ docker compose exec -it opcache-demo php -i | grep opcache.enable
opcache.enable => On => On
opcache.enable_cli => Off => Off
opcache.enable_file_override => Off => Off

Quellen

  1. https://github.com/JorgenEvens/php-cache-dashboard ↩︎
  2. https://www.php.net/manual/en/opcache.configuration.php ↩︎
  3. https://www.php.net/manual/en/apcu.configuration.php ↩︎

Leave a Reply