Caching on Rails

Page caching

Page caching is a coarse approach to caching whereby an entire page is rendered once and stored on the web server so that subsequent requests don’t even reach the application server.

While this approach offers a significant performance boost, it’s only appropriate for stateless pages where all visitors are treated the same.

Examples: Weblogs, wikis, static pages. Anything that doesn’t change based on, say, whether or not the user is logged in.

Nonexamples: Pages requiring authentication for any other before_action (since requests never reaches a before_action / before_filter). Pages that are rendered differently for different users.

In Rails 4, page caching must be enabled via the actionpack-page_caching gem.

# config/environments/development.rb
config.action_controller.perform_caching = false  # enables caching locally
# Gemfile
gem 'actionpack-page_caching'

Set the page_cache_directory:

# config/application.rb
config.action_controller.page_cache_directory = Rails.root.join('public', 'cache')

And declare which actions render templates that can be cached in their entirety:

# app/controllers/posts_controller.rb
class PostsController < ActionController::Base
  caches_page :index, :show
  # . . .
end

Cache expiration

To expire a cached page, call expire_page whenever any of its data becomes outdated. For example,

class PostsController < ActionController::Base
  caches_page :index, :show
  after_action :clear_posts_cache, only: %i(index show)
  # . . .
  private

  def clear_posts_cache
    expire_page action: :index
    expire_page action: :show, id: @post
  end
end

Sweepers

But what happens when you add a comments controller and need to expire a post’s page cache when a new comment on it is created? You could move the defintion of clear_posts_cache one level up to ApplicationController or to a namespace both have access to, such as a concern.

A cleaner, more object-oriented way is to use a Sweeper. These too have been removed from the Rails core with Rails 4.0, so a bundle install is in order:

# Gemfile
gem 'rails-observers'

Sweepers are responsible for observing models and expiring caches when an assigned model’s attributes change value.

# app/sweepers/post_sweeper.rb

class PostSweeper < ActionController::Caching::Sweeper
  observe Post, Comment

  def after_save(record)
    post = post_from(record)
    clear_posts_cache_for(post)
  end

  def after_destroy(record)
    post = post_from(record)
    clear_posts_cache_for(post)
  end

  private

  def clear_posts_cache_for(post)
    expire_page(controller: :posts, action: %i(index show), id: post
  end

  def post_from(record)
    record.is_a?(Post) ? record : record.post
  end
end

You can then call this Sweeper via a callback in any controllers that might change the pages being cached:

class PostsController < ApplicationController
  cache_sweeper :post_sweeper, only: %i(edit destroy)
end
class CommentsController < ApplicationController
  cache_sweeper :post_sweeper, only: %i(create edit destroy)
end

Stateful Pages

AJAX as a workaround

One obvious if hacky way to make page caching work with pages that have some kind of state is to use AJAX calls to dynamically update only the required data on the page. This might be fine if it’s a small amount of data (say, a login link), but in most cases there are better approaches to take.

Action Caching

So what happens when you are working with pages that need to hit controller filters? That’s where our next strategy comes in: action caching. More on that next time.

This is the second post on caching in Rails apps. In the last one, I discussed page caching, which stores the entire output of a request on the web server’s file system.

A limitation of this strategy is that it intercepts requests before they reach your app, and thus doesn’t work well for controllers that expose endpoints requiring a before_action (e.g., as with pages that require authentication).

Action caching

Action caching offers the same level of granularity as page caching (i.e., the entire response is cached), but happens deeper into the request-response cycle: after a request hits the Rails stack and before_actions execute on it.

It’s been removed from core Rails (as of 4.0), and must be installed via the actionpack-action_caching gem.

Setup is mostly the same as with page caching, except you have more cache store options (more on that below).

Fragment caching

Fragment caching allows a portion of a template to be wrapped in a cache block, which will serve the fragment out of the cache store whenever possible.

<% cache(action: 'recent', action_suffix: 'all_products') do %>
  All products:
  <%= render @products %>
<% end %>

To expire a cached fragment, Rails provides the expire_fragment method:

expire_fragment(controller: 'products', action: 'recent', action_suffix: 'all_products')

But it’s better to avoid having to do this manually by setting our cache keys strategically.

Enter memcached

With memcached, we don’t have to expire cached fragments manually. Instead, we continuously add to the cache store, with each new entry keyed by a generated hash (more on that below).

That way, invalidated fragments will just be replaced with valid ones, and we can let the cache store automatically garbage-collect invalidated fragments.

Setting it up is easy:

# config/application.rb (or config/environments/*.rb)
config.cache_store = :mem_cache_store

for non-local environments, you’ll want to specify the addresses of all memcache servers in your cluster.

# config/application.rb (or config/environments/*.rb)
config.cache_store = :mem_cache_store, 'cache1.example.com', 'cache2.example.com'

Other cache stores

memory store (for small apps)

config.cache_store = :memory_store, { size: 64.megabytes }

filestore (the rails default)

config.cache_store = :file_store, "/path/to/cache/directory"

ehcache (for jruby)

config.cache_store = :ehcache_store

null store (i.e., don’t cache. for development)

config.cache_store = :null_store

custom cache store (for plugging in an arbitrary cache store)

config.cache_store = MyCacheStore.new

Cache keys

Hashing for caching

To generate a hash to use as a cache key, define a method that maps from whatever attributes can potentially invalidate the cached fragment to a unique string.

The caching performed above will be invalidated when the number of products changes or whenever a product is updated, so we can define the following helper:

module ProductsHelper
  def cache_key_for_products
    product_count = Product.count

    time_of_most_recent_update =
      Product.maximum(:updated_at).try(:utc).try(:to_s, :number)

    "products/all-#{product_count}-#{time_of_most_recent_update}"
  end
end

The call to cache above then becomes

<% cache(cache_key_for_products) do %>
  All products:
  <%= render @products %>
<% end %>

In general, a hash function should

ActiveRecord’s cache_key

You can also pass an AR model to cache, and let Rails magic take care of the rest:

  <!-- _product.rb, called from <%= render @products %> -->
  <% cache(product) do %>
    <%= link_to product.name, product_url(product) %>
  <% end %>

Under the hood, cache_key is invoked on the model, producing a cache key composed of the model name, its id, and the updated_at timestamp: products/23-20130109142513.

Note that you can override cache_key or implement it on a PORO as needed.

Russian Doll caching

Russian doll caching is a technique that combines the two approaches to cache key generation above in order to maximize the amount of data that’s cached by nesting cached fragments.

<% cache(cache_key_for_products) do %>
  All products:
  <% render @products %>
<% end %>
<!-- _product.rb -->

<% cache(product) do %>
  <%= link_to product.name, product_url(product) %>
<% end %>

The benefit of this approach is that if only one product is updated, we needn’t re-render the entire set. That invidivual product can be rendered and re-cached, and the set as a whole can be rendered using all the other, still valid, cached fragments.

Low-Level Caching

To manually set or retrieve an item from the Rails cache, use Rails.cache.fetch.

The method works as both a setter when passed a block…

def competing_price
  Rails.cache.fetch('product/competing_price', expires_in: 12.hours) do
    Competitor::API.find_price(id)
  end
end

…and as a getter when passed only a key.

Rails.cache.fetch('product/competing_price')