SQL Performance und Datenintegrität

Veröffentlicht am

Veröffentlicht von

Schlagwörter:

Datenbankoptimierung für bessere Performance.

Die 5 Fehler die ich immer wieder sehe

Die Datenbank ist das Fundament jeder Anwendung. Das Frontend lässt sich neu bauen, Business-Logik refactoren, ein Framework austauschen — aber eine schlecht strukturierte oder langsame Datenbankschicht zieht die gesamte Anwendung nach unten. Und das meist schleichend: Nicht mit einem Knall, sondern mit Abfragen die nach und nach langsamer werden, mit Daten die irgendwann nicht mehr stimmen, mit einem System das unter Last in die Knie geht.

Trotzdem bekommt die Datenbank in vielen Projekten die wenigste Aufmerksamkeit. Sie läuft, also ist sie gut. Bis sie es nicht mehr ist.

In der Praxis sehe ich immer wieder dieselben Muster: Egal ob Neuprojekt oder gewachsenes System. Hier sind die fünf die mir am häufigsten begegnen.

1. Fehlende Fremdschlüssel — auch dort wo man es nicht erwartet

Fremdschlüssel-Constraints sind kein optionales Feature. Sie sind der Mechanismus der sicherstellt dass Relationen in der Datenbank tatsächlich existieren — und nicht nur in der Anwendungslogik angenommen werden.

Trotzdem fehlen sie in erschreckend vielen Projekten. Auch in vermeintlich professionellen Systemen. WordPress zum Beispiel definiert keine einzigen Fremdschlüssel-Constraints obwohl die Tabellen klar miteinander in Beziehung stehen. Das ist keine Designentscheidung für Flexibilität. Das ist eine Einladung für inkonsistente Daten.

Was passiert ohne FK-Constraints? Wird ein Datensatz gelöscht, bleiben alle abhängigen Datensätze einfach stehen. Ein Post wird gelöscht, die Kommentare existieren weiter, referenzieren aber nichts mehr. Was will man mit Kommentaren ohne Post? Mit Orders ohne Customer? Das sind keine Daten mehr, das ist Datenmüll der die Tabellen aufbläht und Abfragen verfälscht.

Der andere Punkt: Ohne FK-Constraints muss die Anwendung das Löschen selbst koordinieren.

// Manuell in PHP: fehleranfällig, langsam, unvollständig
$db->delete('comments', ['post_id' => $postId]);
$db->delete('likes', ['post_id' => $postId]);
$db->delete('posts', ['id' => $postId]);

Das erzeugt Last in PHP, mehrere Datenbankaufrufe und das Risiko dass bei einem Fehler mittendrin die Hälfte gelöscht ist und die andere nicht. Die Datenbank kann das besser, schneller und transaktionssicher:

-- FK mit CASCADE DELETE: ein Befehl, konsistentes Ergebnis
ALTER TABLE comments
ADD CONSTRAINT fk_comments_post
FOREIGN KEY (post_id) REFERENCES posts(id)
ON DELETE CASCADE;

-- Jetzt reicht ein einziger Delete
DELETE FROM posts WHERE id = 42;
-- Kommentare, Likes, alles abhängige verschwindet automatisch

Ein Befehl. Keine PHP-Schleife. Keine halbgelöschten Zustände. Die Datenbank übernimmt was sie am besten kann.

Und noch ein Vorteil der gerne übersehen wird: Wer seine Datenbank konsequent mit Fremdschlüsseln ausstattet bekommt ein sauberes Entity-Relationship-Modell quasi geschenkt. Jedes gute Datenbank-Tool kann daraus automatisch ein ERM generieren: Struktur, Beziehungen, Abhängigkeiten, alles sichtbar ohne dass jemand eine Zeile Dokumentation geschrieben hat. Ein neuer Entwickler versteht die Datenbankstruktur in Minuten statt Stunden. Kein veraltetes Wiki, kein „frag mal den der das gebaut hat“ — die Datenbank dokumentiert sich selbst.

Ein Thema das direkt damit zusammenhängt und das eine eigene Betrachtung verdient: Transaktionen. Viele Systeme nutzen sie nicht konsequent — manche Entwickler kennen sie kaum. Dabei sind sie neben FK-Constraints das wichtigste Werkzeug um sicherzustellen dass Daten entweder vollständig und korrekt geschrieben werden oder gar nicht. Dazu mehr in Teil 2.

