PostgreSQL
 sql >> Base de données >  >> RDS >> PostgreSQL

SELECT ou INSERT est-il dans une fonction sujette à des conditions de concurrence ?

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 le INSERT 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 évidemment UNIQUE .

  • Supprimer FOR SHARE dans mon exemple si vous n'avez généralement pas DELETE simultané ou UPDATE sur la table tag . 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 ou name . 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 avec INSERT contenant l'EXCEPTION clause est rarement saisie. La requête est également plus simple.

  • FOR SHARE n'est pas possible ici (non autorisé dans UNION requête).

  • LIMIT 1 ne serait pas nécessaire (testé à la page 9.4). Postgres dérive LIMIT 1 de INTO _tag_id et ne s'exécute que jusqu'à ce que la première ligne soit trouvée.