2015-09-03 1 views
2

Je rencontre une condition de course à ActiveRecord avec PostgreSQL où je lis une valeur puis incrémenter et l'insertion d'un nouveau record:PostgreSQL et ActiveRecord subselect pour connaître l'état de la course

num = Foo.where(bar_id: 42).maximum(:number) 
Foo.create!({ 
    bar_id: 42, 
    number: num + 1 
}) 

A l'échelle, plusieurs threads seront lire simultanément puis écrire la même valeur de number. Envelopper ceci dans une transaction ne fixe pas la condition de concurrence car le SELECT ne verrouille pas la table. Je ne peux pas utiliser un incrément automatique, car number n'est pas unique, il est seulement unique compte tenu d'un certain bar_id. Je vois 3 solutions possibles: (un blocage de niveau ligne)

  • Explicitement utiliser un verrouillage postgres
  • Utilisez une contrainte unique et essayez de nouveau sur échoue
  • Override sauf à utiliser un sous-(beurk!) , C'EST À DIRE

    INSERT INTO foo (bar_id, number) VALUES (42, (SELECT MAX(number) + 1 FROM foo WHERE bar_id = 42));

Toutes ces solutions semblent comme je serais Réimplémenter une grande partie de ActiveRecord::Base#save! est-il un moyen plus facile?

MISE À JOUR: Je pensais que j'ai trouvé la réponse avec Foo.lock(true).where(bar_id: 42).maximum(:number) mais qui utilise SELECT FOR UDPATE ce qui est interdit sur les requêtes globales

MISE À JOUR 2: Je viens d'être informé par notre DBA, que même si nous pouvions faire INSERT INTO foo (bar_id, number) VALUES (42, (SELECT MAX(number) + 1 FROM foo WHERE bar_id = 42)); qui ne fixe pas quoi que ce soit, puisque le SELECT fonctionne dans une serrure différente de celle INSERT

+0

Si elle était calculée à la volée, elle changerait au fil du temps et serait soumise aux mêmes conditions de concurrence –

Répondre

2

Vos options sont:

  • Run dans SERIALIZABLE isolement. Les transactions interdépendantes seront annulées à la validation comme ayant un échec de sérialisation. Vous obtiendrez beaucoup de spam sur les journaux d'erreurs, et vous ferez beaucoup de tentatives, mais cela fonctionnera de manière fiable.

  • Définissez une contrainte UNIQUE et réessayez en cas d'échec, comme vous l'avez noté. Les mêmes problèmes que ci-dessus.

  • S'il existe un objet parent, vous pouvez SELECT ... FOR UPDATE l'objet parent avant d'exécuter votre requête max. Dans ce cas, vous auriez SELECT 1 FROM bar WHERE bar_id = $1 FOR UPDATE. Vous utilisez bar comme verrou pour tous les foo avec bar_id. Vous pouvez alors savoir qu'il est sûr de continuer, tant que chaque requête qui effectue votre incrément de compteur le fait de manière fiable. Cela peut très bien fonctionner. Cela fait toujours une requête agrégée pour chaque appel, ce qui (par option suivante) est inutile, mais au moins il ne spamme pas le journal des erreurs comme les options ci-dessus.

  • Utilisez une table de compteur. C'est ce que je ferais.Que ce soit dans bar, ou dans une table de chevet comme bar_foo_counter, acquérir un ID de ligne en utilisant

    UPDATE bar_foo_counter SET counter = counter + 1 
    WHERE bar_id = $1 RETURNING counter 
    

    ou l'option moins efficace si votre cadre ne peut pas gérer RETURNING:

    SELECT counter FROM bar_foo_counter 
    WHERE bar_id = $1 FOR UPDATE; 
    
    UPDATE bar_foo_counter SET counter = $1; 
    

    Ensuite, dans la même transaction, utilisez la ligne de compteur générée pour le number. Lorsque vous validez, la ligne de table de compteur pour ce bar_id est déverrouillée pour la requête suivante à utiliser. Si vous revenez en arrière, la modification est ignorée.

Je recommande l'approche du compteur, en utilisant une table d'appoint dédié pour le compteur au lieu d'ajouter une colonne à bar. C'est plus propre à modéliser, et signifie que vous créez moins de bouffées de mise à jour dans bar, ce qui peut ralentir les requêtes à bar.