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
- Note that any subdirectories of
app
belong automatically toautoload_paths
. - Note also that our sweeper hooks here are
after_save
andafter_destroy
, but we can use any ActiveRecord callbacks.
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_action
s 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')