缓存是提升系统性能非常有效的手段,常常起到立竿见影的效果,但是有时不恰当的使用不但起不到优化效果,反而可能让系统更慢。下面总结缓存使用过程中常见的一些陷阱。
大家应该比较熟悉数据库查询时的 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 左右,应用性能立即大幅提升。
为什么批量读取时间消耗大幅减少呢?因为每一次缓存读取过程有很多固定开销,包括加锁,系统(网络)调用等等,当使用批量读取时,这些固定开销统统节省了,而缓存服务器单次 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 篇,接下来还会带来:
打算陆续分享薄荷系统中遇到并解决的一些问题,希望对大家有所帮助。 薄荷正在招聘 ruby 伙伴,想和我们一起 pair 吗?有兴趣请看 薄荷热聘