Rails 缓存使用的 N+1 问题 - 缓存使用陷阱 1

vincent · 2015年05月27日 · 最后由 teddy_1004 回复于 2015年06月01日 · 5163 次阅读

缓存可能让你的应用更慢

缓存是提升系统性能非常有效的手段,常常起到立竿见影的效果,但是有时不恰当的使用不但起不到优化效果,反而可能让系统更慢。下面总结缓存使用过程中常见的一些陷阱。


缓存使用的 N+1 问题

大家应该比较熟悉数据库查询时的 N+1 问题,在缓存中同样存在 N+1 问题。当应用中出现需要多次读取缓存的时候,虽然单次读取缓存速度很快,但是多次读取缓存累计时间相当可观,很可能会成为一个性能瓶颈。

直接给一个演示例子,生成 10000 个缓存对象 user::counter 存储整数,然后分别单次,批量读取缓存,统计每种方式消耗时间。

代码如下所示:

require 'dalli'
require 'active_support'
require 'active_support/cache/dalli_store'

CACHE = ::ActiveSupport::Cache::DalliStore.new '192.168.1.20', namespace: 'demo'

n = 10000
n.times {|i| CACHE.write "user:#{i}:counter", i * i }

def read_in_batch(total, batch_size)
  (1..total).each_slice(batch_size) do |slice| 
    batch = slice.map { |i| "user:#{i}:counter" }
    CACHE.read_multi(*batch)
  end
end

Benchmark.bmbm do |x|
  x.report "1-1" do
    n.times do |i| 
      CACHE.read "user:#{i}:counter"
    end
  end

  x.report "2-1" do
    read_in_batch n, 1
  end 

  x.report " 10" do
    read_in_batch n, 10
  end

  x.report " 30" do
    read_in_batch n, 30
  end  

  x.report " 50" do
    read_in_batch n, 50
  end

  x.report "100" do
    read_in_batch n, 100
  end  
end

执行结果如下所示:

          user     system      total        real
1-1   0.660000   0.620000   1.280000 (  4.236520)
2-1   1.400000   0.750000   2.150000 (  5.674942)
 10   0.450000   0.100000   0.550000 (  1.114594)
 30   0.420000   0.070000   0.490000 (  0.772291)
 50   0.410000   0.060000   0.470000 (  0.678551)
100   0.380000   0.060000   0.440000 (  0.560123)

从结果中可以看到,分批读取速度更快,使用 read_multi 读取(每次 50 个),要比单次 read 读取 快 6~7 倍。【特别感谢 @lithium4010 指出原来测试代码一个 bug】

薄荷生产环境的例子

在薄荷生产系统性能优化中,我们遇到过好几次类似问题。例如有一个 api 需要返回多个存放缓存的用户资料,单个用户资料缓存读取时间接近 1 ms,50 个用户资料消耗接近 45 ms 时间,它导致这个 api 响应时间很长,把 50 次用户资料缓存读取放到一次批量读取后,缓存读取时间减少为 3 ms 左右,应用性能立即大幅提升。

N + 1 问题的原因

为什么批量读取时间消耗大幅减少呢?因为每一次缓存读取过程有很多固定开销,包括加锁,系统(网络)调用等等,当使用批量读取时,这些固定开销统统节省了,而缓存服务器单次 key 查找和数据返回时间和批量相比差异没有那么大,所以整体时间大幅减少。

注意事项

批量读取增加了应用的复杂度,如果应用性能没有问题,或者缓存读取次数很少,并没有必要改造成批量读取形式。 通常我们以 fetch 方法使用缓存对象,这时批量读取方法如下所示:

keys = user_ids.map { |user_id| "user:#{user_id}" }

user_hash = Rails.cache.fetch_multi(*keys) do |key|
  User.find_by_id key.gsub('user:', '')
end

总结

缓存虽然很快,但它毕竟也是一次 IO 操作,同样需要消耗一定时间,如果某一次特别大量读写缓存,很可能会造成性能问题,通过批量读取方式是解决该问题的有效手段。

后续

这是“缓存使用陷阱”系列文章的第 1 篇,接下来还会带来:

  • 2. 空数据导致缓存失效
  • 3. 缓存大量无用数据导致内存浪费
  • 4. 过度使用缓存
  • 5. 把缓存当成存储使用导致数据丢失

打算陆续分享薄荷系统中遇到并解决的一些问题,希望对大家有所帮助。 薄荷正在招聘 ruby 伙伴,想和我们一起 pair 吗?有兴趣请看 薄荷热聘

