通过异步设置键值对来改进Rails.cache.write

时间:2013-12-21 22:23:39

标签: ruby-on-rails caching heroku worker memcachier

我正在考虑在使用dalli将项目写入memcachier云时提高Rails.cache.write的性能。

与缓存相关的堆栈目前是:

heroku, memcachier heroku addon, dalli 2.6.4, rails 3.0.19

我使用newrelic进行性能监控。

我正在为一个给定的登录用户提取“活跃学生”,这个用户由BusinessUser实例代表,当从处理需要列表“”的请求的控制器调用其active_students方法时活跃的学生“:

class BusinessUser < ActiveRecord::Base
  ...
  def active_students
    Rails.cache.fetch("/studio/#{self.id}/students") do
      customer_users.active_by_name
    end
  end
  ...
end

在看了newrelic之后,我基本上缩小了应用程序在memcachier上设置关键值的一个重要性能。每次平均需要 225ms 。此外,看起来设置memcache键值会阻塞主线程并最终中断请求队列。显然这是不可取的,特别是当缓存策略的重点是减少性能瓶颈时。

此外,我使用普通dalli和Rails.cache.write对缓存存储进行基准测试,以获得相同值的1000个缓存集:

heroku run console -a {app-name-redacted}
irb(main):001:0> require 'dalli'
=> false
irb(main):002:0> cache = Dalli::Client.new(ENV["MEMCACHIER_SERVERS"].split(","),
irb(main):003:1*                     {:username => ENV["MEMCACHIER_USERNAME"],
irb(main):004:2*                      :password => ENV["MEMCACHIER_PASSWORD"],
irb(main):005:2*                      :failover => true,
irb(main):006:2*                      :socket_timeout => 1.5,
irb(main):007:2*                      :socket_failure_delay => 0.2
irb(main):008:2>                     })
=> #<Dalli::Client:0x00000006686ce8 @servers=["server-redacted:11211"], @options={:username=>"username-redacted", :password=>"password-redacted", :failover=>true, :socket_timeout=>1.5, :socket_failure_delay=>0.2}, @ring=nil>
irb(main):009:0> require 'benchmark'
=> false
irb(main):010:0> n = 1000
=> 1000
irb(main):011:0> Benchmark.bm do |x|
irb(main):012:1*   x.report { n.times do ; cache.set("foo", "bar") ; end }
irb(main):013:1>   x.report { n.times do ; Rails.cache.write("foo", "bar") ; end }
irb(main):014:1> end
       user     system      total        real
 Dalli::Server#connect server-redacted:11211
Dalli/SASL authenticating as username-redacted
Dalli/SASL: username-redacted
  0.090000   0.050000   0.140000 (  2.066113)

Dalli::Server#connect server-redacted:11211
Dalli/SASL authenticating as username-redacted
Dalli/SASL: username-redacted

  0.100000   0.070000   0.170000 (  2.108364)

使用普通dalli cache.set,我们使用2.066113s将1000个条目写入缓存,平均cache.set时间为2.06毫秒。

使用Rails.cache.write,我们使用2.108364s将1000个条目写入缓存,平均Rails.cache.write时间为2.11毫秒。

⇒看起来问题不在于memcachier,而在于我们试图存储的数据量。

根据the docs for the #fetch method,如果我想将缓存集抛出到单独的线程或工作者中,看起来它不会是我想要的方式,因为我无法拆分{{3}}来自write的1}} - 不言而喻,我不想异步阅读。

在设置键值时,是否可以通过向工作人员投掷read来减少瓶颈?或者,更一般地,是否有更好的模式来执行此操作,因此每次我想执行Rails.cache.write时我都不会阻止主线程?

2 个答案:

答案 0 :(得分:2)

在正常情况下,有两个因素会导致整体延迟:客户端编组/压缩和网络带宽。

Dalli mashalls并可选择压缩数据,这可能非常昂贵。以下是编组和压缩随机字符列表的一些基准测试(一种人工用户ID列表或类似的东西)。在这两种情况下,结果值约为200KB。两个基准都是在Heroku dyno上运行的 - 性能显然取决于CPU和机器的负载:

