Wie Entwicklerinnen und Entwickler effiziente Software schreiben Development-Tipps zur Performance-Optimierung
Anbieter zum Thema
Dieser Artikel soll eine neue Perspektive aufzeigen, die über den Fokus auf die funktionale Implementierung beim Programmieren hinausgeht, und dazu anregen, auch stets Performance (-Optimierung) zu berücksichtigen. Das Bewusstsein dafür erhöht sowohl Qualität als auch Stabilität in der Entwicklung.

Developer interessieren sich nur für Funktionalität?
Natürlich konzentrieren sich Programmierer und Programmiererinnen bei der Softwareerstellung hauptsächlich auf die Funktionalität. Es ist aber auch wichtig, zu erkennen, dass die Softwareentwicklung heutzutage eine umfassendere Sichtweise erfordert. Moderne Developer sollten immer auch die Performance im Blick haben.
Ziel sollte es sein, Software zu entwickeln, die nicht nur die erforderliche Funktionalität bietet, z.B. dass die Anwendung die richtigen Informationen aus der Datenbank ausliest, sondern dabei auch eine optimale (oder zumindest gute) Effizienz aufweist. Der Fokus bleibt zwar weiterhin auf der Funktionalität, gleichzeitig wird aber auch ein Augenmerk auf eine bessere Leistung gelegt.
Test- vs. Produktionsumgebung
Viele Programmierende machen die Erfahrung, dass ihre Applikation zwar in der lokalen Entwicklungsumgebung einwandfrei funktionierte, aber später beim Betrieb in der tatsächlichen Produktionsumgebung Probleme auftraten. Diese Diskrepanz ergibt sich oft aus Unterschieden zwischen dem lokalen Setup und dem echten Produktionssystem.
Während die lokale Umgebung vermutlich nur eine kleinen Menge an Daten nutzt, wird das Produktionssystem wesentlich größere Datenmengen umfassen – möglicherweise Millionen oder sogar Milliarden von Datensätzen. Manche Probleme bleiben bei der Arbeit mit kleinen Datenmengen verborgen und treten erst bei größeren Datenmengen auf. Diese Unterschiede können zu massiven Problemen führen, wenn sie bei der Entwicklung und dem Testen der Anwendung nicht berücksichtigt werden.
Hierbei ist es wichtig zu beachten, dass die Art und Weise, wie die Anwendung implementiert wird, eine entscheidende Rolle spielt. Eine schlechte Implementierung kann zu Performance-Einbußen führen und bestimmte Prozesse innerhalb eines Projektes verlangsamen. Während diese Probleme in Szenarien mit limitierten Datenmengen vielleicht nicht offensichtlich oder relevant sind, können sie in einer Produktionsumgebung dramatisch eskalieren.
Denken wir zum Beispiel an eine Aufgabe, die – ohne saubere Implementierung im Hinblick auf Performance – nur zwei Millisekunden länger pro Entität dauert als nötig. In einem kleinen Datensatz von 1000 Entitäten führt dies zu zwei Sekunden mehr Verarbeitungszeit, was höchstwahrscheinlich in Ordnung ist und sogar unbemerkt bleibt, aber in größeren Datensätzen mit Millionen von Entitäten hingegen summiert sich dies zu Minuten oder sogar Stunden auf.
Verteilte Systeme
Was zudem zu beachten ist: Innerhalb der verteilten Infrastruktur eines Produktionssystems gibt es meist ein kompliziertes Netzwerk von miteinander verbundenen Services. Diese Komponenten müssen nahtlos miteinander kommunizieren, um das kohärente Funktionieren des gesamten Systems zu gewährleisten.
Eben diese Kommunikation umfasst heutzutage in der Regel komplexe Netzwerke, Cloud-Systeme und kann sich über verschiedene geografische Regionen erstrecken. Diese verteilte Infrastruktur hat jedoch ihren Preis in Form von Latenzen, Anforderungen an die (hohe) Bandbreite sowie Zuverlässigkeit und Stabilität der Netzwerkkomponenten, was in lokalen Umgebungen, in denen sich im Grunde alles auf demselben Rechner befindet, keine Rolle spielt und daher manchmal vergessen wird.
Hohe Last
Ein weiterer zu berücksichtigender Aspekt sind die Auswirkungen von Traffic, was eng mit der verteilten Infrastruktur zusammenhängt. Bei der lokalen Entwicklung wird die Plattform in der Regel nur von einer einzigen Person genutzt, es gibt zum jeweiligen Zeitpunkt also immer nur eine Anfrage. Somit stehen dieser Anfrage stets alle Ressourcen für die Bearbeitung zur Verfügung.
In der Produktionsumgebung gibt es jedoch mitunter Tausende simultaner Anfragen von Benutzern und Benutzerinnen. Diese User-Aktivität kann zu Problemen führen, wenn das System nicht über ausreichende Ressourcen verfügt, um alle eingehenden Anfragen effektiv und effizient zu verarbeiten.
Bei Performance- oder Lasttests wird häufig ein Testsystem einer erheblichen Belastung ausgesetzt, oft mit Tausenden von gleichzeitigen Anfragen. Spezielle Tools erzeugen künstlichen Traffic, um potenzielle Engpässe zu ermitteln. Den zu erwartenden Datenverkehr realistisch nachzubilden, bleibt dennoch stets eine Herausforderung.
Es ist praktisch unmöglich, für manuelle und Regressionstests das Produktionssystem exakt zu replizieren, um alle potenziellen Engpässe zu identifizieren und zu beseitigen. Die Realität ist viel komplexer. Trotz umfassender lokaler Tests und Qualitätssicherung treten nach dem Deployment in der Regel trotzdem immer noch Fehler auf.
Um das Risiko von kritischen Fehlern in der Produktion zu minimieren, ist eine umfassende Emulation des Produktionssystems in allen Phasen der Implementierung erforderlich. Dazu gehört das Aufsetzen und Nutzen von allen Services in der lokalen Entwicklungsumgebung – sowie jeder anderen für Tests verwendeten Umgebung –, die auch in der Produktionsumgebung vorhanden sind, idealerweise in genau denselben Versionen.
Selbst wenn die Replikation von Produktionsdaten manchmal nicht oder nur eingeschränkt möglich ist, z.B. aufgrund von Datenschutzbestimmungen, ist die Bereitstellung eines repräsentativen, ausreichend großen Datensatzes für Entwicklung und Tests von zentraler Bedeutung. Natürlich sind nicht alle Projekte gleich, aber diese Punkte zu berücksichtigen, kann unter Umständen die Entwicklungsergebnisse bereits erheblich verbessern.
Drei Lösungsvorschläge
Es gibt drei Lösungsansätze, auf die ich hier für den Umgang mit Performance-Problemen den Fokus legen möchte. Gehen wir sie der Reihe nach durch:
Tipp 1: Skalierung
Skalieren scheint ein simpler erster Lösungsansatz zu sein. Die Idee, das Produktionssystem durch vertikale und/oder horizontale Skalierung zu erweitern, kann verlockend sein: Man löst das Problem einfach mit mehr Ressourcen, wenn es das Budget denn zulässt – ohne jedwede Code-Optimierung. Im Grunde ist dies ein „Geld löst alles“-Ansatz.
Dieser Weg ist allerdings möglicherweise nicht so nachhaltig, wie er zunächst scheint. Wenn keine Ressourcen vorhanden oder anderweitig eingeplant sind, sollten alternative, optimierende Lösungen in Erwägung gezogen werden. Zudem führt die Skalierung auch zu wiederkehrenden monatlichen Kosten, die sich im Laufe der Zeit erheblich aufsummieren können.
Im Gegensatz dazu sind Performance-Optimierungen zwar mit einer am Anfang höheren, in der Regel aber einmaligen Investition in die Entwicklung verbunden. Das zahlt sich vielleicht nicht sofort aus, ist aber eine Strategie, die auf lange Sicht sinnvoller ist. Wenn man einen Monat lang dedizierte Entwicklungsarbeit für die Optimierung in einen bestimmten Systembereich, einschließlich DevOps, investiert, spart man sich eventuell in Zukunft wiederkehrende hohe Infrastrukturkosten.
Tipp 2: Optimierung der Datenbank-Performance
Wenn wir von einer „großen Datenmenge“ sprechen, kann das subjektive Verständnis sehr unterschiedlich sein. Um das mal in die richtige Perspektive zu rücken: Was ist im jeweiligen Tagesgeschäft ein großer Datensatz? Reden wir über 100.000 Datensätze? Vielleicht 1.000.000 Datensätze? Oder sogar 100.000.000 (oder noch mehr)?
Bei einigen Projekten sind wir mit Milliarden von Datensätzen konfrontiert. Hier sind vier Tipps zur Optimierung der Datenbank-Performance:
Größe der zu übertragenden Datenmenge
Zur Performance-Optimierung von Datenbankabfragen gibt es einen ersten einfachen, aber unter Umständen sehr effektiven Weg: Überlege dir, welche bzw. wie viele Daten tatsächlich übertragen werden müssen.
Normalerweise konzentrieren sich Entwickler und Entwicklerinnen auf die Ausführungszeit, die für eine Abfrage benötigt wird (query execution time). Häufig ist nämlich die Ausführung der Abfrage der Engpass, der zu Verzögerungen führt und die Performance negativ beeinflusst. Man muss aber über die reine Ausführungszeit der Abfrage hinausdenken.
Ebenso gilt es, die Datenmenge zu bedenken, die hin- und hergeschickt wird, insbesondere in einem Netzwerk mit relevanten Latenzen. Je mehr Daten von A nach B übertragen werden müssen, desto länger dauert es. Die Übertragungszeit (transmission time) ist also ebenfalls zu berücksichtigen.
Hier ein Beispiel aus der Praxis: Eine Tabelle mit 1.200 Datensätzen und einer Gesamtgröße von 42 MB soll komplett ausgelesen werden. Nun ist diese Tabelle vielleicht nicht riesig, was die Anzahl der Datensätze angeht, aber wenn man alle Datensätze immer mit allen Spalten abruft, sieht die Sache schon anders aus. Die gesamten Daten (42 MB) müssen über das Netzwerk übertragen werden, und das kann eine Weile dauern.
Fragt man nun andererseits jeweils nur die ID eines Datensatzes ab, weil dieser Wert für den aktuellen Anwendungsfall ausreichend ist, hat man es plötzlich mit einer viel kleineren Datenmenge zu tun: nur 1200 Integer-Werte. Das entspricht etwa 5 KB. Ein großer Unterschied, oder?
Der Schwerpunkt liegt also nicht nur auf der Geschwindigkeit der Abfrage, sondern auch auf der sorgfältigen Auswahl der abzurufenden Daten, denn auch die Übertragung kostet stets Zeit. Man sollte also idealerweise immer nur die Daten abfragen, die tatsächlich benötigt werden.
Indizes-Analyse und -Optimierung
Die Optimierung der Datenbankperformance durch das Erstellen von sinnvollen Indizes ist keine Raketenwissenschaft, sondern gehört eigentlich zum grundlegenden Datenbankwissen von Developern. Leider wird es jedoch gerne mal vernachlässigt.
Werden Indizes genutzt, wird allzu oft dazu tendiert, es mit selbigen zu übertreiben und sie ohne gründliche Analyse großzügig auf viele einzelne Spalten zu setzen. Das hat dann jedoch auch meist negative Auswirkungen auf die Leistung. Klug gewählte zusammengesetzte Indizes (composite indices) hingegen sind vielseitiger und können dann für unterschiedlichen Abfragen genutzt werden.
Man muss sich darüber im Klaren sein, dass das Hinzufügen eines Index mit Kosten verbunden ist – er muss erstellt und stets aktualisiert werden, wenn Daten in der Datenbank gespeichert werden. Viele Indizes können beim Abrufen von Daten von Vorteil sein und somit die Performance bei Leseoperationen verbessern, ein Übermaß von ihnen kann aber auf der anderen Seite Schreiboperationen verlangsamen und die Leistung in diesem Bereich verringern.
Daher sollte immer die Art der Nutzung der Tabelle beachtet werden. Wird sie häufig aktualisiert? In diesem Fall kann eine Vielzahl von Indizes die Gesamtleistung verringern. Wenn hingegen nur selten in die Tabelle geschrieben, aber häufig aus ihr gelesen wird, können Indizes Wunder wirken. Natürlich sollte man auch nicht zu freigiebig sein und einfach pauschal und ohne wirkliche Notwendigkeit einen Index auf jede einzelne Tabellenspalte setzen – das ist definitiv nicht effizient.
Entscheidend sind sinnvolle Indizes, die auf die tatsächlichen Abfragen der Datenbank zugeschnitten sind. Ungenutzte Indizes sind kontraproduktiv; sie beeinträchtigen die Schreibleistung, ohne die Leseleistung zu verbessern. Man muss eine Kosten-/Nutzen-Analyse zwischen besserer Leseleistung und besserer Schreibleistung machen.
Wie heißt es so schön: TANSTAAFL! (There ain’t no such thing as a free lunch!)
Keyset-/Cursor-Paginierung
Vor mehr als zehn Jahren wurde ich auf die sog. Keyset (oder Cursor) Pagination aufmerksam. Im Gegensatz zur weiter verbreiteten Limit-/Offset-Paginierung wird bei der Keyset Pagination ein eindeutiger Zeiger (cursor) definiert, der den Startpunkt für das Lesen aus der Datenbank genau festlegt und so das Lesen und Überspringen früherer Ergebnisse überflüssig macht. Dadurch bleibt die Ausführungszeit konstant und die Probleme des Offset-basierten Ansatzes werden umgangen.
Diese Methode der Paginierung ist für die ersten zwei oder drei Seiten eines Abfrageergebnisses nicht effizienter; tatsächlich ist die Leistung am Anfang meist sogar geringfügig schlechter als die des Limit/Offset-Ansatzes – aber je weiter man „blättert“, desto mehr macht sich die bessere Performance im Vergleich zur Nutzung von Limit/Offset bemerkbar.
In der Abbildung links ist grafisch dargestellt, wie beim Limit/Offset-Ansatz immer die gesamte Ergebnismenge abgefragt wird und die Datenbank-Engine dann den vom Offset definierten Abschnitt überspringt. Wenn man z.B. ein Limit von 50 und einen Offset von 100 definiert, werden 150 Datensätze gelesen, die ersten 100 übersprungen und die folgenden 50 zurückgegeben.
Die Performance dieses Ansatzes nimmt bei höheren Offsets schnell ab. Während dies bei der browserbasierten Paginierung, bei der die Benutzer in der Regel nicht über Seite zwei oder drei hinausgehen, meist kein großes Problem darstellt, kann es beim Iterieren über große Datenmengen in Prozessen wie Importen oder Exporten extrem negative Auswirkungen haben.
Bei der Keyset-Pagination werden hingegen nur die tatsächlich benötigten Daten aus der Datenbank abgerufen, so dass das Überspringen von Datensätzen vollständig entfällt. Um wieder ein konkretes Beispiel zu geben: In einem unserer Projekte hatten wir Ausführungszeiten von über 45 Minuten für einen bestimmten Prozess, der über eine Menge von ca. vier Millionen Datensätzen iterierte. Durch die reine Umstellung auf Keyset-Pagination reduzierte sich die Ausführungszeit auf nur vier Minuten.
Für schlichte Fälle ist Keyset Pagination eigentlich auch einfach zu implementieren: Angenommen, es soll über eine Tabelle iteriert werden, die nach ID sortiert ist. Die initiale Abfrage wird mit einem Limit von 100 ausgeführt. Für die nächste Iteration (=Seite) merkt man sich die ID des letzten Datensatzes aus der vorhergehenden Iteration, die nun als Cursor fungiert. Nun fragt man die nächsten hundert Zeilen ab, deren ID größer ist als dieser Cursor. Dieses Vorgehen sorgt für eine konstante Ausführungszeit.
Komplizierter wird es dann bei komplexeren Abfragen, bei denen ein kombinierter Cursor (bestehend aus mehreren Werten) benötigt wird – beispielsweise bei Abfrageergebnissen, die nach einem Zeitstempel sortiert sind, z.B. dem Erstelldatum eines Datensatzes. Eindeutige Bezeichner sind hier entscheidend, der Zeitstempel allein ist dies in der Regel nicht, da mehrere Datensätze den gleichen Zeitstempel haben könnten.
Ein kombinierter Cursor müsste in diesem Fall sowohl den letzten Zeitstempel als auch die letzte ID beinhalten, wobei dann bei der Abfrage berücksichtigt werden muss, ob die cursor-relevanten Werte auf- oder absteigend sortiert sind. Allerdings ist Keyset-Paginierung kein „Allheilmittel“, sondern hat bestimmte Limitierungen, die man ebenfalls kennen muss, um entscheiden zu können, ob man diesen Ansatz wählen möchte.
Leider ist es mit Keyset Pagination ausschließlich möglich, zur nächsten bzw. vorhergehenden Seite zu gehen, ein Überspringen von Seiten lässt sich nicht realisieren. Falls das aber kein Problem darstellt, weil z.B. im Browser bei Suchergebnissen Endless-Scrolling genutzt wird, sollte möglichst Keyset-Paginierung genutzt werden.
Unnötige Datenbankinteraktionen vermeiden
So einfach es auch klingen mag: Um die Effizienz einer Anwendung zu maximieren, ist die Minimierung von Datenbankabfragen ebenfalls sehr wichtig, da diese so „teuer“ sind. Wenn möglich, sollten unnötige Datenbank-Interaktionen immer vermieden werden.
Dazu zwei grundlegende Strategien: das Lesen/Schreiben von vielen Datensätzen auf einmal (batch/bulk queries) anstatt Datensätze einzeln zu lesen/schreiben und die Vermeidung von mehrfachem Abrufen von sich selten ändernden Daten durch Caching der Abfrageergebnisse.
Durch die Einführung von Keyset-Pagination und die Optimierung der Datenbankabfragen können Entwickler:innen die Leistung der Datenbank und die Ressourcennutzung optimieren und so die Effizienz ihrer Anwendungen steigern.
Tipp 3: Caching
Neben der Optimierung der Datenbank-Performance können Caching-Strategien auch die Performance der Anwendungen auf verschiedenen Ebenen erheblich verbessern, bspw. durch prozessinternes Caching, Reverse Proxies oder schnellere Alternativen zu Datenbanken (wie z.B. Key/Value-Datenbanken).
Die Entscheidung, wie lange Daten zwischengespeichert werden sollen, mag einfach erscheinen - möglichst langes Caching wird oft als ideal angesehen -, aber gute Ergebnisse können auch durch Caching-Strategien mit teilweise sehr kurzen Lebenszeiten erzielt werden, manchmal reichen sogar Sekunden.
Betrachten wir zum Beispiel eine Chat-Funktion, die wir vor einigen Jahren in einem Projekt für Universal Music implementieren sollten. Bei diesem Projekt wurde ein iFrame genutzt, in dem die 50 neuesten Nachrichten anzeigt wurden. Durch einen Ajax-Reload alle zehn Sekunden überschwemmten zahlreiche gleichzeitige Anfragen die Datenbank, da wir in der Regel Hunderte von gleichzeitigen Usern hatten und jede Anfrage die Nachrichten direkt aus der Datenbank las.
Durch die Zwischenschaltung eines einfachen Cache-Layers konnte die Menge der Datenbankanfragen deutlich reduziert werden. Da sich die Nachrichten aus Sicht der Anwendung nur selten änderten, verbesserte so die lokale Zwischenspeicherung dieser Nachrichten für nur fünf Sekunden die Benutzererfahrung erheblich, da sie nahezu in Echtzeit aktualisiert werden konnten, ohne dass eine wesentliche Verzögerung auftrat.
Durch diesen Ansatz wurde nicht nur die Datenbank entlastet, sondern auch die Performance erheblich gesteigert. Wie man sieht, ist die Nutzung von Cache also durchaus sehr vielschichtig und erstreckt sich über mehrere Ebenen, um den Datenabruf zu optimieren.
Die richtige „Balance“ bei der Cache-Dauer ist hier von zentraler Bedeutung, wie im Zusammenhang mit dieser Echtzeit-Chat-Funktion deutlich wird: so lange wie möglich, so kurz wie nötig. Durch eine durchdachte Cache-Integration können Anwendungen die Belastung der Datenbank verringern und gleichzeitig die Benutzerfreundlichkeit und Leistung verbessern.
Letzten Endes kommt es immer darauf an, so produktionsnah wie möglich zu entwickeln:
Man sollte stets versuchen, alle Nicht-Produktionsumgebungen so produktionsnah wie möglich zu gestalten! Auf diese Weise können Performance-Probleme und Bottlenecks bereits in einem frühen Stadium des Implementierungs- oder Testprozesses erkannt werden.
Hier hilft die Verwendung von Docker: Dadurch können Entwickler:innen (fast) die gleichen Dienste wie auf ihrem Produktionssystem während der Entwicklung verwenden. Idealerweise nutzt man immer die gleichen Komponenten/Services in genau der gleichen Version und mit den gleichen Ressourcen (CPU, Speicher usw.).
Falls dies nicht möglich ist, sollte man zumindest dieselben Komponenten/Services in derselben Hauptversion und mit ähnlichen Ressourcen verwenden. Docker erweist sich hier als wertvolles Werkzeug, da es eine sehr ähnliche Umgebung wie ein Produktions-Setup ermöglicht.
Das Streben nach Konsistenz, ob mit identischen Versionen und Ressourcen oder ähnlichen Setups, sorgt für reibungslosere Übergänge zwischen den einzelnen Phasen. Jede Umgebung ist einzigartig und die Vernachlässigung der technischen Wartung und Aktualisierung kann zu langfristigen Leistungseinbußen und komplexen Problemen führen.
Wenn man also an neuen Funktionen arbeitet, sollte man nie vergessen, wie wichtig es ist, sich um technische Schulden (tech debt), Aktualisierungen und Refactoring zu kümmern. Man kann die Leistung durch Skalierung, Optimierung der Datenbank-Performance und den geschickten Einsatz von Caching verbessern. Mit ein wenig investierter Zeit vermeidet man so größere Probleme zu einem späteren Zeitpunkt.
* Über den Autor
Bernd Alter ist Co-CTO bei Turbine Kreuzberg.
Bildquelle: HannaBeckerPhotography
(ID:49742494)