Datenbankperformance in agilen Teams adressieren

In vielen Projekten führt bereits während der Entwicklung eine mangelnde Performance einer relationalen Datenbank häufig zu Problemen. Und wie kann verhindert werden, dass Entwicklungsteams immer größere Testserver fordern und der Kundensupport ebenfalls regelmäßig größere Systeme empfehlen muss? Inwieweit beeinflusst die Entscheidung für eine agile Entwicklungsmethode später die Eigenschaften Performance und Skalierbarkeit einer relationalen Datenbank? Lässt sich ein agiles Framework wie Scrum sinnvoll um das Thema Datenbankperformance ergänzen?
Ein Überblick über Scrum als agiles Entwicklungsframe
Eine stetig wachsende Anzahl von Unternehmen als auch Softwarehersteller setzen mittlerweile auf agile Vorgehensweisen bei der Entwicklung. Gerade die Versprechen hinsichtlich einer schnelleren Umsetzung von neuen Anforderungen als auch eine stark verkürzte Zeit bis zu einer Markteinführung sind sehr attraktiv dabei. Und natürlich verspricht man sich auch, dass die Entwicklung kostensparender wird.
Mittlerweile gibt es eine Vielzahl von Vertretern des agilen Grundgedankens. Bekannte Vertreter sind u. a. Scrum und Kanban Boards. Für Außenstehende erscheinen agile Vorgehensmodell wie Scrum auf den ersten Blick einfach umsetzbar und leicht zu erlernen. Aufgaben werden in sich wiederholenden, gleich langen Zeitintervallen umgesetzt, es gibt klare Zuständigkeiten und eine ebenfalls einleuchtende Abfolge von Meetings. Eine wesentliche Säule einer agilen Vorgehensweise ist die frühzeitige Gewinnung von Feedback durch Nutzer:innen hinsichtlich der Qualität der Anforderungsumsetzung.
Damit sollen diverse Probleme in der klassischen Softwareentwicklung mit dem altgedienten Wasserfallmodell vermieden werden, bei dem erst kurz vor der eigentlichen Produktivsetzung einer Anwendung erstes Feedback durch Anwender:innen gesammelt werden kann.
Um möglichst frühzeitig und häufig Feedback zu bekommen, wird das Softwareentwicklungsvorhaben in eine Vielzahl von Teilanforderungen, sogenannten Storys, zerlegt. Die Storys werden von der Rolle Product Owner verwaltet und im Rahmen eines Backlogs ihrer Priorität nach gepflegt, welches alle bisher bekannten Teilanforderungen beinhaltet. Eine deutliche Mehrheit der Stories bezieht sich in der Regel auf einzelne Funktionen, welche Anwender:innen direkt eingebracht haben. Eine geringere Anzahl der Stories in einem Backlog beziehen sich auf nichtfunktionale Eigenschaften, welche oft von Architekt:innen spezifiziert werden. Diese "Architectural Stories" umfassen in der Regel wiederverwendbare Module und Funktionen, die meist nicht sichtbare Qualitätsmerkmale der Anwendung beschreiben.
Das Softwareentwicklungsteam wählt eine Reihe von Teilaufgaben aus und einigt sich darauf, diese innerhalb eines festgelegten Zeitraums umzusetzen. Dieser Zeitraum wird auch als Sprint bezeichnet. Oft ist das Vorgehen dann so, dass einzelne Entwickler:innen aus den ausgewählten Teilaufgaben eine übernehmen und diese vollständig umsetzen. Die innerhalb eines Sprints realisierten Storys stellen Teillieferungen eines großen Ganzen dar. Damit die Entwickler:innen echtes und ehrliches Feedback bekommen können, müssen die Teillieferungen jeweils voll funktionsfähig sein. Aus dieser Anforderung heraus entstehen für Softwareentwicklungsteams eine Reihe von Herausforderungen.
Da bei diesem inkrementellem Vorgehen mit jeder weiteren Anforderung oft ein kompletter Durchstich durch die gesamte Architektur einer Anwendung nötig wird, werden Entwickler:innen benötigt, die diesen eigenverantwortlich umsetzen können. Von zum Beispiel einer Weboberfläche durch die diversen Middleware-Schichten hindurch bis zum Datenbanksystem. Dieses ist u. a. einer der Gründe, warum mittlerweile die Nachfrage nach Fullstackentwickler:innen so groß geworden ist.
Mit diesem inkrementellen Projektvorgehen kann erreicht werden, dass in einer Iteration eine möglichst hohe Anzahl an Anforderungen als Teillieferungen umgesetzt werden können. Ein wesentliches Merkmal der meisten agilen Projekte ist, dass nicht das gesamte Entwicklungsvorhaben im Detail komplett geplant wurde, sondern sich nur auf die nächste anstehende Iteration konzentriert wird. Natürlich wird im Vorfeld nach Möglichkeit die grobe Architektur der benötigten Anwendung geklärt, welche mittels der bereits erwähnten "Architectural Stories" umgesetzt wird. Es werden dabei Frameworks für die Entwicklung der GUI, Komponenten für eine Middleware und der notwendigen Persistenzschicht ausgewählt.
Ohne jetzt auf jedes Detail des Scrum-Frameworks einzugehen, sollten folgende wesentliche Bestandteil noch erwähnt werden, um diese später mit Erweiterungsvorschlägen bzgl. der Datenbankperformance wieder adressieren zu können.
Sprint-Planungssitzung
Zunächst wäre da die obligatorische "Sprint-Planungssitzung", in welcher der nächste Sprint vom Team geplant wird. Dazu werden aus dem Product Backlog priorisierte Anforderungen ausgewählt und die Durchführbarkeit der Umsetzung innerhalb der nächsten Iteration besprochen. Obwohl sich hierfür unterschiedliche Methoden entwickelt haben, so haben die meisten doch gemein, dass sich weniger auf die notwendige Zeit als eher auf einen abstrakten Komplexitätswert konzentriert wird. Obwohl die Dauer eines Sprints vorweg klar definiert wurde und damit auch die theoretisch zur Verfügung stehende Zeit, wird der Sprint über die Summe der vom Team geschätzten umsetzbaren Komplexitätspunkte definiert. Ein Vorteil dieses Vorgehens ist, dass während des eigentlichen Entwicklungsprozesses eine Anforderung nicht mit einem direkten Zeitaufwand in Verbindung gebracht wird.
Definition of Ready
Bevor mit der Umsetzung einer Anforderung begonnen werden kann, müssen erst alle Bedingungen der sogenannten "Definition of Ready" erfüllt sein. Damit soll sichergestellt werden, dass wirklich alle notwendigen Kriterien für eine möglichst effiziente Umsetzung erfüllt sind. Häufig unterscheiden sich diese Bedingungen von Team zu Team und werden an die organisatorischen und technischen Rahmenbedingungen des Projektes angepasst.
Generische Beispiele wären:
- Ist die User Story klar und eindeutig definiert?
- Ist die User Story von der Schätzung her kleinteilig genug, um im Rahmen einer Iteration effektiv umgesetzt zu werden? In der Regel sollte eine User Story so klein sein, dass sie innerhalb weniger Stunden oder Tage implementiert werden kann.
- Sind die notwendigen Kriterien für die spätere Abnahme klar und eindeutig definiert und ist damit auch das gewünschte Ergebnis klar?
Performance kann auch ein explizites Kriterium sein, welches aber dann eher zu den Abnahmebedingungen zählt. Oft wird es in Form einer maximalen Latenz für eine Rückmeldung eines API-Calls oder einer Oberflächeninteraktion angegeben.
Der Fortschritt der Entwicklungstätigkeiten wird während des Sprints durch die bereits umgesetzten Storys und die damit verbundenen Komplexitätspunkte gemessen. Primär findet eine Konzentration auf die täglich erhobene Anzahl von geleistetem Punkten statt. Es wird auch von einem Verbrennen dieser gesprochen. Viele Teams visualisieren ihren Fortschritt auch mit Hilfe eines sogenannten Burn-Down-Charts, welches als Bar-Chart für jeden Tag die noch verbleibenden bzw. noch zu leistenden Komplexitätspunkte darstellt.
Definition of Done
Am Ende der Umsetzung einer User Story wird anhand der sogenannten obligatorischen "Definition of Done" überprüft, ob diese wirklich als fertig umgesetzt angesehen werden kann. Ähnlich der "Definition of Ready" kann auch diese Liste an Bedingungen vom Team an die Projektgegebenheiten angepasst werden.
Generische Beispiele wären hierfür:
- Alle notwendigen automatisierten Tests wurden erstellt. In der Regel handelt es sich hierbei um klassische Unit-Tests, welche auf eine saubere Fehlerverarbeitung und fachliche Korrektheit prüfen.
- Die vorher definierten Anteile der benötigten Dokumentation wurden einpflegt.
- Es fand ein Code-Review durch andere Entwickler:innen statt.
- Alle generellen Kriterien für die abschließende Präsentation wurden erfüllt.
Sprint Review
Eine Iteration wird in der Regel mit einem sogenannten "Sprint-Review" abgeschlossen. Bei diesem geht das Team für jede umgesetzte User Story durch die "Definition of Done" und überprüft diese. Teil der Überprüfung ist die Demonstration aller für die Story notwendigen Interaktionen innerhalb der Anwendung. Abschließend erfolgt durch den Product Owner die Bewertung, ob die User Story damit erfüllt ist und ausgeliefert werden kann.
Jede Teillieferung eines Features in Form einer User Story muss lauffähig sein und auch nach weiteren Iterationen lauffähig bleiben. Daher wird im Zusammenhang mit Scrum auch oft von einer "potenziell releasefähigen Anwendung" gesprochen.
Retrospektive
Den Abschluss einer Iteration bildet meist ein weiteres Teammeeting, die "Retrospektive", welche rückblickend diese bewertet. In diesem Meeting wird festgehalten, was besonders gut und was besonders schlecht gelaufen ist. Es wird über mögliche Verbesserungen im Ablauf und bei den eingesetzten Werkzeugen gesprochen und evtl. Entscheidungen protokolliert.
Soweit ein kurzer Überblick über das Scrum-Framework.
Überblick über die Funktionsweise des MS SQL Server als relationales Datenbanksystem
Wie stellt sich nun aber die Softwareentwicklung mit einem relationalen Datenbank-System wie dem Microsoft SQL Server dar? Auf den ersten Blick werden Daten stark strukturiert in Tabellenform verwaltet. Jede Spalte einer Tabelle hat einen eindeutigen Datentyp. Tabellen können untereinander Beziehungen haben, welche über Primär- und Fremdschlüssel verwaltet werden. Der Zugriff bzw. die Verwaltung der Daten erfolgt über eine Implementierung der Datenbanksprache SQL (Structured Query Language). Der Hersteller Microsoft nennt seinen Dialekt Transact-SQL, kurz T-SQL. Die Arbeit mit T-SQL erscheint, nachdem man innerhalb kurzer Zeit die Grundlagen erlernt hat, als recht einfach und überschaubar.
Für die Formulierung von T-SQL-Anweisungen könnte vereinfacht gesagt werden, dass zunächst alle notwendigen Spalten und deren Tabellen als auch deren Verknüpfungen benannt werden. Als Nächstes werden die notwendigen Filter zur Einschränkung des Ergebnisses beschreiben. Sofern Daten aggregiert werden sollen, folgen die Angaben für eine Gruppierung und wiederum einer Filterung dieser Aggregation. Zu guter Letzt kann noch eine Sortierung des Ergebnisses definiert werden.
T-SQL als deskriptive Sprache verstehen
Es wird immer eine Ergebnismenge beschrieben. Die Aufgabe des Datenbanksystems ist es, diese eigenständig zu produzieren. Es werden keine Detailschritte angegeben, wie die Daten im Einzelnen gelesen und verarbeitet werden sollen. Das Datenbanksystem verwendet für die Erstellung der Detailschritte eine interne Komponente namens Abfrageoptimierer. Dieser erstellt für jede T-SQL-Anweisung einen Abfrageausführungsplan.
Dieser Erstellungsprozess ist aber nicht mit dem Kompilieren von Quelltext in einer klassischen Programmiersprache zu verwechseln. Die Übersetzung von Quelltext in einen ausführbaren Zustand, meist eine ausführbare Datei, ist ein Prozess mit einem statischen Ergebnis. Derselbe Quelltext führt i. d. R. zur selben Ausführung. Optimierungen am Quelltext haben daher einen direkten Einfluss auf das spätere Laufzeitverhalten.
Das ist nicht die Idee eines relationalen Datenbanksystems, da hier ein Ergebnis und nicht der Weg zu diesem beschrieben wird. Dazu kommt, dass der bereits erwähnte Abfrageoptimierer nicht nur auf die T-SQL-Anweisung schaut, sondern u. a. auch auf die in den Tabellen gespeicherten Daten. So kann dieser berücksichtigen, ob bei der Ausführung eher weniger oder mehr Daten verarbeitet werden müssen. Hierbei ist nicht nur die Menge an Daten innerhalb einer Tabelle entscheidend, sondern ebenfalls die gemessene Häufigkeit von gleichen Werten innerhalb einer Spalte.
Ein Beispiel wäre eine Tabelle mit Verkaufspositionen. Jede Zeile entspricht einem verkauften Artikel. Eine oder mehrere Zeilen ergeben einen Verkaufsbeleg (z. B. Lieferschein oder Rechnung). Die Anforderung wäre nun, eine T-SQL-Anweisung zu formulieren, welche alle Belegpositionen gefiltert nach einer Artikelnummer ausgibt. Zusätzlich wurde ein Index für die Artikelnummer in dieser Tabelle im Datenbanksystem angelegt, um den Suchvorgang zu beschleunigen. Was nach einer einfachen Anforderung klingt, kann aber schnell komplex werden. Die Entwickler:innen stellen nämlich schon nach wenigen Tagen in einer produktiven Umgebung fest, dass sich ihre Anwendung unvorhersehbar verhält. Jemand kommt auf die Idee, sich den Abfrageausführungsplan für diese Anweisung anzuschauen und stellt fest, dass mindestens zwei unterschiedliche Varianten eines Plans für diese T-SQL-Anweisung existieren können. In einer Variante nutzt der Microsoft SQL Server den erwähnten Index. In der anderen Variante entscheidet er sich dafür, die gesamte Tabelle zu lesen. Warum ist dem so? Der Abfrageoptimierer erkennt auch die durch die Verwendung eines Indexes möglichen Kosten während der Laufzeit. Sollten diese geschätzten Kosten einen möglichen Nutzen übersteigen, entscheidet der Abfrageoptimierer, den Index nicht zu verwenden und daher die ganze Tabelle zu durchsuchen.
In diesem einfachen Beispiel war nur eine Tabelle mit einem einfachen Filter involviert. Sofern mehrere Tabellen in einer T-SQL-Anweisung in Beziehung zueinander gesetzt werden, zieht der Abfrageoptimierer auch hierfür die physikalische Sortierreihenfolge der Tabellen und die vorhandenen Indexe mit in seine Berechnungen ein und optimiert damit zusätzlich noch die Auswahl der zur Verfügung stehenden Algorithmen zur Zusammenführung der Tabellen. Bereits jetzt ist klar erkennbar, dass die Übersetzung von T-SQL-Anweisungen in einen Abfrageausführungsplan ein eher dynamischer Vorgang ist, auf welchen Entwickler:innen keinen direkten Einfluss haben bzw. haben sollten.
Performance-Hotspots
Oft existieren in Anwendungen, welche auf einem relationalen Datenbanksystem basieren, essenzielle Tabellen, welche Geschäftsobjekte wie Kunden, Patienten, Produkte, Behandlungen, Reservierungen usw. beinhalten. Alle diese Geschäftsobjekte sind wesentlich für die jeweilige Anwendung und müssen äußerst performant zur Verfügung stehen. Da diese Objekte aber für viele Prozesse und Interaktionen mit der Anwendung notwendig sind, wird auf diese auch an vielen Stellen im Code referenziert. Damit steigt auch das Risiko, dass es genau für diese Tabellen zu Performanceproblemen kommen kann, deutlich an.
Agilität und nachhaltige Datenbankperformance. Ein Widerspruch?
Wenn nun agiles Projektvorgehen, wie Scrum, und die Funktionsweise einer relationalen Datenbank aneinander gespiegelt werden, ist es wenig verwunderlich, warum viele Projekte früher oder später in Performance- bzw. Skalierungsprobleme laufen müssen. Ein Kerngedanke des agilen Vorgehens ist die frühzeitige Nutzung von Feedback, um notwendige Korrekturen möglichst problemlos zu implementieren. Das Risiko des Scheiterns des Gesamtvorhabens wird durch eine Strategie eines "fail fast and fix quickly"-Ansatzes vermindert. Dieses Vorgehen kann aber bei der Verwendung einer relationalen Datenbank bedeuten, dass viele Details immer wieder neu gedacht und neu entwickelt werden müssen.
Das Thema Performance wird dann meist als ein Problem für spätere Administratoren oder gar für den Endkunden gesehen, welcher deutlich leistungsfähigere Server als eigentlich nötig einsetzen werden. Die häufigste Frage eines agilen Teams, welches für dieses Spannungsverhältnis sensibilisiert wurde, ist die nach einer pragmatischen und realistischen Lösung.
Wie bereits erkennbar, reichen bei einer Anwendung, welche auf einem relationalen Datenbanksystem basiert, schon kleine Änderungen bzw. Erweiterungen an einer einzelnen Tabelle, Sicht, Index oder Prozedur aus, damit der Abfrageoptimierer für viele der schon vorhandenen und getesteten T-SQL-Anweisungen neue und damit unbekannte Abfrageausführungspläne erzeugt. Diese müssen nicht immer gleich eine Performance-Regression nach sich ziehen, aber das Risiko steigt deutlich an. Damit wird eine voneinander isolierte Entwicklung bzw. Erweiterung von einzelnen Features einer Anwendung deutlich erschwert.
Um die Funktionsfähigkeit der gesamten Anwendung zu gewährleisten, wird meist auf Unit-Tests gesetzt. Diese testen automatisiert sowohl die fachliche als auch diverse Fehlerfälle innerhalb der Anwendung auf unerwartete Abweichungen oder gar Fehler. Selten kann dabei das Thema Performance betrachtet werden.
Aufbau eines Risikomanagement-Systems
Als pragmatischer Ansatz, die Möglichkeit des Eintretens von performance-relevanten Risiken zu minimieren, wird empfohlen, eine Form von Dokumentation im Team zu pflegen und zu verwenden, welche explizit diese Risiken adressiert.
Der Begriff Dokumentation ist leider häufig negativ belegt und wird eher als Bremse betrachtet. Und gerade im Umfeld von agilen Frameworks und Methoden wird das Thema Dokumentation gerne vertagt, da ein meist missverstandenes Prinzip agiler Methoden eine funktionierende Software über eine umfassende Dokumentation stellt. Es geht dabei aber um die Überführung der möglichen Risiken in ein gelebtes Risikomanagement-System. Der Zweck ist die Performance und die Eigenschaft der Skalierbarkeit möglichst auf einem hohen Niveau zu halten. Die Maßnahmen des Risikomanagement-Systems entsprechen hier sowohl technischen Maßnahmen bei der eigentlichen Softwareentwicklung als auch dem Dokumentieren des erwarteten Verhaltens der Anwendung unter Last, dem sogenannten Workload.
Dokumentation sollte hierbei nicht mit einer leeren Bildschirmseite verwechselt werden. Wie im vorherigen Abschnitt gesehen, gibt es eine Reihe von generischen Herausforderungen bei dem Design von SQL-Server-Tabellen und der Entwicklung von T-SQL- Anweisungen und -Prozeduren. Jeder dieser Punkte stellt ein mögliches Risiko dar, welches in eine Checkliste überführt wird. Diese Checkliste wird bei jeder Anpassung bzw. Erweiterung von datenbankrelevanten Teilen der Anwendung genutzt, um systematisch zu überprüfen und damit zu dokumentieren, dass die Möglichkeit des späteren Auftretens von Performanceproblemen vermindert wurde.
Checklisten für toxische Spalten und Joins
Ein konkretes Beispiel für einen Checklistenpunkt wäre die Prüfung, ob bei einem Filter (where clause) und/oder JOIN sogenannte toxische Spalten verwendet wurden. Als toxisch werden alle Spalten einer Tabelle bezeichnet, welche eine ungleichmäßige Verteilung der Werte aufweisen. Die Nutzung solcher Spalten als Filter sind in einer T-SQL-Anweisung meist der Grund für die Erzeugung von unterschiedlichen Abfrageausführungsplänen und damit oft auch die Treiber für sogenannte "Parameter Sniffing"-Herausforderungen. Daher sollte das Risikomanagement-System ein Verzeichnis aller Tabellen und deren Spalten beinhalten und einen entsprechenden Hinweis, ob mit einer ungleichmäßigen Verteilung gerechnet werden muss oder ob evtl. in produktiven Umgebungen bereits eine solche festgestellt wurde. Sofern ja, dann müssen die Entwickler:innen mit technischen Maßnahmen gegen das Risiko von ungemanagter Planerzeugung vorgehen. Dazu können im Einzelfall eine erzwungene Neukompilierung der T-SQL-Anweisung als auch eine Aufteilung von Prozeduren in diverse gemanagte Unterprozeduren zählen.
Vertikale Partitionierung als Risikominimierung
Ein weiteres Beispiel für die Umsetzung eines Risikomanagement-Systems wäre die Nutzung von vertikaler Partitionierung. Häufig wird bei dem Design von Datenbankmodellen nach Geschäftsobjekten getrennt. Es gibt zum Beispiel eine Tabelle "Kunde", in welcher eine Zeile die primären Informationen rund um einen Kunden speichert. Dann gibt es meist eine Tabelle "Produkt" für wiederum die wesentlichen Informationen für ein Produkt. Dazwischen können Tabellen für Bereiche wie Akquise, Verkauf, Wartung usw. erzeugt werden.
Wie sicherlich vorstellbar ist, müssen aber alle diese Unternehmensbereiche und damit Prozesse innerhalb einer Anwendung meist auf einen Kunden und dessen Produkte zugreifen. Das heißt, dass wohl jede Userstory entweder den Kunden oder das Produkt oder gar beide adressiert.
Bei einem gewöhnlichen ER-Diagramm in der Modellierung können nun die Full-Stack-Entwickler:innen leicht auf den Gedanken kommen, dass weitere notwendige Informationen als zusätzliche Spalten an die Tabellen "Kunden" und "Produkt" hinzugefügt werden. Damit steigt aber bei jeder Erweiterung des Datenbankschemas deutlich das Risiko, dass für bereits realisierte Features der Anwendung zum Beispiel bisher genutzte Indexe nun nicht mehr vom Abfrageoptimierer verwendet werden, was zu Performance-Regressionen führen kann.
Um dieses Risiko zu mindern, sollten Geschäftsobjekte, wie zum Beispiel Kunden und Produkte, auf mehrere Tabellen verteilt werden. Hierbei muss nun aber bewusst zwischen der allgemein gebräuchlichen Normalisierung und einer vertikalen Partitionierung unterschieden werden. Während bei der Normalisierung Redundanzen vermieden werden sollen, ist das Ziel der vertikalen Partitionierung, Performance-Problemen vorzubeugen, in dem in zusätzlichen Tabellen jeweils die für einen Prozess oder ein Feature notwendigen Daten verwaltet werden. Evtl. werden einige wesentliche Kernspalten auch in mehreren dieser Tabellen redundant gehalten, um zu viele komplexe Joins zu vermeiden.
Isolation von Kernstrukturen
Als zusätzliche Maßnahme kann auch die Nutzung von Kernstrukturen, also zum Beispiel der Tabelle, welche die grundlegenden Daten eines Kunden enthält, für Entwickler:innen nicht direkt zur Verfügung stehen, sondern nur über eine gespeicherte Prozedur angesprochen werden. Dieses Vorgehen schützt diese Strukturen vor der Verwendung in zu vielen komplexen und meist unoptimierten Abfragen. Des Weiteren sollte das Risikomanagement-System auch Informationen über die erwartete Nutzung von Indexen und der erwarteten Abfrageausführungspläne enthalten. Diese Informationen können automatisiert erhoben werden und sollten idealerweise wiederum in einem Datenbanksystem gespeichert vorliegen, so dass spätere automatisierte Testläufe auf diese zugreifen können. Zusätzlich sind diese Daten auch für den Betrieb der Anwendung in einer produktiven Umgebung von Wert und stellen damit bereits eine Grundlage für eine Baseline für ein mögliches Monitoring bereit.
Zusätzlich erfüllt ein entsprechend gepflegtes Risikomanagement-System noch eine weitere wichtige Funktion. Es ist eine belastbare Quelle für die verbesserte Schätzung von Aufwänden für die Umsetzung einer Story, sofern diese mit dem Datenbanksystem interagiert. Das Team ist nun in der Lage, wesentlich besser während der Sprint-Planungssitzung die Komplexität der Entwicklung mit dem Datenbanksystem abzuschätzen. Während der Sprint-Retrospektive wiederum kann das Risikomanagement-System um Erkenntnisse erweitert werden, welche zu Verzögerungen in der Entwicklung oder gar Datenbankperformance-Problemen geführt haben.
Definitionen für Performance
Quasi bei jeder Entwicklung von Software ist Performance irgendwann ein Thema und viel zu oft auch dann gleich ein Problem. Sei es in den Definitionen von Anforderungen, während der eigentlichen Entwicklung, bei anschließenden Tests oder spätestens bei der Abnahme. Dabei spielt es bei der jetzigen Betrachtung gerade noch keine Rolle, ob es sich um ein klassische Softwareentwicklung handelt oder um ein agiles Vorgehen, wie mit Scrum. Oft fängt das Thema bereits bei der Definition von Performance an.
Performance wird zum Beispiel bei Anwendungen mit einer Benutzeroberfläche gerne gleichgesetzt mit einem flüssigen Gefühl von Arbeit. Das ist naturgemäß schwer greifbar, sofern dieses nicht präzisiert wird. Des Weiteren drückt der Wunsch nach einer flüssigen Arbeit am Frontend zum Beispiel noch nicht aus, unter welchen Bedingungen dies möglich sein soll. Eine Präsentation eines Features findet häufig auf einem isolierten Demosystem statt, welches keine weiteren parallelen Anwender:innen hat. Sofern also Performance durch einen direkten Test mittels einer Benutzeroberfläche bewiesen werden soll, dann würde für realistische Erfahrungen eigentlich die Simulation von hunderten wenn nicht hunderttausenden Anwender:innen notwendig sein.
Sofern Performance genauer beschrieben wird, dann kommt häufig das Thema "gemessene Zeit bzw. maximale Latenz" ins Spiel. Es wird also bereits überlegt, wie lange eine bestimmte Transaktion in der Anwendung "dauern" darf. Dieses Vorgehen führt dann oft dazu, dass sich Entwickler:innen bei der Entwicklung und dem Testen von T-SQL-Anweisungen auf den Faktor Dauer konzentrieren. Das mag bei Berichtsabfragen, welche zum Beispiel "weniger als eine Minute dauern" sollen, völlig in Ordnung sein, da hier keine detaillierte Betrachtung notwendig sein wird. Geht es aber um T-SQL-Anweisungen, welche innerhalb eines OLTP-Systems vielleicht mehrmals pro Sekunde ausgeführt werden müssen, dann kann die Betrachtung von Dauer schnell zu Problemen und in eine Sackgasse führen. Warum ist dem so? Performance ist doch Geschwindigkeit, also wie schnell etwas passiert bzw. wie schnell etwas von A nach B kommt. Im Grunde ist das korrekt. Es ist nur leider auch sehr ungenau.
Zeit ist keine belastbare Messgröße
Zunächst einmal hängt eine Zeitmessung von dem genutzten Werkzeug der Messung ab. Das klassische Werkzeug für die Entwicklung von T-SQL-Code ist immer noch das Microsoft SQL Server Management Studio (SSMS). Dieses stellt standardmäßig in der Entwicklungsumgebung eine kleine Stoppuhr dar, welche in Stunden, Minuten und Sekunden misst. Sofern eine Abfrage also unter einer Sekunde läuft, dann sehen die Entwickler:innen die Anzeige 00:00:00. Diese Darstellung führt dann dazu, dass sich gefragt wird, was denn da noch optimiert werden kann? Es gibt zwar diverse Ansätze, um an granularere Werte zu kommen, welche dann im Millisekunden-Bereich liegen können, doch nutzt das tatsächlich etwas?
Wie schon mehrmals betont, haben Entwickler:innen nur bedingt wirklich Einfluss auf die Abarbeitung von T-SQL-Anweisungen. Es ist zum Beispiel nicht klar, ob benötigte Daten bereits im Buffercache des SQL Servers liegen oder erst von einem Storagesystem geladen werden müssen. Man spricht dann gerne auch von einem warmen bzw. kalten Cache. Natürlich könnten die Entwickler:innen die T-SQL-Anweisung einfach mehrmals auf dem Testsystem ausführen. Dann liegen die Daten sehr wahrscheinlich im Buffercache. Nur ist das realistisch? Es kann für einen späteren produktiven Datenbankserver keine Grundannahmen über den Buffercache-Inhalt getroffen werden. Insbesondere, wenn viele parallele Prozesse laufen werden.
Der nächste Punkt bei der Bewertung der Performance von Datenbanksystemen mittels Zeitmessung ist die generelle Ausstattung des Servers. Es wird von Entwicklungsteams sehr gerne das Argument ins Gespräch gebracht, dass ein wirkliches und verbindliches Testen ohne vergleichbare Hardware unmöglich sei. Dieses Argument ist für das verantwortliche Management meist ein verständlicher Grund, dass dem Thema Performance während der Entwicklung nur bedingt Aufmerksamkeit geschenkt werden kann. Denn es ist nur in den seltensten Fällen möglich, vergleichbare Serversysteme zur Verfügung zu stellen. Gerade die Ressourcen Prozessorkerne (dazu zählt neben GHz und der Anzahl auch die Wahl der Generation usw.) und Hauptspeicher sind sehr kostenintensiv. Die Ressourcen Storage und Netzwerkanbindung sind meist generisch gelöst und zum Teil zwischen Test und produktiven Systemen vergleichbar. Dennoch kann sich ein Team darauf zurückziehen, dass es zum Beispiel mindestens einen Server mit 1,5 TB Hauptspeicher und 48 Prozessorkernen bräuchte, da vergleichbare Systeme bei Kunden verwendet werden. Das ist oft aus technischen, organisatorischen und natürlich budget-bedingten Gründen nicht realisierbar.
Für den eher unwahrscheinlichen Fall, dass das Management zumindest vergleichbare Systeme bereitstellt, ist dann immer noch der eigentliche Lasttest eine technische Herausforderung. Wie können zuverlässig und vergleichbar zum Beispiel hunderttausend Anwender:innen eines Webshops oder einer App simuliert werden?
Wie bereits beschrieben, führen diese Umstände häufig dazu, dass das Thema Performance hinsichtlich des Datenbanksystems niedrig priorisiert wird. Was wären nun mögliche Wege, um Datenbankperformance zuverlässig und automatisch auf budgetfreundlichen Systemen zu testen? Zunächst einmal gilt es, sich beim Thema Performance von einer Konzentration auf die gemessene Dauer einer Operation zu lösen.
Logical Read als verbindlicher Maßstab
Um eine T-SQL-Anweisung unabhängig von der Servergröße zu beurteilen, wird eine andere Maßeinheit als Zeit für "notwendige Arbeit" benötigt. Hierfür hat sich in der Praxis die Einheit "Logical Reads" äußerst bewährt. Microsoft selbst verwendet diese Einheit in diversen Berichten und Analysen zur Datenbankperformance. Mit einem "Logial Read" ist nicht das Lesen einer einzelnen Datenzeile gemeint, sondern das Lesen einer Datenbankseite. Datenbankensysteme unterteilen die Datenbankdateien häufig in kleine und gleichgroße Einheiten. In diesen Einheiten sind u. a. die Daten eines Datenbanksystems gespeichert. Eine Tabelle zum Beispiel besteht aus einer Menge von Datenbankseiten. Indizes werden ebenfalls in Datenbankseiten gespeichert. Beim Microsoft SQL Server kann gesagt werden, dass so gut wie jede Form von Daten in Seiten gespeichert und verarbeitet wird. Eine Datenbankseite ist beim SQL Server immer 8kb groß. Dieser Wert kann nicht konfiguriert werden und ist daher eine feste Größe.
Entwickler:innen können also für eine T-SQL-Anweisung ermitteln, wie viele 8kb-Seiten dafür gelesen werden mussten. Da hier die Einheit "Logial Read" verwendet wird, ist es unerheblich, ob die Datenbankseite bereits im Buffercache des SQL Servers lag oder diese erst vom Storage geladen werden musste. Der Wert ist identisch. Damit wird der Diskussion um die Größe eines Testservers bereits eine Voraussetzung genommen. Es ist nicht mehr relevant, ob das System nun 1,5 TB oder "nur" 64-128 GB hat. Wichtig ist hier zu verstehen, dass dieses kein Freibrief sein darf, um Testserver mit nur noch 8-16 GB auszustatten.
Wichtig für diese Art der Betrachtung mit Hilfe der Einheit "Logial Read" ist es, dass sinnvolle und repräsentative Testdaten genutzt werden. Damit sind Daten gemeint, die sowohl von der Anzahl der Datenzeilen als auch bei der bereits thematisierten Verteilung von Werten auf den Spaltenebenen her vergleichbar sind mit produktiven Daten. Diese Testdatenbestände können entweder synthetisch erzeugt werden oder auch eine anonymisierte Variante der produktiven Daten sein. Eine bewährte Empfehlung ist, dass die produktiven Datenbestände hinsichtlich Menge, Struktur, Nutzung und Verteilung von Werten automatisiert analysiert und diese Metadaten dann wiederum genutzt werden, um synthetische Testdaten zu generieren.
Dokumentieren von Parametergruppen für automatisierte Tests
Eine weitere Herausforderung bei der Bewertung von T-SQL-Anweisungen ist die Auswahl der Parameter, welche für die Tests genutzt werden sollen. Wie bereits geschildert, hängen die erzeugten Abfrageausführungspläne u. a. davon ab, wie viele Datenzeilen der SQL Server als zu verarbeiten schätzt.
Daher ist es schon während der Entwicklung von T-SQL-Anweisungen entscheidend, dass Entwickler:innen sich damit auseinandersetzen, welche Gruppen an Parametern später getestet werden sollten. Diese Parametergruppen stellen wiederum eine der Grundlagen für automatisierte Performancetests.
Automatisierte Tests
Das Ziel der Performancetests ist dabei nicht, Geschwindigkeit oder Zeit zu messen, sondern ob die Qualität bzw. der Aufbau der erzeugten Abfrageausführungspläne nach jeder Iteration des agilen Entwicklungsprozesses stabil bleibt. Es geht dabei um die Kontrolle, ob durch Erweiterungen des Datenbankschemas – sei es durch neue Spalten in Tabellen oder Abfragen, als auch durch neue Indexe – andere Ausführungspläne generiert werden, als in den vorherigen Iterationen. Die gute Nachricht hierbei ist, dass Microsoft ein solches Vorgehen schon vor geraumer Zeit vorgesehen hat und der SQL Server die benötigten Informationen liefern kann.
Ein oft geäußertes Argument gegen dieses Vorgehen ist, dass kein Entwicklungsteam im Detail Abfrageausführungspläne dokumentieren oder gar vergleichen könnte. Dieses ist aber nicht notwendig. Der Microsoft SQL Server generiert seit der Version 2008 für jede T-SQL-Anweisung als auch für jeden Abfrageausführungsplan einen Hashwert, den sogenannten QueryHash und QueryPlanHash. Dieser wird meist in hexadezimaler Schreibweise dargestellt und ist leicht über entsprechende Data-Management- Views abrufbar.
Dieselbe T-SQL-Anweisung wird dabei immer denselben Hashwert erzeugen – egal auf welcher Hardware, solange die SQL Server Version identisch ist. Dasselbe gilt für Abfrageausführungspläne. Da aus einer T-SQL-Anweisung mindestens ein Plan erzeugt wird, treten diese beiden Hashwerte gemeinsam in Erscheinung. Wie bereits mehrmals erwähnt, kann eine T-SQL-Anweisung eine Vielzahl an unterschiedlichen Abfrageausführungspläne generieren. Daher ist von einer 1:N-Beziehung von T-SQL zu Plan auszugehen.
Im Rahmen eines automatisierten Tests sind nun nach Möglichkeit alle verwendeten T-SQL-Anweisungen mit den bei der Entwicklung erhobenen Parametergruppen auszuführen. In Abhängigkeit zur verwendeten SQL-Server-Version, stehen unterschiedliche Werkzeuge zur Verfügung, um diese Ausführungen mitzuschneiden und die entsprechenden Hashwerte zu protokollieren.
Ab der Version 2016 wurde das sehr nützliche Feature Query Store in den SQL Server integriert. Dieses ist in der Lage, Ausführungen von T-SQL-Anweisungen und die verwendeten Abfrageausführungspläne mit nur minimalem Aufwand aufzuzeichnen. Der Query Store ist daher für die meisten aktuellen Entwicklungsvorhaben als primäre Datenquelle als Datenbasis für ein entsprechendes Risikomanagement-System zu empfehlen.
Schlussfolgerungen
- Performanceprobleme haben ihren Ursprung nicht in der Verwendung von agilen Methoden, aber Kompromisse und falsch verstandene Risikofreudigkeit sind leider viel zu oft ein nur zu dankbarer Nährboden, um bei steigender Komplexität und Arbeitslast nicht wie erwartet zu skalieren.
- Performance und Skalierbarkeit sollte nicht als eine nachgelagerte Herausforderung gesehen werden. Beide Eigenschaften können einer bereits existierenden Anwendung nur bedingt und unter meist hohen Aufwänden wieder hinzugefügt werden. Eine Rückkehr zu plangetriebenen Methoden, wie einem Wasserfall oder hybriden Modellen, ist meist keine ausreichende Lösung und steht auch im deutlichen Widerspruch zu den eigentlich verfügbaren Möglichkeiten.
- Da viele Anwendungen – mit zunehmender Komplexität während der Entwicklung über die agilen Iterationen hinweg – die Eigenschaften Performance und Skalierbarkeit langsam aber sicher verlieren, macht es Sinn, bereits vor einer ersten Iteration ein entsprechendes Risikomanagement zu etablieren.
- Die Verwendung eines relationalen Datenbanksystems ist kein Substitut für die Bereitstellung einer API. Tabellen für Kernobjekte, wie Kunden, Produkte usw., dürfen im Datenbankschema für Entwickler:innen nicht direkt erreichbar sein.
- Wie gezeigt, lässt sich durch leichte Anpassungen ein agiles Projektvorgehen durch zusätzliche Tools so erweitern, dass mögliche Performance- und Skalierungsrisiken bereits frühzeitig abgemildert werden können.