web-dev-qa-db-ja.com

ActiveRecord find_eachと指値および注文の組み合わせ

ActiveRecordのfind_eachメソッドを使用して約50,000レコードのクエリを実行しようとしていますが、次のように他のパラメーターを無視しているようです:

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と同様の動作をする方法がありますが、合計最大制限があり、並べ替え基準を尊重しますか?

62
Avishai

ドキュメント は、find_eachとfind_in_batchesがソート順と制限を保持しないことを示しています。

  • PKでのASCの並べ替えは、バッチ順序付けを機能させるために使用されます。
  • 制限は、バッチサイズを制御するために使用されます。

@rorraのように、この関数の独自のバージョンを作成できます。ただし、オブジェクトを変更すると問題が発生する可能性があります。たとえば、created_atで並べ替えてオブジェクトを保存すると、次のバッチのいずれかで再び表示される場合があります。同様に、クエリを実行して次のバッチを取得するときに結果の順序が変更されたため、オブジェクトをスキップできます。そのソリューションは、読み取り専用オブジェクトでのみ使用してください。

今、私の最大の懸念は、30000 +個のオブジェクトを一度にメモリにロードしたくないということでした。私の懸念は、クエリ自体の実行時間ではありませんでした。したがって、元のクエリを実行するが、IDのみをキャッシュするソリューションを使用しました。次に、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を取得するために完全なクエリが実行されます
  • すべてのIDの配列はメモリに保持されます
  • MySQL固有のFIELD()関数を使用します

お役に立てれば!

59
Dirk Geurs

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


2番目の質問に答えて、自分でロジックを作成できます。

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

最初に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クエリを内部呼び出しに追加することも重要です。

17
Thomas Klemm

1つのオプションは、特定のモデルに合わせて調整された実装をモデル自体に配置することです(そういえば、通常、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"

追伸 this answer に従ってコードをファイルに入れています。

4
x-yuri

標準の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には両方の境界が含まれますが、1つだけを含める必要があるためです。

確かに、このアプローチでは、一部のレコードが既に削除されているため、バッチで1000レコード未満をフェッチできますが、私の場合はこれで問題ありません。

3
Lev Lukomsky

@Kirkがコメントの1つで述べたように、find_eachは、バージョン 5.1.limitをサポートします。

変更ログの例:

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

ドキュメント の意味:

制限は順守され、存在する場合、バッチサイズの要件はありません。制限より小さくても、等しくても、大きくてもかまいません。

(カスタムオーダーの設定はまだサポートされていません)

2
tsauerwein

試すことができますar-as-batchesGem。

ドキュメント から、次のようなことができます

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

私は同じ行動を探していて、この解決策を考えました。これはcreated_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のレコードが保証されますが、IDがスキップされた場合は少なくなります。

2
Moemars

または他の何かを使用すると簡単です。

バッチローダークラスを作成します。

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
0
merqlove