C'est le problème récurrent de SELECT
ou INSERT
sous une éventuelle charge d'écriture simultanée, liée à (mais différente de) UPSERT
(qui est INSERT
ou UPDATE
).
Cette fonction PL/pgSQL utilise UPSERT (INSERT ... ON CONFLICT .. DO UPDATE
) à INSERT
ou SELECT
une ligne unique :
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$;
Il y a encore une petite fenêtre pour une condition de course. Pour être absolument sûr nous obtenons 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<>jouez ici
Cela continue de boucler jusqu'à ce que INSERT
ou SELECT
réussit.Appel :
SELECT f_tag_id('possibly_new_tag');
Si des commandes ultérieures dans la même transaction compter sur l'existence de la ligne et il est en fait possible que d'autres transactions la mettent à jour ou la suppriment simultanément, vous pouvez verrouiller une ligne existante dans le SELECT
déclaration avec FOR SHARE
.
Si la ligne est insérée à la place, elle est verrouillée (ou non visible pour les autres transactions) jusqu'à la fin de la transaction de toute façon.
Commencez par le cas courant (INSERT
contre SELECT
) pour le rendre plus rapide.
Connexe :
- Obtenir l'ID d'un INSERT conditionnel
- Comment inclure des lignes exclues dans RETURNING from INSERT ... ON CONFLICT
Solution associée (pur SQL) à INSERT
ou SELECT
plusieurs lignes (un ensemble) à la fois :
- Comment utiliser RETURNING avec ON CONFLICT dans PostgreSQL ?
Qu'est-ce qui ne va pas avec ça une solution SQL pure ?
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$;
Pas tout à fait faux, mais cela ne parvient pas à sceller une échappatoire, comme @FunctorSalad l'a fait. La fonction peut produire un résultat vide si une transaction concurrente essaie de faire la même chose en même temps. Le manuel :
Toutes les instructions sont exécutées avec le même instantané
Si une transaction simultanée insère la même nouvelle balise un instant plus tôt, mais n'a pas encore été validée :
-
La partie UPSERT apparaît vide, après avoir attendu la fin de la transaction concurrente. (Si la transaction simultanée doit être annulée, elle insère toujours la nouvelle balise et renvoie un nouvel ID.)
-
La partie SELECT apparaît également vide, car elle est basée sur le même instantané, où la nouvelle balise de la transaction simultanée (encore non validée) n'est pas visible.
Nous n'obtenons rien . Pas comme prévu. C'est contre-intuitif à la logique naïve (et je me suis fait prendre là-bas), mais c'est ainsi que fonctionne le modèle MVCC de Postgres - doit fonctionner.
Ne l'utilisez donc pas si plusieurs transactions peuvent essayer d'insérer la même balise en même temps. Ou boucle jusqu'à ce que vous obteniez réellement une ligne. De toute façon, la boucle ne sera presque jamais déclenchée dans les charges de travail courantes.
Postgres 9.4 ou version antérieure
Soit ce tableau (légèrement simplifié) :
CREATE table tag (
tag_id serial PRIMARY KEY
, tag text UNIQUE
);
Un presque 100 % sécurisé fonction pour insérer une nouvelle balise / sélectionner une balise existante, pourrait ressembler à ceci.
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<>jouez ici
Vieux sqlfiddle
Pourquoi pas 100% ? Considérez les notes du manuel pour le UPSERT
associé exemple :
- https://www.postgresql.org/docs/current/plpgsql-control-structures.html#PLPGSQL-UPSERT-EXAMPLE
Explication
-
Essayez le
SELECT
premier . De cette façon, vous évitez le beaucoup plus cher gestion des exceptions 99,99 % du temps. -
Utilisez un CTE pour minimiser le créneau horaire (déjà minuscule) pour la condition de concurrence.
-
La fenêtre de temps entre le
SELECT
et leINSERT
en une seule requête est super petit. Si vous n'avez pas de charge simultanée importante, ou si vous pouvez vivre avec une exception une fois par an, vous pouvez simplement ignorer la casse et utiliser l'instruction SQL, qui est plus rapide. -
Pas besoin de
FETCH FIRST ROW ONLY
(=LIMIT 1
). Le nom de la balise est évidemmentUNIQUE
. -
Supprimer
FOR SHARE
dans mon exemple si vous n'avez généralement pasDELETE
simultané ouUPDATE
sur la tabletag
. Coûte un tout petit peu de performance. -
Ne jamais citer le nom de la langue :
'plpgsql'.plpgsql
est un identifiant . Les guillemets peuvent causer des problèmes et ne sont tolérés que pour la rétrocompatibilité. -
N'utilisez pas de noms de colonne non descriptifs comme
id
ouname
. Lorsque vous rejoignez quelques tables (c'est ce que vous faites dans une base de données relationnelle), vous vous retrouvez avec plusieurs noms identiques et devez utiliser des alias.
Intégré à votre fonction
En utilisant cette fonction, vous pourriez grandement simplifier votre FOREACH LOOP
à :
...
FOREACH TagName IN ARRAY $3
LOOP
INSERT INTO taggings (PostId, TagId)
VALUES (InsertedPostId, f_tag_id(TagName));
END LOOP;
...
Plus rapide, cependant, en tant qu'instruction SQL unique avec unnest()
:
INSERT INTO taggings (PostId, TagId)
SELECT InsertedPostId, f_tag_id(tag)
FROM unnest($3) tag;
Remplace toute la boucle.
Solution alternative
Cette variante s'appuie sur le comportement de UNION ALL
avec un LIMIT
clause :dès que suffisamment de lignes sont trouvées, le reste n'est jamais exécuté :
- Comment essayer plusieurs SELECT jusqu'à ce qu'un résultat soit disponible ?
Sur cette base, nous pouvons externaliser le INSERT
dans une fonction distincte. Seulement là, nous avons besoin de la gestion des exceptions. Aussi sûr que la première solution.
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$;
Qui est utilisé dans la fonction main :
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$;
-
C'est un peu moins cher si la plupart des appels n'ont besoin que de
SELECT
, car le bloc le plus cher avecINSERT
contenant l'EXCEPTION
clause est rarement saisie. La requête est également plus simple. -
FOR SHARE
n'est pas possible ici (non autorisé dansUNION
requête). -
LIMIT 1
ne serait pas nécessaire (testé à la page 9.4). Postgres dériveLIMIT 1
deINTO _tag_id
et ne s'exécute que jusqu'à ce que la première ligne soit trouvée.