Datenbank-Performance-Tuning: Wenn Queries Deine Skalierung killen
Beim Launch lief die Anwendung einwandfrei. Requests wurden in unter 200 Millisekunden beantwortet, der Datenbankserver dümpelte gemütlich bei 15 % CPU, und niemand dachte groß über Query-Performance nach. Dann wuchsen die Nutzerzahlen, die Datenmengen verdreifachten sich, und plötzlich produzierte derselbe Code, der im Staging problemlos lief, Seitenladezeiten von 8 Sekunden, eine bei 90 % festgenagelte Datenbank-CPU und Engineers, die hektisch Indizes hinzufügten, ohne so recht zu verstehen, warum bestimmte Queries langsam geworden waren.
Datenbank-Performance-Tuning ist die Disziplin, die diesen Verlauf verhindert - oder umkehrt, wenn er bereits begonnen hat. Die zentrale Erkenntnis: Die meisten Performance-Probleme von Anwendungen sind verkappte Datenbankprobleme. Nicht weil Datenbanken schlecht designt wären, sondern weil Query-Muster, die bei kleinen Datenmengen unsichtbar sind, bei großem Volumen katastrophal werden. Dieser Beitrag behandelt die praktischen Techniken, um diese Muster zu finden und zu beheben, bevor sie Dich finden.
Warum Datenbank-Queries sich bei Skalierung anders verhalten
Bei 1.000 Zeilen ist fast jede Query schnell. Bei 1.000.000 Zeilen kann eine Query ohne passenden Index von einstelligen Millisekunden auf Sekunden degradieren. Bei 10.000.000 Zeilen ist dieselbe Query womöglich nie fertig, bevor die Verbindung in den Timeout läuft.
Der Grund ist der Query-Ausführungsplan. Die Datenbank-Engine entscheidet, wie sie eine Query erfüllt, indem sie aus verfügbaren Strategien wählt: jede Zeile scannen, einen Index nutzen, per Hash- oder Nested-Loop-Join verknüpfen und so weiter. Bei kleinen Datenmengen wählt die Engine oft Full Table Scans, weil sie schnell genug und einfacher als Index-Lookups sind. Wachsen die Daten, werden genau diese Scans zunehmend teurer, und die Engine wechselt die Strategie nicht unbedingt automatisch. Das Ergebnis ist eine lineare Degradierung, die sich als Code-Problem tarnt, bis sich jemand die tatsächlichen Queries ansieht.
Drei Kategorien von Query-Problemen verursachen die Mehrheit der datenbankbedingten Verlangsamungen in PHP/Symfony-Anwendungen:
- Fehlende oder falsch ausgerichtete Indizes, die Full Scans auf großen Tabellen erzwingen
- N+1-Query-Muster, die hunderte Queries erzeugen, wo eine genügen würde
- Query-Strukturen, die vorhandene Indizes aushebeln, obwohl sie existieren
Jede Kategorie zu verstehen - und zu wissen, wie man sie findet - ist die Grundlage jeder ernsthaften Initiative für Datenbank-Performance-Tuning.
Das N+1-Problem im Doctrine ORM
Das N+1-Query-Problem ist die häufigste Ursache unerwarteter Verlangsamungen in Anwendungen mit einem ORM wie Doctrine. Der Name beschreibt das Muster: Du führst 1 Query aus, um eine Collection zu laden, und dann N weitere Queries - eine pro Element - um zugehörige Daten zu laden. Wächst die Collection, wächst die Query-Anzahl mit.
So sieht das Muster in Doctrine aus:
// Lädt alle Bestellungen - 1 Query
$orders = $orderRepository->findAll();
foreach ($orders as $order) {
// Löst pro Bestellung eine neue Query aus, um den Kunden zu laden - N Queries
echo $order->getCustomer()->getName();
}
Bei 500 Bestellungen führt diese Schleife 501 Queries aus. Bei 5.000 Bestellungen sind es 5.001. Jede Query ist für sich genommen schnell - vielleicht 2 Millisekunden - aber 5.001 sequenzielle Roundtrips summieren sich auf 10 Sekunden Datenbankzeit, bevor irgendeine Anwendungslogik läuft.
Der Fix ist explizites Eager Loading mit Doctrines Query Builder und einem JOIN-Fetch:
$orders = $entityManager->createQueryBuilder()
->select('o', 'c')
->from(Order::class, 'o')
->join('o.customer', 'c')
->getQuery()
->getResult();
// getCustomer() ist jetzt bereits geladen - keine weiteren Queries
foreach ($orders as $order) {
echo $order->getCustomer()->getName();
}
Das kollabiert die 5.001 Queries in eine einzige JOIN-Query. Für paginierte Ergebnisse mit komplexen Assoziationen kannst Du zusätzlich mit ->addSelect() mehrere Beziehungen in einem Rutsch laden.
Um N+1-Muster in einer laufenden Anwendung zu finden, brauchst Du einen Query-Logger. In der Entwicklung zeigt der Symfony Web Profiler die vollständige Query-Anzahl und -Zeit pro Request - ein Request, der für eine einfache Seite mehr als 10 bis 15 Queries ausführt, ist ein starkes Signal. In Produktion brauchst Du das Slow Query Log oder ein dediziertes APM-Tool. Blackfire.io und Tideways integrieren sich beide gut mit Symfony und machen N+1-Muster explizit sichtbar.
Query-Profiling: Herausfinden, was tatsächlich langsam ist
Bevor Du Indizes hinzufügst oder Queries umschreibst, musst Du wissen, welche Queries tatsächlich Probleme verursachen. Die falsche Query zu optimieren - selbst perfekt - bringt keine messbare Verbesserung.
Das Slow Query Log
MySQLs Slow Query Log ist das direkteste Werkzeug, um teure Queries in Produktion zu finden. Aktiviere es mit einem Schwellenwert, der alles Relevante erfasst, ohne das Log zu fluten:
-- Slow Query Log mit 1-Sekunden-Schwelle aktivieren
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
-- Zusätzlich Queries erfassen, die Full Table Scans machen, auch wenn sie schnell sind
SET GLOBAL log_queries_not_using_indexes = 'ON';
Das resultierende Log zeigt Query-Text, Ausführungszeit, untersuchte Zeilen und gesendete Zeilen. Das Verhältnis von untersuchten zu gesendeten Zeilen ist das nützlichste Signal: Eine Query, die 500.000 Zeilen untersucht, um 10 Ergebnisse zu liefern, hat mit hoher Wahrscheinlichkeit keinen passenden Index oder ist so strukturiert, dass kein Index greifen kann.
Verarbeite das Slow Query Log mit pt-query-digest (aus dem Percona Toolkit), um wiederholte Queries zu aggregieren und nach Gesamtausführungszeit zu sortieren - nicht nur nach der einzelnen langsamsten Query. Eine Query, die 200 Millisekunden braucht, aber 10.000 Mal pro Stunde läuft, verdient mehr Aufmerksamkeit als eine, die 5 Sekunden braucht, aber zweimal am Tag läuft.
EXPLAIN: Den Query-Ausführungsplan lesen
Hast Du eine problematische Query identifiziert, zeigt EXPLAIN, wie MySQL sie auszuführen plant:
EXPLAIN SELECT o.id, o.total, c.name
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.status = 'pending'
AND o.created_at > '2026-01-01'
ORDER BY o.created_at DESC;
Die wichtigsten Spalten der Ausgabe:
- type: Der Join-Typ.
ALLbedeutet Full Table Scan - auf großen Tabellen fast immer falsch.refoderrangebedeutet, dass ein Index genutzt wird. - key: Welchen Index MySQL gewählt hat.
NULLheißt: kein Index genutzt. - rows: MySQLs Schätzung, wie viele Zeilen untersucht werden. Vergleiche das mit der tatsächlichen Ergebnisanzahl.
- Extra: Achte auf
Using filesort(teures Sortieren ohne Index) undUsing temporary(Ergebnisse werden in eine temporäre Tabelle materialisiert, was unter Last extrem langsam sein kann).
EXPLAIN ANALYZE (ab MySQL 8.0) führt die Query aus und liefert tatsächliche Ausführungsstatistiken statt Schätzungen - nützlicher, wenn die Zeilenschätzungen des Optimizers deutlich danebenliegen.
Index-Design für reale Query-Muster
Ein Index existiert, die Query ist langsam, und EXPLAIN zeigt, dass der Index nicht genutzt wird. Das ist eine der frustrierendsten Situationen im Datenbank-Performance-Tuning - und sie hat fast immer eine klare Erklärung.
Spaltenreihenfolge bei zusammengesetzten Indizes
MySQL kann einen Index nur von der äußersten linken Spalte aus nutzen. Ein Index auf (status, created_at) kann Queries bedienen, die nur auf status filtern, oder auf status AND created_at. Er kann keine Queries bedienen, die nur auf created_at filtern.
Für eine Query mit Filter auf status = 'pending' AND created_at > '2026-01-01' sollte ein zusammengesetzter Index die Gleichheitsbedingung zuerst führen:
-- Korrekt: Gleichheitsspalte zuerst, Range-Spalte danach
CREATE INDEX idx_orders_status_created ON orders (status, created_at);
-- Falsch für diese Query: Range-Spalte zuerst begrenzt den Selektivitätsvorteil
CREATE INDEX idx_orders_created_status ON orders (created_at, status);
Index-Abdeckung
Ein Covering Index enthält alle Spalten, die die Query referenziert - nicht nur die WHERE-Klausel, sondern auch die SELECT- und ORDER-BY-Spalten. Wird eine Query vollständig von einem Index abgedeckt, kann MySQL die Ergebnisse direkt aus dem Index liefern, ohne die Haupttabelle anzufassen. Für hochfrequente Queries auf großen Tabellen können Covering Indizes die Query-Zeit um eine Größenordnung senken.
-- Covering Index: enthält alle Spalten, die die Query referenziert
CREATE INDEX idx_orders_covering ON orders (status, created_at, id, total);
Bestätige die Abdeckung, indem Du in der Extra-Spalte der EXPLAIN-Ausgabe nach Using index suchst.
Wenn Indizes nicht greifen
Mehrere verbreitete Query-Muster verhindern die Index-Nutzung, selbst wenn ein passender Index existiert:
// Funktionsaufruf um die Spalte hebelt den Index auf created_at aus
->where('YEAR(o.created_at) = 2026')
// Fix: stattdessen eine Range-Bedingung verwenden
->where('o.created_at >= :start AND o.created_at < :end')
->setParameter('start', '2026-01-01')
->setParameter('end', '2027-01-01')
// Implizite Typkonvertierung hebelt den Index aus
->where('o.customer_id = :id')
->setParameter('id', "42") // String für eine Integer-Spalte übergeben
// Fix: den Typ explizit setzen
->setParameter('id', 42, Types::INTEGER)
Doctrines DQL-Schicht abstrahiert einen Teil dieser Komplexität weg, aber für kritische Queries lohnt es sich immer, das rohe SQL zu generieren und EXPLAIN auf der Ausgabe laufen zu lassen - gerade bei Endpoints mit viel Traffic. Der "Doctrine"-Tab des Symfony Profilers zeigt das generierte SQL für jede Query eines Requests, was das in der Entwicklung einfach macht.
Query-Muster, die Performance bei Volumen killen
Jenseits von N+1 und fehlenden Indizes richtet eine Handvoll Query-Muster überproportionalen Schaden an, wenn die Datenmengen wachsen.
Unbegrenzte Queries auf großen Tabellen. Eine Query ohne LIMIT auf einer stetig wachsenden Tabelle wird irgendwann eine Produktionsdatenbank lahmlegen, wenn ein Edge-Case-Codepfad sie ohne Pagination auslöst. Versieh administrative und Reporting-Queries immer mit vernünftigen LIMIT-Klauseln und mache findAll() auf Entity-Repositories zur Code-Review-Flagge für Repositories mit viel Traffic.
COUNT mit GROUP BY auf nicht indizierten Spalten. Aggregat-Queries für Dashboards und Reports werden oft einmal geschrieben und vergessen. Bei 10 Millionen Zeilen wird ein SELECT status, COUNT(*) FROM orders GROUP BY status ohne Index auf status zum sekundenlangen Full Table Scan - ausgelöst bei jedem Öffnen des Admin-Panels.
LIKE mit führenden Wildcards. Eine Query wie WHERE name LIKE '%berlin%' kann überhaupt keinen B-Tree-Index nutzen. Für Substring-Suchen auf wachsenden Textspalten solltest Du Volltext-Indizes (MATCH AGAINST in MySQL) oder eine externe Suchmaschine in Betracht ziehen.
Lang laufende Transaktionen, die Row Locks halten. Ein Web-Request, der eine Transaktion öffnet, mitten in der Transaktion eine externe API aufruft und erst nach deren Antwort committet, kann Row Locks mehrere Sekunden halten. Unter paralleler Last stauen sich andere Requests hinter diesen Locks, und die Latenz steigt auf breiter Front. Halte Transaktionen kurz und vermeide Netzwerk-I/O innerhalb von Transaktionsgrenzen.
Query-Performance über die Zeit überwachen
Indizes und Query-Umbauten beheben bekannte Probleme. Damit sich unbekannte Probleme nicht still ansammeln, braucht es laufendes Monitoring.
Die Metriken, die sich auf Datenbankebene zu verfolgen lohnen:
- Slow-Query-Anzahl pro Minute - ein sprunghafter Anstieg signalisiert eine neue problematische Query oder eine überschrittene Datenvolumen-Schwelle
- Verhältnis von untersuchten zu gesendeten Zeilen - ein steigendes Verhältnis zeigt, dass die Index-Effektivität relativ zum Datenwachstum nachlässt
- InnoDB-Buffer-Pool-Trefferquote - fällt sie unter 95 %, passt das Arbeitsset der Daten nicht mehr in den Speicher, und jeder Cache-Miss wird zum Festplattenzugriff
- Aktive Verbindungen und Verbindungswartezeit - eine Erschöpfung des Connection Pools zeigt sich hier, bevor sie als Anwendungsfehler sichtbar wird
Für PHP/Symfony-Anwendungen kombiniert ein praktikables Monitoring-Setup ohne Enterprise-Lizenzkosten MySQLs Performance Schema, den mysqld_exporter von Prometheus und ein Grafana-Dashboard. Das liefert Sichtbarkeit auf Query-Ebene und historische Trends auf bescheidener Infrastruktur - ganz ohne Datadog-Vertrag.
Wo Du bei einer bestehenden Anwendung anfängst
Zeigt eine bestehende Anwendung datenbankbedingte Verlangsamungen, ist die Prioritätenreihenfolge eindeutig. Aktiviere das Slow Query Log und lasse pt-query-digest über 24 Stunden Produktions-Traffic laufen - das bringt die echten Übeltäter ans Licht, nicht die, die sich in der Entwicklung langsam anfühlen. Führe EXPLAIN auf den fünf Queries mit der höchsten Gesamtausführungszeit aus, nicht nur auf der langsamsten einzelnen. Behebe zuerst N+1-Muster, denn sie liefern die größten Gewinne mit dem geringsten Schema-Risiko: Der Fix ist Anwendungscode, keine Migration. Ergänze danach fehlende oder falsch ausgerichtete zusammengesetzte Indizes, getestet gegen eine Staging-Umgebung mit Datenmengen in Produktionsgröße.
Datenbank-Performance-Probleme verschärfen sich mit der Zeit, wenn sie unbehandelt bleiben. Ein Code-Quality-Audit der Query-Schicht einer PHP/Symfony-Anwendung bringt regelmäßig Muster ans Licht, die bei aktuellen Datenmengen unsichtbar sind, aber innerhalb von 6 bis 12 Monaten Ausfälle verursachen werden, wenn der Datenbestand wächst. Sie proaktiv zu erwischen kostet einen Bruchteil dessen, was eine Incident Response kostet.
Wenn Deine Anwendung Anzeichen datenbankbedingter Verlangsamungen zeigt - oder Du validieren willst, dass Deine Query-Muster der Skalierung standhalten, bevor Du sie erreichst - kann Wolf-Tech mit einem fokussierten Performance-Review helfen. Erreiche uns unter hello@wolf-tech.io oder besuche wolf-tech.io für eine kostenlose Beratung.

