Grundlagen

Relationale Datenbanken

Tabellen, Schlüssel und SQL – und die zwei Dinge, die im Alltag wirklich zählen: warum ein Index aus zehn Millionen Lesezugriffen vier macht und warum zwei gleichzeitige Buchungen trotz Transaktion denselben letzten Platz bekommen können.

Lesestand

Eine relationale Datenbank speichert Daten in Tabellen: Zeilen sind Datensätze, Spalten sind Eigenschaften. Jede Zeile hat einen Primärschlüssel, und Tabellen verweisen über Fremdschlüssel aufeinander. Abgefragt wird mit SQL – man beschreibt, was man will, die Datenbank entscheidet, wie sie es holt.

Zwei Dinge machen den Unterschied in der Praxis: Indizes – meist B-Bäume, die eine Suche in einer Milliarde Zeilen mit vier Lesezugriffen erledigen. Und Transaktionen – sie garantieren, dass zusammengehörige Änderungen ganz oder gar nicht passieren. Wie gut sich gleichzeitige Transaktionen voneinander abschirmen, regelt das Isolationslevel – und das Standardlevel schützt weniger, als die meisten denken.

Tabellen, Schlüssel, Beziehungen

Ein kleiner Online-Shop: Kunden geben Bestellungen auf, eine Bestellung enthält mehrere Positionen, jede Position verweist auf einen Artikel.

kunde🔑 idnameemailbestellung🔑 idkunde_id →datumposition🔑 idbestellung_id →artikel_id →mengeartikel🔑 idnamepreisEin Kunde – viele Bestellungen. Eine Bestellung – viele Positionen.Der Fremdschlüssel verhindert Bestellungen für Kunden, die es nicht gibt.

Warum die Daten auf mehrere Tabellen verteilt werden, statt Name und E-Mail des Kunden in jede Bestellung zu schreiben? Weil sonst eine geänderte E-Mail-Adresse an hundert Stellen geändert werden müsste – und irgendwann irgendwo nicht. Diese Aufteilung heißt Normalisierung: jede Tatsache genau einmal. Für Auswertungen, bei denen nur gelesen wird, verdoppelt man manchmal bewusst Daten, um Abfragen zu beschleunigen – das ist dann Denormalisierung.

SQL und JOINs

Verteilte Daten muss man beim Lesen wieder zusammenführen. Das macht der JOIN: Er verknüpft Zeilen zweier Tabellen, deren Schlüssel zusammenpassen. Welche Art von JOIN man nimmt, entscheidet, was mit Zeilen ohne Partner passiert:

JOIN zum Ausprobieren

kunde

bestellung

Ergebnis

Indizes: vier Zugriffe statt zehn Millionen

Ohne Hilfe muss die Datenbank für WHERE email = '…' jede Zeile anschauen – ein Full Table Scan. Ein Index ist eine zusätzliche, sortierte Struktur, meist ein B-Baum (genauer ein B+-Baum): ein sehr breiter, sehr flacher Suchbaum, dessen Knoten genau eine Speicherseite füllen. In jedem Knoten steht, in welchem Teilbaum ein Wert liegen muss. Gesucht wird von oben nach unten – wie im Telefonbuch erst die richtige Seite, dann der Name.

Suchen mit und ohne Index
Ein Mini-B-Baum: In jedem Knoten haben nur drei Werte Platz. Echte Knoten fassen Hunderte.
Zeilen in der Tabelle

Annahmen: 100 Zeilen pro Datenseite, 500 Verweise pro Indexknoten.

Weil jeder Knoten Hunderte Verweise hat, wächst die Tiefe des Baums extrem langsam: Von einer Million auf eine Milliarde Zeilen kommt gerade eine Ebene hinzu. Die oberen Ebenen liegen ohnehin fast immer im Arbeitsspeicher. Und weil die Blätter sortiert und miteinander verkettet sind, helfen B-Bäume auch bei Bereichen (BETWEEN, >) und bei ORDER BY.

Umsonst ist ein Index nicht: Jedes INSERT, UPDATE und DELETE muss ihn mitpflegen, und er braucht Platz. Ob die Datenbank ihn nutzt, sieht man mit EXPLAIN – das zeigt den Ausführungsplan, den der Abfrageoptimierer gewählt hat.

Transaktionen und ACID

Eine Buchung besteht aus mehreren Schritten: Platz reduzieren, Buchung anlegen, vielleicht Zahlung vermerken. Eine Transaktion fasst sie zusammen. Sie garantiert vier Dinge, zusammengefasst als ACID:

A

Atomarität

Alles oder nichts. Stürzt etwas mittendrin ab, wird alles zurückgerollt.

C

Konsistenz

Nach der Transaktion gelten alle Regeln wieder – Fremdschlüssel, Eindeutigkeit, Prüfbedingungen.

I

Isolation

