PostgreSQL
 sql >> Datenbank >  >> RDS >> PostgreSQL

Ist SELECT oder INSERT in einer Funktion anfällig für Racebedingungen?

Es ist das wiederkehrende Problem von SELECT oder INSERT unter möglicher gleichzeitiger Schreiblast, bezogen auf (aber anders als) UPSERT (das ist INSERT oder UPDATE ).

Diese PL/pgSQL-Funktion verwendet UPSERT (INSERT ... ON CONFLICT .. DO UPDATE ) zu INSERT oder SELECT eine einzelne Zeile :

CREATE OR REPLACE FUNCTION f_tag_id(_tag text, OUT _tag_id int)
  LANGUAGE plpgsql AS
$func$
BEGIN
   SELECT tag_id  -- only if row existed before
   FROM   tag
   WHERE  tag = _tag
   INTO   _tag_id;

   IF NOT FOUND THEN
      INSERT INTO tag AS t (tag)
      VALUES (_tag)
      ON     CONFLICT (tag) DO NOTHING
      RETURNING t.tag_id
      INTO   _tag_id;
   END IF;
END
$func$;

Es gibt noch ein winziges Fenster für eine Rennbedingung. Um absolut sicher zu sein wir bekommen eine ID:

CREATE OR REPLACE FUNCTION f_tag_id(_tag text, OUT _tag_id int)
  LANGUAGE plpgsql AS
$func$
BEGIN
   LOOP
      SELECT tag_id
      FROM   tag
      WHERE  tag = _tag
      INTO   _tag_id;

      EXIT WHEN FOUND;

      INSERT INTO tag AS t (tag)
      VALUES (_tag)
      ON     CONFLICT (tag) DO NOTHING
      RETURNING t.tag_id
      INTO   _tag_id;

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

db<>hier fummeln

Dies wird so lange wiederholt, bis entweder INSERT oder SELECT gelingt.Aufruf:

SELECT f_tag_id('possibly_new_tag');

Wenn nachfolgende Befehle in derselben Transaktion sich auf die Existenz der Zeile verlassen und es tatsächlich möglich ist, dass andere Transaktionen sie gleichzeitig aktualisieren oder löschen, können Sie eine vorhandene Zeile im SELECT sperren Anweisung mit FOR SHARE .
Wenn die Zeile stattdessen eingefügt wird, ist sie ohnehin bis zum Ende der Transaktion gesperrt (oder für andere Transaktionen nicht sichtbar).

Beginnen Sie mit dem üblichen Fall (INSERT vs SELECT ), um es schneller zu machen.

Verwandte:

  • Id von einem bedingten INSERT erhalten
  • So schließen Sie ausgeschlossene Zeilen in RETURNING von INSERT ... ON CONFLICT ein

Verwandte (reine SQL) Lösung zu INSERT oder SELECT mehrere Zeilen (ein Satz) auf einmal:

  • Wie verwendet man RETURNING mit ON CONFLICT in PostgreSQL?

Was ist mit diesem falsch reine SQL-Lösung?

CREATE OR REPLACE FUNCTION f_tag_id(_tag text, OUT _tag_id int)
  LANGUAGE sql AS
$func$
WITH ins AS (
   INSERT INTO tag AS t (tag)
   VALUES (_tag)
   ON     CONFLICT (tag) DO NOTHING
   RETURNING t.tag_id
   )
SELECT tag_id FROM ins
UNION  ALL
SELECT tag_id FROM tag WHERE tag = _tag
LIMIT  1;
$func$;

Nicht ganz falsch, aber es schließt keine Lücke, wie @FunctorSalad herausgearbeitet hat. Die Funktion kann zu einem leeren Ergebnis führen, wenn eine gleichzeitige Transaktion versucht, dasselbe zur gleichen Zeit zu tun. Das Handbuch:

Alle Anweisungen werden mit demselben Snapshot ausgeführt

Wenn eine gleichzeitige Transaktion das gleiche neue Tag einen Moment früher einfügt, aber noch nicht festgeschrieben wurde:

  • Der UPSERT-Teil wird leer angezeigt, nachdem darauf gewartet wurde, dass die gleichzeitige Transaktion abgeschlossen wird. (Wenn die gleichzeitige Transaktion zurückgesetzt werden sollte, fügt sie dennoch das neue Tag ein und gibt eine neue ID zurück.)

  • Der SELECT-Teil ist ebenfalls leer, da er auf demselben Snapshot basiert, in dem das neue Tag aus der (noch nicht festgeschriebenen) gleichzeitigen Transaktion nicht sichtbar ist.

Wir bekommen nichts . Nicht wie beabsichtigt. Das ist kontraintuitiv für naive Logik (und da bin ich hängengeblieben), aber so funktioniert das MVCC-Modell von Postgres – muss funktionieren.

Verwenden Sie dies also nicht, wenn mehrere Transaktionen versuchen können, dasselbe Tag gleichzeitig einzufügen. Oder Schleife, bis Sie tatsächlich eine Zeile erhalten. Die Schleife wird sowieso kaum in normalen Arbeitslasten ausgelöst.

Postgres 9.4 oder älter

Angesichts dieser (leicht vereinfachten) Tabelle:

CREATE table tag (
  tag_id serial PRIMARY KEY
, tag    text   UNIQUE
);

Ein fast 100 % sicherer Funktion zum Einfügen eines neuen Tags / Auswählen eines vorhandenen Tags könnte so aussehen.

