Gleichzeitige Transaktionen führen zu einer Racebedingung mit einer eindeutigen Einschränkung beim Einfügen


7

Ich habe einen Webdienst (http api), mit dem ein Benutzer eine Ressource in Ruhe erstellen kann. Nach der Authentifizierung und Validierung übergebe ich die Daten an eine Postgres-Funktion und erlaube ihr, die Autorisierung zu überprüfen und die Datensätze in der Datenbank zu erstellen.

Ich habe heute einen Fehler gefunden, als zwei http-Anfragen innerhalb derselben Sekunde gestellt wurden, wodurch diese Funktion zweimal mit identischen Daten aufgerufen wurde. Innerhalb der Funktion gibt es eine Klausel, die eine Auswahl in einer Tabelle vornimmt, um festzustellen, ob ein Wert vorhanden ist. Wenn er vorhanden ist, nehme ich die ID und verwende sie bei meiner nächsten Operation. Wenn dies nicht der Fall ist, füge ich die Daten ein Sichern Sie die ID und verwenden Sie diese bei der nächsten Operation. Unten ist ein einfaches Beispiel.

select id into articleId from articles where title = 'my new blog';
if articleId is null then
    insert into articles (title, content) values (_title, _content)
    returning id into articleId;
end if;
-- Continue, using articleId to represent the article for next operations...

Wie Sie wahrscheinlich erraten können, habe ich ein Phantom auf die Daten gelesen, bei denen beide Transaktionen in den if articleId is null thenBlock eingegeben und versucht haben, sie in die Tabelle einzufügen. Einer war erfolgreich und der andere explodierte aufgrund einer einzigartigen Einschränkung für ein Feld.

Ich habe mich umgesehen, wie ich mich dagegen verteidigen kann, und ein paar verschiedene Optionen gefunden, aber keine scheint aus einigen Gründen unseren Bedürfnissen zu entsprechen, und ich habe Mühe, Alternativen zu finden.

  1. insert ... on conflict do nothing/update...Ich habe mir zuerst die on conflictOption angesehen, die gut aussah, aber die einzige Option ist, do nothingdie dann nicht die ID des Datensatzes zurückgibt, der die Kollision verursacht hat, und do updatenicht funktioniert, da dies dazu führt, dass Trigger ausgelöst werden, wenn in Wirklichkeit die Daten vorliegen hat sich nicht geändert. In einigen Fällen ist dies kein Problem, aber in vielen Fällen können Sitzungen Benutzersitzungen ungültig machen, was wir nicht tun können.
  2. set transaction isolation level serializable;Dies scheint die attraktivste Antwort zu sein, aber selbst unsere Testsuite kann Lese- / Schreibabhängigkeiten verursachen, bei denen wir wie oben einfügen möchten, wenn etwas nicht vorhanden ist, und es zurückgeben möchten, wenn dies der Fall ist, und weitere Vorgänge fortsetzen möchten. Wenn mehrere Transaktionen anstehen, die den obigen Code ausführen, führt dies zu einem Lese- / Schreibabhängigkeitsfehler, wie in der Transaktions-ISO der Postgres-Dokumente beschrieben .

Wie soll diese Art von gleichzeitiger Lese- / Schreibtransaktion behandelt werden?

Weder ich noch mein Team behaupten, Datenbankexperten zu sein, geschweige denn Postgres-Experten, aber ich bin der Meinung, dass dies ein gelöstes Problem sein muss oder dass eine Person in der Vergangenheit auf sie gestoßen ist. Wir sind offen für Vorschläge. Wenn die oben angegebenen Informationen nicht ausreichen, kommentieren Sie dies bitte und ich werde bei Bedarf weitere Informationen hinzufügen.


Aktualisieren Sie möglicherweise nur Spalten, für die keine Aktualisierungsauslöser angezeigt werden (und ändern Sie den Wert in diesen Spalten nicht), oder setzen Sie sie if new is not distinct from old then return new; end if;an die Spitze aller Aktualisierungsauslöser.
Jasen

