When using Rails’ built-in fragment caching, parts of rendered views are stored as view fragments and reused if they're requested again. These cached fragments are reused until they turn stale, meaning they're outdated because the data they display has changed since creating the fragment.
While this gives a nice speed boost, especially for complex views or views with a lot of rendered partials, we can improve on this some more by doubling down with an approach named Russian doll caching.
When using this caching approach, view fragments are placed inside each other, much like the “Matryoshka” dolls the strategy is named after. By breaking up cached fragments into smaller pieces, the outer cache can be rendered faster when only one of its nested fragments changes.
Product variants
As an example, let's use a store that sells products. Each product can have a number of variants, which allow for selling multiple colors of one item, for example. On the index, we'll show each product that's available for sale, as well as all of its variants.
On the product index, we've wrapped each product partial in a cache
block. We're using the product
object to build the cache key, which is used to invalidate the cached fragment. It consists of the object's id, it's updated_at date, and a digest of the template tree, so it's automatically considered stale if the object changes, or if the template's contents change.
# app/views/products/index.html.erb
<h1>Products</h1>
<% @products.each do |product| %>
<% cache product do %>
<%= render product %>
<% end %>
<% end %>
In the products partial, we render a row for each of the product's variants.
# app/views/products/_product.html.erb
<article>
<h1><%= product.title %></h1>
<ul>
<% product.variants.each do |variant| %>
<%= render variant %>
<% end %>
</ul>
</article>
% end %>
Cache invalidation
Although the cache keys in Rails' fragment caching make cache invalidation easier, you're never fully free of worrying about cache validation (one of the famous two hard things in computer science).
In this example, we cache the products partial, which contains a list of the product's variants. Since the cache key doesn't include any information about the variants, any newly added variants won't show up unless the product itself changes too.
The way to fix this is to make sure the product does change when anything changes in one of its variants. To do that, we'll update the product's updated_at
attribute whenever that happens. Since this is so common, there's an argument for belongs_to
(and ActiveModel's other relation methods), called :touch
, that will automatically update the parent object's updated_at for us.
class Variant < ApplicationRecord
belongs_to :product, touch: true
end
Nested fragments
Now that we've made sure to update the product fragments when their variants change, it's time to cache the variants too. Like before, we'll add a cache
block around each one.
<article>
<h1><%= product.title %></h1>
<ul>
<% product.variants.each do |variant| %>
<% cache(variant) do %>
<%= render variant %>
<% end %>
<% end %>
</ul>
</article>
On a cold cache (you can clear the cache by running rake tmp:cache:clear
), the first request will render each product partial.
When requesting the page now (don't forget to turn on caching in development by running rails dev:cache
), each product partial will be cached as a partial, and the second request will return the cached fragments.
Started GET "/products" for 127.0.0.1 at 2018-03-30 14:51:38 +0200
Processing by ProductsController#index as HTML
Rendering products/index.html.erb within layouts/application
Product Load (0.2ms) SELECT "products".* FROM "products"
Variant Load (0.9ms) SELECT "variants".* FROM "variants" WHERE "variants"."product_id" IN (27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51)
Rendered variants/_variant.html.erb (0.5ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.0ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered products/_product.html.erb (44.8ms) [cache miss]
...
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered variants/_variant.html.erb (0.1ms)
Rendered products/_product.html.erb (46.2ms) [cache miss]
Rendered products/index.html.erb within layouts/application (1378.6ms)
Completed 200 OK in 1414ms (Views: 1410.5ms | ActiveRecord: 1.1ms)
Started GET "/products" for 127.0.0.1 at 2018-03-30 14:51:41 +0200
Processing by ProductsController#index as HTML
Rendering products/index.html.erb within layouts/application
Product Load (0.3ms) SELECT "products".* FROM "products"
Variant Load (12.7ms) SELECT "variants".* FROM "variants" WHERE "variants"."product_id" IN (27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51)
Rendered products/index.html.erb within layouts/application (48.1ms)
Completed 200 OK in 76ms (Views: 59.0ms | ActiveRecord: 13.0ms)
The magic of Russian doll caching can be seen when changing one of the variants. When requesting the index again after one of the variants change, the cached product fragment is rerendered because its updated_at
atttibute changed.
The product partial includes each of the product's variants. The cached fragment for the variant we just changed is stale, so it needs to be regenereated, but the other variants didn't chanage, so their cached fragments are reused. In the logs, we can see the both the variant and product partials being rendered once.
Started GET "/products" for 127.0.0.1 at 2018-03-30 14:52:04 +0200
Processing by ProductsController#index as HTML
Rendering products/index.html.erb within layouts/application
Product Load (0.3ms) SELECT "products".* FROM "products"
Variant Load (1.2ms) SELECT "variants".* FROM "variants" WHERE "variants"."product_id" IN (27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51)
Rendered variants/_variant.html.erb (0.5ms)
Rendered products/_product.html.erb (13.3ms) [cache miss]
Rendered products/index.html.erb within layouts/application (45.9ms)
Completed 200 OK in 78ms (Views: 73.5ms | ActiveRecord: 1.5ms)
By nesting cache fragments like this, the view is almost never rendered completely, unless the cache is completely empty. Even when the data changes, most of the rendered pages are served straight from the cache.
How are you liking our articles about caching in Rails so far? Please don't hesitate to let us know at @AppSignal. Of course, we'd love to know what you'd like us to write about (caching-related or otherwise) too!
Top comments (0)