Gleichzeitige Transaktionen stören sich nicht – in welchem Maß, bestimmt das Isolationslevel.

D

Dauerhaftigkeit

Was bestätigt ist, übersteht auch einen Stromausfall. Dafür schreibt die Datenbank jede Änderung zuerst in ein Write-Ahead-Log und erzwingt es mit fsync – dasselbe Prinzip wie das Journal im Dateisystem.

Der letzte freie Platz

Das „I“ ist das tückischste. Die meisten Datenbanken – PostgreSQL, Oracle, SQL Server – arbeiten standardmäßig mit READ COMMITTED: Jede Abfrage sieht alles, was andere bis dahin bestätigt haben. Das verhindert viel, aber nicht alles. Zwei Kunden buchen gleichzeitig den letzten Platz eines Flugs. Probier die Varianten durch:

Zwei Buchungen, ein Platz

T1 bucht für Anna, T2 für Ben. Verhalten am Beispiel PostgreSQL; andere Datenbanken unterscheiden sich in Details.

IsolationslevelWas es verhindertStandard bei
READ UNCOMMITTEDpraktisch nichts – man sieht sogar Unbestätigtes–
READ COMMITTEDLesen von UnbestätigtemPostgreSQL, Oracle, SQL Server
REPEATABLE READzusätzlich: Ein zweites Lesen derselben Zeile liefert dasselbeMySQL (InnoDB)
SERIALIZABLEalles: Ergebnis wie bei strikt nacheinander ausgeführten Transaktionen – mit dem Preis, dass manche abbrechen und wiederholt werden müssen–

Für Entwickler übersetzt

void buche(long flugId, long kundeId) throws SQLException {
    try (Connection con = dataSource.getConnection()) {      // aus dem Pool, z. B. HikariCP
        con.setAutoCommit(false);                            // Transaktion beginnt
        try {
            try (var ps = con.prepareStatement(
                    "UPDATE flug SET frei = frei - 1 WHERE id = ? AND frei > 0")) {
                ps.setLong(1, flugId);
                if (ps.executeUpdate() == 0) throw new AusgebuchtException(flugId);
            }
            try (var ps = con.prepareStatement(
                    "INSERT INTO buchung (flug_id, kunde_id) VALUES (?, ?)")) {
                ps.setLong(1, flugId);
                ps.setLong(2, kundeId);
                ps.executeUpdate();
            }
            con.commit();
        } catch (SQLException | RuntimeException e) {
            con.rollback();
            throw e;
        }
    }
}
ThemaWas man wissen sollte
PreparedStatementWerte immer als Parameter übergeben, nie per String-Verkettung ins SQL – sonst SQL-Injection.
Auto-CommitJDBC bestätigt standardmäßig jede einzelne Anweisung sofort. Für eine Transaktion über mehrere Anweisungen muss man es abschalten – Spring erledigt das mit @Transactional.
Optimistisches LockingIn JPA genügt ein Feld mit @Version. Bei einem Konflikt kommt eine OptimisticLockException, die man mit einem neuen Versuch beantwortet.
Das N+1-ProblemLädt man 100 Bestellungen per JPA und greift dann auf bestellung.getKunde() zu, feuert Hibernate oft 100 einzelne Abfragen hinterher. Abhilfe: JOIN FETCH in der Abfrage oder ein @EntityGraph. Im SQL-Log sofort zu erkennen.
Connection-PoolEine Verbindung aufzubauen kostet einen Netzwerk-Handshake und eine Anmeldung. Pools halten Verbindungen offen. Zu große Pools schaden aber: Die Datenbank hat nur begrenzt Kerne.

Häufige Irrtümer

„Eine Transaktion schützt vor allen Konflikten.“ – Unter READ COMMITTED geht im naiven „lesen, prüfen, schreiben“ trotzdem eine Änderung verloren. Man braucht Sperren, ein atomares UPDATE, eine Versionsspalte oder SERIALIZABLE.
„Ein Index auf (nachname, vorname) hilft auch bei der Suche nach dem Vornamen.“ – Meist nicht. Ein zusammengesetzter Index ist nach der ersten Spalte sortiert, wie ein Telefonbuch: Nach Vornamen sucht man darin auch Seite für Seite.
„Mehr Indizes, mehr Tempo.“ – Beim Lesen vielleicht. Jedes Schreiben wird langsamer, und auf Spalten mit wenigen Werten – etwa einem Ja/Nein-Feld – nützt ein Index kaum.
„WHERE spalte = NULL findet leere Felder.“ – Es findet nichts. NULL bedeutet „unbekannt“, und unbekannt = unbekannt ist wieder unbekannt. Richtig ist IS NULL.
„Mit einem ORM muss ich kein SQL können.“ – Bis die erste Seite langsam ist. Wer nicht ins SQL-Log und in den Ausführungsplan schaut, findet N+1-Probleme und fehlende Indizes nie.

Verwandte Begriffe