Antworten:


5

Versuchen Sie das inserterste mit on conflict ... do nothingund returning id. Wenn der Wert bereits vorhanden ist, erhalten Sie kein Ergebnis dieser Anweisung. Sie müssen dann a ausführen select, um die ID zu erhalten.

Wenn zwei Transaktionen dies gleichzeitig versuchen, blockiert eine von ihnen die insert(da die Datenbank noch nicht weiß, ob die andere Transaktion festgeschrieben oder zurückgesetzt wird) und fährt erst fort, nachdem die andere Transaktion abgeschlossen wurde.


Vielen Dank dafür, eine einfache Lösung, die wir jedoch übersehen haben, da sie möglicherweise an vielen Orten durchgeführt wird. Das heißt aber nicht, dass es schlecht ist, sondern nur, dass wir noch etwas zu tun haben!
Elliot Blackburn

Dies ist zwar einfach und sollte in den meisten Fällen funktionieren, es kann jedoch keine Zeile (obwohl die Zeile vorhanden ist) oder eine Zeile in der gefunden werden SELECT, die eine gleichzeitige Transaktion bereits gelöscht hat (jedoch nicht festgeschrieben wurde). Bei starker gleichzeitiger Belastung müssen Sie möglicherweise mehr tun (Schleife), wie die anderen Antworten zeigen.
Erwin Brandstetter

@ErwinBrandstetter Ich habe das getestet. Bitte geben Sie eine Befehlsfolge an, die das Problem zeigt.
CL.

Ich habe mögliche Probleme hier ausführlich besprochen: stackoverflow.com/a/42217872/939860
Erwin Brandstetter

@ErwinBrandstetter In meinem Test lautet der Satz "Der SELECT sieht vom Beginn der Abfrage an denselben Snapshot und kann auch die noch unsichtbare Zeile nicht zurückgeben." scheint falsch zu sein. [In zwei Verbindungen: C1: BEGIN; C2: BEGIN; C1: EINFÜGEN; C2: versucht dasselbe INSERT, blockiert; C1: COMMIT; C2: entsperrt; C2: SELECT sieht die neue Zeile.] Geben Sie erneut ein Beispiel an, das die Existenz des Problems belegt.
CL.

4

Die READ COMMITTEDUrsache des Problems liegt darin, dass mit der Standardisolationsstufe jedes gleichzeitige UPSERT (oder jede andere Abfrage) nur Zeilen sehen kann, die zu Beginn der Abfrage sichtbar waren. Das Handbuch:

Wenn eine Transaktion diese Isolationsstufe verwendet, werden bei einer SELECTAbfrage (ohne FOR UPDATE/ SHARE-Klausel) nur Daten festgeschrieben, die vor Beginn der Abfrage festgeschrieben wurden. Es werden weder nicht festgeschriebene Daten noch Änderungen angezeigt, die während der Ausführung der Abfrage durch gleichzeitige Transaktionen festgeschrieben wurden.

Ein UNIQUEIndex ist jedoch absolut und muss gleichzeitig eingegebene Zeilen berücksichtigen - auch noch unsichtbare Zeilen. Sie können also eine Ausnahme für eine eindeutige Verletzung erhalten, aber die widersprüchliche Zeile in derselben Abfrage immer noch nicht sehen . Das Handbuch:

INSERTBei einer ON CONFLICT DO NOTHINGKlausel wird die Einfügung für eine Zeile möglicherweise aufgrund des Ergebnisses einer anderen Transaktion nicht fortgesetzt, deren Auswirkungen für den INSERTSnapshot nicht sichtbar sind . Auch dies ist nur im Read Committed-Modus der Fall.

Die Brute-Force- "Lösung" für dieses Problem besteht darin, widersprüchliche Zeilen mit zu überschreiben ON CONFLICT ... DO UPDATE. Die neue Zeilenversion ist dann in derselben Abfrage sichtbar. Aber es gibt mehrere Nebenwirkungen und ich würde davon abraten. Eine davon ist, dass UPDATEAuslöser ausgelöst werden - das, was Sie ausdrücklich vermeiden möchten. Eng verwandte Antwort auf SO:

