ActiveRecord find_each结合限制和顺序


70

我正在尝试使用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但具有最大总限制并遵守我的排序标准?


您尚未接受任何答案的任何特定原因?
Dirk Geurs

抱歉,我忘记了:-\
Avishai

1
在诸如find_each之类的批处理操作中,将忽略find_in_batches范围内的订单和限制,它被强制为批次订单和批次大小
Deepak Lamichhane 2014年

Answers:


67

该文档说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()函数

希望这可以帮助!


4
4.x文档说不支持限制。但是最新的5.x文档似乎确实有极限。
柯克(Kirk)

26

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

是否有其他方法可以实现这一目标?
Avishai

@ jan-hettich,我在原始答案中写道find_in_batches不支持limit选项,我还指出了实现该选项但从未被接受/合并的拉取请求。
rorra

1
如果在处理批处理时对对象进行突变,此解决方案将使您陷入麻烦。如果该突变对数据库中的排序顺序有影响,则可以跳过一些或使用双精度。
Dirk Geurs

1
total_records - batch可能小于batch大小,这将是一个负范围。我要求abs进行计算以确保至少迭代一次结果:例如(0..(total_records - batch).abs)
Ben Simpson

1
为了不错过最后一个批次total_records(不是)的倍数batch(甚至是)的倍数,您的范围应为(0..(total_records - 1))
丹尼斯·威廉姆森

18

检索第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查询添加到内部调用中也很重要。


2
与公认的答案不同,这在PostgreSQL中有效。同样,保持答案简洁明了。
kdt

1
这将需要在一个查询中拔出表的所有ID,而且我不知道对于较大的表是否建议这样做(无论如何,这是您要使用find_in_batches的地方)。
Ibrahim

尽管我猜想像这样的事情,但如果需要按任意列排序,则可能不得不求助于所有ID。
Ibrahim

@Darme-确实不应该这样,因为它将所有ID从表中拉到RAM中。这就是原始问题想要避免的。在Ruby中,ID数组存在巨大的数据库查询和无限制的RAM需求,因此当ID列表通过引用回到数据库时,必须将其全部编组到所有SQL查询的母亲中WHERE...IN。它看起来很优雅,但是使用Ruby有时会带来风险-您有时没有意识到这种影响。它不像完整记录那样糟糕,但是即使使用大型表,即使ID也会很沉重。
Andrew Hodgkinson

@AndrewHodgkinson,您是对的:乍一看似乎正确,但过了一会儿我遇到了这个具体问题。
达美(Darme)

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"

附言:我正在根据此答案将代码放入文件中。


优秀的方法,这解决了我
update_at

3

您可以通过标准的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条记录,因为其中一些记录已被删除,但就我而言,这是可以的。


2

我一直在寻找相同的行为,并想出了这个解决方案。这不由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,您得到的查询将会更少。


由于我是在查找skip + find_each时发现的,因此在这里值得一提::start选项可以等效于skip(),否则可能会用到。
Yourpalal 2013年

2

您可以尝试ar-as-batch宝石。

从他们的文档中,您可以执行以下操作

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

看起来不是可以从ruby gems上获得的,但是从github上要求它的效果很好
max pleaner

2

如@Kirk在其中一项注释中所述,从5.1.0版本开始find_each支持。limit

来自变更日志的示例:

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

文档说:

遵守限制,如果存在限制,则不要求批量大小:它可以小于,等于或大于限制。

(尽管仍然不支持设置自定义订单)



0

使用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

0

添加find_in_batches_with_order确实解决了我的用例,在该用例中我已经有ID,但是需要进行批处理和排序。它的灵感来自@ 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


0

我在查询该字段的DISTINCT ON位置时遇到了同样的问题ORDER BY,所以这是我使用Postgres的方法:

def filtered_model_ids
  Model.joins(:father_model)
       .select('DISTINCT ON (model.field) model.id')
       .order(:field)
       .map(&:id)
end

def processor
  filtered_model_ids.each_slice(BATCH_SIZE).lazy.each do |batch|
    Model.find(batch).each do |record|
      # Code
    end
  end
end

-1

在一个查询中执行此操作,避免重复:

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

将产生这样的查询

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

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.