February 2, 2016
This blog is part of our Rails 5 series.
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:
ActiveRecord::Relation#cache_key
Consider the following example where we are fetching a collection of all users belonging to city of Miami.
@users = User.where(city: 'miami')
Here @users is a collection of records and is an object of class
ActiveRecord::Relation.
Whether the result of the above query would be same depends on following conditions.
Rails community
implemented caching for a collection of records
. Method cache_key was added to ActiveRecord::Relation which 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 ActiveRecord::Relation. Now let's execute
cache_key method on it.
@users.cache_key
=> "users/query-67ed32b36805c4b1ec1948b4eef8d58f-3-20160116111659084027"
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 User. Hence users is to illustrate that
we are holding users records.
query- is hardcoded value and it will be same in all cases.
67ed32b36805c4b1ec1948b4eef8d58f 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.
20160116111659084027 is timestamp of the most recently updated record in
the collection. By default, the timestamp column considered is updated_at and
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.
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def index
@users = User.where(city: 'Miami')
end
end
# users/index.html.erb
<% cache(@users) do %>
<% @users.each do |user| %>
<p> <%= user.city %> </p>
<% end %>
<% end %>
# 1st Hit
Processing by UsersController#index as HTML
Rendering users/index.html.erb within layouts/application
(0.2ms) SELECT COUNT(*) AS "size", MAX("users"."updated_at") AS timestamp FROM "users" WHERE "users"."city" = ? [["city", "Miami"]]
Read fragment views/users/query-37a3d8c65b3f0f9ece7f66edcdcb10ab-4-20160704131424063322/30033e62b28c83f26351dc4ccd6c8451 (0.0ms)
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."city" = ? [["city", "Miami"]]
Write fragment views/users/query-37a3d8c65b3f0f9ece7f66edcdcb10ab-4-20160704131424063322/30033e62b28c83f26351dc4ccd6c8451 (0.0ms)
Rendered users/index.html.erb within layouts/application (3.7ms)
# 2nd Hit
Processing by UsersController#index as HTML
Rendering users/index.html.erb within layouts/application
(0.2ms) SELECT COUNT(*) AS "size", MAX("users"."updated_at") AS timestamp FROM "users" WHERE "users"."city" = ? [["city", "Miami"]]
Read fragment views/users/query-37a3d8c65b3f0f9ece7f66edcdcb10ab-4-20160704131424063322/30033e62b28c83f26351dc4ccd6c8451 (0.0ms)
Rendered users/index.html.erb within layouts/application (3.0ms)
From above, we can see that for the first hit, a count query is fired to get
the latest updated_at and size from the users collection.
Rails will write a new cache entry with a cache_key generated from above
count query.
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 SQL query.
Previously we mentioned that cache_key method uses updated_at column.
cache_key also provides an option of passing custom column as a parameter and
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 last_bought_at in
products table as a factor to decide caching, then you can use the following
code.
products = Product.where(category: 'cars')
products.cache_key(:last_bought_at)
=> "products/query-211ae6b96ec456b8d7a24ad5fa2f8ad4-4-20160118080134697603"
Before you start using cache_key there are some edge cases to watch out for.
Consider you have an application where there are 5 entries in users table with
city Miami.
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.
users = User.where(city: 'Miami').limit(3)
users.cache_key
=> "users/query-67ed32b36805c4b1ec1948b4eef8d58f-3-20160116144936949365"
Here users contains only three records and hence the cache_key has 3 for size
of collection.
Now let's try to execute same query without fetching the records first.
User.where(name: 'Sam').limit(3).cache_key
=> "users/query-8dc512b1408302d7a51cf1177e478463-5-20160116144936949365"
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.
I want 3 users in the descending order of ids.
users1 = User.where(city: 'Miami').order('id desc').limit(3)
users1.cache_key
=> "users/query-57ee9977bb0b04c84711702600aaa24b-3-20160116144936949365"
Above statement will give us users with ids [5, 4, 3].
Now let's remove the user with id = 3.
User.find(3).destroy
users2 = User.where(first_name: 'Sam').order('id desc').limit(3)
users2.cache_key
=> "users/query-57ee9977bb0b04c84711702600aaa24b-3-20160116144936949365"
Note that cache_key both users1 and 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.
Just like 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.
User.select(:first_name).group(:first_name).cache_key
=> "users/query-92270644d1ec90f5962523ed8dd7a795-1-20160118080134697603"
In the above case, the size is 1 in cache_key. 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.
users = User.select(:first_name).group(:first_name)
users.cache_key
=> "users/query-92270644d1ec90f5962523ed8dd7a795-2-20160118080134697603"
In the above case, the size is 2 in cache_key. 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.
If this blog was helpful, check out our full blog archive.