Performance-Optimierungen für Yii2

Jeder erfahrene Entwickler kennt die Situation: Den ganzen Entwicklungsprozess über wurde die Performance hinten angestellt, um den Fokus stattdessen (zurecht) auf Korrektheit oder (diskutabel) auf Entwicklungsgeschwindigkeit zu legen. Bis dann allerdings die Beschwerde vom Kunden kommt - das System ist viel zu langsam, damit möchte man nicht arbeiten.

Um in dieser Situation zu helfen, bieten wir hier einen kleinen Einblick in unsere Performance-Trickkiste, mit einem spezifischen Fokus auf das Yii2-Framework für PHP - vieles davon lässt sich aber abgewandelt auch mit anderen Technologien anwenden.

Dieser Artikel teilt sich in zwei Teile auf: Zuerst zeige ich, wie man Performance-Probleme mit Yii2s eingebauten Profiling-Tools gut aufspüren kann, anschließend werfen wir einen Blick auf einige Tricks, die in fast jedem Yii2-Projekt helfen sollten.

 

Teil 1: Performance-Probleme aufspüren

Zum Finden von Performance-Problemen benutze ich hauptsächlich zwei Tools, eines davon Yii2-spezifisch und eines für alle PHP-Projekte anwendbar:

Yii2s Debug-Bar

Yii2 kommt selbst mit einer eingebauten Debug-Bar, die auch einige Performance-Features bietet. Dem Tutorial folgend aufgesetzt, lässt sich hier schnell die Request-Zeit und Speicherverbrauch* prüfen. Am wichtigsten ist in der Praxis aber der "DB"-Tab, der alle Datenbank-Queries mitsamt Details anzeigt.

* Obacht: Der Speicherverbrauch steigt beim Setzen von YII_DEBUG automatisch, da die Debug-Daten sich wie ein Memory Leak verhalten. Ich bin mir sicher, dass das noch keinen Entwickler bei uns dazu gebracht hat, stundenlang nach ebendiesem Memory Leak zu suchen. Keinen einzigen.

Performance-Probleme einzelner Queries lassen sich hier durch Sortierung nach Abfragedauer finden, ein eingebautes EXPLAIN unterstützt beim Überprüfen und fixen.

Fast nützlicher ist allerdings die Duplikatserkennung: Bei jeder Abfrage wird erfasst, wie oft diese im Zuge der Request abgeschickt wurde. Gerade bei fehlenden Eager Loads schießt die Menge an gedoppelten Abfragen oft in die Höhe, was diese einfach zu finden macht - mehr Details dazu lassen sich im zweiten Teil dieses Artikels finden.

Für Probleme, die sich nicht in Datenbankabfragen quantifizieren lassen, sind Yii2s Profiling-Tools allerdings nicht sonderlich hilfreich. Hier bietet sich ein traditioneller Profiler, der bei XDebug bereits eingebaut ist, besser an:

XDebug Profiler

Wenn man XDebug bereits aufgesetzt hat, ist der Profiler mit einigen php.ini-Einstellungen relativ leicht zu erreichen:

 

xdebug.mode = profile
xdebug.output_dir = /path/to/output/directory
xdebug.profiler_output_name = cachegrind.out.%R%u

 

%R%u für den Output zu verwenden, hat sich für mich als am effizientesten erwiesen - dadurch sind Profiler-Snapshots bei alphabetischer Sortierung direkt nach URI und Zeitpunkt sortiert.

Ein danach erfasster Snapshot lässt sich mit mehreren Tools recht einfach öffnen - Ich benutze in der Regel einfach PhpStorms eingebauten Viewer. Die über diesen Profiler auffindbaren Probleme sind recht individuell und breit gefächert, es gibt also keine eine "richtige" Herangehensweise.

Mein Workflow sieht allerdings in der Regel wie folgt aus:

  1. Die aufgelisteten Funktionsaufrufe nach Laufzeit absteigend sortieren - PhpStorm kann das von Haus aus recht einfach. Hierbei zu beachten: Ich sortiere nach Laufzeit, nicht nach eigener Laufzeit (d.h. Zeit, die in der Funktion selbst und nicht in von ihr aufgerufenen Funktionen vebracht wurde) - letztere ist nur selten wirklich aussagekräftig.

  2. Am allgemeingültigen Overhead vorbeiscrollen - die ersten angezeigten Zeilen sind das übliche App-Bootstrapping, bei Yii2 also alles von index.php, yii\base\Application->run bis hinunter zur tatsächlichen Action-Methode.

  3. Ab hier nach Auffälligkeiten suchen; Das kann bspw. eine Funktion sein, in der mehr Zeit verbracht wird, als sie eigentlich benötigen sollte. Von hier ab kann man dann prüfen, ob die Funktion exzessiv oft aufgerufen wird (bspw. wegen fehlendem Caching), ob die Funktion selbst zu viel Zeit braucht, et cetera.

