web-dev-qa-db-fra.com

Comment puis-je exécuter des mises à jour par lots dans Rails 3/4?

Je dois mettre à jour en masse plusieurs milliers d'enregistrements et je souhaite traiter les mises à jour par lots. J'ai d'abord essayé:

Foo.where(bar: 'bar').find_in_batches.update_all(bar: 'baz')

... que j'espérais générer du SQL tel que:

"UPDATE foo SET bar = 'baz' where bar='bar' AND id > (whatever id is passed in by find_in_batches)"

Cela ne fonctionne pas car find_in_batches renvoie un tableau, alors que update_all nécessite une relation ActiveRecord. 

C'est ce que j'ai essayé ensuite:

Foo.where(bar: 'bar').select('id').find_in_batches do |foos|
  ids = foos.map(&:id)
  Foo.where(id: ids).update_all(bar: 'baz')
end

Cela fonctionne, mais il exécute évidemment une sélection suivie de la mise à jour, plutôt qu'une seule mise à jour basée sur mes conditions "où". Y at-il un moyen de nettoyer cela, de sorte que la sélection et la mise à jour ne doivent pas être des requêtes séparées?

24
MothOnMars

Dans Rails 5, il existe une nouvelle méthode pratique ActiveRecord::Relation#in_batches pour résoudre ce problème:

Foo.in_batches.update_all(bar: 'baz')

Vérifiez documentation pour plus de détails.

47
dlackty

Je suis également surpris qu'il n'y ait pas de moyen plus facile de faire cela ... mais j'ai proposé cette approche:

batch_size = 1000
0.step(Foo.count, batch_size).each do |offset|
  Foo.where(bar: 'bar').order(:id)
                       .offset(offset)
                       .limit(batch_size)
                       .update_all(bar: 'baz')
end

Fondamentalement, cela:

  1. Créez un tableau de décalages entre 0 et Foo.count en passant de batch_size à chaque fois. Par exemple, si Foo.count == 10500 vous obtiendrez: [0, 1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000, 9000, 10000]
  2. Parcourez ces nombres et utilisez-les comme OFFSET dans la requête SQL, en vous assurant de commander par id et en vous limitant au batch_size.
  3. Mettez à jour au plus batch_size enregistrements dont "l'index" est supérieur à offset.

Il s’agit essentiellement d’une manière manuelle d’exécuter ce que vous espériez obtenir dans le code SQL généré. Dommage que cela ne puisse pas déjà être fait de cette façon avec une méthode de bibliothèque standard ... bien que je sois sûr que vous puissiez en créer une vous-même.

10
pdobb

Nous avons 2 ans de retard, mais les réponses sont a) très lentes pour les grands ensembles de données et b) ignorer les fonctionnalités Rails intégrées ( http://api.rubyonrails.org/classes/ActiveRecord/Batches.html ) .

À mesure que la valeur de décalage augmente, en fonction de votre serveur de base de données, il effectue une analyse de séquence jusqu'à atteindre votre bloc, puis extrait les données pour les traiter. Lorsque votre offset atteindra des millions, ce sera extrêmement lent.

utilisez la méthode d'itérateur "find_each":

Foo.where(a: b).find_each do |bar|
   bar.x = y
   bar.save
end

Cela présente l'avantage supplémentaire d'exécuter les rappels de modèle à chaque sauvegarde. Si vous ne vous souciez pas des rappels, essayez:

Foo.where(a: b).find_in_batches do |array_of_foo|
  ids = array_of_foo.collect &:id
  Foo.where(id: ids).update_all(x: y)
end
5
Faisal

la réponse de pdobb est sur la bonne voie, mais n'a pas fonctionné pour moi dans Rails 3.2.21 en raison de ce problème d'ActiveRecord qui n'analysait pas OFFSET avec des appels UPDATE:

https://github.com/Rails/rails/issues/10849

J'ai modifié le code en conséquence et cela a bien fonctionné pour définir simultanément la valeur par défaut sur ma table Postgres:

batch_size = 1000
0.step(Foo.count, batch_size).each do |offset|
  Foo.where('id > ? AND id <= ?', offset, offset + batch_size).
      order(:id).
      update_all(foo: 'bar')
end
3
Charlie Tran

J'ai écrit une petite méthode pour appeler update_all par lots:

https://Gist.github.com/VarunNatraaj/420c638d544be59eef85

J'espère que c'est utile! :)

0
Varun Natraaj

Je n'ai pas encore eu l'occasion de tester cela, mais vous pourriez peut-être utiliser ARel et une sous-requête.

Foo.where(bar: 'bar').select('id').find_in_batches do |foos|
  Foo.where( Foo.arel_table[ :id ].in( foos.to_arel ) ).update_all(bar: 'baz')
end
0
Paul Alexander