Dies ist eine Geschichte darüber, warum Sie Fehler niemals ignorieren sollten , wenn Sie sich in einer Transaktion in einer Datenbank befinden. Es ist keine Option, herauszufinden, wie Transaktionen richtig verwendet werden und was zu tun ist, wenn sie verwendet werden. Spoiler: Es geht um Beratungssperren in PostgreSQL!
Ich habe an einem Projekt gearbeitet, in dem Benutzer eine große Anzahl schwerer Entitäten (nennen wir sie Produkte) von einem externen Dienst in unsere Anwendung importieren können. Für jedes Produkt werden noch vielfältigere Daten von externen APIs geladen. Es ist nicht ungewöhnlich, dass ein Benutzer Hunderte von Produkten zusammen mit allen Abhängigkeiten lädt. Daher dauert der Import eines Produkts spürbar (30 bis 60 Sekunden), und der gesamte Vorgang kann lange dauern. Der Benutzer kann es leid sein, auf das Ergebnis zu warten, und er hat das Recht, jederzeit auf die Schaltfläche "Abbrechen" zu klicken. Die Anwendung sollte für die Anzahl der Produkte nützlich sein, die zu diesem Zeitpunkt heruntergeladen werden konnten.
Der „unterbrochene Import“ wird wie folgt implementiert: Zu Beginn wird für jedes Produkt ein temporärer Aufgabendatensatz auf dem Typenschild in der Datenbank erstellt. Für jedes Produkt wird eine Hintergrundimportaufgabe gestartet, die das Produkt herunterlädt, zusammen mit allen Abhängigkeiten in der Datenbank speichert (alles im Allgemeinen erledigt) und ganz am Ende seinen Aufgabendatensatz löscht. Wenn zum Zeitpunkt des Starts der Hintergrundaufgabe kein Datensatz in der Datenbank vorhanden ist, wird die Aufgabe einfach stillschweigend beendet. Um den Import abzubrechen, reicht es also aus, einfach alle Aufgaben zu löschen und fertig.
Es spielt keine Rolle, ob der Import vom Benutzer abgebrochen oder vollständig von ihm selbst abgeschlossen wurde. In jedem Fall bedeutet das Fehlen von Aufgaben, dass alles vorbei ist und der Benutzer die Anwendung verwenden kann.
Das Design ist einfach und zuverlässig, aber es gab einen kleinen Fehler. Ein typischer Fehlerbericht über ihn lautete: „Nachdem der Import abgebrochen wurde, wird dem Benutzer eine Liste seiner Waren angezeigt. Wenn Sie die Seite jedoch aktualisieren, wird die Liste der Produkte durch mehrere Einträge ergänzt. " Der Grund für dieses Verhalten ist einfach: Wenn der Benutzer auf die Schaltfläche "Abbrechen" klickte, wurde er sofort in die Liste aller Produkte übernommen. Zu diesem Zeitpunkt laufen jedoch bereits begonnene Importe bestimmter Waren noch.
Dies ist natürlich eine Kleinigkeit, aber die Benutzer waren von der Bestellung verwirrt, daher wäre es schön, sie zu beheben. Ich hatte zwei Möglichkeiten: bereits laufende Aufgaben irgendwie zu identifizieren und zu "töten" oder, wenn ich auf die Schaltfläche "Abbrechen" klicke, zu warten, bis sie abgeschlossen sind, und "ihren eigenen Tod zu sterben", bevor ich den Benutzer weiter übertrage. Ich entschied mich für den zweiten Weg - zu warten.
Transaktionssperren eilen zur Rettung
Für alle, die mit (relationalen) Datenbanken arbeiten, liegt die Antwort auf der Hand: Verwenden Sie Transaktionen !
Es ist wichtig zu beachten, dass in den meisten RDBMS Datensätze, die innerhalb einer Transaktion aktualisiert wurden, blockiert werden und für Änderungen durch andere Prozesse nicht zugänglich sind, bis diese Transaktion abgeschlossen ist. Mit SELECT FOR UPDATE
ausgewählte Datensätze werden ebenfalls gesperrt.
Genau unser Fall! Ich habe die Aufgabe des Imports einzelner Waren in eine Transaktion verpackt und den Aufgabendatensatz ganz am Anfang blockiert:
ActiveRecord::Base.transaction do task = Import::Task.lock.find_by(id: id)
Wenn der Benutzer den Import abbrechen möchte, löscht der Importstopp die Aufgaben für die noch nicht gestarteten Importe und muss auf den Abschluss der bereits vorhandenen warten:
user.import_tasks.delete_all
Einfach und elegant! Ich habe die Tests durchgeführt, den Import lokal und bei der Bereitstellung überprüft und "in den Kampf" eingesetzt.
Nicht so schnell…
Zufrieden mit meiner Arbeit war ich sehr überrascht, bald Fehlerberichte und Unmengen von Fehlern in den Protokollen zu finden. Viele Produkte wurden überhaupt nicht importiert. In einigen Fällen konnte nach Abschluss aller Importe nur noch ein einziges Produkt übrig bleiben.
Fehler in den Protokollen waren ebenfalls nicht ermutigend: PG::InFailedSqlTransaction
mit einem Backtrack, der zu dem Code führte, der die unschuldigen PG::InFailedSqlTransaction
ausführte. Was ist überhaupt los?
Nach einem Tag anstrengenden Debuggens habe ich drei Hauptursachen für die Probleme identifiziert:
- Wettbewerbsfähiges Einfügen widersprüchlicher Datensätze in die Datenbank.
- Automatische Transaktionsstornierung in PostgreSQL nach Fehlern.
- Schweigen von Problemen (Ruby-Ausnahmen) im Anwendungscode.
Problem Eins: Wettbewerbsorientiertes Einfügen von widersprüchlichen Einträgen
Da jeder Importvorgang bis zu einer Minute dauert und es viele dieser Aufgaben gibt, führen wir sie parallel aus, um Zeit zu sparen. Abhängige Warendatensätze können sich überschneiden, sofern sich alle Produkte des Benutzers auf einen einzigen Datensatz beziehen können, der einmal erstellt und dann wiederverwendet wird.
Es gibt Überprüfungen, um dieselben Abhängigkeiten im Anwendungscode zu finden und wiederzuverwenden. Wenn wir jedoch Transaktionen verwenden, sind diese Überprüfungen unbrauchbar geworden : Wenn Transaktion A einen abhängigen Datensatz erstellt hat , dieser jedoch noch nicht abgeschlossen wurde, kann Transaktion B seine Existenz nicht herausfinden und versucht, ein Duplikat zu erstellen aufnehmen.
Problem 2: Automatische Transaktionsstornierung von PostgreSQL nach Fehlern
Natürlich haben wir die Erstellung doppelter Aufgaben auf Datenbankebene mithilfe der folgenden DDL verhindert:
ALTER TABLE product_deps ADD UNIQUE (user_id, characteristics);
Wenn eine laufende Transaktion A einen neuen Datensatz einfügt und Transaktion B versucht, einen Datensatz mit denselben Werten wie die Felder user_id
und characteristics
user_id
, erhält Transaktion B einen Fehler:
BEGIN; INSERT INTO product_deps (user_id, characteristics) VALUES (1, '{"same": "value"}');
Es gibt jedoch eine Funktion, die nicht vergessen werden sollte: Transaktion B wird nach dem Erkennen eines Fehlers automatisch abgebrochen und alle darin ausgeführten Arbeiten werden den Bach runtergehen. Diese Transaktion ist jedoch immer noch in einem "fehlerhaften" Zustand geöffnet. Bei jedem Versuch, eine selbst harmloseste Anforderung auszuführen, werden jedoch nur Fehler als Antwort zurückgegeben:
SELECT * FROM products; ERROR: current transaction is aborted, commands ignored until end of transaction block
Es ist völlig unnötig zu sagen, dass alles, was bei dieser Transaktion in die Datenbank eingegeben wurde, nicht gespeichert wird:
COMMIT;
Problem drei: Stille
Zu diesem Zeitpunkt war bereits klar, dass das einfache Hinzufügen von Transaktionen zur Anwendung sie brach. Es gab keine Wahl: Ich musste in den Importcode eintauchen. Im Code fielen mir oft folgende Muster auf:
def process_stuff(data)
Der Code-Autor hier sagt uns: "Wir haben es versucht, es ist uns nicht gelungen, aber es ist okay, wir machen ohne weiter." Und obwohl die Gründe für diese Auswahl durchaus erklärbar sein können (nicht alles kann auf Anwendungsebene verarbeitet werden), macht dies eine auf Transaktionen basierende Logik unmöglich: Eine verworfene Ausführung kann nicht zum transaction
schweben und führt nicht zu einem korrekten Rollback Transaktionen (ActiveRecord fängt alle Fehler in diesem Block ab, setzt die Transaktion zurück und wirft sie erneut aus).
Perfekter Sturm
Und so kamen alle drei Faktoren zusammen, um das Perfekte zu schaffen der Sturm Fehler:
- Eine Anwendung in einer Transaktion versucht, einen widersprüchlichen Datensatz in die Datenbank einzufügen, und verursacht einen "doppelten Schlüssel" -Fehler von PostgreSQL. Dieser Fehler führt jedoch nicht dazu, dass die Transaktion in der Anwendung zurückgesetzt wird, da sie in einem der Teile der Anwendung "vertuscht" wird.
- Die Transaktion wird ungültig, aber die Anwendung weiß nichts davon und arbeitet weiter. Bei jedem Versuch, auf die Datenbank zuzugreifen, erhält die Anwendung erneut einen Fehler. Diesmal wird "aktuelle Transaktion abgebrochen". Dieser Fehler kann jedoch auch verworfen werden ...
- Sie haben wahrscheinlich bereits verstanden, dass etwas in der Anwendung weiterhin kaputt geht, aber niemand wird davon erfahren, bis die Ausführung den ersten Punkt erreicht hat, an dem es keine übermäßig gierige
rescue
und an dem der Fehler möglicherweise auftaucht, protokolliert wird. im Fehler-Tracker registriert - alles. Aber dieser Ort wird bereits sehr weit von dem Ort entfernt sein, der zur Hauptursache des Fehlers wurde, und dies allein wird das Debuggen zu einem Albtraum machen.
Alternative zu Transaktionssperren in PostgreSQL
Die Suche nach rescue
im Anwendungscode und das Umschreiben der gesamten Importlogik ist keine Option. Eine lange Zeit. Ich brauchte eine schnelle Lösung und fand sie bei den Postgres! Es verfügt über eine integrierte Lösung für Sperren, eine Alternative zum Sperren von Datensätzen in Transaktionen und Sperren für Besprechungsempfehlungen. Ich habe sie wie folgt benutzt:
Zuerst habe ich zuerst die Wrapping-Transaktion entfernt. In jedem Fall ist die Interaktion mit externen APIs (oder anderen „Nebenwirkungen“) aus dem Anwendungscode mit einer offenen Transaktion eine schlechte Idee, denn selbst wenn Sie die Transaktion zusammen mit allen Änderungen in unserer Datenbank zurücksetzen, bleiben die Änderungen in externen Systemen erhalten und die Anwendung als Ganzes kann sich in einem seltsamen und unerwünschten Zustand befinden. Mit dem Isolator-Juwel können Sie sicherstellen, dass Nebenwirkungen ordnungsgemäß von Transaktionen isoliert sind.
Dann sperre ich bei jedem Importvorgang einen eindeutigen Schlüssel für den gesamten Import (z. B. erstellt aus der Benutzer-ID und dem Hash aus dem Namen der Operationsklasse):
SELECT pg_advisory_lock_shared(42, user.id);
Gemeinsame Sperren für denselben Schlüssel können von einer beliebigen Anzahl von Sitzungen gleichzeitig ausgeführt werden.
Durch das gleichzeitige Abbrechen des Importvorgangs werden alle Aufgabeneinträge aus der Datenbank gelöscht und versucht, denselben Schlüssel exklusiv zu sperren. In diesem Fall muss sie warten, bis alle freigegebenen Sperren freigegeben sind:
SELECT pg_advisory_lock(42, user.id)
Und das ist alles! Jetzt wartet die "Stornierung", bis alle "laufenden" Importe einzelner Waren abgeschlossen sind.
Da wir jetzt nicht durch eine Transaktion verbunden sind, können wir einen kleinen Hack verwenden, um die Wartezeit auf den Abbruch des Imports zu begrenzen (falls einige Importsticks "hängen bleiben"), da es nicht gut ist, den Webserverfluss für eine lange Zeit zu blockieren (und zu erzwingen) auf Benutzer warten):
transaction do execute("SET LOCAL lock_timeout = '30s'") execute("SELECT pg_advisory_lock(42, user.id)") rescue ActiveRecord::LockWaitTimeout nil
Es ist sicher, einen Fehler außerhalb des transaction
abzufangen, da ActiveRecord die Transaktion bereits zurücksetzt .
Aber was tun mit dem wettbewerbsfähigen Einfügen identischer Datensätze?
Leider kenne ich keine Lösung, die mit wettbewerbsfähigen Beilagen gut funktionieren würde. Es gibt die folgenden Ansätze, aber alle blockieren gleichzeitige Einfügungen, bis die erste Transaktion abgeschlossen ist:
INSERT … ON CONFLICT UPDATE
(verfügbar seit PostgreSQL 9.5) in der zweiten Transaktion wird blockiert, bis die erste Transaktion abgeschlossen ist, und gibt dann den Datensatz zurück, der von der ersten Transaktion eingefügt wurde.- Sperren Sie einen allgemeinen Datensatz in einer Transaktion, bevor Sie Überprüfungen ausführen, um einen neuen Datensatz einzufügen. Hier warten wir, bis der in eine andere Transaktion eingefügte Datensatz sichtbar ist und die Validierungen nicht vollständig funktionieren können.
- Nehmen Sie eine Art allgemeine Empfehlungssperre - der Effekt ist der gleiche wie beim Blockieren eines allgemeinen Datensatzes.
Wenn Sie keine Angst haben, mit Fehlern auf Basisebene zu arbeiten, können Sie einfach den Eindeutigkeitsfehler abfangen:
def import_all_the_things
Stellen Sie einfach sicher, dass dieser Code nicht mehr in eine Transaktion eingeschlossen ist.
Warum sind sie blockiert?
Die Einschränkungen UNIQUE und EXCLUDE blockieren potenzielle Konflikte, indem sie verhindern, dass sie gleichzeitig aufgezeichnet werden. Wenn Sie beispielsweise eine eindeutige Einschränkung für eine Ganzzahlspalte haben und eine Transaktion eine Zeile mit dem Wert 5 einfügt, werden andere Transaktionen, die ebenfalls versuchen, 5 einzufügen, blockiert, aber Transaktionen, die versuchen, 6 oder 4 einzufügen, werden sofort erfolgreich, ohne zu blockieren. Da die tatsächliche tatsächliche Transaktionsisolationsstufe von PostgreSQL READ COMMITED
, ist eine Transaktion nicht berechtigt, nicht READ COMMITED
Änderungen von anderen Transaktionen READ COMMITED
. Daher kann ein INSERT
mit einem widersprüchlichen Wert erst akzeptiert oder abgelehnt werden, wenn die erste Transaktion ihre Änderungen festschreibt (dann erhält die zweite einen Eindeutigkeitsfehler) oder zurückgesetzt wird (dann ist das Einfügen in die zweite Transaktion erfolgreich). Lesen Sie mehr darüber in einem Artikel des Autors von EXCLUDE-Einschränkungen .
Verhindern Sie zukünftige Katastrophen
Jetzt wissen Sie, dass nicht der gesamte Code in eine Transaktion eingeschlossen werden kann. Es wäre schön sicherzustellen, dass in Zukunft niemand mehr solchen Code in eine Transaktion einbindet und meinen Fehler wiederholt.
Zu diesem Zweck können Sie alle Ihre Vorgänge in ein kleines Hilfsmodul einschließen, das prüft, ob die Transaktion geöffnet ist, bevor der umschlossene Vorgangscode ausgeführt wird (hier wird davon ausgegangen, dass alle Ihre Vorgänge dieselbe Schnittstelle haben - die call
).
Wenn jemand versucht, einen gefährlichen Dienst in eine Transaktion einzubinden, erhält er sofort einen Fehler (es sei denn, er schweigt natürlich).
Zusammenfassung
Die wichtigste Lektion, die gelernt werden muss: Seien Sie mit Ausnahmen vorsichtig. Behandeln Sie nicht alles hintereinander, sondern fangen Sie nur die Ausnahmen ab, mit denen Sie umgehen können, und lassen Sie den Rest in die Protokolle gelangen. Ignorieren Sie niemals Ausnahmen (nur wenn Sie nicht 100% sicher sind, warum Sie dies tun). Je früher ein Fehler bemerkt wird, desto einfacher ist das Debuggen.
Und übertreiben Sie es nicht mit Transaktionen in der Datenbank. Dies ist kein Allheilmittel. Verwenden Sie unseren Edelsteinisolator und after_commit_everywhere - damit Ihre Transaktionen absolut kinderleicht werden.
Was zu lesen
Außergewöhnlicher Rubin von Avdi Grimm . In diesem kurzen Buch erfahren Sie, wie Sie mit vorhandenen Ausnahmen in Ruby umgehen und ein Ausnahmesystem für Ihre Anwendung richtig entwerfen.
Verwenden von Atomtransaktionen zur Stromversorgung einer Idempotenten API von @Brandur. Sein Blog enthält viele nützliche Artikel über Anwendungszuverlässigkeit, Ruby und PostgreSQL.