DHH 大神说要用“套娃机制”的呢。

SLC 可以自动完成这个功能:

对于 N+1 情况,照常使用 includes:

  1. slc 自动使用 read multi 从缓存查询,如果全部命中,不执行任何 sql。 2.read multi 只命中部分缓存,对于剩下未命中的记录,进行一次 IN 查询,完成后写入缓存。
  • 最好的情况是 No N+1 Query,No N + 1 Cache Read。
  • 最坏的情况是 IN Query + Cache Read Multi。

https://github.com/csdn-dev/second_level_cache/commit/e885dae732b920012c51016b0715f049b66f0776

#3 楼 @victor “Russian Doll Caching”主要是为了避免嵌套缓存版本更新问题,因为 top-level 有一次整体的缓存读写,性能还是比较好的。我认为 http://guides.rubyonrails.org/caching_with_rails.html#fragment-caching 里介绍的例子有误导嫌疑,按上面的做法通常只能让性能更差。

#4 楼 @hooopo SLC 很酷!比较好的解决这个问题。

实际中比这个复杂。我的对象经常树状的嵌套,例如 MessageThread -> Message->Attachment。我现在寻求一个办法将下级对象一次都读取出来。

MessageThread.read_mulit(message_thread_ids,:include=>[:message=>[:attachment]])

貌似还没有这样的 cache Gem。

#7 楼 @jimrokliu 你可以试试 identity_cache identity_cache 有一个 fetch_multi 方法,可以一次获取多个 id 对应的 ActiveRecord 对象, ActiveRecord 的关联对象可以通过 cache_has_many 设置,它还提供 embed 选项把关联对象全部放置一起,貌似能解决你的问题。

1 里面为啥不用 read_in_batch n, 1

#9 楼 @lithium4010 因为读取单个缓存对象用 read 方法,读取多个用 read_multi 方法。

#8 楼 @vincent 简单的看了一下,回头专门研究一下,谢谢分享了。

#10 楼 @vincent 我测了一下,改 read_in_batch n, 1 以后,数据如下

       user     system      total        real
  1  0.010000   0.000000   0.010000 (  0.018565)
 10  0.230000   0.010000   0.240000 (  0.229515)
 30  0.020000   0.000000   0.020000 (  0.019327)
 50  0.020000   0.000000   0.020000 (  0.020574)
100  0.030000   0.000000   0.030000 (  0.032168)

用原方法,本机数据如下

       user     system      total        real
  1  0.770000   0.110000   0.880000 (  0.880746)
 10  0.010000   0.000000   0.010000 (  0.015449)
 30  0.020000   0.000000   0.020000 (  0.016035)
 50  0.030000   0.000000   0.030000 (  0.032530)
100  0.030000   0.000000   0.030000 (  0.025726)

和你的结果一致。

不知道怎么解释前一种情况,直觉上文中的解释说不通

都使用 read_in_batch 时:

去掉 1,“大时间”在 1, 2 行交错

       user     system      total        real
10  0.020000   0.000000   0.020000 (  0.011991)
 30  0.230000   0.010000   0.240000 (  0.235225)
 50  0.010000   0.000000   0.010000 (  0.017332)
100  0.030000   0.000000   0.030000 (  0.021796)

       user     system      total        real
 10  0.250000   0.000000   0.250000 (  0.258668)
 30  0.010000   0.000000   0.010000 (  0.012602)
 50  0.020000   0.000000   0.020000 (  0.020062)
100  0.020000   0.000000   0.020000 (  0.014228)

去掉 10 , “大时间”在 1, 2 行交错

       user     system      total        real
  1  0.270000   0.000000   0.270000 (  0.271896)
 30  0.010000   0.000000   0.010000 (  0.012453)
 50  0.010000   0.000000   0.010000 (  0.013926)
100  0.030000   0.000000   0.030000 (  0.028314)

       user     system      total        real
  1  0.020000   0.000000   0.020000 (  0.013825)
 30  0.180000   0.000000   0.180000 (  0.178828)
 50  0.010000   0.000000   0.010000 (  0.012010)
100  0.010000   0.000000   0.010000 (  0.012555)

#8 楼 @vincent bug.

if i + 1 % batch_size == 0

应该为

if (i + 1) % batch_size == 0

我机器上测 ruby 2.2 上面的写法永远不会 == 0

#10 楼 @vincent read_in_batch n 其实读的是 n - 1 个数据

改正: read_in_batch n 其实读的不是 n 个数据

#10 楼 @vincent 测试代码:

