web-dev-qa-db-fra.com

Atomic UPDATE .. SELECT dans Postgres

Je construis une sorte de mécanisme de mise en file d'attente. Il existe des lignes de données qui doivent être traitées et un indicateur d'état. J'utilise un update .. returning clause pour le gérer:

UPDATE stuff
SET computed = 'working'
WHERE id = (SELECT id from STUFF WHERE computed IS NULL LIMIT 1)
RETURNING * 

La partie sélectionnée imbriquée est-elle le même verrou que la mise à jour, ou ai-je ici une condition de concurrence critique? Si tel est le cas, la sélection interne doit-elle être un select for update?

47
kolosy

Alors que la suggestion d'Erwin est probablement la manière la plus simple d'obtenir un comportement correct (tant que vous réessayez votre transaction si vous obtenez une exception avec SQLSTATE de 40001), les applications de mise en file d'attente de par leur nature ont tendance à mieux fonctionner avec le blocage des demandes pour avoir une chance de prendre leur tour dans la file d'attente qu'avec l'implémentation PostgreSQL des transactions SERIALIZABLE, ce qui permet une concurrence plus élevée et est un peu plus "optimiste" "sur les chances de collision.

L'exemple de requête dans la question, en l'état, dans la valeur par défaut READ COMMITTED le niveau d'isolement des transactions permettrait à deux (ou plus) connexions simultanées de "revendiquer" la même ligne de la file d'attente. Ce qui va arriver, c'est ceci:

  • T1 démarre et va jusqu'à verrouiller la ligne dans la phase UPDATE.
  • T2 chevauche T1 au moment de l'exécution et tente de mettre à jour cette ligne. Il bloque en attendant le COMMIT ou ROLLBACK de T1.
  • T1 s'engage, après avoir "revendiqué" avec succès la ligne.
  • T2 essaie de mettre à jour la ligne, constate que T1 a déjà, recherche la nouvelle version de la ligne, constate qu'elle satisfait toujours les critères de sélection (qui est juste que id correspond), et "revendique" également la rangée.

Il peut être modifié pour fonctionner correctement (si vous utilisez une version de PostgreSQL qui autorise le FOR UPDATE clause dans une sous-requête). Il suffit d'ajouter FOR UPDATE à la fin de la sous-requête qui sélectionne l'identifiant, et cela se produira:

  • T1 démarre et verrouille maintenant la ligne avant de sélectionner l'id.
  • T2 chevauche T1 dans le temps d'exécution et se bloque tout en essayant de sélectionner un identifiant, en attendant le COMMIT ou ROLLBACK de T1.
  • T1 s'engage, après avoir "revendiqué" avec succès la ligne.
  • Au moment où T2 est capable de lire la ligne pour voir l'id, il voit qu'elle a été revendiquée, donc il trouve l'id disponible suivant.

Au REPEATABLE READ ou SERIALIZABLE au niveau d'isolement des transactions, le conflit d'écriture génèrerait une erreur, que vous pourriez détecter et déterminer s'il s'agissait d'un échec de sérialisation basé sur SQLSTATE, puis réessayer.

Si vous souhaitez généralement des transactions SERIALISABLES mais que vous souhaitez éviter les tentatives dans la zone de mise en file d'attente, vous pouvez peut-être y parvenir en utilisant un verrouillage consultatif .

35
kgrittn

Si vous êtes le seul utilisateur , la requête devrait être correcte. En particulier, il n'y a ni condition de concurrence ni blocage dans la requête elle-même (entre la requête externe et la sous-requête). Je cite le manuel ici :

Cependant, une transaction n'est jamais en conflit avec elle-même.

Pour utilisation simultanée , la question peut être plus compliquée. Vous seriez du bon côté avec SERIALIZABLE mode de transaction :

BEGIN ISOLATION LEVEL SERIALIZABLE;
UPDATE stuff
SET    computed = 'working'
WHERE  id = (SELECT id FROM stuff WHERE computed IS NULL LIMIT 1)
RETURNING * 
COMMIT;

Vous devez vous préparer aux échecs de sérialisation et réessayer votre requête dans un tel cas.

Mais je ne suis pas entièrement sûr que ce ne soit pas exagéré. Je vais demander à @kgrittn de s'arrêter .. il est le expert des transactions simultanées et sérialisables ..

Et il l'a fait.:)


Le meilleur des deux mondes

Exécutez la requête en mode de transaction par défaut READ COMMITTED.

Pour Postgres 9.5 ou version ultérieure, utilisez FOR UPDATE SKIP LOCKED. Voir:

Pour les versions plus anciennes, revérifiez la condition computed IS NULL explicitement dans le UPDATE externe:

UPDATE stuff
SET    computed = 'working'
WHERE  id = (SELECT id FROM stuff WHERE computed IS NULL LIMIT 1)
AND   computed IS NULL;

Comme @ kgrittn l'a indiqué dans le commentaire de sa réponse, cette requête peut apparaître vide, sans avoir rien fait, dans le cas (peu probable) où elle est liée à une transaction simultanée.

Par conséquent, cela fonctionnerait un peu comme la première variante en mode de transaction SERIALIZABLE, vous devrez réessayer - juste sans la pénalité de performance.

Le seul problème: bien que le conflit soit très improbable car la fenêtre d'opportunité est si petite, il peut se produire sous une lourde charge. Vous ne pouviez pas dire avec certitude s'il ne restait finalement plus de lignes.

Si cela n'a pas d'importance (comme dans votre cas), vous avez terminé ici.
Si c'est le cas, pour être absolument sûr, lancez une autre requête avec verrouillage explicite après avoir obtenu un résultat vide. Si cela apparaît vide, vous avez terminé. Sinon, continuez.
Dans plpgsql cela pourrait ressembler à ceci:

LOOP
   UPDATE stuff
   SET    computed = 'working'
   WHERE  id = (SELECT id FROM stuff WHERE computed IS NULL
                LIMIT 1 FOR UPDATE SKIP LOCKED);  -- pg 9.5+
   -- WHERE  id = (SELECT id FROM stuff WHERE computed IS NULL LIMIT 1)
   -- AND    computed IS NULL; -- pg 9.4-

   CONTINUE WHEN FOUND;  -- continue outside loop, may be a nested loop

   UPDATE stuff
   SET    computed = 'working'
   WHERE  id = (SELECT id FROM stuff WHERE computed IS NULL
                LIMIT 1 FOR UPDATE);

   EXIT WHEN NOT FOUND;  -- exit function (end)
END LOOP;

Cela devrait vous donner le meilleur des deux mondes: performances et fiabilité.

20
Erwin Brandstetter