Warum eine Banküberweisung mehr als zwei UPDATE-Anweisungen braucht
Verschieben Sie 50 € von einem Konto auf ein anderes, und die naive Version sind zwei Anweisungen: vom Konto A abziehen, dem Konto B hinzufügen. Führen Sie sie getrennt aus, und wenn der Prozess abstürzt oder die Verbindung zwischen den beiden abbricht, ist das Geld von A verschwunden und kommt nie bei B an. Die Datenbank hat genau das getan, was ihr gesagt wurde, zweimal. Das Geschäft hat trotzdem 50 € verloren.
Eine Transaktion fasst beide Anweisungen so zusammen, dass die Datenbank sie als eine Einheit behandelt: entweder passieren beide, oder keine. Diese Garantie, plus drei weitere, ist das, was “ACID” bezeichnet. Deshalb können Sie einer Datenbank Geld oder Lagerbestände anvertrauen, wo “halb erledigt” schlimmer ist als “nicht erledigt”.
Was die vier ACID-Eigenschaften garantieren
- Atomarität (Atomicity). Alle Anweisungen einer Transaktion werden zusammen committet, oder keine davon. Stürzt der Prozess nach der Abbuchung, aber vor der Gutschrift ab, macht die Datenbank auch die Abbuchung rückgängig. Es gibt keinen Teilzustand, den Sie von Hand aufräumen müssten.
- Konsistenz (Consistency). Eine Transaktion kann die Datenbank nur von einem gültigen Zustand in einen anderen überführen. Sagt eine
CHECK-Constraint, dass ein Saldo nicht negativ werden darf, darf keine Transaktion eine Zeile committen, die das verletzt, egal was der Anwendungscode versucht hat. - Isolation. Gleichzeitig laufende Transaktionen sehen die halbfertige Arbeit der anderen nicht. Wie strikt das durchgesetzt wird, ist ein Regler, keine feste Regel: Dieser Regler ist die Isolationsstufe, die weiter unten behandelt wird.
- Dauerhaftigkeit (Durability). Sobald die Datenbank meldet, dass eine Transaktion committet wurde, überlebt dieser Datensatz einen Absturz eine Sekunde später. Dafür ist das Write-Ahead-Log da: Die Änderung ist auf der Festplatte (oder repliziert), bevor der Commit zurückkehrt.
Für Atomarität und Dauerhaftigkeit sorgt die Datenbank, und die gängigen Engines setzen das korrekt um. Bei der Isolation entscheiden Sie mit. Strengere Isolation kostet Nebenläufigkeit, und diese Kosten zeigen sich in den Latenzgraphen der Produktion.
Eine Transaktion mit BEGIN, COMMIT und ROLLBACK ausführen
In PostgreSQL (und den meisten SQL-Datenbanken, mit kleinen Syntaxunterschieden) ist eine Transaktion die Menge der Anweisungen zwischen BEGIN und COMMIT:
| |
Geht vor dem COMMIT etwas schief, etwa eine Constraint-Verletzung oder eine Geschäftsregel-Prüfung im Anwendungscode, macht ROLLBACK alles rückgängig, was seit BEGIN passiert ist:
| |
Nach dem Rollback ist der Saldo von Konto 1 genau der, den er vor dem Ausführen von BEGIN hatte. Für eine lange Transaktion, bei der Sie nur einen Teil der Arbeit rückgängig machen wollen, gibt Ihnen ein Savepoint ein partielles Rollback:
| |
Außerhalb eines expliziten BEGIN führen die meisten Treiber jede Anweisung in ihrer eigenen, automatisch committeten Transaktion aus. Jede Anweisung ist für sich atomar, aber nichts verknüpft zwei Anweisungen miteinander. Lassen Sie BEGIN bei der Banküberweisung weg, und Sie haben die verlorenen 50 € wieder.
Was Isolationsstufen von Transaktionen verhindern
Zwei gleichzeitige Transaktionen können sich auf drei konkrete Arten gegenseitig stören, und Isolationsstufen werden danach definiert, welche davon sie zulassen:
| Anomalie | Was passiert |
|---|---|
| Dirty Read | Transaktion A liest eine Zeile, die B geschrieben, aber noch nicht committet hat. Rollt B zurück, hat A Daten gelesen, die nie existiert haben. |
| Non-Repeatable Read | A liest dieselbe Zeile zweimal innerhalb einer Transaktion und bekommt unterschiedliche Werte, weil B dazwischen eine Änderung committet hat. |
| Phantom Read | A führt dieselbe WHERE-Abfrage zweimal aus und bekommt eine andere Zeilenmenge, weil B dazwischen eine passende Zeile eingefügt oder gelöscht hat. |
Der SQL-Standard definiert vier Stufen rund um diese drei Anomalien:
| Isolationsstufe | Dirty Read | Non-Repeatable Read | Phantom Read |
|---|---|---|---|
| Read Uncommitted | Möglich | Möglich | Möglich |
| Read Committed | Verhindert | Möglich | Möglich |
| Repeatable Read | Verhindert | Verhindert | Möglich (vom Standard erlaubt) |
| Serializable | Verhindert | Verhindert | Verhindert |
READ UNCOMMITTED werden Sie selten wollen. PostgreSQL implementiert es nicht einmal und führt stillschweigend Read Committed aus; MySQL hält sich tatsächlich daran und zeigt Ihnen Dirty Reads. In der Praxis zählen die beiden Stufen Read Committed und Repeatable Read, und die beiden großen Open-Source-Datenbanken liefern unterschiedliche Standardwerte:
- PostgreSQL verwendet standardmäßig Read Committed. Jede Anweisung innerhalb einer Transaktion sieht einen frischen Snapshot, der beim Start dieser Anweisung erstellt wird, sodass zwei
SELECTs in derselben Transaktion unterschiedliche Daten liefern können, wenn eine andere Transaktion dazwischen committet hat. - MySQL (InnoDB) verwendet standardmäßig Repeatable Read. Der Snapshot wird nur einmal erstellt, beim ersten Lesen der Transaktion, und jedes spätere einfache
SELECTin dieser Transaktion sieht denselben Snapshot, selbst wenn eine andere Transaktion seitdem ein Update committet hat.
In PostgreSQL legen Sie die Stufe direkt nach BEGIN fest, bevor irgendeine Abfrage läuft:
| |
In MySQL ändert die SESSION-Form die Stufe für jede folgende Transaktion auf dieser Verbindung:
| |
Wie Repeatable Read einen Phantom Read in PostgreSQL blockiert
Öffnen Sie zwei psql-Sitzungen gegen dieselbe Tabelle und führen Sie das Folgende aus. Es einmal zu sehen, bleibt besser im Kopf als die Tabelle auswendig zu lernen.
Sitzung A:
| |
Sitzung B, während die Transaktion von A noch offen ist:
| |
Sitzung A, dieselbe offene Transaktion, führt dieselbe Abfrage erneut aus:
| |
Die Zeile, die B eingefügt und committet hat, taucht nicht auf. Das Repeatable Read von PostgreSQL nimmt seinen Snapshot bei der ersten Abfrage der Transaktion von A und behält ihn bis zum COMMIT, sodass neue Zeilen aus anderen Transaktionen nie erscheinen. Das ist strenger, als der Standard verlangt: Die Tabelle oben erlaubt Phantoms auf dieser Stufe, aber die Implementierung von PostgreSQL erzeugt sie nicht. Führen Sie dieselbe Sequenz mit A unter Read Committed aus, liefert das zweite SELECT die neue Zeile, weil Read Committed pro Anweisung einen frischen Snapshot nimmt.
Serializable geht weiter. Es erkennt, wenn zwei gleichzeitige Transaktionen ein Ergebnis erzeugt haben, das in keiner sequenziellen Reihenfolge entstehen könnte, und bricht eine davon mit einem Serialisierungsfehler ab (PostgreSQL liefert den SQLSTATE 40001), statt die Anomalie committen zu lassen. Das ist die Stufe, die die klassische Race Condition schließt, bei der zwei Transaktionen jeweils einen Wert lesen, auf dieser Basis entscheiden und beide schreiben. Code, der SERIALIZABLE nutzt, muss bereit sein, es erneut zu versuchen:
| |
Was strengere Isolationsstufen kosten
Der Preis für strengere Isolation ist Nebenläufigkeit:
- Schreibvorgänge bleiben bis zum Commit gesperrt, auf jeder Stufe. Ein
UPDATEnimmt in dem Moment, in dem es ausgeführt wird, eine Zeilensperre und hält sie bisCOMMIToderROLLBACK, unabhängig von der Isolationsstufe. Zusätzlich haltenRepeatable ReadundSerializabledenselben Snapshot (oder beiSerializablePrädikatssperren auf den Zeilen, die Ihre Abfragen untersucht haben) für die ganze Transaktion. Machen Sie einen langsamen externen API-Aufruf zwischenBEGINundCOMMIT, bleibt jede Zeile, die Sie geschrieben haben, gesperrt, und jede Zeile, die Sie unterSerializablegelesen haben, bleibt nachverfolgt — so lange, wie dieser Aufruf dauert. - Unter Serializable sind Wiederholungsversuche nicht optional. Es lehnt scheinbar gültige Transaktionen unter gleichzeitiger Last absichtlich ab. Fängt Ihre Anwendung Serialisierungsfehler nicht ab und versucht es nicht erneut, schlagen diese Transaktionen unter Last sporadisch fehl — der schlechteste Zeitpunkt, es herauszufinden.
Repeatable Readkann denselben40001-Fehler bei einem einfachen gleichzeitigen Update auslösen: Der Wiederholungscode ist also nicht nur fürSerializablenötig, sondern für jede Transaktion oberhalb von Read Committed. - Lange Transaktionen blockieren Schemaänderungen und lassen den Speicherplatz aufblähen. Das MVCC von PostgreSQL hält alte Zeilenversionen so lange vor, wie eine offene Transaktion sie noch brauchen könnte. Eine stundenlang offen gelassene Transaktion (ein verbreiteter Bug: eine in einem Web-Request öffnen und nie committen oder zurückrollen) verhindert, dass
VACUUMdiesen Speicherplatz zurückgewinnt, und einALTER TABLEauf einer betroffenen Tabelle wartet, bis sie fertig ist. - Nicht jeder Schreibvorgang braucht eine explizite Transaktion. Eine einzelne
UPDATE-Anweisung ist bereits atomar. Eine einzelne Anweisung inBEGIN/COMMITeinzupacken fügt einen Roundtrip ohne zusätzliche Garantie hinzu. Greifen Sie zu einer expliziten Transaktion, wenn die Korrektheit davon abhängt, dass zwei oder mehr Anweisungen zusammen gelingen oder fehlschlagen: die Banküberweisung, eine Registrierung, die sowohl eineusers- als auch eineaccounts-Zeile schreibt, eine Bestellung, die den Lagerbestand verringert und den Bestellsatz einfügt.
ORMs (Entity Framework, SQLAlchemy, ActiveRecord) öffnen normalerweise eine Transaktion pro Arbeitseinheit und committen sie für Sie. Arbeiten Sie nicht dagegen. Der Bug, auf den Sie achten sollten, ist das Gegenteil: Code, der .save() oder .commit() getrennt für zwei zusammenhängende Schreibvorgänge aufruft und damit die Atomarität verschenkt, die das ORM geliefert hätte.
Welche Isolationsstufe Sie verwenden sollten
| Wenn Ihre Last… ist | Verwenden Sie |
|---|---|
| typisches Web-App-CRUD, Lese- und Schreibzugriffe pro Request | Read Committed. Es ist aus gutem Grund der PostgreSQL-Standard: geringste Kontention, und Dirty Reads sind bereits ausgeschlossen. |
| Berichte oder Batch-Jobs, bei denen jede Abfrage in der Transaktion mit den anderen übereinstimmen muss | Repeatable Read. Jede Abfrage liest denselben Snapshot, sodass Summen und Detailzeilen mitten im Bericht nicht auseinanderlaufen können. |
| Finanztransfers, Bestandszählungen, alles, wo ein Rennen zwischen zwei Transaktionen ein Korrektheitsfehler ist, kein Performance-Ärgernis | Serializable, mit Wiederholungslogik bei 40001. Halten Sie diese Transaktionen kurz, dann bleiben die Nebenläufigkeitskosten gering. |
| Eine einzelne Anweisung ohne zusammenhängende Schreibvorgänge | Keine explizite Transaktion nötig. Die Anweisung ist bereits atomar. |
Starten Sie bei Read Committed und gehen Sie nur für die konkreten Transaktionen höher, bei denen Sie das Rennen benennen können, das Sie schließen. Das ist dieselbe Disziplin wie einen Index hinzuzufügen erst, nachdem Sie die langsame Abfrage gefunden haben. Können Sie dieses Rennen nicht in einem Satz aufschreiben, brauchen Sie noch keine strengere Stufe.