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
, но с максимальным максимальным пределом и с учетом моих критериев сортировки?
Ответы
Ответ 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]