Mit der Erfahrung zeigt sich dabei, dass sich bei den hier auffindbaren Problemen einige "übliche Verdächtige" einstellen:

 

Teil 2: Allgemeine Tipps

In dieser Sektion findet sich eine Sammlung von üblichen Problemen und Tricks, über die man oft stolpert und die sich teils "im Vorbeigehen" fixen lassen:

Weniger Queries - Mehr Schneller

Wenn eine Aufgabe keine besonders komplexen Berechnungen oder Datenbankabfragen erfordert, ist der größte Performance-Bottleneck meiner Erfahrung nach fast immer der Overhead einzelner Datenbankabfragen. Vergleichen wir mal diese beiden Codeschnipsel:

 

// Version 1
foreach ($day = 1; $day <= 31; $day++) {
    $date = sprintf('2025-01-%02d', $day);
    $isHoliday = Holiday::find()
        ->andWhere(['hol_date' => $date])
        ->exists();
    if ($isHoliday) {
        echo $date . ' ist ein Feiertag!<br />'
    }
}

// Version 2
$holidays = Holiday::find()
    ->indexBy('hol_date')
    ->andWhere(['<=', 'hol_date', '2025-01-31'])
    ->andWhere(['>=', 'hol_date', '2025-01-01'])
    ->all();
foreach ($day = 1; $day <= 31; $day++) {
    $date = sprintf('2025-01-%02d', $day);
    $isHoliday = array_key_exists($date, $holidays);
    if ($isHoliday) {
        echo $date . ' ist ein Feiertag!<br />'
    }
}

 

Der große Unterschied: In Version 1 wird für jeden Tag eine einzelne Query abgefeuert, während Version 2 nur eine einzelne, marginal komplexere Query benötigt. Dadurch muss die Datenbank zwar ein wenig mehr arbeiten, dafür muss aber keine 31 Mal eine neue Query aufgebaut, an die Datenbank geschickt und das Resultat zurückgeschickt werden. Dieser Overhead kostet bei nicht überkomplizierten Abfragen immer mehr Zeit als die Abfrage selbst.

Neben diesen mehr oder weniger offensichtlichen Fällen gibt es auch einige subtilere Tricks, um Queries zu vermeiden:

Eager Loading

Yii2s ORM unterstützt (wie die meisten ORMs) Eager Loading. Soll heißen: Beim Laden eines Objekts können weitere, zusammenhängende Daten direkt mitgeladen werden. Sagen wir als Beispiel, dass wir ein Modell Person mit n Orders, die wiederum jeweils einen OrderType haben und uns sämtliche Orders einer Person samt OrderType ausgeben wollen:

 

$person = Person::findOne($id); // Erste Query zum Laden der Person hier
foreach ($person->orders as $order) { // Zweite Query hier, die alle Orders lädt
    echo $order->amount;
    echo ' ';
    echo $order->orderType->name; // Für jede Order wird hier aufs Neue ein OrderType geladen
}

 

Für diese Anzeige feuern wir also 2+n Queries ab, wobei n die Anzahl an Bestellungen ist. Mit Eager Loading geht das besser:

 

$person = Person::findOne($id); // Erste Query zum Laden der Person hier
$person->loadRelations('orders.orderType'); // Zweite und dritte Query hier, für alle Orders und OrderTypes
// alternativ: $person = Person::find()->andWhere(['psn_id' => $id])->with('orders.orderType')->one();
foreach ($person->orders as $order) {
    echo $order->amount;
    echo ' ';
    echo $order->orderType->name; // Keine Query mehr notwendig, weil `orderType` oben schon geladen wurde.
}

 

Der Clou hierbei ist, dass Yii2 hier nicht mehr für jeden OrderType eine eigene Query abfeuert. Stattdessen wird zuerst aus allen geladenen Orders der Fremdschlüssel, der auf OrderType verweist, gezogen, um anschließend alle zu ladenden OrderTypes in einer einzelnen Query ziehen zu können.