CREATE OR REPLACE FUNCTION f_tag_id(_tag text, OUT tag_id int)
  LANGUAGE plpgsql AS
$func$
BEGIN
   LOOP
      BEGIN
      WITH sel AS (SELECT t.tag_id FROM tag t WHERE t.tag = _tag FOR SHARE)
         , ins AS (INSERT INTO tag(tag)
                   SELECT _tag
                   WHERE  NOT EXISTS (SELECT 1 FROM sel)  -- only if not found
                   RETURNING tag.tag_id)       -- qualified so no conflict with param
      SELECT sel.tag_id FROM sel
      UNION  ALL
      SELECT ins.tag_id FROM ins
      INTO   tag_id;

      EXCEPTION WHEN UNIQUE_VIOLATION THEN     -- insert in concurrent session?
         RAISE NOTICE 'It actually happened!'; -- hardly ever happens
      END;

      EXIT WHEN tag_id IS NOT NULL;            -- else keep looping
   END LOOP;
END
$func$;

db<>hier fummeln
Altes sqlfiddle

Warum nicht 100%? Beachten Sie die Hinweise im Handbuch zum zugehörigen UPSERT Beispiel:

  • https://www.postgresql.org/docs/current/plpgsql-control-structures.html#PLPGSQL-UPSERT-EXAMPLE

Erklärung

  • Versuchen Sie es mit SELECT zuerst . So vermeiden Sie das deutlich Teuerere Ausnahmebehandlung in 99,99 % der Fälle.

  • Verwenden Sie einen CTE, um das (ohnehin winzige) Zeitfenster für die Race-Condition zu minimieren.

  • Das Zeitfenster zwischen SELECT und das INSERT innerhalb einer Abfrage ist superwinzig. Wenn Sie keine hohe gleichzeitige Last haben oder wenn Sie einmal im Jahr mit einer Ausnahme leben können, können Sie den Fall einfach ignorieren und die SQL-Anweisung verwenden, die schneller ist.

  • FETCH FIRST ROW ONLY ist nicht erforderlich (=LIMIT 1 ). Der Tag-Name ist offensichtlich UNIQUE .

  • Entfernen Sie FOR SHARE in meinem Beispiel, wenn Sie normalerweise nicht gleichzeitig DELETE haben oder UPDATE auf der Tabelle tag . Kostet ein klein wenig Performance.

  • Zitieren Sie niemals den Sprachnamen:'plpgsql' . plpgsql ist eine Kennung . Zitieren kann Probleme verursachen und wird nur aus Gründen der Abwärtskompatibilität toleriert.

  • Verwenden Sie keine nicht aussagekräftigen Spaltennamen wie id oder name . Wenn Sie an ein paar Tischen teilnehmen (was Sie tun in einer relationalen DB) erhalten Sie am Ende mehrere identische Namen und müssen Aliase verwenden.

Integriert in Ihre Funktion

Mit dieser Funktion können Sie Ihre FOREACH LOOP stark vereinfachen zu:

...
FOREACH TagName IN ARRAY $3
LOOP
   INSERT INTO taggings (PostId, TagId)
   VALUES   (InsertedPostId, f_tag_id(TagName));
END LOOP;
...

Allerdings schneller als einzelne SQL-Anweisung mit unnest() :

INSERT INTO taggings (PostId, TagId)
SELECT InsertedPostId, f_tag_id(tag)
FROM   unnest($3) tag;

Ersetzt die gesamte Schleife.

Alternative Lösung

Diese Variante baut auf dem Verhalten von UNION ALL auf mit einem LIMIT Klausel:Sobald genügend Zeilen gefunden wurden, wird der Rest nie ausgeführt:

  • Möglichkeit, mehrere SELECTs auszuprobieren, bis ein Ergebnis verfügbar ist?

Darauf aufbauend können wir das INSERT auslagern in eine eigene Funktion. Nur dort brauchen wir eine Ausnahmebehandlung. Genauso sicher wie die erste Lösung.

CREATE OR REPLACE FUNCTION f_insert_tag(_tag text, OUT tag_id int)
  RETURNS int
  LANGUAGE plpgsql AS
$func$
BEGIN
   INSERT INTO tag(tag) VALUES (_tag) RETURNING tag.tag_id INTO tag_id;

   EXCEPTION WHEN UNIQUE_VIOLATION THEN  -- catch exception, NULL is returned
END
$func$;

Was in der Hauptfunktion verwendet wird:

CREATE OR REPLACE FUNCTION f_tag_id(_tag text, OUT _tag_id int)
   LANGUAGE plpgsql AS
$func$
BEGIN
   LOOP
      SELECT tag_id FROM tag WHERE tag = _tag
      UNION  ALL
      SELECT f_insert_tag(_tag)  -- only executed if tag not found
      LIMIT  1  -- not strictly necessary, just to be clear
      INTO   _tag_id;

      EXIT WHEN _tag_id IS NOT NULL;  -- else keep looping
   END LOOP;
END
$func$;
  • Dies ist etwas billiger, wenn die meisten Aufrufe nur SELECT benötigen , weil der teurere Block mit INSERT mit der EXCEPTION Klausel wird selten eingegeben. Auch die Abfrage ist einfacher.

  • FOR SHARE ist hier nicht möglich (nicht erlaubt in UNION Abfrage).

  • LIMIT 1 wäre nicht erforderlich (getestet in S. 9.4). Postgres leitet LIMIT 1 ab von INTO _tag_id und wird nur ausgeführt, bis die erste Zeile gefunden wird.