Ruby和Ruby on Rails中的Memoization和缓存

5

给定的应用正在遍历多个字段,即使我已经缓存了对象,为什么应用程序仍然会进行多次 SQL 调用?

或者

给定的应用正在遍历许多项,如何防止应用在每个项上进行昂贵的计算?

示例Rails代码

  • Work(工作)有许多评论
  • 只有没有评论或管理员用户才能删除工作
  • 我们的视图界面将只显示“删除工作”,如果它可以被删除

注意:我们使用策略视图对象,如http://www.eq8.eu/blogs/41-policy-objects-in-ruby-on-rails所述。

class WorksController < ApplicationController
  def index
    @works = Work.all
  end
end

<% @works.each do |work| %>
   <%= link_to("Delete work", work, method: delete) if work.policy.able_to_delete?(current_user: current_user) %>
<% end %>

class Work < ActiveRecord::Base
  has_many :comments

  def policy
     @policy ||= WorkPolicy.new
  end
end

class Comment
  belongs_to :work
end

class WorkPolicy
  attr_reader :work

  def initialize(work)
    @work = work
  end

  def able_to_delete?(current_user: nil)
    work_has_no_comments || (current_user && current_user.admin?)
  end

  private

  def work_has_no_comments
    work.comments.count < 1
  end
end

