Rails 谨防 ActiveSupport::Cache::Store 缓存 nil 值

martin91 · October 30, 2015 · Last by martin91 replied at June 10, 2022 · 5801 hits
Topic has been selected as the excellent topic by the admin.

Rails 中的 active_support 组件主要基于 Rails 需要提供了很多非常有用的基础工具以及对 Ruby 内置类进行扩展。其中的 cache 模块主要提供了 Rails 中底层缓存的定义以及简单实现。今天要跟大家探讨的是之前在使用此模块所遇到的一个坑,有兴趣学习其基本用法的可以点击以下两个链接:

从 ActiveSupport::Cache::Store#fetch 聊起

之前在实现一个需要从外部服务请求数据的功能时,处于性能考虑,我在代码中使用了缓存,并且设置缓存失效时间为 7 天,示例代码如下:

def read_external_service(params)
  # 这段代码稍微解释下:
  #   当缓存命中时,则直接读取缓存,如果无期待缓存,则通过 HTTP 向外请求结果,并且将结果
  #   缓存下来,这样子,当下次继续调用时,则可直接返回缓存内容,而无需重复向外请求
  #
  Rails.cache.fetch 'example_cache_key_here', expires_in: 7.days do
    response = HTTParty.get 'https://example.com/example/request/path'
    JSON.parse(response.body)["data"]
  end
end

上面的代码其实不复杂,核心代码就是使用了 ActiveSupport::Cache::Store#fetch 方法。

一切都很正常地运行着,直到有一天,线上系统不断报警,出错原因就是这段代码总是返回 nil ,而调用者又因为没有判断 nil 值,就会出现 undefined method 'xxx' for nil:NilClass 错误。在 debug 时,我尝试了直接调用外部服务接口,发现请求都有正确返回数据,不可能返回 nil 啊,难道是缓存了 nil 值?下面就直接通过代码验证一下!

[1] pry(main)> require 'active_support'
=> true
[2] pry(main)> cache = ActiveSupport::Cache::MemoryStore.new
=> <#ActiveSupport::Cache::MemoryStore entries=0, size=0, options={}>
[3] pry(main)> cache.read :nil_value
=> nil
[4] pry(main)> cache.exist? :nil_value
=> false
[5] pry(main)> cache.fetch :nil_value do
[5] pry(main)*   nil   # this `nil` value will be cached
[5] pry(main)* end
=> nil
[6] pry(main)> cache.read :nil_value
=> nil
[7] pry(main)> cache.exist? :nil_value
=> true

看吧, fetch 方法确实会缓存 nil 值(通过 exist? 方法可以判断是否缓存了指定的 key),所以系统出错原因就清晰了:在某次代码执行中,我的缓存刚好失效了,所以系统向外部发送了请求,恰巧这时候外部系统因为故障或者其他可能原因,没有返回期待数据,导致代码中最终缓存了 nil 值,在接下来的时间里,虽然外部系统可能恢复了正确服务,可是这时候因为我们的系统已经缓存了 nil值,所以在每次调用时都返回缓存的 nil,而不是重新请求正确结果,导致最后不停的报错告警。

这里插播一句,通过后来仔细查阅文档,才发现文档里已经注明:

Nil values can be cached.

╮(╯▽╰)╭ 怪我咯~

解决方案

意识到这个问题之后,解决思路简单粗暴,就是在可能返回 nil 值的地方放弃写入缓存:

def read_external_service(params)
  cache_key = 'example_cache_key_here'
  result = Rails.cache.read(cache_key)
  # 缓存命中,且内容不为 nil ,直接返回缓存内容
  return result if result.present?

  # 缓存失效,只能重新请求了~
  response = HTTParty.get 'https://example.com/example/request/path'
  result = JSON.parse(response.body)["data"]

  # 请求结果正确,写入缓存;否则,放弃之~~~
  Rails.cache.write(cache_key, result, expires_in: 7.days) if result.present?
  result
end

呃~~~虽然解决问题了,可是,就为了告诉系统不要相信 nil,就写得这么繁琐,好么?好么?好么?

踏上阅读源码之路

我尝试搜索了 #fetch 方法是否有支持比如 reject_nil 这样的 option,可惜的是,没有!可是真的没有吗?我不信!看源码去!

