【问题标题】:Eager loading with scope in Rails在 Rails 中使用范围急切加载
【发布时间】:2018-10-19 09:40:01
【问题描述】:

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

我有一个模型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]]

您可以看到它会多次加载 DB,从而导致性能不佳。

#<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>

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

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 问题。

干杯

【问题讨论】:

  • 您添加has_many :trailer_videos 的方法是应对您的情况的最佳方法。我认为向模型添加更多此类关联没有任何缺点。

标签: ruby-on-rails


【解决方案1】:

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

【讨论】:

  • 感谢您的回答。例如,如果我有一个页面,对于每个程序,我将 1) 显示所有视频的 url,以及 2) 显示 trailer 视频的图像。那么您认为@programs.includes(:videos, :trailer_videos) 是最好的方法吗? trailer 中的视频将被加载两次。
  • @Stephen 在那种情况下,为什么要再次包含:trailer_videos:videos 将包含所有视频,包括预告片视频。不是吗?
  • 因为你需要调用trailer,它会加载数据库。例如,&lt;%= program.trailer_videos.size %&gt;/&lt;%= program.videos.size %&gt;。然后它会加载两次
  • Video Load (0.3ms) SELECT "videos".* FROM "videos" WHERE "videos"."program_id" IN (17, 16, 13, 12, 11, 9, 8, 15, 14, 10) Video Load (0.2ms) 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]]
  • 想了很多,最终还是选择了这种方式。即使加载两次,也比加载N+1次要好。谢谢。
【解决方案2】:

一种解决方案是使用mergeeager_load

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

它只产生一个查询。

【讨论】:

    【解决方案3】:

    在 Rails 中 Associations 有一个可选的范围参数,该参数接受应用于 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 重用它。

    【讨论】:

      【解决方案4】:

      如果程序员知道视频类型,您可以使用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
      

      【讨论】:

      • 感谢您的回答。但我的问题不在于如何动态创建has_many 关联。
      猜你喜欢
      • 2011-12-17
      • 1970-01-01
      • 2021-07-19
      • 1970-01-01
      • 2014-05-21
      • 2016-08-17
      • 1970-01-01
      • 1970-01-01
      • 2019-04-07
      相关资源
      最近更新 更多