程序人生

写优雅的程序,做优雅的人

缓存可能让你的应用更慢 - 缓存使用的 N+1 问题

| Comments

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

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

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

代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
n = 10000
n.times {|i| Rails.cache.write "user:#{i}:counter", i * i }

def read_by_batch(total, batch_size)
  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

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_by_batch n, 10
  end

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

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

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

执行结果如下所示:

1
2
3
4
5
6
   user     system      total        real
  1  0.910000   0.210000   1.120000 (  1.316800)
 10  0.010000   0.000000   0.010000 (  0.013985)
 30  0.010000   0.000000   0.010000 (  0.009109)
 50  0.010000   0.000000   0.010000 (  0.009200)
100  0.040000   0.010000   0.050000 (  0.044768)

从结果中可以看到,分批读取(每次 30~50时)速度很快,要比每次 1 个对象快 100 多倍,每次读取 10 个对象也要快 100 倍,批次并不是越大越好,每次读取 100 个速度比 10 个更慢。

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

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

当然,批量读取增加了应用的复杂度,如果应用性能没有问题,或者缓存读取次数很少,并没有必要改造成批量读取形式。

最后一点是,通常我们以 fetch 方法使用缓存对象,这时批量读取方法如下所示:

1
2
3
4
5
  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 操作,同样需要消耗一定时间,如果某一次特别大量读写缓存,很可能会以前性能问题,通过批量读取方式是解决该问题的有效手段。

Comments