在Rails中使用范围进行预先加载

时间:2018-10-19 09:40:01

标签: ruby-on-rails

我发现了很多标题相似的问题,但是没有一个可以解决我的问题。

我有一个模型Program,其中有很多Videos

class Program < ActiveRecord::Base
  has_many :videos
  ...
end

然后我在Video中拥有作用域:

class Video < ActiveRecord::Base
  belongs_to :program

  scope :trailer, -> { where(video_type: 0) }
  ...
end

首先,当我有一个程序列表并想要访问视频时,我没有使用include方法的N + 1程序:

> @programs.includes(:videos).map { |p| p.videos.size }
  Program Load (0.6ms)  SELECT  "programs".* FROM "programs"  ORDER BY "programs"."id" ASC LIMIT 10
  Video Load (0.5ms)  SELECT "videos".* FROM "videos" WHERE "videos"."program_id" IN (8, 9, 10, 11, 12, 13, 14, 15, 16, 17)

但是,当我尝试获取范围时,它将再次触摸数据库:

> @programs.includes(:videos).map { |p| p.videos.trailer }
  Program Load (0.6ms)  SELECT  "programs".* FROM "programs"  ORDER BY "programs"."id" ASC LIMIT 10
  Video Load (0.5ms)  SELECT "videos".* FROM "videos" WHERE "videos"."program_id" IN (8, 9, 10, 11, 12, 13, 14, 15, 16, 17)
  Video Load (0.4ms)  SELECT  "videos".* FROM "videos" WHERE "videos"."program_id" = $1 AND "videos"."video_type" = $2  ORDER BY "videos"."id" ASC LIMIT 1  [["program_id", 8], ["video_type", 0]]
  Video Load (0.4ms)  SELECT  "videos".* FROM "videos" WHERE "videos"."program_id" = $1 AND "videos"."video_type" = $2  ORDER BY "videos"."id" ASC LIMIT 1  [["program_id", 9], ["video_type", 0]]
  Video Load (12.4ms)  SELECT  "videos".* FROM "videos" WHERE "videos"."program_id" = $1 AND "videos"."video_type" = $2  ORDER BY "videos"."id" ASC LIMIT 1  [["program_id", 10], ["video_type", 0]]
  Video Load (0.3ms)  SELECT  "videos".* FROM "videos" WHERE "videos"."program_id" = $1 AND "videos"."video_type" = $2  ORDER BY "videos"."id" ASC LIMIT 1  [["program_id", 11], ["video_type", 0]]
  Video Load (0.3ms)  SELECT  "videos".* FROM "videos" WHERE "videos"."program_id" = $1 AND "videos"."video_type" = $2  ORDER BY "videos"."id" ASC LIMIT 1  [["program_id", 12], ["video_type", 0]]
  Video Load (0.3ms)  SELECT  "videos".* FROM "videos" WHERE "videos"."program_id" = $1 AND "videos"."video_type" = $2  ORDER BY "videos"."id" ASC LIMIT 1  [["program_id", 13], ["video_type", 0]]
  Video Load (0.3ms)  SELECT  "videos".* FROM "videos" WHERE "videos"."program_id" = $1 AND "videos"."video_type" = $2  ORDER BY "videos"."id" ASC LIMIT 1  [["program_id", 14], ["video_type", 0]]
  Video Load (0.3ms)  SELECT  "videos".* FROM "videos" WHERE "videos"."program_id" = $1 AND "videos"."video_type" = $2  ORDER BY "videos"."id" ASC LIMIT 1  [["program_id", 15], ["video_type", 0]]
  Video Load (0.4ms)  SELECT  "videos".* FROM "videos" WHERE "videos"."program_id" = $1 AND "videos"."video_type" = $2  ORDER BY "videos"."id" ASC LIMIT 1  [["program_id", 16], ["video_type", 0]]
  Video Load (0.4ms)  SELECT  "videos".* FROM "videos" WHERE "videos"."program_id" = $1 AND "videos"."video_type" = $2  ORDER BY "videos"."id" ASC LIMIT 1  [["program_id", 17], ["video_type", 0]]

