Often while developing a Rails application you may look to have one of these caching techniques to boost the performance. Along with these, Rails 5 now provides a way of caching a collection of records, thanks to the introduction of the following method:
1 2ActiveRecord::Relation#cache_key 3
What is collection caching?
Consider the following example where we are fetching a collection of all users belonging to city of Miami.
1 2@users = User.where(city: 'miami') 3
@users is a collection of records and is an object of class
Whether the result of the above query would be same depends on following conditions.
- The query statement doesn't change. If we change city name from "Miami" to "Boston" then result might change.
- No record is deleted. The count of records in the collection should be same.
- No record is added. The count of records in the collection should be same.
implemented caching for a collection of records .
cache_key was added to
takes into account many factors including
query statement, updated_at column value and the count of the records in collection.
We have object
@users of class
Now let's execute
cache_key method on it.
1 2 @users.cache_key 3 => "users/query-67ed32b36805c4b1ec1948b4eef8d58f-3-20160116111659084027" 4
Let's try to understand each piece of the output.
users represents what kind of records we are holding.
In this example we have collection of records of class
users is to illustrate that we are holding
query- is hardcoded value and it will be same in all cases.
is a digest of the query statement that will be executed.
In our example it is
MD5( "SELECT "users".* FROM "users" WHERE "users"."city" = 'Miami'")
3 is the size of collection.
is timestamp of the most recently updated record in the
collection. By default, the timestamp column considered is
hence the value will be the most recent
updated_at value in the collection.
Let's see how to use
cache_key to actually cache data.
In our Rails application, if we want to cache records of users belonging to "Miami" then we can take following approach.
1 2# app/controllers/users_controller.rb 3 4class UsersController < ApplicationController 5 6 def index 7 @users = User.where(city: 'Miami') 8 end 9end 10 11# users/index.html.erb 12 13<% cache(@users) do %> 14 <% @users.each do |user| %> 15 <p> <%= user.city %> </p> 16 <% end %> 17<% end %> 18 19# 1st Hit 20Processing by UsersController#index as HTML 21 Rendering users/index.html.erb within layouts/application 22 (0.2ms) SELECT COUNT(*) AS "size", MAX("users"."updated_at") AS timestamp FROM "users" WHERE "users"."city" = ? [["city", "Miami"]] 23Read fragment views/users/query-37a3d8c65b3f0f9ece7f66edcdcb10ab-4-20160704131424063322/30033e62b28c83f26351dc4ccd6c8451 (0.0ms) 24 User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."city" = ? [["city", "Miami"]] 25Write fragment views/users/query-37a3d8c65b3f0f9ece7f66edcdcb10ab-4-20160704131424063322/30033e62b28c83f26351dc4ccd6c8451 (0.0ms) 26Rendered users/index.html.erb within layouts/application (3.7ms) 27 28# 2nd Hit 29Processing by UsersController#index as HTML 30 Rendering users/index.html.erb within layouts/application 31 (0.2ms) SELECT COUNT(*) AS "size", MAX("users"."updated_at") AS timestamp FROM "users" WHERE "users"."city" = ? [["city", "Miami"]] 32Read fragment views/users/query-37a3d8c65b3f0f9ece7f66edcdcb10ab-4-20160704131424063322/30033e62b28c83f26351dc4ccd6c8451 (0.0ms) 33 Rendered users/index.html.erb within layouts/application (3.0ms) 34
From above, we can see that
for the first hit,
count query is fired to get the
size from the users collection.
Rails will write a new cache entry with a
generated from above
Now on second hit,
it again fires
count query and
checks if cache_key for this query exists or not.
If cache_key is found, it loads data without firing
What if your table doesn't have updated_at column?
Previously we mentioned that
cache_key method uses
cache_key also provides an option of
passing custom column as a parameter
then the highest value of that column among the records in the collection will be considered.
For example if your business logic considers
a column named
products table as a factor to decide caching, then you can use the following code.
1 2 products = Product.where(category: 'cars') 3 products.cache_key(:last_bought_at) 4 => "products/query-211ae6b96ec456b8d7a24ad5fa2f8ad4-4-20160118080134697603" 5
Edge cases to watch out for
Before you start using
cache_key there are some edge cases to watch
Consider you have an application where there are
5 entries in
users table with
Using limit puts incorrect size in cache key if collection is not loaded.
If you want to fetch three users belonging to city "Miami" then you would execute following query.
1 2 users = User.where(city: 'Miami').limit(3) 3 users.cache_key 4 => "users/query-67ed32b36805c4b1ec1948b4eef8d58f-3-20160116144936949365" 5
Here users contains only three records
cache_key has 3 for size of collection.
Now let's try to execute same query without fetching the records first.
1 2 User.where(name: 'Sam').limit(3).cache_key 3 => "users/query-8dc512b1408302d7a51cf1177e478463-5-20160116144936949365" 4
You can see that the count in the cache is 5 this time even though we have set a limit to 3. This is because the implementation of ActiveRecord::Base#collection_cache_key executes query without limit to fetch the size of the collection.
Cache key doesn't change when an existing record from a collection is replaced
I want 3 users in the descending order of ids.
1 2 users1 = User.where(city: 'Miami').order('id desc').limit(3) 3 users1.cache_key 4 => "users/query-57ee9977bb0b04c84711702600aaa24b-3-20160116144936949365" 5
Above statement will give us
users with ids
[5, 4, 3].
Now let's remove the user with id = 3.
1 2 User.find(3).destroy 3 4 users2 = User.where(first_name: 'Sam').order('id desc').limit(3) 5 users2.cache_key 6 => "users/query-57ee9977bb0b04c84711702600aaa24b-3-20160116144936949365" 7
users2 is exactly same.
This is because none of the parameters that affect the cache key is changed
i.e., neither the number of records,
nor the query statement,
nor the timestamp of the latest record.
There is a discussion undergoing about adding ids of the collection records as part of the cache key. This might help solve the problems discussed above.
Using group query gives incorrect size in the cache key
limit case discussed above
cache_key behaves differently
when data is loaded and when data is not loaded in memory.
Let's say that we have two users with first_name "Sam".
First let's see a case where collection is not loaded in memory.
1 2 User.select(:first_name).group(:first_name).cache_key 3 => "users/query-92270644d1ec90f5962523ed8dd7a795-1-20160118080134697603" 4
In the above case, the size is 1 in
For the system mentioned above, the sizes that you will get shall either be 1 or 5.
That is, it is size of an arbitrary group.
Now let's see when collection is first loaded.
1 2 users = User.select(:first_name).group(:first_name) 3 users.cache_key 4 => "users/query-92270644d1ec90f5962523ed8dd7a795-2-20160118080134697603" 5
In the above case, the size is 2 in
You can see that the count in the cache key here
is different compared to that where the collection was unloaded
even though the query output in both the cases will be exactly same.
In case where the collection is loaded, the size that you get is equal to the total number of groups. So irrespective of what the records in each group are, we may have possibility of having the same cache key value.