Want to see the full-length video right now for free?
Sign In with GitHub for Free AccessN+1 queries are a common performance issue when querying related data. A typical example would be a page showing a lits of posts, and the associated comments for those posts.
The default query structure would involve one query for the group of posts,
and then n
, where n
is the number of posts, queries for comments. The
problem with a situation like this is that your page will perform worse and
worse as your data set grows.
The majority of the time, it is much more efficient to make 2 or 3 larger queries, batching up similar queries, than to query multiple times as would be the case with N+1s.
For this example, we'll be working with a simplified version of the Practice
page on Upcase. The code of interest is in the #index
action and its
query for trails:
def index
@trails = Trail.joins(:topic).order("topics.name")
end
There are number of tools available for analyzing queries and database performance. Two particular tools used on Upcase are:
While both are great tools, they won't catch everything. In the end, your logs are often the most trustworthy source of information about how many queries are being made for a given request.
One caveat on using the logs is that they have to readable. By default, Rails
will log every asset request in development which is extremely noisy and can
make it hard to understand the actual queries and view rendering. To silence
this noise, add the quiet-assets gem to your development
group for a
better tomorrow!
Similarly, if you're tailing the logs, they are just lines of text which the
command line is super good at manipulating. Want to see all the SELECT
statements? Then pipe it through grep
:
$ tail -f log/development.log | grep SELECT
Now we'll have a nice focused stream of just the SELECT
statements which we
can focus in on.
Based on a quick review of the logs, it's clear that we're making multiple
queries for the topics
relation. This is one of the easiest query problems
to fix, using an eager load of the relation.
@trails = Trail.includes(:topics).joins(:topic).order("topics.name")
The includes(:topics)
instructs Rails to load the topcis
once at the
outset, but then we don't need to query topics
again after that. This is the
sort of thing that the Bullet gem would indeed find, and why it's likely a
good idea to just have that running in the background.
Similar to the topics
N+1, we have an N+1 for the steps
in each of the
trails, as well as the video
or exercise
associated with that step.
The simplest solution would be to eager load steps
just like we did with
topics
:
@trails = Trail.includes(:topics, :steps).joins(:topic).order("topics.name")
This works for the steps
, but we want to go even further and optimize the
queries for the dependent video
and exercise
records:
@trails = Trail.
includes(:topics, steps: [:video, :exercise]).
joins(:topic).
order("topics.name")
This, unfortunately, crashes the app as steps
don't have videos
or
exercises
, but instead have the polymorphic completeable
representing
either. For this case, we can use an alternative eager loading syntax:
@trails = Trail.
preload(steps: :completeable).
includes(:topic).
joins(:topic).
order("topics.name")
Here we add preload(steps: :completeable)
and Rails will properly eager
load, taking into account the polymorphic nature of the completeable
relation.
includes
- works for anything that is not polymorphic, and it allows you
reference something from the query in the association.preload
- works with polymorphic associations, but doesn't allow you to
reference something in the query.Thus far we've been able to solve all of our querying woes with simple eager
loading, but we were cheating a bit and ignoring the fact that the /practice
page actually renders each step taking into account the user's status on that
step. This sort of parameterized query can not be solved with the ActiveRecord
querying tactics shown above. We have to go deeper.
<section class="progress">
<% trail.steps.each do |step| %>
<%= render(
"trails/step",
completeable: step.completeable,
state: step.state_for(current_user)
) %>
<% end %>
</section>
In the above snippet from the trail
partial, we iterate over the steps,
passing in the state for the current user. This parameterized style of query
makes it difficult for Rails to eager load around.
As a first step, we can extract a new class to wrap up our query logic.
Typically this style of class is called a "query object". We'll name ours
StatusFinder
. It's job will be to intelligently preload the needed objects
based the known context (the current_user
and the completeables
).
class StatusFinder
def initialize(user, completeables)
@user = user
@completeables = completeables
end
def state_for(completeable)
statuses.detect do |status|
status.completeable_id == completeable.id &&
status.completeable_type == completeable.class.name
end.try(:state) || Status::UNSTARTED
end
private
def statuses
@statuses ||= @user.
statuses.
where(completeable_id: @completeables.map(&:id)).
order(:created_at)
end
end
This query object exposes a state_for
method that takes a specific
completable, and returns the status for the user by finding the needed status
in the cache of statuses. We can provide this to the view by building a
StatusFinder
instance in the controller:
def index
@trails = Trail.
preload(steps: [:completeable]).
includes(:topic).
order("topics.name")
@status_finder = StatusFinder.new(
current_user,
@trails.flat_map(&:completeable)
)
end
And we can use this initialized @status_finder
in our trail partial to query
for the state:
<section class="progress">
<% trail.steps.each do |step| %>
<%= render(
"trails/step",
completeable: step.completeable,
state: @status_finder.state_for(step.completeable)
) %>
<% end %>
</section>
And voila, our query problems are solved! We are now making all our queries
from a pre-build cache, fully aware of the current_user
and the
completeables
of interest.
With the query performance optimized, we can now turn our eye to refactoring
our optimized code. One unfortunate side effect of this optimization is that
now we have this @status_finder
object that we need to pass around and
reference. Wouldn't it be great if instead of:
state: @status_finder.state_for(step.completeable)
we could simply use this?
state: step.state
We can support this by decorating the trails and steps to produce a
StatusFinder
aware version of the methods, but allow our view code to be
blissfully unaware of the StatusFinder
.
To start, we'll update controller action to decorate the trails:
def index
trails = Trail.
preload(steps: [:completeable]).
includes(:topic).
order("topics.name")
status_finder = StatusFinder.new(
current_user,
trails.flat_map(&:completeable)
)
@trails = trails.map { |trail| TrailWithState.new(trail, status_finder) }
end
Now we need to define the TrailWithState
decorator, and nested
StepWithState
decorator as well:
class TrailWithState < SimpleDelegator
def initialize(trail, status_finder)
super(trail)
@status_finder = status_finder
end
def steps
super.map do |step|
StepWithState.new(step, @status_finder)
end
end
class StepWithState < SimpleDelegator
def initialize(step, status_finder)
super(step)
@status_finder = status_finder
end
def state
@status_finder.state_for(completeable)
end
end
end
To break this down, we're using SimpleDelegator, which ships with Ruby, to
build our decorator. We initialize our decorator instances with a trail
instance, and SimpleDelegator
will then forward on all methods to that trail
object. The one expectation is the steps
method which we implement directly
to provide our custom behavior.
In the steps
method, we call super
to get the steps
from the decorated
trail instance, then decorate each of those with a similar StepWithState
decorator. Now each step
we work with in our trail partial will expose a
custom StatusFinder
aware version of the state
method, but otherwise
behave as a normal Step
instance.
The above example used a simplified form of the practice page and thus the
exact implementation shown above won't work completely. For an actual class
using a StatusFinder
like query object, check out the TrailWithProgress
class in the Upcase repo.
In addition, Joe went ahead and performed the same sort of optimizations on the real practice page in Upcase. The final implementation is, by necessity, more complex than what was shown in this episode, but it does a great job of showing how to scale up this sort of optimization. You can check out that work in PR 1493.
While we would normally go through the steps of this process under a full TDD red, green, refactor loop, for the sake of time we chose to walk through this example using "refresh driven development" and eschew testing. Be sure to check out the videos on our Testing Track for a better view of what this would look like with a normal TDD workflow.
Likewise, the full process Joe walked through would likely map to five or more incremental Git commits. Remember; Always. Be. Commiting! Check out the Weekly Iteration episode on the thoughtbot Git workflow for more on our Git process.