In der Regel lohnt es sich, alles zu eager-loaden, was man für das entsprechende Objekt benötigen wird. Die Ausnahme bilden hier nur Fälle, in denen so viele Objekte geladen werden müssten, dass man in Speicherlimits läuft. In der Praxis passiert das aber fast nur in Grenzfällen, in denen es sowieso effizienter wäre, die mit den Modellen zu tätigenden Berechnungen direkt von der Datenbank ausführen zu lassen.

Unnötige Queries vermeiden

Dieser Abschnitt dreht sich um einen kleinen Quirk mit Yii2s Relations-System: Wenn der Fremdschlüssel für eine optionale Relation NULL ist (also kein zugehöriges Objekt existiert), wird trotzdem einmal versucht, die Relation zu laden. Dies lässt sich mit einem einfachen Check beim Aufruf der Relation vermeiden:

 

$person = $order->personId !== null ? $order->person : null

 

Alles cachen, was nicht bei drei auf den Bäumen ist

Wenn abzusehen ist, dass sich Daten über den Verlauf einer Request (oder länger) nicht ändern, lohnt sich oft auch, diese Daten zu cachen. Gerade Stammdaten, die sich selten bis nie ändern, müssen nicht jedes Mal umständlich aus der Datenbank gezogen werden; Sie stattdessen in einem APCu-Cache oder auch einfach in einem statischen Array vorzuhalten, kann einige Queries einsparen. Was genau wie am sinnvollsten zu cachen ist, hängt selbstverständlich vom betreffenden System ab.

asArray() - Wer braucht schon Objekte?

Yii2s Konstruktoren für ActiveRecord-Objekte sind nicht kompliziert, aber Kleinvieh macht auch Mist; Entsprechend kann man mit dem Profiler oft feststellen, dass Anfragen erstaunlich viel Zeit in yii\base\BaseObject->construct und zusammenhängenden Funktionen verbringen.

Hier kann Yii2 mit einem großen Vorteil punkten: Indem man bei einer Query asArray() angibt, werden die von der Datenbank zurückgegebenen Daten einfach in einem assoziativen Array gelassen, anstatt sie danach noch in ein Objekt zu gießen. Wenn ein roher Blick auf die Daten reicht und andere Klassenmethoden gar nicht benötigt werden, lässt sich so beträchtlich Zeit sparen.

Hinweis: Yii2s asArray() verhält sich hier anders als Eloquents toArray(). Bei Letzterem wird erst die Objektklasse instanziiert (inklusive Konstruktor-Overhead) und dann aus diesem ein Array gezogen, bei Yii2 wird die Objektklasse komplett übersprungen.

Pjax und teilweises Nachladen

Yii2s Integration mit Pjax ermöglicht es, Teilbereiche einer Seite schnell nachzuladen, ohne die gesamte Seite neu laden zu müssen.

Dies kann sich schon dann lohnen, wenn das den eigentlichen Content umgebende Layout (bspw. Navigations- und Seitenleisten) teurer zu berechnen ist; Im Fall eines Kundenprojekts, an dem ich aktuell arbeite, erfordert dieses Layout zwischen Rechte-checks, Benachrichtigungen, Lesezeichen, Konfiguration etc. allein fast 50 Queries, deren Resultat sich nur im seltensten Fall ändert.

Um vollen Gebrauch von Pjax zu machen, muss man sich allerdings bewusst sein, dass das yii\widgets\Pjax-Widget zwar das Main-Layout abschaltet, die gesamte restliche Seite aber dennoch komplett gerendert wird. Ein Beispielfall, in dem dies für uns zu einem Problem wurde, war eine Übersichtsseite mit mehreren, je mit einem eigenen Pjax versehenen Tabs. Beim Neuladen eines der Tabs wird die gesamte Seite, inklusive aller Tabs, neu geladen, obwohl nur die Daten des einzelnen Tabs relevant sind.

Die schönste Lösung hierfür ist es in der Regel, das Pjax-Widget samt Inhalt in einen eigenen View zu extrahieren und eine neue Controller-Action, die nur diesen Teilview zurückgibt, anzubieten. Dadurch kann eine große Menge unnötiger Berechnungen vermieden werden.

 

Fazit

Die Technologien, mit denen man arbeitet, so gut wie möglich zu kennen, ist der Schlüssel dazu, effiziente Software zu schreiben. Ebenso wichtig ist es, die Lücken in der eigenen Erfahrung durch Blicke in die Internals besagter Technologien füllen zu können - so bin ich auf einen guten Teil der hier benannten Tricks gestoßen. Dieser Artikel hat dir (hoffentlich) damit geholfen, auf diese beiden Arten besser mit Yii2 umgehen zu können.

 

 

Autorin: Christina Reichel

08.07.2025