2017-04-16

read_fragmentとフラグメントキャッシュ

Railsのフラグメントキャッシュでビュー側にcacheメソッド、コントローラ側にread_fragmentメソッドを利用するように書かれている記事があるけど併用すると害があるケースがありそうだったので備忘録。

例えば以下のような使い方をするとエラーになるケースがあります。

<div>
  <ul>
    <% cache 'hoge', expires_in: 5.seconds, skip_digest: true do %>
      <% @records.each do |record| %>
        <li><%= record.id %></li>
      <% end %>
    <% end %>
  </ul>
</div>
class HomeController < ApplicationController
  def index
    unless read_fragment 'hoge'
      @records= Hoge.all
    end
  end
end
````
上記のコードは、ビューのcacheブロックを書くだけでもSQL文は発行されないので十分コストは低いのですが、コントローラ側でread_fragmentでフラグメントキャッシュの有無を調べればActiveRecord::Relationなどのインスタンス生成のコスト自体も抑えれるのでさらに無駄がない、という意図になります。

が、このコントローラのread_fragment評価時とビューのcacheメソッド評価時は同一のタイミングではないのでこの間にキャッシュの状態が変わることは有り得ます。つまり、read_fragment評価時にはキャッシュされていて、cacheメソッド評価時にはキャッシュ切れしていた場合は、ブロック内の変数定義がされないためビューでの表示が狂ってしまう(上記例の場合は@records未定義なのでエラーになる)ことになります。

これは以下のようにsleep文を入れれば簡単に検証可能で、read_fragment呼び出しからアクションメソッド終了までの時間が長ければ長いほどキャッシュ切れが発生する可能性が上がることになります。
```ruby
class HomeController < ApplicationController
  def index
    unless read_fragment 'hoge'
      @records= Hoge.all
    end
    sleep 10
  end
end

コントローラではActiveRecord::Relationの状態で、ビュー側でSQLが発行されるケース(例えばeachやto_a呼び出し)は、インスタンス化は大したコストにならないので、cacheブロックだけでも十分高速なケースが多いと思います。また、コントローラ内でActiveRecord::Baseのサブクラスのインスタンス(及びそのArray)になってしまう場合は、Rails.cache.fetchなどのアプリキャッシュを利用するのが良さそうです。Rails.cache.fetchであれば値を取得/格納する過程が変わるだけでビューに渡される結果は同じになり、今回のようなキャッシュ切れによる差分が発生しません。

評価タイミングがミリ秒レベルの差分なので、そもそも発生しにくい問題ではありますが、再現しづらい事象なのでフラグメントキャッシュを利用する場合はこれらの挙動を考慮した方が良さそうです。

このエントリーをはてなブックマークに追加