2. Fehlende Indizes auf Fremdschlüsseln

FK-Constraints sorgen für Integrität — aber für Performance braucht es zusätzlich den passenden Index. Ohne Index macht die Datenbank bei jedem JOIN einen Full Table Scan und liest die gesamte Tabelle um die passenden Zeilen zu finden.

-- Ohne Index: Full Table Scan bei jedem JOIN
SELECT orders.* FROM orders
JOIN users ON orders.user_id = users.id
WHERE users.status = 'active';

-- Fix
CREATE INDEX idx_orders_user_id ON orders(user_id);

Klingt trivial. Wird trotzdem regelmäßig vergessen — besonders in Projekten die organisch gewachsen sind und bei denen Tabellen nachträglich um Relationen erweitert wurden. Ein EXPLAIN ANALYZE zeigt sofort ob ein Seq Scan dort auftaucht wo ein Index Scan sein sollte.

3. LIKE mit führendem Wildcard

-- Kein Index nutzbar — immer Full Table Scan
WHERE name LIKE '%mueller%'

-- Index nutzbar
WHERE name LIKE 'mueller%'

Ein führender Wildcard macht jeden Index auf der Spalte wertlos. Die Datenbank kann nicht von vorne in den Index einsteigen, sie muss alles lesen. Wer echte Volltextsuche braucht sollte FULLTEXT-Indizes nutzen oder eine dedizierte Suchlösung. Für einfache Präfixsuchen reicht der trailing Wildcard und ein normaler Index.

4. Daten zu spät einschränken

CTEs sind gut für Lesbarkeit und Struktur. Aber sie verführen dazu Filter ans Ende zu stellen statt früh einzubauen.

-- Lädt alle Orders, filtert erst danach
WITH alle_orders AS (
    SELECT * FROM orders
)
SELECT * FROM alle_orders WHERE status = 'open';

-- Besser: Filter direkt in die CTE
WITH offene_orders AS (
    SELECT * FROM orders WHERE status = 'open'
)
SELECT * FROM offene_orders;

Das Prinzip gilt überall: Je früher Daten eingeschränkt werden desto weniger muss die Datenbank durch den Rest der Abfrage schleppen. Bei kleinen Tabellen kaum messbar. Bei großen Tabellen der Unterschied zwischen Millisekunden und Minuten.

Ein Zusatztipp: Was sich auch bewährt hat: Erst eine Einzelabfrage, Wert in PHP auslesen und dann die gezielte Abfrage mit dem konkreten Wert durchzuführen.

-- mithilfe von PHP die Anfrage aufsplitten in zwei saubere kleine SQLs
// Schritt 1: Gezielter Wert holen
$userId = DB::table('users')
    ->where('email', 'dennis@example.com')
    ->value('id');

// Schritt 2: Konkrete Abfrage mit dem Wert — kein Subquery, kein JOIN
$orders = DB::table('orders')
    ->where('user_id', $userId)
    ->get();



-- statt verschachteltes sql, was bei größeren Konstrukten auch schwerer wartbar ist
SELECT * FROM orders
WHERE user_id = (
    SELECT id FROM users WHERE email = 'dennis@example.com'
);

5. is_deleted statt echter Löschstrategie

-- Soft Delete: fühlt sich nach Löschen an — ist es aber nicht
UPDATE orders SET is_deleted = 1 WHERE id = 42;

-- Hard Delete mit CASCADE
DELETE FROM posts WHERE id = 42;

Soft Deletes mit is_deleted = 1 fühlen sich sicher an. In der Praxis sind sie eine Quelle für mehrere Probleme gleichzeitig.

Das offensichtliche: Die Tabelle wächst unbegrenzt, Indizes werden größer, Abfragen müssen überall WHERE is_deleted = 0 tragen — und irgendwann vergisst jemand genau diesen Filter. Das Ergebnis sind vermeintlich gelöschte Datensätze die plötzlich wieder auftauchen.

Das gravierendere: Soft Deletes erzeugen eine gefährliche Illusion. Der Nutzer klickt auf „Account löschen“ und bekommt eine Bestätigung. Im Frontend verschwindet alles. In der Datenbank steht noch alles: Vollständig, auslesbar, in jedem Backup. Das ist kein Löschen. Das ist Verstecken.