Die verbleibende Möglichkeit ist , einen neuen Befehl zu starten (in derselben Transaktion), die dann sehen diese widersprüchlichen Zeilen aus der vorherige Abfrage. Beide vorhandenen Antworten legen dies nahe. Das Handbuch noch einmal:

Es werden jedoch SELECTdie Auswirkungen früherer Aktualisierungen angezeigt, die innerhalb der eigenen Transaktion ausgeführt wurden, obwohl sie noch nicht festgeschrieben wurden. Beachten Sie auch, dass zwei aufeinanderfolgende SELECTBefehle unterschiedliche Daten anzeigen können, obwohl sie sich innerhalb einer einzelnen Transaktion befinden, wenn andere Transaktionen nach dem ersten SELECTStart und vor dem zweiten SELECTStart Änderungen festschreiben.

Aber du willst mehr :

- Fahren Sie fort und verwenden Sie articleId, um den Artikel für die nächsten Operationen darzustellen ...

Wenn gleichzeitige Schreibvorgänge die Zeile möglicherweise ändern oder löschen können, müssen Sie die ausgewählte Zeile auch sperren , um absolut sicher zu sein . (Die eingefügte Zeile ist trotzdem gesperrt.)

Und da Sie anscheinend sehr wettbewerbsfähige Transaktionen haben, sollten Sie eine Schleife bis zum Erfolg durchführen , um sicherzustellen, dass Sie erfolgreich sind . Eingehüllt in eine plpgsql-Funktion:

CREATE OR REPLACE FUNCTION f_articleid(_title text, _content text, OUT _articleid int) AS
$func$
BEGIN
   LOOP
      SELECT articleid
      FROM   articles
      WHERE  title = _title
      FOR    UPDATE          -- or maybe a weaker lock 
      INTO   _articleid;

      EXIT WHEN FOUND;

      INSERT INTO articles AS a (title, content)
      VALUES (_title, _content)
      ON     CONFLICT (title) DO NOTHING  -- (new?) _content is discarded
      RETURNING a.articleid
      INTO   _articleid;

      EXIT WHEN FOUND;
   END LOOP;
END
$func$ LANGUAGE plpgsql;

Ausführliche Erklärung:


3

Ich denke, die beste Lösung besteht darin, nur die Einfügung vorzunehmen, den Fehler abzufangen und richtig damit umzugehen. Wenn Sie bereit sind, Fehler zu behandeln, ist eine serialisierbare Isolationsstufe (anscheinend) für Ihren Fall nicht erforderlich. Wenn Sie nicht auf Fehler vorbereitet sind, hilft die serialisierbare Isolationsstufe nicht - es entstehen nur noch mehr Fehler, auf die Sie nicht vorbereitet sind.

Eine andere Möglichkeit wäre, ON CONFLICT DO NOTHING auszuführen. Wenn dann nichts passiert, führen Sie anschließend die Abfrage aus, die Sie bereits ausführen, um den Wert zu erhalten, der jetzt vorhanden sein muss. Mit anderen Worten, wechseln Sie select id into articleId from articles where title = 'my new blog';von einem vorbeugenden Schritt zu einem Schritt, der nur ausgeführt wird, wenn ON CONFLICT DO NOTHING tatsächlich nichts tut. Wenn es möglich ist, einen Datensatz einzufügen und dann wieder zu löschen, sollten Sie dies in einer Wiederholungsschleife tun.


Vielen Dank dafür, ich werde einen Blick auf den On-Konflikt werfen, zunächst nichts tun und mehr über das richtige Abfangen von Postgres-Fehlern lesen und herausfinden, welche den Code am besten lesbar machen. Ich kann mir vorstellen, dass wir jetzt in unserem Code eine Kombination aus beiden verwenden werden.
Elliot Blackburn
Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.