Die meisten Teams führen langsame Datenbanken auf zu wenig CPU, zu große Tabellen oder ein träges Netzwerk zurück. Oft ist das falsch. Ein dokumentierter Fall: Ein Nachtjob lädt 100.000 Zeilen aus einer Datei in Postgres und braucht 41 Minuten. Die Maschine hat kaum Last, die Zeilen sind klein, die Tabelle hat nur drei Indizes. Das sind ungefähr 40 Zeilen pro Sekunde. Wer so eine Zahl sieht, vermutet einen Defekt. Es liegt keiner vor. Der Job schickt 100.000 einzelne Statements, jedes in einer eigenen Transaktion, über eine Leitung mit 0,4 ms Latenz. Jede dieser Transaktionen wartet einzeln darauf, dass das Speichermedium den Schreibvorgang bestätigt – daher die 41 Minuten. Stellt man auf COPY um, sinkt die Laufzeit auf 11 Sekunden. Die Ursache liegt woanders.
Die Arbeit steckt nicht in der Zeile, sondern rundherum
Bei einem kleinen Insert ist das Einfügen selbst nicht der teure Teil. Der Aufwand entsteht rundherum. Jedes Statement kostet einen Netzwerk-Roundtrip. Der Server parst den SQL-Text, plant die Ausführung und führt sie aus. Die Transaktionsmaschinerie öffnet, protokolliert und committet. Danach geht das Ergebnis zurück, der Client beginnt mit der nächsten Zeile. Der reine Insert dauert rund 20 Mikrosekunden. Die übrigen 980 Mikrosekunden sind Overhead. Batching setzt genau hier an: Man zahlt den Overhead nicht pro Zeile, sondern pro Batch. Das ist dieselbe Rechnung wie beim N+1-Problem, bei dem 400 schnelle Queries teurer sind als eine langsame. Lesen und Schreiben folgen derselben Logik: Übersteigt der Overhead die eigentliche Arbeit, ist die Anzahl der Operationen die Stellschraube.
Die Batch-API, die nicht batcht
Der folgende JDBC-Code sieht auf den ersten Blick korrekt aus. Er nutzt das Standardrezept für JDBC-Batching. Mit MySQL-Standardeinstellungen schickt er trotzdem 100.000 einzelne INSERT-Statements. Der Treiber nimmt sie in einen Batch auf und sendet sie einzeln, weil er Multi-Value-Statements nur nach expliziter Aktivierung baut. Der entscheidende Parameter ist rewriteBatchedStatements=true in der JDBC-URL. Ohne ihn ist addBatch eine clientseitige Bequemlichkeit, die am Draht nichts bewirkt. Mit ihm kombiniert der Treiber die Zeilen zu INSERT INTO events VALUES (...), (...), (...). Die Zahl der Roundtrips bricht ein. Bei Postgres heißt der Parameter reWriteBatchedInserts=true. Beide sind standardmäßig deaktiviert. Beide erzeugen Code, der Reviews besteht, Tests besteht und sich verhält, als hätte er nie gebatcht. In der Praxis hat dieser Schalter mehrfach den kompletten Inhalt eines Performance-Fixes ausgemacht – die API weist nicht darauf hin. Setze eine Batch-Größe explizit, statt alle Zeilen auf einmal zu sammeln. Sonst baut der Treiber die gesamte Anweisung im Speicher auf. Zwischen 500 und 5.000 Zeilen pro Batch ist ein guter Bereich. Darüber wird das Statement so groß, dass Speicher und Parse-Zeit den Gewinn auffressen.
Autocommit verwandelt jede Zeile in eine Barriere
setAutoCommit(false) bewirkt mehr, als der Name vermuten lässt. Bei aktivem Autocommit ist jeder Insert eine eigene Transaktion. Jede Transaktion committet, und jeder Commit ist eine Durability-Barriere: Das Speichermedium muss bestätigen, dass der Write-Ahead-Log-Eintrag sicher liegt. Diese Barriere ist keine CPU-Arbeit, die man parallelisieren könnte. Sie ist Wartezeit auf das Gerät und begrenzt den Durchsatz auf etwa einen Commit pro Flush-Latenz. 100.000 autocommittete Inserts erzeugen 100.000 Flushes. Verpackt man die gesamte Last in eine Transaktion, gibt es nur eine Barriere für den kompletten Lauf. Die WAL-Records werden weiter geschrieben, geflusht wird am Ende. Das hat Grenzen: Eine Transaktion über 100 Millionen Zeilen hält Locks für die gesamte Dauer, erhält jede Zeilenversion für VACUUM und rollt bei einem einzelnen Fehler den gesamten Lauf zurück. Eine Aufteilung in Transaktionen von einigen zehntausend Zeilen bringt fast die volle Ersparnis und hinterlässt einen Wiederaufsetzpunkt.
Die Leiter und wo COPY steht
Weil jede Sprosse einen anderen Overhead entfernt, multiplizieren sich die Gewinne. Die Stufen im Überblick: Einzelsätze mit Autocommit verursachen einen Roundtrip pro Zeile, einen Parse pro Zeile und eine Transaktion pro Zeile. Einzelsätze in einer Transaktion senken die Transaktionen auf eine, lassen Roundtrips und Parses unverändert. JDBC-Batch ohne das Flag verhält sich wie Einzelsätze. JDBC-Batch mit Flag reduziert Roundtrips und Parses auf einen pro Batch. COPY und LOAD DATA INFILE streamen die Zeilen direkt: kein Roundtrip pro Zeile, kein Parse pro Zeile, eine Transaktion insgesamt. COPY ist kein schnelleres Statement, sondern ein anderer Protokollpfad. Es gibt kein SQL, das pro Zeile geparst wird, keinen Plan, keine Statement-Verwaltung. Der Client streamt, der Server schreibt. Für echte Massenladungen ist das das richtige Werkzeug. Der Unterschied ist deutlich. Aber dieser Pfad ist kein allgemeiner Schreibpfad: Ältere Postgres-Versionen unterstützen kein ON CONFLICT, generierte Schlüssel lassen sich nicht zurückgeben, und Fehler brechen den Stream ab. Ein gängiges Muster ist, via COPY in eine UNLOGGED-Tabelle zu laden und das Ergebnis mit einem einzigen INSERT ... SELECT ... ON CONFLICT in die Zieltabelle zu überführen. Das verbindet Tempo mit Konfliktbehandlung.
Dasselbe Prinzip auf der Leseseite
Lesen hat dasselbe Problem, nur unsichtbarer, weil eine Schleife mit Queries wie normaler Code aussieht. IN-Listen sollten begrenzt bleiben. Einige tausend Werte sind in Ordnung, 100.000 machen das Statement so groß, dass schon das Parsen langsam wird. Bei Postgres kann außerdem das Limit für Bind-Parameter reißen. Teile die IDs in Seiten zu tausend Werten und schicke wenige Abfragen. Auch hier gilt: Abfragen bündeln statt einzeln abfeuern.
Was du konkret prüfen solltest
Drei Checks trennen Ursache von Vermutung. Erstens: Führe den Ladevorgang mit gesetztem rewriteBatchedStatements oder reWriteBatchedInserts aus und vergleiche die Zeiten. Ändert sich etwas, hat der Code nie gebatcht. Jede weitere Optimierung wäre dann auf einer falschen Grundlage. Zweitens: Zähle Roundtrips, statt zu spekulieren. log_statement = 'all' bei Postgres oder die Query-Count-Metriken des Connection-Pools zeigen in einer einzigen Zahl, ob der Batch am Draht angekommen ist. Drittens: Prüfe Autocommit auf jedem Codepfad, der mehr als eine Handvoll Zeilen schreibt. Die meisten Treiber aktivieren es standardmäßig, und es ist der größte Multiplikator dieser Aufzählung. Alle diese Hebel sind am Aufrufort unsichtbar. Die Schleife sieht identisch aus, egal ob sie einen Roundtrip auslöst oder hunderttausend. Die Batch-API sieht identisch aus, egal ob der Treiber sie nutzt oder ignoriert. Autocommit ist eine Connection-Eigenschaft, die an anderer Stelle gesetzt wird. Der Code verrät nicht, welche Variante vorliegt. Nur der Draht kann es.
Quelle: ankit-rana.com
