web-dev-qa-db-fra.com

Les transactions simultanées entraînent une condition de concurrence avec une contrainte unique sur l'insertion

J'ai un service Web (http api) qui permet à un utilisateur de créer une ressource de manière reposante. Après authentification et validation, je transmets les données à une fonction Postgres et lui permet de vérifier l'autorisation et de créer les enregistrements dans la base de données.

J'ai trouvé un bug aujourd'hui lorsque deux requêtes http avaient été faites dans la même seconde, ce qui a provoqué l'appel de cette fonction avec des données identiques deux fois. Il y a une clause à l'intérieur de la fonction qui fait une sélection sur une table pour voir si une valeur existe, si elle existe, alors je prends l'ID et l'utilise lors de ma prochaine opération, si ce n'est pas le cas, j'insère les données, récupère sauvegarder l'ID et ensuite l'utiliser sur l'opération suivante. Voici un exemple simple.

select id into articleId from articles where title = 'my new blog';
if articleId is null then
    insert into articles (title, content) values (_title, _content)
    returning id into articleId;
end if;
-- Continue, using articleId to represent the article for next operations...

Comme vous pouvez probablement le deviner, j'ai obtenu une lecture fantôme sur les données où les deux transactions sont entrées dans le if articleId is null then bloquer et essayé d'insérer sur la table. L'un a réussi et l'autre a explosé en raison d'une contrainte unique sur un champ.

J'ai jeté un coup d'œil à la façon de se défendre contre cela et j'ai trouvé quelques options différentes, mais aucune ne semble correspondre à nos besoins pour plusieurs raisons et j'ai du mal à trouver des alternatives.

  1. insert ... on conflict do nothing/update... J'ai d'abord regardé le on conflict option qui avait l'air bien mais la seule option est de do nothing qui ne renvoie alors pas l'ID de l'enregistrement à l'origine de la collision, et do update ne fonctionnera pas car il déclenchera le déclenchement des déclencheurs alors qu'en réalité les données n'ont pas changé. Dans certains cas, ce n'est pas un problème, mais dans de nombreux cas, cela peut invalider les sessions utilisateur, ce qui n'est pas quelque chose que nous pouvons faire.
  2. set transaction isolation level serializable; cela semble être la réponse la plus attrayante, mais même notre suite de tests peut provoquer des dépendances en lecture/écriture où, comme ci-dessus, nous voulons insérer si quelque chose n'existe pas et le retourner si c'est le cas et continuer avec d'autres opérations. Si nous avons plusieurs transactions en attente qui exécutent le code ci-dessus, cela entraînera une erreur de dépendance en lecture/écriture comme indiqué dans la transaction-iso des documents Postgres .

Comment ce type de transaction de lecture/écriture simultanée doit-il être géré?

Ni moi-même ni mon équipe ne prétend être des experts en bases de données, encore moins des experts Postgres, mais ils pensent que cela doit être un problème résolu, ou qu'une personne a rencontrée par le passé. Nous sommes ouverts à toutes suggestions. Si les informations fournies ci-dessus ne suffisent pas, veuillez commenter et j'ajouterai plus d'informations si nécessaire.

7
Elliot Blackburn

Essayez d'abord le insert, avec on conflict ... do nothing et returning id. Si la valeur existe déjà, vous n'obtiendrez aucun résultat de cette instruction, vous devez donc exécuter un select pour obtenir l'ID.

