Подтвердить что ты не робот

ActiveRecord find_each в сочетании с лимитом и порядком

Я пытаюсь запустить запрос около 50 000 записей с использованием метода ActiveRecord find_each, но он, кажется, игнорирует мои другие параметры:

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

Вместо того, чтобы останавливаться на 50 000, я хотел бы и сортировать по created_at, здесь результирующий запрос, который запускается по всему набору данных:

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

Есть ли способ получить подобное поведение с find_each, но с максимальным максимальным пределом и с учетом моих критериев сортировки?

4b9b3361

Ответ 1

В документации говорится, что find_each и find_in_batches не сохраняют порядок сортировки и ограничение, потому что:

  • Сортировка ASC на PK используется для выполнения упорядочивания партий.
  • Предел используется для управления размерами партии.

Вы можете написать свою собственную версию этой функции, как это сделал @rorra. Но вы можете столкнуться с проблемами при мутации объектов. Если, например, вы сортируете по create_at и сохраняете объект, он может появиться снова в одной из следующих партий. Аналогичным образом вы можете пропустить объекты, потому что порядок результатов изменился при выполнении запроса для получения следующей партии. Используйте это решение только с объектами только для чтения.

Теперь моя главная проблема заключалась в том, что я не хотел загружать сразу 30000+ объектов в память. Меня беспокоило не время выполнения самого запроса. Поэтому я использовал решение, которое выполняет исходный запрос, но только кэширует идентификаторы. Затем он делит массив ID на куски и запрашивает/создает объекты на кусок. Таким образом, вы можете безопасно мутировать объекты, потому что порядок сортировки сохраняется в памяти.

Вот минимальный пример, похожий на то, что я сделал:

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

Компромиссы для этого решения:

  • Полный запрос выполняется для получения идентификатора
  • Массив всех ID хранится в памяти
  • Использует специфичную для MySQL функцию FIELD()

Надеюсь, это поможет!

Ответ 2

find_each использует find_in_batches под капот.

Невозможно выбрать порядок записей, как описано в find_in_batches, автоматически устанавливается на восходящий по первичному ключу ( "id ASC" ), чтобы сделать процесс упорядочивания партий.

Однако критерии применяются, что вы можете сделать:

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

Что касается ограничения, он еще не был реализован: https://github.com/rails/rails/pull/5696


Отвечая на ваш второй вопрос, вы можете сами создать логику:

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

Ответ 3

Сначала получение ids и обработка in_groups_of

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

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

  # ...
end

Также важно добавить запрос ORDER BY к внутреннему вызову.

Ответ 4

Один из вариантов - поставить реализацию, адаптированную для вашей конкретной модели, в саму модель (говоря о которой id обычно является лучшим выбором для упорядочивания записей, created_at может иметь дубликаты):

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

Или вы можете немного обобщить вещи и заставить их работать для всех моделей:

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. Я помещаю код в файлы в соответствии с этим ответом.

Ответ 5

Вы можете повторять итерации стандартными итераторами ruby:

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

Примечание: +1 заключается в том, что BETWEEN, который будет в запросе, включает обе границы, но нам нужно включить только один.

Конечно, при таком подходе может быть выбрано менее 1000 записей в пакетном режиме, потому что некоторые из них уже удалены, но в моем случае это нормально.

Ответ 6

Я искал такое же поведение и придумал это решение. Это НЕ приказывает create_at, но я думал, что я буду публиковать в любом случае.

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

Недостатки этого подхода: - Вам нужно 2 запроса (первый должен быть быстрым) - Это гарантирует максимум 50K записей, но если идентификаторы пропущены, вы получите меньше.

Ответ 7

Вы можете попробовать ar-as-batches Gem.

Из документации вы можете сделать что-то вроде этого

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

Ответ 8

Как отметил @Kirk в одном из комментариев, find_each поддерживает limit find_each с версии 5.1.0.

Пример из журнала изменений:

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

В документации сказано:

Пределы соблюдаются, и если они присутствуют, требования к размеру партии не предъявляются: он может быть меньше, равен или превышать лимит.

(установка пользовательского заказа все еще не поддерживается)

Ответ 9

Используя Kaminari или что-то другое, это будет легко.

Создать класс пакетного загрузчика.

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

Создать репозиторий

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

Используйте репозиторий

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

Ответ 10

Добавление find_in_batches_with_order решило мой сценарий использования, где у меня уже были идентификаторы, но мне нужны пакетирование и заказ. Это было вдохновлено решением @dirk-geurs

# Create file config/initializers/find_in_batches_with_order.rb with follwing code.
ActiveRecord::Batches.class_eval do
  ## Only flat order structure is supported now
  ## example: [:forename, :surname] is supported but [:forename, {surname: :asc}] is not supported
  def find_in_batches_with_order(ids: nil, order: [], batch_size: 1000)
    relation = self
    arrangement = order.dup
    index = order.find_index(:id)

    unless index
      arrangement.push(:id)
      index = arrangement.length - 1
    end

    ids ||= relation.order(*arrangement).pluck(*arrangement).map{ |tupple| tupple[index] }
    ids.each_slice(batch_size) do |chunk_ids|
      chunk_relation = relation.where(id: chunk_ids).order(*order)
      yield(chunk_relation)
    end
  end
end

Оставляя Гист здесь https://gist.github.com/the-spectator/28b1176f98cc2f66e870755bb2334545

Ответ 11

Сделайте это в одном запросе и избегайте повторения:

User.offset(2).order('name DESC').last(3)

будет обрабатывать такой запрос

SELECT "users".* FROM "users" ORDER BY name ASC LIMIT $1 OFFSET $2 [["LIMIT", 3], ["OFFSET", 2]