Nplusone Queries Basics
Nplusone Queries Basics
by Benito Serna
1 / 38
Index
What is an n+1 queries problem
Understanding preload eager_load includes and joins
Preloading practice
Understanding when the query will be executed
Practice the identification of when the query will be executed
Tools to help you detect n+1 queries
Final notes
2 / 38
What is an n+1 queries problem?
An n+1 queries problem means that a query is executed for every result of a previous query.
For example, with this records...
Post.all.each do |post|
post.comments.each do |comment|
puts comment.body
end
end
3 / 38
Understanding preload, eager_load, includes and joins
Rails provide a lot of methods to work with associations, but the ones that are more involved in fixin n+1 queries
are preload, eager_load, includes and joins.
Here you will find a simple explanation with examples to help you understand the difference between them and
to help you decide which is better on different use cases.
Example data
Imagine that you have the next data in your database
Preload
It preloads the associociations using different queries.
For example, this code will use one query for the posts and other for the comments.
posts = Post.preload(:comments).to_a
# SELECT "posts".* FROM "posts"
# SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN ($1, $2)
[[nil, 1], [nil, 2]]
And you will be able to use the comments without n+1 queries.
4 / 38
posts.map(&:comments)
Eager load
It forces the eager loading in one query performing a LEFT OUTER JOIN.
For example, this code will use just one query to load both, the posts and the comments.
posts = Post.eager_load(:comments).to_a
# SELECT "posts"."id" AS t0_r0, "posts"."title" AS t0_r1, "posts"."body" AS
t0_r2, "comments"."id" AS t1_r0, "comments"."body" AS t1_r1,
"comments"."post_id" AS t1_r2 FROM "posts" LEFT OUTER JOIN "comments" ON
"comments"."post_id" = "posts"."id"
And you will be able to use the comments without n+1 queries.
posts.map(&:comments)
5 / 38
Post.eager_load(:comments).where(title: "Hola").to_a
# SELECT "posts"."id" AS t0_r0, "posts"."title" AS t0_r1, "posts"."body" AS
t0_r2, "comments"."id" AS t1_r0, "comments"."body" AS t1_r1,
"comments"."post_id" AS t1_r2 FROM "posts" LEFT OUTER JOIN "comments" ON
"comments"."post_id" = "posts"."id" WHERE "posts"."title" = $1 [["title",
"Hola"]]
And you also will be able to add conditions for the associated models.
Includes
By default it will load the included associations using different queries, like preload.
For example, this code will use one query for the posts and other for the comments.
posts = Post.includes(:comments).to_a
# SELECT "posts".* FROM "posts"
# SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN ($1, $2)
[[nil, 1], [nil, 2]]
And you will be able to use the comments without new queries.
posts.map(&:comments)
You will also be able to add conditions for the base model.
Post.includes(:comments).where(title: "Hola").to_a
# SELECT "posts".* FROM "posts" WHERE "posts"."title" = $1 [["title", "Post
1"]]
# SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN ($1, $2)
[[nil, 1], [nil, 2]]
6 / 38
And in this case, you will also be able to add conditions for the included associations. Because it will perform a
LEFT OUTER JOIN like eager load does.
Note that if you want to pass the coditions as strings, you will need to use the references method, like this...
Post.includes(:comments).where("comments.body = ?",
"Hola").references(:comments).to_a
comments.map(&:post)
# SELECT "posts".* FROM "posts" WHERE "posts"."id" = $1 LIMIT $2 [["id", 2],
["LIMIT", 1]]
# SELECT "posts".* FROM "posts" WHERE "posts"."id" = $1 LIMIT $2 [["id", 2],
["LIMIT", 1]]
If you want to preload the post, you would need to use one of the methods above (includes, preload, eager
load)...
For example...
7 / 38
comments = Comment.joins(:post).where(post: {title: "Post
1"}).preload(:post).to_a
# SELECT "comments".* FROM "comments" INNER JOIN "posts" post ON post."id" =
"comments"."post_id" WHERE "post"."title" = $1 [["title", "Post 1"]]
# SELECT "posts".* FROM "posts" WHERE "posts"."id" = $1 [["id", 2]]
And now you will avoid the n+1 queries when you do...
comments.map(&:post)
You must be aware that when you joins a has many association rails will return duplicated records, because it
will return an instance for each row in the joined table.
Post.count #=> 2
Post.joins(:comments).count #=> 4
Post.distinct.count #=> 2
Post.joins(:comments).distinct.count #=> 4
Useful links
You can use the rails docs to learn more:
preload
eager_load
includes
joins
8 / 38
Preloading practice
Now that you know the theory here you will find a set of examples to help you practice this 4 methods, and
some more.
For each exercise try read the description the task and write as solution before watching the answer, but if you
find the problem too hard, don't worry, on the answers section you will find:
The description of a task
The query written with ActiveRecord
The produced SQL query
The model of the example
All the examples are based on the next model…
ActiveRecord::Schema.define(version: 1) do
create_table :accomodations, if_not_exists: true do |t|
t.column :name, :string
t.column :bathrooms_count, :decimal
t.column :guests_count, :integer
end
9 / 38
class Accomodation < ActiveRecord::Base
has_many :rooms
has_many :reviews
end
Working environment
To help you really work on this examples I have prepared a git repo with the setup that you need to run the
examples.
On /exercises you will find a list of exercises with:
The description of a task
And a function to write the results
You should provide a way to fetch the records.
For example:
puts "-----------------------------"
puts "TASK"
puts "1. Fetch all acomodations preloading rooms (with includes)"
puts "-----------------------------"
# accomodations = ?
# display_with_rooms(accomodations)
10 / 38
Exercises
. Fetch all acomodations preloading rooms (with includes)
. Fetch all acomodations preloading rooms (with preload)
. Fetch all acomodations preloading rooms (with eager load)
. Fetch all acomodations preloading reviews (with includes)
. Fetch all acomodations preloading reviews (with preload)
. Fetch all acomodations preloading reviews (with eager_load)
. Fetch all acomodations preloading reviews and rooms (with includes)
. Fetch all acomodations preloading reviews and rooms (with preload)
. Fetch all acomodations preloading reviews and rooms (with eager_load)
. Fetch all acomodations for exactly 4 guests, preloading reviews (with includes)
. Fetch all acomodations for exactly 4 guests, preloading reviews (with preload)
. Fetch all acomodations for exactly 4 guests, preloading reviews (with eager_load)
. Fetch all acomodations with reviews of 4 stars (not in average), preloading reviews (with includes)
. Fetch all acomodations with reviews of 4 stars (not in average), preloading reviews (with preload)
. Fetch all acomodations with reviews of 4 stars (not in average), preloading reviews (with eager_load)
. List of accomodations with reviews of 4 stars (in average), preloading reviews (with preload)
. List of accomodations with exactly 2 rooms, preloading rooms (with preload)
11 / 38
Answers
. Fetch all acomodations preloading rooms (with includes)
Accomodation.includes(:rooms)
Accomodation.preload(:rooms)
Accomodation.eager_load(:rooms)
Accomodation.includes(:reviews)
12 / 38
SELECT "accomodations".* FROM "accomodations"
SELECT "reviews".* FROM "reviews" WHERE "reviews"."accomodation_id" IN ($1,
$2, $3, $4) [[nil, 1], [nil, 2], [nil, 3], [nil, 4]]
Accomodation.preload(:reviews)
Accomodation.eager_load(:reviews)
Accomodation.includes(:reviews, :rooms)
13 / 38
. Fetch all acomodations preloading reviews and rooms (with preload)
Accomodation.preload(:reviews, :rooms)
Accomodation.eager_load(:reviews, :rooms)
. Fetch all acomodations for exactly 4 guests, preloading reviews (with includes)
Accomodation.where(guests_count: 4).includes(:reviews)
14 / 38
. Fetch all acomodations for exactly 4 guests, preloading reviews (with preload)
Accomodation.where(guests_count: 4).preload(:reviews)
. Fetch all acomodations for exactly 4 guests, preloading reviews (with eager_load)
Accomodation.where(guests_count: 4).eager_load(:reviews)
. Fetch all acomodations with reviews of 4 stars (not in average), preloading reviews (with includes)
15 / 38
. Fetch all acomodations with reviews of 4 stars (not in average), preloading reviews (with preload)
Accomodation.joins(:reviews).where(reviews: {stars:
4}).distinct(true).preload(:reviews)
. Fetch all acomodations with reviews of 4 stars (not in average), preloading reviews (with eager_load)
Accomodation.joins(:reviews).group(:id).having("avg(reviews.stars) >
4").preload(:reviews)
16 / 38
. List of accomodations with exactly 2 rooms, preloading rooms
Accomodation.joins(:rooms).group(:id).having("count(rooms.id) =
2").preload(:rooms)
17 / 38
Understanding when the query will be executed
Imagine that you have a method that works fine with a single record, but when you use it on a list causes N+1
queries.
Like in the next model…
class Posts
has_many :comments
def latest_comment
comments.order(:created_at).last
end
end
post = Post.find(id)
puts post.latest_comment
But when you try to fetch a list of posts, the method seems to ignore the includes, and runs a query for each
post to get the latest_comment for each post!
Post.includes(:comments).each do |post|
puts post.latest_comment
end
Why?
Well, let's start by understanding method chaining in Active Record.
Method chaining in Active Record
Active Record implements "method chaining" which allow us tu use multiple Active Record methods together.
You can chain methods in a statement when the previous method returns an ActiveRecord::Relation, like
all, where, includes, joins and order.
You can't chain Active Record methods, after a method that does not return an ActiveRecord::Relation,
like to_a, find or last. You need to put those methods at the end of the statement.
18 / 38
Note: You can learn more on the rails guides
What is the problem in the example?
When you are fetching a single post there is no problem, because all the methods before last return an
ActiveRecord::Relation
post = Post.find(id)
puts post.latest_comment
If you try each link of the chain, you will see that just the call to last does not return an
ActiveRecord::Relation.
relation = ActiveRecord::Relation
comments = Post.find(id).comments
Post.includes(:comments).each do |post|
puts post.latest_comment
end
At the moment you call each on the ActiveRecord::Relation, it will execute the query, and in your logs
you will see something like this...
But then for each post it will execute a new query, because although you already have the comments loaded,
with order(:created_at).last you are building a new query to fetch the latest comment with a different
order.
19 / 38
That is why when you try to fetch a list of posts, the method seems to ignore the includes, and runs a query
for each post to get the latest_comment.
How can you solve this problem?
You can solve this problem and other similar problems where you are also trying to fetch the latest "X" in a list
of records using an association with a default order.
Something like this…
class Post
has_many :comments, -> { order(:created_at) }
def latest_comment
comments.last
end
end
Post.includes(:comments).each do |post|
puts post.latest_comment
end
It won't execute n+1 queries, because you are fetching the comments already on the right order, and you don't
need to ask the database to sort them again.
If you want to see other options you can see this post with some other ways to solve the problem.
20 / 38
Practice the identification of when the query will be
executed
Now that you understand when the query will be execute, is time to practice this ability and try to make it
automatic.
This exercises will try to help you identify when a query will execute by just watching the code!
Instructions
I will show you 10 code examples using ActiveRecord's query interface, like includes, order and where.
For each code example you will:
. Try to identify when ActiveRecord will execute de query
. Compare your answer with the one I will provide after the example.
Working environment
To help you really work on this examples I have prepared a git repo with the setup that you need to run the
examples.
You can use it to run the examples, and play with the code to explore more.
You can find the repo here: github.com/bhserna/when_the_query_is_executed
There you will find the instructions to run the project.
21 / 38
Exercises
Exercise 1
Post.includes(:comments).to_a
Exercise 2
Post.includes(:comments).order(:id).to_a
Exercise 3
Post.all.each do |post|
post.comments
post.sorted_comments.to_a
end
Exercise 4
Post.includes(:comments).each do |post|
post.comments.to_a
end
Exercise 5
Post.includes(:comments).each do |post|
post.comments.order(:id)
post.comments.order(:id).to_a
end
22 / 38
Exercise 6
Post.includes(:sorted_comments).each do |post|
post.sorted_comments.to_a
post.comments.to_a
end
Exercise 7
Post.includes(:sorted_comments).each do |post|
post.sorted_comments.first
post.comments.first
end
Exercise 8
Post.includes(:sorted_comments).map do |post|
post.sorted_comments.last
post.comments.last
end
Exercise 9
Post.includes(:sorted_comments).sort_by(&:id).each do |post|
post.sorted_comments.limit(1).first
post.comments.to_a.limit(1).first
end
Exercise 10
Post.includes(:comments).where("comments.points >
5").references(:comments).each do |post|
post.comments.to_a
post.comments.order(:id).to_a
post.comments.sort_by(&:id).to_a
end
23 / 38
Answers
Answer 1
Post.includes(:comments).to_a
24 / 38
Answer 5
Post.includes(:comments).each do |post|
post.comments.order(:id)
post.comments.order(:id).to_a
end
26 / 38
Tools to help you detect n+1 queries
This is a little reference of tools to help you detect n+1 queries on a rails app.
Bullet
Prosopite
NPlusOneControl
Rails strict_loading
Rack mini profiler
Bullet
How can it help you?
It will watch your queries while you develop your application and notify you when it detects a problem.
It can detect:
n+1 queries
eager-loaded associations which are not used
unnecessary COUNT queries which could be avoided with a counter cache
You can use it on the development and testing environments.
Sometimes Bullet may notify you of query problems you don't care to fix, or which come from outside your
code. You can whitelist these to ignore them.
How does the error report looks like?
For n+1 queries...
GET /posts
USE eager loading detected
Post => [:comments]
Add to your query: .includes([:comments])
Call stack
/Users/benitoserna/code/bullet-test/app/views/posts/index.html.erb:20:in
`map'
/Users/benitoserna/code/bullet-test/app/views/posts/index.html.erb:20:in
`block in
_app_views_posts_index_html_erb__1178069968615334744_70147771830640'
/Users/benitoserna/code/bullet-test/app/views/posts/index.html.erb:16:in
`_app_views_posts_index_html_erb__1178069968615334744_70147771830640'
bin/rails g bullet:install
This will help you configure bullet on the development and test enviroments.
References
github/flyerhzm/bullet
Prosopite
How can it help you?
Like Bullet, Prosopite is able to auto-detect Rails N+1 queries, but it also can help you detect some cases
where bullet will give you false positives or false negatives.
Prosopite monitors all SQL queries using the Active Support instrumentation and looks for a pattern which is
present in all N+1 query cases: More than one queries have the same call stack and the same query fingerprint.
Compared to bullet, Prosopite can auto-detect the following extra cases of N+1 queries:
28 / 38
N+1 queries after record creations (usually in tests)
FactoryBot.create_list(:leg, 10)
Leg.last(10).each do |l|
l.chair
end
Leg.last(4).each do |l|
Chair.find(l.chair_id)
end
Chair.last(20).each do |c|
c.legs.first
c.legs.last
c.legs.pluck(:id)
end
29 / 38
Mongoid models calling ActiveRecord
class Leg::Design
include Mongoid::Document
...
field :cid, as: :chair_id, type: Integer
...
def chair
@chair ||= Chair.where(id: chair_id).first!
end
end
Leg::Design.last(20) do |l|
l.chair
end
gem 'prosopite'
30 / 38
And then execute:
$ bundle install
Development environment
Prosopite auto-detection can be enabled on all controllers with...
after_action do
Prosopite.finish
end
end
end
# config/environments/development.rb
config.after_initialize do
Prosopite.rails_logger = true
end
Test environment
Tests with N+1 queries can be configured to fail with:
# config/environments/test.rb
config.after_initialize do
Prosopite.rails_logger = true
Prosopite.raise = true
end
31 / 38
And each test can be scanned with:
# spec/spec_helper.rb
config.before(:each) do
Prosopite.scan
end
config.after(:each) do
Prosopite.finish
end
References
github/charkost/prosopite
NPlusOneControl
How can it help you?
It gives you rspec and minitest matchers to prevent the n+1 queries problem.
It evaluates the code under consideration several times with different scale factors to make sure that the
number of DB queries behaves as expected (i.e. O(1) instead of O(N)).
So, it's for performance testing and not feature testing.
How does the error report looks like?
In the default mode it can give you something like this.
32 / 38
And in the "verbose" mode, it can give you something like this...
group :test do
gem "n_plus_one_control"
end
require "n_plus_one_control/rspec"
33 / 38
Then:
specify do
expect { get :index }.to perform_constant_number_of_queries
end
end
References
github/palkan/n plus one control
Squash N+1 queries early with n plus one control test matchers for Ruby and Rails
Rails strict_loading
How it can help you?
You can add #strict_loading! to any record to prevent lazy loading of associations. Strict will cascade
down from the parent record to all the associations to help you catch any places where you may want to use
preload instead of lazy loading.
How do you use it?
On a record...
user = User.first
user.strict_loading!
user.comments.to_a
=> ActiveRecord::StrictLoadingViolationError
On a relation...
user = User.strict_loading.first
user.comments.to_a
=> ActiveRecord::StrictLoadingViolationError
34 / 38
References
ActiveRecord::Core
ActiveRecord::QueryMethods
Pull request: Add strict_loading mode to optionally prevent lazy loading
Rack mini profiler
How it can help you?
It can help you with more than just detecting n+1 queries!... It is a production and development profiler, it allows
you to quickly isolate performance bottlenecks, both on the server and client.
It can help you with...
Database profiling
Call-stack profiling
Memory profiling
How does the report looks like?
It displays a speed badge for every html page that if you click it, it will show you a page with the profiling
information of the current page.
And if you click on of the sql queries count, it will show you a list with all the queries...
35 / 38
Although it will not tell you exactly that you have an n+1 quieries problem, it can help you a lot to visualize it.
How do you use it?
On development
In rails all you have to do is to include the Gem and you're good to go in development.
Install/add to Gemfile in Ruby 2.4+ (The gem is added by default to rails since version 6.1)
gem 'rack-mini-profiler'
NOTE: Be sure to require rack_mini_profiler below the pg and mysql gems in your Gemfile.
rack_mini_profiler will identify these gems if they are loaded to insert instrumentation. If included too
early no SQL will show up.
You can also include optional libraries to enable additional features.
36 / 38
On production
It is designed with production profiling in mind. To enable that run
Rack::MiniProfiler.authorize_request once you know a request is allowed to profile.
before_action do
if current_user && current_user.is_admin?
Rack::MiniProfiler.authorize_request
end
end
References
github/MiniProfiler/rack-mini-profiler
The announcement posts from 2012
Conclusion
As you can see, each tool can help you in different ways, some of them could be of more value to you than
others, but that is not a problem, because you can use them in combination if you need it.
37 / 38
Final notes
You made it! Congrats!
Let's recap what you can do now...
Now you can fix a lot of n+1 queries problems and find efficient solutions by yourself
You are fluent with some of the most used methods of ActiveRecord
You can identify when the query will be execute and be faster to identify how to fix an n+1 problem
You what tools can help your team to avoid n+1 queries and how to use them
Now you can help and teach your team how to avoid n+1 queries
I hope that you can feel that now you are a better rails developer!
I hope you have enjoyed this book!
If you have some feedback, please write me to [email protected]
38 / 38