现在假设我们在数据库中有100份作品。
这将导致多个SQL调用:
SELECT "works".* FROM "works"
SELECT COUNT(*) FROM "comments" WHERE "comments"."work_id" = $1  [["work_id", 1]
SELECT COUNT(*) FROM "comments" WHERE "comments"."work_id" = $1  [["work_id", 2]
SELECT COUNT(*) FROM "comments" WHERE "comments"."work_id" = $1  [["work_id", 3]
SELECT COUNT(*) FROM "comments" WHERE "comments"."work_id" = $1  [["work_id", 4]

注意:最近我向一个同事解释了这个例子,我认为值得为更多的开发人员记录下来。
1个回答

16

备忘录模式

首先,让我们回答一个问题:

即使我使用了备忘录模式,为什么应用程序还会进行多个SQL调用

是的,我们正在使用@policy ||= WorkPolicy.new备忘录Policy对象。

但是我们没有备忘录对象调用的内容。这意味着我们需要备忘录基础对象方法调用结果。

因此,如果我们执行以下操作:

@work = Work.last
@work.policy.able_to_delete?
#=> SELECT COUNT(*) FROM "comments" WHERE "comments"."work_id" = $1  [["work_id", 100] # sql call 
@work.policy.able_to_delete?
#=> SELECT COUNT(*) FROM "comments" WHERE "comments"."work_id" = $1  [["work_id", 100] # sql call 
@work.policy.able_to_delete?
#=> SELECT COUNT(*) FROM "comments" WHERE "comments"."work_id" = $1  [["work_id", 100] # sql call 

...我们需要多次调用comments.count

但是如果我们引入另一层记忆化:

那么让我们改变这个:

class WorkPolicy
  # ...

  def work_has_no_comments
    work.comments.count < 1
  end
end

到这个:

class WorkPolicy
  # ...

  def work_has_no_comments
    @work_has_no_comments ||= comments.count < 1
  end
end


@work = Work.last
@work.policy.able_to_delete?
#=> SELECT COUNT(*) FROM "comments" WHERE "comments"."work_id" = $1  [["work_id", 100] # sql call 
@work.policy.able_to_delete?
@work.policy.able_to_delete?

正如您所看到的,对计数的SQL调用仅在第一次进行,然后从对象状态的内存中返回结果。

缓存

但是,在我们“循环遍历多个工作”的情况下,这种方法行不通,因为我们正在使用100个WorkPolicy对象初始化100个Work对象

最好的理解方法是在irb中运行此代码:

class Foo
  def x
    @x ||= calculate
  end

  private

  def calculate
      sleep 2 # slow query
      123
  end
end

class Bar
  def y
    @y ||= Foo.new
  end
end

p "10 times calling same memoized object\n"
bar = Bar.new
10.times do
  puts  bar.y.x
end

p "10 times initializing new object\n"

10.times do
  bar = Bar.new
  puts  bar.y.x
end

解决这个问题的一种方法是使用Rails缓存

class WorkPolicy
  # ...

  def work_has_no_comments
    Rails.cache.fetch [WorkPolicy, 'work_has_no_comments', @work] do
      work.comments.count < 1
    end
  end
end

class Comment
  belongs_to :work, touch: true    # `touch: true` will update the Work#updated_at each time new commend is added/changed, so that we drop the cache 
end

现在这只是一份愚蠢的示例。我知道可以通过引入Work#comments_count方法并在其中缓存评论计数来解决这个问题。我只是想演示一些选项。

有了这样的缓存,第一次运行WorksController#index时,我们将会进行多次 SQL 调用:

SELECT "works".* FROM "works"
SELECT COUNT(*) FROM "comments" WHERE "comments"."work_id" = $1  [["work_id", 1]
SELECT COUNT(*) FROM "comments" WHERE "comments"."work_id" = $1  [["work_id", 2]
SELECT COUNT(*) FROM "comments" WHERE "comments"."work_id" = $1  [["work_id", 3]
SELECT COUNT(*) FROM "comments" WHERE "comments"."work_id" = $1  [["work_id", 4]
# ...

但第二次、第三次通话将会是这样:

SELECT "works".* FROM "works"
# no count call

如果您向ID为3的工作添加新评论:

SELECT "works".* FROM "works"
SELECT COUNT(*) FROM "comments" WHERE "comments"."work_id" = $1  [["work_id", 3]

正确的SQL

现在我们还不满意。我们希望第一次运行能够快速!问题在于我们调用关联(评论)的方式是延迟加载:

Work.limit(3).each {|w| w.comments }

# => SELECT  "works".* FROM "works" WHERE  ORDER BY "works"."id" DESC LIMIT 10
# => SELECT "comments".* FROM "comments" WHERE "comments"."work_id" = $1  ORDER BY comments.created_at ASC  [["work_id", 97]]
# => SELECT "comments".* FROM "comments" WHERE "comments"."work_id" = $1  ORDER BY comments.created_at ASC  [["work_id", 98]]
# => SELECT "comments".* FROM "comments" WHERE "comments"."work_id" = $1  ORDER BY comments.created_at ASC  [["work_id", 99]]

但是如果我们急切地加载它们:

  Work.limit(3).includes(:comments).map(&:comments)

  SELECT  "works".* FROM "works" WHERE "works"."deleted_at" IS NULL LIMIT 3
  SELECT "comments".* FROM "comments" WHERE "comments"."status" = 'approved' AND "comments"."work_id" IN (97, 98, 99)  ORDER BY comments.created_at ASC

阅读更多关于http://blog.scoutapp.com/articles/2017/01/24/activerecord-includes-vs-joins-vs-preload-vs-eager_load-when-and-where中的includesjoins
因此,我们的代码可能是这样的:
class WorksController < ApplicationController
  def index
    @works = Work.all.includes(:comments)
  end
end

class WorkPolicy
  # ...

  def work_has_no_comments
    work.comments.size < 1        # we changed `count` to `size`
  end
end

问:等一下,comments.countcommets.size不是一样的吗?

并不完全相同。

10.times do
  work.comments.size
end  
# SELECT "comments".* FROM "comments" WHERE "comments"."work_id" = $1    ORDER BY comments.created_at ASC  [["work_id", 1]]

...将所有评论加载到(类似)数组中,并对大小进行数组计算(好像[].size一样)

10.times do
  work.comments.count
end
# SELECT COUNT(*) FROM "comments" WHERE "comments"."work_id" = $1  [["work_id", 1]]
# SELECT COUNT(*) FROM "comments" WHERE "comments"."work_id" = $1  [["work_id", 1]]
# SELECT COUNT(*) FROM "comments" WHERE "comments"."work_id" = $1  [["work_id", 1]]
# ...

...执行 SELECT COUNT 比加载“所有评论”以计算大小要快得多,但是当您需要执行此操作10次时,您明确地进行了10次调用

现在我夸大了 work.comments.size Rails 更聪明地确定是否只想要 size。 在某些情况下,它只执行 SELECT COUNT(*) 而不是“将所有评论加载到数组” 并执行[].size。

这很类似于.pluck.map

scope = Work.limit(10)
scope.pluck(:title)
# SELECT  "works"."title" FROM "works" LIMIT 10
# => ['foo', 'bar', ...]
scope.pluck(:title)
# SELECT  "works"."title" FROM "works" LIMIT 10
# => ['foo', 'bar', ...]

scope.map(&:title)
# SELECT  "works".* FROM "works" LIMIT 10
# => ['foo', 'bar', ...]
scope.map(&:title)
# => ['foo', 'bar', ...]
  • pluck 只选择 title 到数组,所以速度更快,但每次都会执行 SQL 查询
  • map 会导致 Rails 对 SELECT * 进行评估以填充 title 到数组,但然后您可以使用已加载的对象进行操作

结论

没有银弹。它总是取决于您想要实现什么。

有人可能会认为“优化 SQL”解决方案效果最佳,但这并不正确。您需要在每个调用 work.policy.able_to_delete 的地方实现类似的 SQL 优化,这可能是 10 或 100 个位置。从性能角度来看,includes 并不总是一个好主意。

缓存可能在事件级别上被超级链接,以确定哪个事件应该删除缓存的哪个部分。如果您没有正确执行此操作,则您的网站可能会显示“过时信息”!对于策略对象而言,这是非常危险的。

记忆化并不总是足够灵活,因为您可能需要重新设计大量代码库才能实现它,并引入过多的不必要的抽象层

更不用说记忆化在像 Rubinius 这样的线程安全环境中是大忌,除非您正确同步线程。如果您使用 MRI,则可以放心使用记忆化(在 95% 的情况下)。Rails 和 Puma 都是线程安全的,但那是一种不同类型的“线程安全”。您真的需要做一些愚蠢的事情才会成为问题。这篇文章太长了,无法涉及该主题。请谷歌它!

具体要看你的应用程序(应用程序的某个部分)的目标是什么。我的唯一建议是:对你的应用程序进行配置文件/基准测试!不要过早地进行优化。使用New relic等工具发现你的应用程序中哪些部分较慢。

逐步优化,不要构建缓慢的应用程序然后决定"好吧,让我们进行优化",因为你可能会发现自己做出了糟糕的设计选择,而你的App需要重写以提高速度。

其他未提到的解决方案

计数器缓存

数据库索引

听起来可能不相关但很多性能问题是因为你的应用程序没有DB索引(或过多的预发布索引)导致的。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接