web-dev-qa-db-fra.com

ActiveRecord find_each combiné avec limit et order

J'essaie d'exécuter une requête d'environ 50 000 enregistrements à l'aide de la méthode find_each d'ActiveRecord, mais elle semble ignorer mes autres paramètres, comme ceci:

Thing.active.order("created_at DESC").limit(50000).find_each {|t| puts t.id }

Au lieu de vous arrêter à 50 000 et de trier par created_at, voici la requête résultante qui est exécutée sur l'ensemble de données whole :

Thing Load (198.8ms)  SELECT "things".* FROM "things" WHERE "things"."active" = 't' AND ("things"."id" > 373343) ORDER BY "things"."id" ASC LIMIT 1000

Existe-t-il un moyen d'obtenir un comportement similaire à find_each mais avec une limite maximale totale et en respectant mes critères de tri?

58
Avishai

La documentation indique que find_each et find_in_batches ne conservent pas l'ordre de tri ni la limite, car:

  • Le tri de l'ASC sur la PC sert à exécuter le processus de commande par lots.
  • La limite est utilisée pour contrôler la taille des lots.

Vous pouvez écrire votre propre version de cette fonction, comme l’a fait @rorra. Mais vous pouvez avoir des problèmes lorsque vous mute les objets. Si, par exemple, vous triez par created_at et enregistrez l’objet, il pourra être récupéré lors de l’un des prochains lots. De même, vous pouvez ignorer des objets car l'ordre des résultats a changé lors de l'exécution de la requête pour obtenir le prochain lot. Utilisez cette solution uniquement avec des objets en lecture seule.

Maintenant, ma principale préoccupation était que je ne souhaitais pas charger plus de 30000 objets en mémoire à la fois. Ma préoccupation n'était pas le temps d'exécution de la requête elle-même. Par conséquent, j'ai utilisé une solution qui exécute la requête d'origine mais met uniquement en cache l'ID. Il divise ensuite le tableau d'identifiants en morceaux et interroge/crée les objets par morceau. De cette façon, vous pouvez muter les objets en toute sécurité car l'ordre de tri est conservé en mémoire.

Voici un exemple minimal similaire à ce que j'ai fait:

batch_size = 512
ids = Thing.order('created_at DESC').pluck(:id) # Replace .order(:created_at) with your own scope
ids.each_slice(batch_size) do |chunk|
    Thing.find(chunk, :order => "field(id, #{chunk.join(',')})").each do |thing|
      # Do things with thing
    end
end

Les compromis à cette solution sont:

  • La requête complète est exécutée pour obtenir l'ID
  • Un tableau de tous les identifiants est conservé en mémoire
  • Utilise la fonction FIELD () spécifique à MySQL

J'espère que cela t'aides!

57
Dirk Geurs

find_each utilise find_in_batches sous le capot.

Il n'est pas possible de sélectionner l'ordre des enregistrements, comme décrit dans find_in_batches, est automatiquement réglé sur croissant sur la clé primaire («id ASC») pour que la commande par lots fonctionne.

Cependant, les critères sont appliqués, vous pouvez faire ce qui suit:

Thing.active.find_each(batch_size: 50000) { |t| puts t.id }

En ce qui concerne la limite, elle n’a pas encore été mise en œuvre: https://github.com/Rails/rails/pull/5696


En répondant à votre deuxième question, vous pouvez créer la logique vous-même:

total_records = 50000
batch = 1000
(0..(total_records - batch)).step(batch) do |i|
  puts Thing.active.order("created_at DESC").offset(i).limit(batch).to_sql
end
24
rorra

Récupération de la ids en premier et traitement du in_groups_of

ordered_photo_ids = Photo.order(likes_count: :desc).pluck(:id)

ordered_photo_ids.in_groups_of(1000).each do |photo_ids|
  photos = Photo.order(likes_count: :desc).where(id: photo_ids)

  # ...
end

Il est important d'ajouter également la requête ORDER BY à l'appel interne.

13
Thomas Klemm