首先还是拜访下 ActiveSupport::Cache::Store 这个类啦,它可是所有缓存实现类的抽象类,别问我抽象类是什么,就是它明明只说话不干活,但是其他干活的都得向它看齐!好啦,说人话,其实就是说,我们在调用 Rails.cache.readRails.cache.fetch 等读写方法时,这些方法都是在 ActiveSupport::Cache::Store 中定义的,但是它只定义逻辑,而实际底层的读写实现,则都是交由其各种子类实现的,比如前面的 ActiveSupport::Cache::MemoryStore

首先让我们来看看 fetch方法的全部内容:

def fetch(name, options = nil)
  if block_given?
    options = merged_options(options)
    key = namespaced_key(name, options)

    instrument(:read, name, options) do |payload|
      cached_entry = read_entry(key, options) unless options[:force]
      payload[:super_operation] = :fetch if payload
      entry = handle_expired_entry(cached_entry, key, options)

      if entry
        payload[:hit] = true if payload
        get_entry_value(entry, name, options)
      else
        payload[:hit] = false if payload
        save_block_result_to_cache(name, options) { |_name| yield _name }
      end
    end
  else
    read(name, options)
  end

从代码中可以看到,当 #fetch 方法调用时没有传递 block 的话,它本质上就是 read 方法的别名而已。而当调用时传递了 block 的话,即如我前面的示例代码,让我们把代码分开看下:

cached_entry = read_entry(key, options) unless options[:force]
payload[:super_operation] = :fetch if payload
entry = handle_expired_entry(cached_entry, key, options)

它首先判断是否设置了 force 选项,如果有,则不读取缓存,由此模拟缓存强制失效;如果未设置 force 选项或者该选项不等于 true value,则尝试读取缓存,并且调用 handle_expired_entry判断缓存是否仍旧有效。

if entry
  payload[:hit] = true if payload
  get_entry_value(entry, name, options)

这三行代码,则是在缓存命中时,直接读取缓存内容并且返回。

else
  payload[:hit] = false if payload
  save_block_result_to_cache(name, options) { |_name| yield _name }
end

else 的代码则表示,在缓存无命中时, #fetch 代码直接调用 #save_block_result_to_cache 方法,并且向其传递了一个 block,这个 block 没有干别的事情,它只会执行我们传递给 #fetch 方法的 block,让我们接着往下看看相关的实现:

def save_block_result_to_cache(name, options)
  result = instrument(:generate, name, options) do |payload|
    yield(name)
  end

  write(name, result, options)
  result
end

可以看到,#save_block_result_to_cache 方法首先执行传递进来的代码块,实际上也就是我们期待在缓存失效时执行的代码,而在获得执行结果 result 后,方法通过调用 #write 方法将结果写入缓存,最后将 result 返回。

通过上面的源码分析,我们可以知道,当缓存失效时,#fetch 方法会直接将其代码块中的代码的返回值不加判断地写入缓存,并且返回该返回值。这里,或许我们可以做点什么,来实现我们想要支持 :reject_nil 的需求?

支持 :reject_nil option

为了支持 :reject_nil,我们只需要在写入缓存前判断是否真的需要 nil 值即可,于是我们只需要在 #save_block_result_to_cache 中加入 #write 的前置条件:

def save_block_result_to_cache(name, options)
  result = instrument(:generate, name, options) do |payload|
    yield(name)
  end

  # options[:reject_nil] && result.nil? 作为前置条件
  write(name, result, options) unless result.nil? && options[:reject_nil]

  result
end

话不多说,让我们来重新试验一番:

[1] pry(main)> require 'active_support'
=> true
[2] pry(main)> cache = ActiveSupport::Cache::MemoryStore.new
=> <#ActiveSupport::Cache::MemoryStore entries=0, size=0, options={}>
[3] pry(main)> cache.fetch :nil_key1 do
[3] pry(main)*   nil
[3] pry(main)* end
=> nil
[4] pry(main)> cache.exist? :nil_key1
=> true
[5] pry(main)> cache.fetch :nil_key2, reject_nil: true do
[5] pry(main)*   nil
[5] pry(main)* end
=> nil
[6] pry(main)> cache.exist? :nil_key2
=> false

可以看到,当我们调用 #fetch 方法时,如果没有传递 reject_nil: true,则 #fetch 方法会默认缓存 nil 值;而如果我们设置 reject_nil: true 的话,则 #fetch 就会放弃写入 nil 值到缓存中。试验成功!!!

基于这样的实现,我的代码就又可以改为如下了:

