È il problema ricorrente di SELECT
o INSERT
sotto possibile carico di scrittura simultaneo, relativo a (ma diverso da) UPSERT
(che è INSERT
o UPDATE
).
Questa funzione PL/pgSQL usa UPSERT (INSERT ... ON CONFLICT .. DO UPDATE
) a INSERT
o SELECT
una riga singola :
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$;
C'è ancora una piccola finestra per una condizione di gara. Per essere assolutamente sicuro otteniamo un 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<>gioca qui
Questo continua a scorrere finché non INSERT
o SELECT
riesce.Chiama:
SELECT f_tag_id('possibly_new_tag');
Se comandi successivi nella stessa transazione fare affidamento sull'esistenza della riga ed è effettivamente possibile che altre transazioni la aggiornino o la eliminino contemporaneamente, puoi bloccare una riga esistente in SELECT
dichiarazione con FOR SHARE
.
Se invece la riga viene inserita, è comunque bloccata (o non visibile per altre transazioni) fino al termine della transazione.
Inizia con il caso comune (INSERT
vs SELECT
) per renderlo più veloce.
Correlati:
- Ottieni l'ID da un INSERT condizionale
- Come includere le righe escluse in RETURNING from INSERT ... ON CONFLICT
Soluzione correlata (SQL puro) a INSERT
o SELECT
più righe (un set) subito:
- Come utilizzare RETURNING con ON CONFLICT in PostgreSQL?
Cosa c'è che non va in questo pura soluzione SQL?
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$;
Non del tutto sbagliato, ma non riesce a sigillare una scappatoia, come ha funzionato @FunctorSalad. La funzione può fornire un risultato vuoto se una transazione simultanea tenta di fare lo stesso contemporaneamente. Il manuale:
Tutte le istruzioni vengono eseguite con la stessa istantanea
Se una transazione simultanea inserisce lo stesso nuovo tag un momento prima, ma non ha ancora eseguito il commit:
-
La parte UPSERT risulta vuota, dopo aver atteso il termine della transazione simultanea. (Se la transazione simultanea deve tornare indietro, inserisce comunque il nuovo tag e restituisce un nuovo ID.)
-
Anche la parte SELECT risulta vuota, perché si basa sulla stessa istantanea, in cui il nuovo tag della transazione simultanea (ancora non vincolata) non è visibile.
Non riceviamo niente . Non come previsto. Questo è contro-intuitivo alla logica ingenua (e sono stato catturato lì), ma è così che funziona il modello MVCC di Postgres:deve funzionare.
Quindi non usarlo se più transazioni possono provare a inserire lo stesso tag contemporaneamente. Oppure ciclo finché non ottieni effettivamente una riga. In ogni caso, il ciclo non verrà quasi mai attivato nei carichi di lavoro comuni.
Postgres 9.4 o precedente
Data questa tabella (leggermente semplificata):
CREATE table tag (
tag_id serial PRIMARY KEY
, tag text UNIQUE
);
Un sicuro quasi al 100% funzione per inserire un nuovo tag / selezionarne uno esistente, potrebbe assomigliare a questo.
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<>gioca qui
Sqlfiddle vecchio
Perché non al 100%? Considera le note nel manuale per il relativo UPSERT
esempio:
- https://www.postgresql.org/docs/current/plpgsql-control-structures.html#PLPGSQL-UPSERT-EXAMPLE
Spiegazione
-
Prova il
SELECT
prima . In questo modo eviti i notevolmente più costosi gestione delle eccezioni il 99,99% delle volte. -
Usa un CTE per ridurre al minimo la fascia oraria (già minuscola) per la condizione di gara.
-
La finestra temporale tra
SELECT
e ilINSERT
entro una query è super piccolo. Se non hai un carico simultaneo pesante o se puoi vivere con un'eccezione una volta all'anno, puoi semplicemente ignorare il caso e utilizzare l'istruzione SQL, che è più veloce. -
Non c'è bisogno di
FETCH FIRST ROW ONLY
(=LIMIT 1
). Il nome del tag è ovviamenteUNIQUE
. -
Rimuovi
FOR SHARE
nel mio esempio se di solito non haiDELETE
simultaneo oUPDATE
sul tavolotag
. Costa un pochino di prestazioni. -
Non citare mai il nome della lingua:
'plpgsql'.plpgsql
è un identificatore . La citazione può causare problemi ed è tollerata solo per compatibilità con le versioni precedenti. -
Non utilizzare nomi di colonna non descrittivi come
id
oname
. Quando ti unisci a un paio di tavoli (che è quello che fai in un DB relazionale) ti ritrovi con più nomi identici e devi usare alias.
Integrato nella tua funzione
Usando questa funzione potresti semplificare ampiamente il tuo FOREACH LOOP
a:
...
FOREACH TagName IN ARRAY $3
LOOP
INSERT INTO taggings (PostId, TagId)
VALUES (InsertedPostId, f_tag_id(TagName));
END LOOP;
...
Più veloce, però, come singola istruzione SQL con unnest()
:
INSERT INTO taggings (PostId, TagId)
SELECT InsertedPostId, f_tag_id(tag)
FROM unnest($3) tag;
Sostituisce l'intero ciclo.
Soluzione alternativa
Questa variante si basa sul comportamento di UNION ALL
con un LIMIT
clausola:non appena vengono trovate righe sufficienti, il resto non viene mai eseguito:
- Come provare più SELECT finché non è disponibile un risultato?
Basandoci su questo, possiamo esternalizzare INSERT
in una funzione separata. Solo lì abbiamo bisogno della gestione delle eccezioni. Sicuro come la prima soluzione.
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$;
Che viene utilizzato nella funzione principale:
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$;
-
Questo è un po' più economico se la maggior parte delle chiamate richiede solo
SELECT
, perché il blocco più costoso conINSERT
contenente l'EXCEPTION
la clausola viene inserita raramente. Anche la query è più semplice. -
FOR SHARE
non è possibile qui (non consentito inUNION
interrogazione). -
LIMIT 1
non sarebbe necessario (testata a pag. 9.4). Postgres derivaLIMIT 1
daINTO _tag_id
e viene eseguito solo fino a quando non viene trovata la prima riga.