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 dasINSERT
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 offensichtlichUNIQUE
. -
Entfernen Sie
FOR SHARE
in meinem Beispiel, wenn Sie normalerweise nicht gleichzeitigDELETE
haben oderUPDATE
auf der Tabelletag
. 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
odername
. 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 mitINSERT
mit derEXCEPTION
Klausel wird selten eingegeben. Auch die Abfrage ist einfacher. -
FOR SHARE
ist hier nicht möglich (nicht erlaubt inUNION
Abfrage). -
LIMIT 1
wäre nicht erforderlich (getestet in S. 9.4). Postgres leitetLIMIT 1
ab vonINTO _tag_id
und wird nur ausgeführt, bis die erste Zeile gefunden wird.