Performanz Tuning: Strategien und Tipps aus der Praxis

Performanz-Problemen sind meist schwierig zu lösen. Hier zeige ich euch, wie wir unser letzten Problem analysiert, gemessen und gelöst haben. Unter dem Zeitdruck des bevorstehenden Go-Lives wählten wir Strategien, um Risiken durch Seiteneffekte zu vermeiden.

Analyse von Performanz-Problemen

Aus Kundensicht ist unser System eine Browser-Anwendung. Wenn die Seiten im Browser langsam laden, haben wir ein Performanz-Problem. Die Diskussion, was langsam bedeutet und ob langsame Ladezeiten in manchen UseCases akzeptabel sind, ignoriere ich hier. Unsere Situation war eindeutig, statt Ladezeiten von 1-2 Sekunden dauerte es in einigen Fällen mehr als 10 Sekunden.

Logs sind ein guter Startpunkt für die Analyse. Eventuell habt Ihr einen Log-Eintrag am Anfang und Ende der Anfrage. Mit den Zeitstempeln dieser beiden Log-Einträge könnt Ihr die Bearbeitungsdauer der Anfrage überprüfen. Das bestätigt objektiv euer Preformanz-Problem.

Wo treten Performanz-Probleme auf?

In unserem Fall betraf das Performanz-Probleme nur UseCases die mit der ShoppingCart API zu tun hatten. Damit war klar, wo die genaue Analyse beginnt. Sollten die Probleme überall im Gesamtsystem auftreten, würde ich am ersten System in der Kette mit der Ursachenforschung beginnen.

Wann treten die Performanz-Probleme auf?

Die Performanz-Probleme traten nicht immer auf. Wir haben dann herausgefunden, dass es einen Zusammenhang zwischen Performanz und Menge der Produkte im ShoppingCart gibt. Damit konnten wir das Problem reproduzieren und zur Lösungsfindung übergehen.

Die Reproduzierbarkeit ist beim Beheben von Performanz-Problemen sehr wichtig. Das fachliche Verhalten der Anwendung darf sich nicht ändern, muss aber schneller werden. Also testen wir vorher und messen dabei die Performanz. Nach dem Tuning testen wir erneut und messen die Performanz. Blieb das getestete Verhalten gleicht, zeigt der Vergleich der Performanz-Messungen, ob unsere Tuning Maßnahmen erfolgreich sind. Ohne Reproduzierbarkeit können wir nicht beurteilen, ob wir überhaupt an der richtigen Stelle getunt haben.

Arbeit strukturieren & priorisieren

Performanz-Probleme eskalieren schnell, wenn wichtige UseCases zu langsam oder im schlimmsten Fall nicht benutzbar sind. Geratet auf keinen Fall in Panik und tunt mit allen EntwicklerInnen überall. Die Gefahr von Seiteneffekten oder neuen Bugs, darf beim Tunen nicht unterschätzt werden. Daher müssen Performanz-Tunings genau so strukturiert wie andere Arbeiten ablaufen. 

Überlegt bei Bedarf mit dem ganzen Team, was Ursachen und Lösungen sein könnten. Erstellt Tickets bzw. Arbeitspakete für die einzelnen Ideen. Diese priorisiert ihr anschließend, so dass ihr sie nach einander abarbeiten könnt. Damit könnt ihr für jede Maßnahme einzelnen prüfen, ob der gewünschte Tuning-Erfolg eingetreten ist. 

Grundlage für unsere Maßnahmen-Priorisierung war der geschätzte Aufwand, das Risiko von Seiteneffekten bzw. Bugs und das Tuning-Potential. Mit hoher Priorität bearbeitet ihr Tickets mit geringem Aufwand und Risiko bei möglichst großem Potential zur Beschleunigung eurer Anwendung. Mehr CPU in euren Server könnte beispielsweise so eine Maßnahme sein (wenn ihr die laufenden Kosten nicht betrachtet). Tickets mit hohem Aufwand und Risiko bei schwer abschätzbarem Tuning Potential sollten am Ende eurer priorisierten Liste stehen. Komplette Technologie-Wechsel wären Beispiele dafür, also beispielsweise Python durch Java ersetzen oder die vorhandene relationale Datenbank durch ein bisher ungenutzte NoSQL Datenbank austauschen.

Schnell genug? Dann beende das Tuning.

Wenn das System schnell genug ist, stellt die Tuning-Maßnahmen ein oder bewertet sie neu. Prüft dazu nach jedem erfolgreichen Performanz-Tuning Change im Master Branch, ob euer System schnell genug ist. Ist das der Fall, beendet das Performanz-Tuning, um die Risiken von ungewollten Seiteneffekten zu minimieren. Tunings sind meist komplexe Änderungen. Je mehr Tuning-Maßnahmen ihr bündelt und abliefert, desto größer ist das Risiko versehentlich neue Fehler einzubauen.