namespace :test do

  task cache: :environment do
     test_cache
    # test_cache_batch
    # test_read_in_batch
  end

end

def read_in_batch(total, batch_size)
  batch_size += 1 # 这里改了
  array = []
  total.times do |i|
    if (i + 1) % batch_size == 0 #这里改了
      Rails.cache.read_multi(*array)
      array = []
    else
      array << "user:#{i}:counter"
    end
  end
end

def test_cache_batch

  n = 10000
  n.times {|i| Rails.cache.write "user:#{i}:counter", i * i }

  Benchmark.bm do |x|
    x.report "  1" do
      read_in_batch n, 1
    end

    x.report " 10" do
      read_in_batch n, 10
    end

    x.report " 30" do
      read_in_batch n, 30
    end

    x.report " 50" do
      read_in_batch n, 50
    end

    x.report "100" do
      read_in_batch n, 100
    end
  end
end

def test_cache
  n = 10000
  n.times {|i| Rails.cache.write "user:#{i}:counter", i * i }

  Benchmark.bm do |x|
    x.report "  1" do
      n.times do |i|
        Rails.cache.read "user:#{i}:counter"
      end
    end

    x.report " 10" do
      read_in_batch n, 10
    end

    x.report " 30" do
      read_in_batch n, 30
    end

    x.report " 50" do
      read_in_batch n, 50
    end

    x.report "100" do
      read_in_batch n, 100
    end
  end
end

代码修正后: test_cache:

        user     system      total        real
  1  0.740000   0.100000   0.840000 (  0.834747)
 10  0.410000   0.050000   0.460000 (  0.465745)
 30  0.430000   0.060000   0.490000 (  0.491213)
 50  0.440000   0.060000   0.500000 (  0.498691)
100  0.420000   0.070000   0.490000 (  0.493613)

test_cache_batch:

       user     system      total        real
  1  0.460000   0.050000   0.510000 (  0.520317)
 10  0.440000   0.060000   0.500000 (  0.493935)
 30  0.450000   0.040000   0.490000 (  0.495721)
 50  0.410000   0.080000   0.490000 (  0.494678)
100  0.450000   0.060000   0.510000 (  0.509924)

结论:

  1. 在读一个数据的时候,read_multi 比 read 稳定快
  2. 你的结论好像不成立,不存在 n + 1 的现象,该现象应该是由于代码 bug 导致的。生产环境的现象我没法解释。。

后续:

其实上面的测试还是有问题,并没有读全部的 cache

然后改了 read_in_batch

def read_in_batch(total, batch_size)
  ids = (1..total).to_a
  while !ids.empty?
    batch = ids.pop(batch_size).map { |i| "user:#{i}:counter" }
    Rails.cache.read_multi(*batch)
  end
end

加了 2 和 3 两个 测试

test_cache

       user     system      total        real
  1  0.770000   0.060000   0.830000 (  0.828525)
  2  0.460000   0.090000   0.550000 (  0.551031)
  3  0.470000   0.060000   0.530000 (  0.537011)
 10  0.470000   0.060000   0.530000 (  0.533800)
 30  0.450000   0.070000   0.520000 (  0.509173)
 50  0.430000   0.080000   0.510000 (  0.509421)
100  0.450000   0.050000   0.500000 (  0.512080)

test_cache_batch
       user     system      total        real
  1  0.750000   0.110000   0.860000 (  0.862681)
  2  0.520000   0.060000   0.580000 (  0.577206)
  3  0.490000   0.060000   0.550000 (  0.546603)
 10  0.490000   0.040000   0.530000 (  0.536715)
 30  0.450000   0.070000   0.520000 (  0.528250)
 50  0.460000   0.090000   0.550000 (  0.545573)
100  0.450000   0.070000   0.520000 (  0.524905)

发现 只有 1 比较慢,2, 3 和其他基本稳定

跑 2w 条


test_cache
       user     system      total        real
  1  1.210000   0.140000   1.350000 (  1.354328)
  2  0.980000   0.120000   1.100000 (  1.098255)
  3  0.940000   0.140000   1.080000 (  1.078272)
 10  0.920000   0.120000   1.040000 (  1.054410)
 30  0.910000   0.130000   1.040000 (  1.036415)
 50  0.900000   0.120000   1.020000 (  1.023310)
100  0.940000   0.100000   1.040000 (  1.049659)