Si deux transactions tentent de le faire en même temps, l'une d'elles se bloquera sur le insert (car la base de données ne sait pas encore si l'autre transaction sera validée ou annulée), et continuera uniquement après l'autre transaction avoir fini.

5
CL.

La racine du problème est qu'avec le niveau d'isolement par défaut READ COMMITTED, Chaque UPSERT simultané (ou n'importe quelle requête, d'ailleurs) ne peut voir que les lignes qui étaient visibles au début de la requête. Le manuel:

Lorsqu'une transaction utilise ce niveau d'isolement, une requête SELECT (sans clause FOR UPDATE/SHARE) ne voit que les données validées avant le début de la requête; il ne voit jamais de données non validées ni de modifications validées lors de l'exécution des requêtes par des transactions simultanées.

Mais un UNIQUE index est absolu et doit toujours prendre en compte les lignes entrées simultanément - même les lignes encore invisibles. Vous pouvez donc obtenir une exception pour une violation unique, mais vous ne pouvez toujours pas voir la ligne en conflit dans la même requête. Le manuel:

INSERT avec une clause ON CONFLICT DO NOTHING peut avoir l'insertion ne pas procéder pour une ligne en raison du résultat d'une autre transaction dont les effets ne sont pas visibles pour l'instantané INSERT. Encore une fois, ce n'est le cas qu'en mode de lecture validée.

La "solution" brute-force à ce problème consiste à remplacer les lignes en conflit avec ON CONFLICT ... DO UPDATE. La nouvelle version de ligne est alors visible dans la même requête. Mais il y a plusieurs effets secondaires et je déconseille cela. L'un d'eux est que les déclencheurs UPDATE sont déclenchés - ce que vous voulez éviter expressément. Réponse étroitement liée à SO:

L'option restante est de démarrer une nouvelle commande (dans la même transaction), qui peut alors voir ces lignes en conflit de la requête précédente. Les deux réponses existantes le suggèrent. Le manuel encore:

Cependant, SELECT voit les effets des mises à jour précédentes exécutées dans sa propre transaction, même si elles ne sont pas encore validées. Notez également que deux commandes SELECT successives peuvent voir des données différentes, même si elles se trouvent dans une même transaction, si d'autres transactions commettent des modifications après le premier SELECT et avant le second SELECT départs.

Mais vous en voulez plus :

- Continuez, en utilisant articleId pour représenter l'article pour les opérations suivantes ...

Si des opérations d'écriture simultanées peuvent modifier ou supprimer la ligne, pour être absolument sûr, vous devez également verrouiller la ligne sélectionnée. (La ligne insérée est de toute façon verrouillée.)

Et puisque vous semblez avoir des transactions très compétitives, pour vous assurer de réussir, boucle jusqu'au succès. Enveloppé dans une fonction plpgsql:

CREATE OR REPLACE FUNCTION f_articleid(_title text, _content text, OUT _articleid int) AS
$func$
BEGIN
   LOOP
      SELECT articleid
      FROM   articles
      WHERE  title = _title
      FOR    UPDATE          -- or maybe a weaker lock 
      INTO   _articleid;

      EXIT WHEN FOUND;

      INSERT INTO articles AS a (title, content)
      VALUES (_title, _content)
      ON     CONFLICT (title) DO NOTHING  -- (new?) _content is discarded
      RETURNING a.articleid
      INTO   _articleid;

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

Explication détaillée:

3
Erwin Brandstetter

Je pense que la meilleure solution est de simplement faire l'insertion, de capturer l'erreur et de la gérer correctement. Si vous êtes prêt à gérer les erreurs, le niveau d'isolement sérialisable est (apparemment) inutile pour votre cas. Si vous n'êtes pas prêt à gérer les erreurs, le niveau d'isolement sérialisable n'aidera pas - il créera simplement encore plus d'erreurs que vous n'êtes pas prêt à gérer.

Une autre option serait de faire ON ON CONFLICT DO NOTHING, puis si rien ne se passe, effectuez le suivi en faisant la requête que vous faites déjà pour obtenir la valeur incontournable. En d'autres termes, déplacez select id into articleId from articles where title = 'my new blog'; d'une étape préventive à une étape exécutée uniquement si ON CONFLICT DO NOTHING ne fait en fait rien. S'il est possible qu'un enregistrement soit inséré puis supprimé à nouveau, vous devez le faire dans une boucle de nouvelle tentative.

3
jjanes