Die DSGVO kennt das Recht auf Vergessenwerden. Ein is_deleted = 1 erfüllt es nicht. Die Daten sind noch da, sie sind nur aus der Ansicht genommen. Und bei einem Datenbankabfluss, einem Leak, einem Angriff, einem ungesicherten Backup, werden genau diese vermeintlich gelöschten Daten mitgegeben. Daten von Nutzern die längst „gelöscht“ haben wollten.

Wer eine Löschhistorie für interne Zwecke braucht führt eine separate Audit-Tabelle mit anonymisierten Metadaten. Wer CASCADE DELETE mit referenzieller Integrität nutzt bekommt konsistente Daten und echte Löschung: Ohne Flags, ohne Risiko, ohne rechtliche Grauzone.

Gelöscht ist gelöscht. Das ist kein Nachteil. Das ist das System das funktioniert wie es soll.

Fazit

Datenbankprobleme entstehen selten durch einen einzelnen Fehler. Es ist die Summe: ein fehlender Constraint hier, ein fehlender Index dort, eine Filterbedingung die zu spät kommt. In gewachsenen Projekten kumuliert das über Jahre und irgendwann fragt jemand warum die Anwendung so langsam ist oder warum Daten nicht mehr stimmen.

Der einfachste erste Schritt: EXPLAIN ANALYZE auf die langsamsten Abfragen. Die Antwort steht meistens in der ersten Zeile des Query Plans.

Teil 2 widmet sich Transaktionen — warum sie existieren, wann sie zwingend nötig sind und was passiert wenn sie fehlen.

Teil 3 geht einen Schritt weiter: Datenbanksicherheit. Warum root ohne Passwort keine Option ist, warum sensible Spalteninhalte verschlüsselt gehören — und warum ein unverschlüsseltes Backup die gesamte Sicherheit einer Anwendung aushebelt.

Datenbank läuft, aber nicht wie sie sollte?

Ich schaue mir das an. PHP, Laravel, MySQL — ich kenne die Stellen die man auch ohne Dokumentation kennen muss. Und ich weiß wo man zuerst hinschaut.

FAQ

Kann ich eine Datenbank auch ohne EXPLAIN ANALYZE optimieren?

Ja — bis zu einem gewissen Grad. Indizes nachrüsten, Queries umschreiben, direkt in phpMyAdmin testen und schauen wann die Datenbank schneller antwortet: das geht immer und bringt oft schon viel. Ab einem gewissen Punkt hilft EXPLAIN ANALYZE aber weiter um Potenzial zu heben das nicht auf den ersten Blick sichtbar ist — vor allem bei Abfragen mit JOINs über mehrere Tabellen.

Kann ich Fremdschlüssel in bestehende Datenbanken nachträglich einbauen?

Ja — aber nur wenn die Daten bereits konsistent sind. Gibt es verwaiste Datensätze, schlägt das ALTER TABLE fehl. Der saubere Weg: Erst Datenmüll bereinigen, dann Constraints setzen. Das ist auch eine gute Gelegenheit um den aktuellen Zustand der Datenbank zu prüfen.

Ist is_deleted in manchen Fällen trotzdem sinnvoll?

Aus meiner Sicht: Nein. Es gibt keinen Anwendungsfall der sich nicht besser lösen lässt. Ergeben sich aus gesetzlichen Aufbewahrungspflichten Anforderungen, ist eine separate Archivtabelle die sauberere Lösung: Sie hält die Produktionstabelle schlank und kommt den Anforderungen sogar näher, weil mit Aufbewahrungspflichten meist auch mit Unveränderlichkeit der Daten einhergeht. Ein boolean-Flag in der Produktionstabelle erfüllt das nicht, weil damit die Daten immer noch im Produktivsystem stehen und somit potentiell veränderlich wären.

Aus der Praxis
Eine Datenbank mit 65 Tabellen und über 200 Millionen Datensätzen. Abfragen die mehrere Minuten liefen. Die Ursache war fast immer dieselbe: fehlende Indizes, zu späte Filter, SELECT * auf Tabellen mit großen Feldern. Nach strukturierter Analyse und gezielten Fixes lagen die kritischen Abfragen unter einer Sekunde. Keine neue Hardware, kein neues System — nur saubere Abfragen.

Weitere Beiträge zu diesem Thema

Kommentare

Kommentar verfassen