irb> val = (1..50000).to_a.map! {rand(255).chr}; nil
# a list of 50000 single character strings
irb> Marshal.dump(val).size
275832
# OK, so roughly 200K. How long does it take to perform this operation
# before even starting to talk to MemCachier?
irb> Benchmark.measure { Marshal.dump(val) }
=>   0.040000   0.000000   0.040000 (  0.044568)
# so about 45ms, and this scales roughly linearly with the length of the list.


irb> val = (1..100000).to_a; nil # a list of 100000 integers
irb> Zlib::Deflate.deflate(Marshal.dump(val)).size
177535
# OK, so roughly 200K. How long does it take to perform this operation
irb>  Benchmark.measure { Zlib::Deflate.deflate(Marshal.dump(val)) }
=>   0.140000   0.000000   0.140000 (  0.145672)

因此,我们基本上只能看到40到150毫秒的性能,仅用于编组和/或压缩数据。编组字符串会便宜得多,而编组类似复杂对象的东西会更昂贵。压缩取决于数据的大小,但也取决于数据的冗余。例如,压缩1MB字符串的所有“a”字符只需要大约10ms。

网络带宽将在这里扮演一些角色,但不是一个非常重要的角色。 MemCachier的值限制为1MB,传输到MemCachier或从MemCachier传输大约需要20ms:

irb(main):036:0> Benchmark.measure { 1000.times { c.set("h", val, 0, :raw => true) } }
=>   0.250000  11.620000  11.870000 ( 21.284664)

这相当于大约400Mbps(1MB * 8MB / Mb *(1000ms / s / 20ms)),这是有道理的。然而,即使是相对较大但仍然较小的200KB值,我们预计会有5倍的加速:

irb(main):039:0> val = "a" * (1024 * 200); val.size
=> 204800
irb(main):040:0> Benchmark.measure { 1000.times { c.set("h", val, 0, :raw => true) } }
=>   0.160000   2.890000   3.050000 (  5.954258)

因此,您可以采取一些措施来加快速度:

  1. 使用更快的编组机制。例如,使用Array#pack("L*")将50,000个32位无符号整数的列表(如第一个基准测试中)编码为长度为200,000的字符串(每个整数为4个字节),只需2ms而不是40ms。使用具有相同编组方案的压缩,获得类似大小的值也非常快(大约2ms),但压缩对随机数据不再有用(Ruby的Marshal甚至在列表上产生相当冗余的字符串)随机整数)。

  2. 使用较小的值。这可能需要深入的应用程序更改,但如果您真的不需要整个列表,那么您应该进行设置。例如,memcache协议具有appendprepend个操作。如果您只是在长列表中添加新内容,则可以使用这些操作。

  3. 最后,正如所建议的那样,从关键路径中删除set / gets可以防止任何延迟影响HTTP请求延迟。您仍然需要将数据提供给工作人员,因此,如果您使用类似工作队列的内容,则发送给工作人员的消息应该只包含有关构建数据的指令而不是数据本身(或者您再次进入同一个洞,只是使用不同的系统。非常轻量级(就编码工作而言)就是简单地分叉一个过程:

    mylist = Student.where(...).all.map!(&:id)
    ...I need to update memcache with the new list of students...
    fork do
      # Have to create a new Dalli client
      client = Dalli::Client.new
      client.set("mylistkey", mylist)
      # this will block for the same time as before, but is running in a separate process
    end
    

    我没有对完整的示例进行基准测试,但由于您不是exec,而Linux fork是写时复制,因此fork调用本身的开销应该是最小的。在我的机器上,它大约是500us( - 秒,而不是毫秒)。

答案 1 :(得分:0)

使用Rails.cache.write在工作站(例如Sidekiq)的缓存中预取和存储数据是我在高容量时看到的。当然,速度与您想花的钱之间存在折衷。想一想:

  • 您应用中最常用的路径(经常active_students访问?);
  • 要存储的内容(只是ID或整个对象或链的下方);
  • 如果您可以优化该查询(n + 1?)。

另外,如果你真的需要速度,可以考虑使用专用的memcache服务,而不是Heroku插件。