I spent most of yesterday trying to come up with a decent caching strategy for this site that would yield visible speed improvements and be easy to maintain as the code grows.
Standard Rails page caching and action caching are not suitable as the interface changes quite a lot depending on who is logged in, so it has to be fragment caching.
I very much liked the key based approach recommended by 37signals here How key-based cache expiration works and the challenge I had is in constructing the cache keys so that the fragment is expired reliably when content changed.
The 37signals approached uses a chain of touches to ensure that the updated_at date is altered up the object graph whenever a change is made to a lower level object.
I could not use the touch method directly on my has_many polymorphic relationships (see entity model on the left).
I tried doing a:
:after_save, :touch_parents def touch_parents ...touch all parent objects end
Unfortunately a touch of a parent object does not trigger the :after_save of the parent, therefore the touch is not propagated up the chain.
If we take my ContentPage model, it can have several ContentItemContainers, each containing a ContentItem. Each ContentItem can then have several ImageContainers, each of those having a ContentImage.
The ContentPage teaser on the home page, which I want to cache, displays the text from the first ContentItem and the image from the first ContentItem that has a ContentImage. So a change in any part of that object graph must invalidate the cache.
To get this all to work I need the cache key to represent the following:
- The type of user.
- My cache identifier.
- The entire object graph.
Cache enabling the models
In each model I wanted a get_cache_key function like this:
def get_cache_key(prefix=nil) cache_key = [] cache_key << prefix if prefix cache_key << self content_item_containers.in_sort_order.each do |content_item_container| cache_key << content_item_container.get_cache_key end return cache_key.flatten end
The function will return an array with an optional prefix, the model instance and an array of keys for child objects one level lower. The lower objects will in turn do the same and the end result will be the key for the entire graph. Here is an example:
views/home_page_blog_content_item/content_item_containers/4-20130108165424/content_items/4-20130108230254/image_containers/23-20130108170918/content_images/22-20130108170856
After an initial implementation with the function in each model I DRY'd it into a Concern.
app/concerns/cache_keys.rb
module CacheKeys extend ActiveSupport::Concern # Instance Methods def get_cache_key(prefix=nil) cache_key = [] cache_key << prefix if prefix cache_key << self self.class.get_cache_key_children.each do |child| if child.macro == :has_many self.send(child.name).all.each do |child_record| cache_key << child_record.get_cache_key end end if child.macro == :belongs_to cache_key << self.send(child.name).get_cache_key end end return cache_key.flatten end # Class Methods module ClassMethods def cache_key_children(*args) @v_cache_key_children = [] # validate the children args.each do |child| #is it an association association = reflect_on_association(child) if association == nil raise "#{child} is not an association!" end @v_cache_key_children << association end end def get_cache_key_children return @v_cache_key_children ||= [] end end end # include the extension ActiveRecord::Base.send(:include, CacheKeys)
config/initializers-cache_keys.rb
require "cache_keys"
config/application.rb
# Custom directories with classes and modules you want to be autoloadable. config.autoload_paths += %W(#{config.root}/app/concerns)
The Concern adds a get_cache_keys function to any ActiveRecord::Base models and provides the ability to use
cache_key_children :image_containers, :content_item_containers
to tell get_cache_keys of any child associations that you want the model to include when generating the key
Here is the full chain for Blog -> ImageContainers -> ContentImage
blog.rb
class Blog < ActiveRecord::Base has_many :image_containers, :as => :container, :dependent => :destroy has_many :content_images, :through => :image_containers has_many :content_item_containers, :as => :container, :dependent => :destroy has_many :content_items, :through => :content_item_containers cache_key_children :image_containers, :content_item_containers end end
image_container.rb
class ImageContainer < ActiveRecord::Base belongs_to :content_image belongs_to :container, :polymorphic => true accepts_nested_attributes_for :container cache_key_children :content_image end
content_image.rb
class ContentImage < ActiveRecord::Base has_many :image_containers mount_uploader :image, ContentImageUploader end
And here is a typical key:
views/blogs/1-20130111210757/content_item_containers/2-20130113115019/content_items/2-20130121193930/image_containers/8-20130120172524/content_images/8-20130120172531/image_containers/14-20130121194002/content_images/14-20130121211211/content_item_containers/1-20130111211032/content_items/1-20130113110420/image_containers/4-20130111211104/content_images/4-20130112112519/image_containers/5-20130111211124/content_images/5-20130112113404/image_containers/7-20130113110522/content_images/7-20130113110534
Detecting the type of user
I added this to the applications_controller.rb. I am using gems devise and declarative_authorization
@cache_name = "public" if Authorization.current_user.role_symbols.include? :admin @cache_name = "admin" end
Caching in the views
Here is the home page HAML with caching set up.
.page-header - if @home_page - cache(@home_page.get_cache_key('home_page_home_page_title')) do ...Render HomePage model titles - if @home_page - cache(@home_page.get_cache_key('home_page_body')) do ...Render HomePage Body - if @content_pages.count > 0 / recent articles %div{:class => "row-fluid home-page-recent-articles-row"} .span12 %h4="Recent Articles" %ul{:class => "thumbnails home-page-article-thumbnails"} - @content_pages.each do |content_page| - cache(content_page.get_cache_key('home_page_content_page')) do ...Render ContentPage model - if @recent_blog_entries.count > 0 / recent blog entries %div{:class => "row-fluid home-page-recent-blog-entries-row"} .span12 %h4="Recent Blog posts" %ul{:class => "thumbnails home-page-blog-thumbnails"} - @recent_blog_entries.each do |blog_content_item_container| - cache(blog_content_item_container.get_cache_key('home_page_blog_content_item')) do ...Render ContentItem model
Here is the caching for the show ContentPage HAML
- provide(:title, @content_page.title) / Content Page - cache(@content_page.get_cache_key(['content_page_show' , @cache_name])) do ...Render ContentPage model and all child objects = render :partial => 'image_carousel/place_carousel_controls' %div{:class => "row-fluid content-page-share-row"} .span6 = " " .span6 = render :partial => 'shared/sharethis' %div{:class => "row-fluid content-page-disqus-row"} .span12 = render :partial => 'shared/disqus', :locals => {:content_title => @content_page.title, :content_ident => "#{@content_page.class.name}_#{@content_page.id.to_s}"}
It calls get_cache_keys on the ContentPage model passing it the type of user as @cache_name so we get one cache for the public and one for Admins. An example key looks like this:
views/content_page_show/admin/content_pages/1-20130121174541/content_item_containers/3-20130121101735/content_items/3-20130121101807/image_containers/9-20130121101754/content_images/9-20130121101802/image_containers/16-20130121214128/content_images/16-20130121214135/content_item_containers/5-20130121174520/content_items/5-20130121174440/image_containers/11-20130121102108/content_images/11-20130121102112/image_containers/13-20130121174324/content_images/13-20130121174323
Results
Having pushed this approach out to all the models and implemented fragment caching on all model based content I am now reliably getting response times from Heroku in to 100-250ms range where without cacheing I was looking at 500-700ms.
What next
My knowledge of the internals of Active Model is limited and I realise that this approach does little to save on access to the database as the entire object graph is accessed in order to get the cache key. That said, on cache hits I am seeing less time spent in ActiveRecord and this will be due to the rendering code that uses models not being run; therefore, in total I have saved some ActiveRecord work.
On a cache miss the database will be hit twice, once for getting the cache key and a second time when doing the rendering, however I think that the automatic ActiveModel caching that is provided once you have turned on caching might mitigate this load.
But all in all I have gained a significant performance improvement and the pages have a much snappier feel in the browser.
Posted: Wednesday 30 January 2013 06:54