EntwicklerInnen sind beim Tuning in ihrem Element und haben meist weitere Verbesserungs-Ideen, die sie gerne ausprobieren wollen. Widersteht diesem Drang. Plant die Arbeit stattdessen im Rahmen eurer normalen Prozesse. Auf diese Weise vermeidet ihr unnötige Risiken zum Erreichen einer Performanz, die im Moment keiner erwartet. Liefert weitere Tunings im Rahmen eurer kontinuierlichen Release-Strategie einzeln aus, so könnt ihr im Fehlerfall leicht zurückrollen.

Performanz-Tunings implementieren

Im folgenden erkläre ich unsere Performanz-Tuning Strategien bzw. Regeln, die wir in der Praxis angewendet haben. Es geht dabei um das Allgemeine Vorgehen, welches unabhängig von Programmiersprachen oder Technologie-Stacks ist.

Erst messen, dann tunen!

Stellt euch folgendes vor:
Beim Lesen des Codes findet Ihr eine Methode, die eine Liste von Objekten durchsucht. Ihr erkennt direkt, dass die Suche einen linearen Aufwand hat. Deshalb baut ihr die Suche so um, dass der Aufwand konstant ist, indem ihr mit Indexen sucht. Danach liefert ihr die beschleunigte Software aus. Für den Kunden bzw. Benutzer ist aber alles genau so langsam wie vorher. Warum?

In diesem Beispiel wurde die Regel "Erst messen, dann tunen" ignoriert. Beim Messen hättet ihr feststellen können, dass die Methode bei der Gesamtverarbeitungsdauer nicht ins Gewicht fällt, da die meiste Zeit z. B. beim Interagieren mit der Datenbank verbraucht wird.

Zum Messen könnt Ihr nach Performanz-Plugins für eurer IDE suchen. Eine einfache Alternative sind zusätzliche Log-Zeilen in eurem Code, z. B. so:

public List<Price> findPrices(List<String> ids) {
    var result = new ArrayList<Price>();
    doOtherCrazyStuff();
    for (var id : ids) {
        var before = System.currentTimeMillis();
        result.add(requestPriceAtPcm(id));
        log.debug("requestPriceAtPcm in ms: " 
            + (System.currentTimeMillis() - before));
    }
    doMoreCrazyStuff();
    return result;
}

Hier hatte ich die Vermutung, dass die Methode requestPriceAtPcm zu häufig aufgerufen wird und zu lange dauert. Daher messe ich die Zeit und logge sie für jeden Aufruf. Um das konkrete Tuning "Beschleunigen der Methode" oder "Parallelisieren der for-Schleife" geht es hier noch nicht. Mit der Messung finde ich zuerst heraus, ob Tunings an dieser Stelle überhaupt lohnen. Lohnen sie nicht, wie beim Anfangsbeispiel mit dem linearen Aufwand einer Suche, so stelle ich dieses Tuning zurück und schaue mir andere Stellen mit Tuning-Potential an.

Nach dem Tunen erneut messen

Euer Performanz-Tuning ist nur dann erfolgreich, wenn die Logik anschließend messbar schneller ist. Wie zuvor erwähnt sind Tunings häufig riskante Anpassungen am Code. Unter Zeitdruck sollten sie nur dann ausgeliefert werden, wenn sich die Anpassung wirklich lohnt. Dazu müsst Ihr nach dem Tuning erneut messen, ob die Anwendung wirklich schneller geworden ist.

Ist die Anwendung nicht schneller, solltet ihr eure Code-Änderungen zurückstellen bzw. nicht im Master-Branch committen. Denn an je mehr Tuning-Schrauben ihr gleichzeitig dreht, desto schwieriger erkennt ihr was wirklich hilft. Außerdem steigt mit jeder Änderung die Wahrscheinlichkeit für neue Seiteneffekte bzw. Bugs.

Tune immer nur eine Stelle

Diese Überschrift bzw. Regel passt zur vorherigen Beschreibung. Hier ein anderes Beispiel:
Euer System besteht aus mehreren Microservices und es ist zu langsam. Jetzt könntet ihr gleichzeitig an Tunings an allen Microservices arbeiten. Das kann funktionieren, es kann aber auch schief gehen. Gegebenenfalls wird euer System schneller, ihr wisst aber nicht an welchem Microservice, die Tunings erfolgreich waren, da an allen optimiert wurde. Mit gutem Logging erkennt ihr es vermutlich doch, aber die Arbeitsweise war nicht effizient.