Une option consiste à insérer une implémentation adaptée à votre modèle particulier dans le modèle lui-même (à savoir, id est généralement le meilleur choix pour commander des enregistrements, created_at peut avoir des doublons):

class Thing < ActiveRecord::Base
  def self.find_each_desc limit
    batch_size = 1000
    i = 1
    records = self.order(created_at: :desc).limit(batch_size)
    while records.any?
      records.each do |task|
        yield task, i
        i += 1
        return if i > limit
      end
      records = self.order(created_at: :desc).where('id < ?', records.last.id).limit(batch_size)
    end
  end
end

Sinon, vous pouvez généraliser un peu les choses et les faire fonctionner pour tous les modèles:

lib/active_record_extensions.rb:

ActiveRecord::Batches.module_eval do
  def find_each_desc limit
    batch_size = 1000
    i = 1
    records = self.order(id: :desc).limit(batch_size)
    while records.any?
      records.each do |task|
        yield task, i
        i += 1
        return if i > limit
      end
      records = self.order(id: :desc).where('id < ?', records.last.id).limit(batch_size)
    end
  end
end

ActiveRecord::Querying.module_eval do
  delegate :find_each_desc, :to => :all
end

config/initializers/extensions.rb:

require "active_record_extensions"

P.S. Je mets le code dans les fichiers en fonction de cette réponse .

4
x-yuri

Vous pouvez itérer en arrière par les itérateurs Ruby standard:

Thing.last.id.step(0,-1000) do |i|
  Thing.where(id: (i-1000+1)..i).order('id DESC').each do |thing|
    #...
  end
end

Note: +1 est parce que BETWEEN qui sera dans la requête inclut les deux bornes mais nous n’en avons besoin que d’une.

Bien sûr, avec cette approche, moins de 1000 enregistrements par lot pourraient être récupérés, car certains d’entre eux sont déjà supprimés, mais c’est acceptable dans mon cas.

3
Lev Lukomsky

Comme l'a remarqué @Kirk dans l'un des commentaires, find_each supporte limit à partir de la version 5.1.0 .

Exemple du changelog:

Post.limit(10_000).find_each do |post|
  # ...
end

La documentation dit:

Les limites sont respectées et, le cas échéant, la taille du lot n'est pas requise: elle peut être inférieure, égale ou supérieure à la limite.

(la définition d'une commande personnalisée n'est toujours pas prise en charge)

2
tsauerwein

Vous pouvez essayer ar-as-batchs Gem.

De leur documentation vous pouvez faire quelque chose comme ceci

Users.where(country_id: 44).order(:joined_at).offset(200).as_batches do |user|
  user.party_all_night!
end
2
Martin

Je recherchais le même comportement et pensais à cette solution. This NE PAS commander par created_at mais je pensais que je posterais de toute façon.

max_records_to_retrieve = 50000
last_index = Thing.count
start_index = [(last_index - max_records_to_retrieve), 0].max
Thing.active.find_each(:start => start_index) do |u|
    # do stuff
end

Inconvénients de cette approche.

2
Moemars

En utilisant Kaminari ou autre chose, ce sera facile. 

Créer une classe de chargeur par lots.

module BatchLoader
  extend ActiveSupport::Concern

  def batch_by_page(options = {})
    options = init_batch_options!(options)

    next_page = 1

    loop do
      next_page = yield(next_page, options[:batch_size])

      break next_page if next_page.nil?
    end
  end

  private

  def default_batch_options
    {
      batch_size: 50
    }
  end

  def init_batch_options!(options)
    options ||= {}
    default_batch_options.merge!(options)
  end
end

Créer un référentiel

class ThingRepository
  include BatchLoader

  # @param [Integer] per_page
  # @param [Proc] block
  def batch_changes(per_page=100, &block)
    relation = Thing.active.order("created_at DESC")

    batch_by_page do |next_page|
      query = relation.page(next_page).per(per_page)
      yield query if block_given?
      query.next_page
    end
  end
end

Utilisez le référentiel

repo = ThingRepository.new
repo.batch_changes(5000).each do |g|
  g.each do |t|
    #...
  end
end
0
merqlove