def read_external_service(params)
  # 所有改动只是加了一个 `reject_nil: true`,多方便,妈妈再也不用担心我掉到坑里去了
  Rails.cache.fetch 'example_cache_key_here', expires_in: 7.days, reject_nil: true do
    response = HTTParty.get 'https://example.com/example/request/path'
    JSON.parse(response.body)["data"]
  end
end

待会去给 Rails 提交 Pull Request 去 O(∩_∩)O~~

总结

  • 缓存是好个东西,用得好能够让应用性能表现突飞猛进
  • 要注意缓存写入的边界条件,要注意避免缓存了空值,但也并非所有空值都不能缓存(比如有些接口确实就是有可能返回空值嘛),具体看业务,没有绝对的要与不要,反正 :reject_nil 给你了,看你要不要

大赞,记得把我代码里面类似的地方改了~~

直接抛出异常算了。如果是 nil 的话

#3 楼 @jimxl 实际上还是会有异常,因为还是照样返回 nil,只是没有写入缓存而已。

非常棒!:plus1:

楼主能否贴个 PR 链接?我们也有类似的代码,只是还没有曝出问题 😄

#5 楼 @billy 哈哈,懒癌发作,还没提交代码,因为还要加上测试,所以看看周末再弄了

@martin91 不急一时的,慢工出细活。

#4 楼 @martin91 出错是肯定了,服务都出错了。只要不写入缓存,服务一恢复就 ok 了。如果不想返回 nil 只需要外部捕获异常了吧。加了 reject_nil: true 一样时返回的 nil。。。你为了不给前端报错,也是要判断 nil,我感觉都差不多。

@martin91 :plus1: 一步步抽丝剥茧,说得很明白,我会踩着你填好的坑走向光明的,咔咔 😄

@jimxl 你把服务呼叫放在外面那缓存还有什么意义。

#8 楼 @jimxl 你的意思是,与其静默地照样返回 nil,还不如直接抛出异常,然后由调用方去处理异常?比如:

# active_support
def save_block_result_to_cache(name, options)
  result = instrument(:generate, name, options) do |payload|
    yield(name)
  end

  if result.nil? && !options[:allow_nil]    # :allow_nil is true default to be compatible with original behaviour
    raise ActiveSupport::Cache::NilError
  end

  write(name, result, options)

  result
end

# 调用者
def read_external_service(params)
  # allow_nil: false will force active_support to raise an error when the block return nil
  Rails.cache.fetch 'example_cache_key_here', expires_in: 7.days, allow_nil: false do
    response = HTTParty.get 'https://example.com/example/request/path'
    JSON.parse(response.body)["data"]
  end
rescue ActiveSupport::Cache::NilError => e
  handler_for_nil_cases(params)
end

改库也行,也可以直接改改代码,应该也可以吧。

def read_external_service(params)
  # 这段代码稍微解释下:
  #   当缓存命中时,则直接读取缓存,如果无期待缓存,则通过 HTTP 向外请求结果,并且将结果
  #   缓存下来,这样子,当下次继续调用时,则可直接返回缓存内容,而无需重复向外请求
  #
  Rails.cache.fetch 'example_cache_key_here', expires_in: 7.days do
    response = HTTParty.get 'https://example.com/example/request/path'
    JSON.parse(response.body)["data"] || raise nil
  end
end

根据需要捕获异常,现在其实要解决的问题就是不要存入 nil,至于是否返回 nil 影响应该不大。

#10 楼 @billy ?没明白你的意思。

#12 楼 @jimxl 嗯嗯,是的,主要还是不想写入 nil,至于 nil 出现,那是后面场景需要解决的。

这个应该是当外部服务出现 nil 时候,我们应当采取的策略问题,你不想把 nil 写入缓存没问题,自己处理就好了,我想把 nil 写入缓存,设置一个很小的缓存时间也是正常的,等等还有其他策略,根据不同业务场景选择不同的策略

这是 Rails 的功能,LocalStorage 默认是存 nil 的。别的三方的 Adapter 可能会覆写这个方法,拒绝 nil,比如 Dalli。

#16 楼 @hlcfan 是的,不过我觉得可以加多一个 option 进行扩展,获得灵活性,默认不拒绝 nil,这样也不影响原有代码。

补充一点:如果用了 memcached,那么恰恰相反,是不会缓存 nil 的,要自己转换一下 nil

Reply to Peter

这个版本的实现还是我写帖子时候提供的实现哈,很早就有了

You need to Sign in before reply, if you don't have an account, please Sign up first.