Im Idealfall loggt jeder Microservice die Verarbeitungsdauer eingehender Requests. Diese Logs zeigen dann, in welchem Microservice das Gesamt-System die meiste Zeit benötigt. Auf diese Microservices fokussiert ihr euch zuerst - hier gibt es das größte Tuning-Potential.

Keine Anfrage ist die schnellste Anfrage

Beispiel: Euer Server hat eine häufig benutzte Methode, ihr tunt sie von zwei Sekunden auf eine Sekunde. Gute Arbeit - hilft aber nichts, weil der Benutzer immer noch lange warten muss. Woran kann es liegen?

Zum Server gibt es immer einen Client. Wenn der Client 10 unnötige Anfragen stellt, kann Server-seitiges Tuning das Problem nicht lösen. Schaut euch auch den Client kritisch an: 
  • Ist die Anfrage an dieser Stelle wirklich nötig? 
  • Können mehrere Anfragen zu einer zusammengefasst werden? 
  • Könnte ein Client-seitiger Cache eingebaut werden? 
Die schnellste Antwort bekommt die Anfrage, die nicht abgeschickt wurde. 

Dazu ein Beispiel aus meiner Praxis: Für jede Darstellung des Einkaufwagen im Online-Shopping-Portal, wurden die Preise aller Produkte im Einkaufwagen neu abgefragt. Diese Logik wurde gebaut, um dem Kunden stets die aktuellen Preise zu zeigen. Je mehr Produkte im Einkaufswagen waren, desto länger musste der Kunde warten bis alle Preise abgefragt waren.
 
Funfact: Die Preise ändern sich nur einmal am Tag und zwar jeden Morgen um 8 Uhr. Unsere performantere Lösung für dieses Problem war ein Preis-Cache einzubauen (Mehr Infos zu Caches, siehe cache.html). Dieser Cache wird jeden morgen um 8 Uhr geleert und neu befüllt. So muss kein Kunde mehr auf das Laden der aktuellen Preise warten - außer dem Kunden der genau um 8 Uhr morgens kommt...

Durch den Cache schicken nur noch zur einmaligen täglichen Befüllung des Caches Anfragen an die Preis API. Für die meisten Kunden ist damit das Abfragen der Preise nicht mehr nötig, da die Preis-Daten schon im Cache sind. Wir sparen also nicht nur die Preis-Anfragen ein, sondern eliminieren damit auch für den Kunden die Wartezeit auf die Preise und haben ein messbares Performanz-Tuning implementiert.  

Verwende realistische Testdaten während der Entwicklung.

Ein- und Ausgabedaten beeinflussen die Performanz. Iterieren wir beispielsweise über eine kleine Datenmenge, fällt die Verarbeitungsdauer der einzelnen Datenelemente kaum ins Gewicht. Ein inperformanter Algorithmus innerhalb der Schleife, kann hinsichtlich der Gesamtdauer für kleine Datenmengen akzeptabel sein. Bei großen Datenmengen beeinflusst der inperformante Algorithmus in der Schleife die Gesamtdauer aber erheblich.

Unrealistisch kleine Testdatenmengen waren in meinem Fall die Ursache des spät entdeckten Performanz-Problems. Realistische Testdaten der Clients unserer ShoppingCart API erhielten wir leider sehr spät, so dass wir kurz vor dem Go-Live des Gesamtsystems tunen mussten. Mit realistischen, großen Datenmengen zu Beginn der Entwicklung erkennen wir Performanz-Probleme direkt (Eventuell entstehen sie gar nicht, da direkt passende Lösungen implementiert werden). Bearbeiten wir Performanz-Probleme in frühen Entwicklungsphasen mit weniger Zeitdruck, lösen wir auch die Auswirkungen von Seiteneffekten entspannter. Dennoch empfehle ich die hier gezeigten Regeln zum disziplinierten Performanz-Tuning - es ist auch ohne Zeitdruck schwierig genug.

Fazit

Mit den hier gezeigten Performanz-Tuning Strategien schafften wir einen erfolgreichen Go-Live unseres neuen Shopping-Portals. Dazu lieferte mein Team eine performante ShoppingCart API. 

Als Zusammenfassung meine Tuning-Regeln:
  • Erst messen, dann tunen!
  • Nach dem Tunen erneut messen
  • Tune immer nur eine Stelle
  • Keine Anfrage ist die schnellste Anfrage
  • Verwende realistische Testdaten während der Entwicklung
  • Schnell genug? Dann beende das Tuning.

Kommentare

Beliebte Posts aus diesem Blog

OpenID Connect mit Spring Boot 3

Authentifizierung in Web-Anwendungen mit Spring Security 6

Reaktive REST-Webservices mit Spring WebFlux