test_cache_batch
       user     system      total        real
  1  1.200000   0.170000   1.370000 (  1.381987)
  2  0.940000   0.160000   1.100000 (  1.099291)
  3  0.940000   0.140000   1.080000 (  1.088021)
 10  0.940000   0.110000   1.050000 (  1.053521)
 30  0.900000   0.140000   1.040000 (  1.044466)
 50  0.890000   0.130000   1.020000 (  1.028398)
100  0.920000   0.140000   1.060000 (  1.049393)

好像只是多了个常数

跑 4w 条

test_cache
       user     system      total        real
  1  2.440000   0.310000   2.750000 (  2.763401)
  2  1.900000   0.250000   2.150000 (  2.152947)
  3  1.870000   0.230000   2.100000 (  2.112615)
 10  1.750000   0.320000   2.070000 (  2.066013)
 30  1.780000   0.260000   2.040000 (  2.050358)
 50  1.770000   0.250000   2.020000 (  2.021323)
100  1.700000   0.300000   2.000000 (  2.014740)

test_cache_batch
       user     system      total        real
  1  2.060000   0.240000   2.300000 (  2.301558)
  2  1.870000   0.290000   2.160000 (  2.164925)
  3  1.890000   0.210000   2.100000 (  2.109823)
 10  1.770000   0.280000   2.050000 (  2.047593)
 30  1.850000   0.170000   2.020000 (  2.035191)
 50  1.800000   0.210000   2.010000 (  2.016329)
100  1.790000   0.230000   2.020000 (  2.027382)

好像这么写更好

def read_in_batch(total, batch_size)
  (1..total).each_slice(batch_size) do |slice| 
    batch = slice.map { |i| "user:#{i}:counter" }
    Rails.cache.read_multi(*batch)
  end
end

#5 楼 @vincent 例子的测试结果应该是由于 只有第一个测试真的去读了缓存。。。

读取单个也可以用 read_muti, 而且比 read 快一些

看 rails 源码 发现 read 比 read_muti 多了一个 修改 payload[:hit] 的操作

#15 楼 @lithium4010 非常感谢指出 benchmark 代码中的一个问题,请原谅我的疏忽。我修正了代码,重新跑了 benchmark,缓存服务器同在一台机器时差异不太多,在远程时差异还比较明显,但是没有上百倍的差距。

#15 楼 @lithium4010 我重新改正代码跑了测试,和你的结果不一样啊,缓存服务器在本机时数据如下:

          user     system      total        real
1-1   0.550000   0.230000   0.780000 (  1.133004)
2-1   1.280000   0.330000   1.610000 (  1.757418)
 10   0.430000   0.050000   0.480000 (  0.512793)
 30   0.330000   0.030000   0.360000 (  0.383506)
 50   0.320000   0.020000   0.340000 (  0.349198)
100   0.290000   0.010000   0.300000 (  0.310068)

在局域网内,如下:

          user     system      total        real
1-1   0.660000   0.620000   1.280000 (  4.236520)
2-1   1.400000   0.750000   2.150000 (  5.674942)
 10   0.450000   0.100000   0.550000 (  1.114594)
 30   0.420000   0.070000   0.490000 (  0.772291)
 50   0.410000   0.060000   0.470000 (  0.678551)
100   0.380000   0.060000   0.440000 (  0.560123)

多个单次读和批量读差异还是挺明显的。

代码见 https://gist.github.com/xiewenwei/b55070b49a73f187c284

#19 楼 @vincent 我是直接本机 rake 的,没有用 dalli,ruby 2.2 + rails 4.2 数据如下:

       user     system      total        real
  1  0.750000   0.050000   0.800000 (  0.798077)
 1_  0.530000   0.060000   0.590000 (  0.595011)
 10  0.480000   0.050000   0.530000 (  0.527158)
 30  0.470000   0.040000   0.510000 (  0.513850)
 50  0.430000   0.080000   0.510000 (  0.515523)
100  0.490000   0.050000   0.540000 (  0.539221)

试了一下换 ruby 版本到 2.0.0 除了慢了(1 秒多),batch 依然更快

代码见 (https://gist.github.com/li-thy-um/c78ca3f5abb60d388951)

@vincent 说差异是由于 我 cache 在内存里没有用 memcached。

我想问一下关于

薄荷生产环境的例子

如果第一次请求 50 个用户,那花在缓存上的时间是不是很多啊 再问一下,api 是使用 jbuilder 吗,每次读取多个缓存是自己手写代码实现,还是用的什么 gem

赞 :plus1: 和 Vincent 在一起工作绝对是每天收获满满~~

需要 登录 后方可回复, 如果你还没有账号请 注册新账号