您可以看到它将多次加载数据库,从而导致性能下降。

#<Benchmark::Tms:0x007f95faa8fab0 @label="", @real=0.02663199999369681, @cstime=0.0, @cutime=0.0, @stime=0.0, @utime=0.019999999999999574, @total=0.019999999999999574>

我能想到的一种解决方案是将视频转换为数组并搜索数组:

> @programs.includes(:videos).map { |program| program.videos.to_ary.select { |v| v.video_type == 0 } }
  Program Load (0.5ms)  SELECT "programs".* FROM "programs" WHERE "programs"."id" IN (8, 9, 10, 11, 12, 13, 14, 15, 16, 17)
  Video Load (0.4ms)  SELECT "videos".* FROM "videos" WHERE "videos"."program_id" IN (17, 16, 13, 12, 11, 9, 8, 15, 14, 10)

性能更好,但是代码很复杂。

#<Benchmark::Tms:0x007f95faac8720 @label="", @real=0.006901999993715435, @cstime=0.0, @cutime=0.0, @stime=0.0, @utime=0.010000000000000675, @total=0.010000000000000675>

我可以想到的另一种解决方案是在has_many中为范围添加新的Program

class Program < ActiveRecord::Base
  has_many :videos
  has_many :trailer_videos, -> { where(video_type: 0) }, class: 'Video'
  ...
end

然后,如果我includes并直接调用新关系,它也将渴望加载。

> @programs.includes(:trailer_videos).map { |program| program.trailer_videos }
  Program Load (0.5ms)  SELECT "programs".* FROM "programs" WHERE "programs"."id" IN (8, 9, 10, 11, 12, 13, 14, 15, 16, 17)
  Video Load (0.3ms)  SELECT "videos".* FROM "videos" WHERE "videos"."video_type" = $1 AND "videos"."program_id" IN (17, 16, 13, 12, 11, 9, 8, 15, 14, 10)  [["video_type", 0]]

以下是基准,这是非常快的:

#<Benchmark::Tms:0x007f95fdea96c0 @label="", @real=0.004801000002771616, @cstime=0.0, @cutime=0.0, @stime=0.0, @utime=0.009999999999999787, @total=0.009999999999999787>

但是,以这种方式,它将使Program模型变得如此沉重。因为对于Video中的每个范围,我需要在Program中添加一个相关的关联。


因此,我正在寻找一种更好的解决方案,该解决方案将范围逻辑保留在Video内,但没有N + 1问题。

欢呼

4 个答案:

答案 0 :(得分:3)

一种解决方案是将mergeeager_load一起使用:

@programs.eager_load(:videos).merge(Video.trailer).map { |p| p.videos.size }

它仅产生一个查询。

答案 1 :(得分:2)

正如我所说,IMO为您添加has_many :trailer_videos, -> { where(video_type: 0) }, class: 'Video'的方法是解决您的问题的最简单,最好的方法。在模型中添加更多此类关联没有任何缺点。

答案 2 :(得分:2)

如果程序员知道视频类型,则可以使用ActiveRecord::Enum和一些简单的元编程来为枚举中的每个可能值以编程方式创建关联。

class Video < ActiveRecord::Base
  enum video_type: [:trailer, :promo, :foo, :bar]
end

class Program < ActiveRecord::Base
  # this creates trailer_videos etc assocations
  Video.video_types.each do |key, int| 
    # eval is needed since we need to dynamically create 
    # the lamba for each type
    has_many "#{key}_videos".to_sym, eval "->{ Video.send(#{key}) }"
  end
end

答案 3 :(得分:0)

在Rails Associations中有一个可选的scope参数,该参数接受应用于Relation的lambda(请参阅https://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html#method-i-has_many-label-Scopes

因此您可以将模型编写为:

# app/models/video.rb
class Video < ActiveRecord::Base
  belongs_to :program
  scope :trailer, -> { where(video_type: 0) }
  ...
end

# app/models/program.rb
class Program < ActiveRecord::Base
  has_many :videos
  has_many :trailer_videos, -> { trailer }, class: 'Video'
  ...
end

这样,您可以将范围的定义保留在Video中,并从Program中重用它。