Kill N+1 Queries For Good with Strict Loading
Starting with Rails 6.1, we can set
a strict_loading
configuration option that, when true
, will throw an error
if your code attempts to lazy load any associations with out strictly including
the association in the ActiveRecord query.
A brief history on N+1 queries and Ruby on Rails
Rails has a long history of scaling problems and a big cause of this is slow database performance of N+1 queries. Rails makes it super easy to get data from a database and magically have all of it at your finger tips. For example, let’s say we have the following model associations:
class User < ApplicationRecord
has_many :activities
...
end
class Activity < ApplicationRecord
belongs_to :user
...
end
Let’s say we want to get a list of activities for the first user:
@user = User.first
<% @user.activities.each do |activity| %>
<p><%= activity.description %></p>
<% end %>
The log files indicates it takes six SQL queries to get the information we want, six queries! That may not sound like a lot, but as users grow and activities grow, that’s going to be a tremendous burden we place on the database.
User Load (0.5ms) SELECT "users".* FROM "users" LIMIT $1 [["LIMIT", 1]]
Activity Load (0.4ms) SELECT "activities".* FROM "activities" WHERE "activities"."id" = $1 LIMIT $2 [["id", 16], ["LIMIT", 1]]
Activity Load (0.4ms) SELECT "activities".* FROM "activities" WHERE "activities"."id" = $1 LIMIT $2 [["id", 16], ["LIMIT", 1]]
Activity Load (0.3ms) SELECT "activities".* FROM "activities" WHERE "activities"."id" = $1 LIMIT $2 [["id", 3], ["LIMIT", 1]]
Activity Load (0.3ms) SELECT "activities".* FROM "activities" WHERE "activities"."id" = $1 LIMIT $2 [["id", 27], ["LIMIT", 1]]
Activity Load (0.4ms) SELECT "activities".* FROM "activities" WHERE "activities"."id" = $1 LIMIT $2 [["id", 16], ["LIMIT", 1]]
Rails took it upon itself to load the activity records for us because we didn’t
explicitly say we needed activity data in our initial call to get the
users. This is called lazy loading
and it’s the default behavior in Rails. To
provide relief for our database, we get around the default behavior and eager
load
associations by adding an includes
statement:
@user = User.includes(:activities).first
Now our logs indicate that we went from six SQL queries, to just two. And it will stay at two even as the activities grow.
User Load (0.4ms) SELECT "users".* FROM "users" LIMIT $1 [["LIMIT", 1]]
Activity Load (0.4ms) SELECT "activities".* FROM "activities" WHERE "activities"."id" IN ($1, $2, $3) [[nil, 16], [nil, 3], [nil, 27]]
Clearly, eager loading is crucial to save on precious database cycles and makes our application more scalable. There are tons of articles out there about eager loading so I won’t go into anymore detail. Instead, I want to focus on how can we be sure we’re using eager loading throughout the development life-cycle.
Make strict loading the default to prevent any lazy loading of database records
To turn off lazy loading completely we just make a simple configuration change:
# config/application.rb
config.active_record.strict_loading_by_default = true
Now, if we attempt to lazy load any records, Rails will throw an error, preventing our application from running and possibly tests from passing until we eager load every query that requires data from an association.
ActiveRecord::StrictLoadingViolationError (`User` is marked for strict_loading.
The `Activity` association named `:activity` cannot be lazily loaded.)
This is a sure-fire way of preventing N+1 queries and forces us to really think about the data we need when writing code. This is my default setting on any new Rails projects and it does take some time getting used to. When I first starting using strict loading, it was a little frustrating to start. My tests wouldn’t pass and the some load pages wouldn’t render. I was tempted to remove the setting, in particular in test mode so my tests would pass again. But, I resisted and instead updated my code to eager load records where needed.
A particular trouble spot are test fixtures. For example, the code below uses
FactoryBot for fixtures and there’s
no simple way to eager load factories. So we can’t get data on associated
models like user.activities
without getting the
ActiveRecord::StrictLoadingViolationError
error. To get around this, I eager load
the association data in the subject
:
class UserTest < ActiveSupport::TestCase
let(:user) { create(:user) }
let(:activity) { create(:activity, user: user) }
subject { User.includes(:activites).find(user.id) }
it "has activities" do
# assert_includes user.activities, activity # throws ActiveRecord::StrictLoadingViolationError error
assert_includes subject.activities, actvity
end
...
end
Not exactly as easy as just using the user
record created from FactoryBot, but
at least now our subject
has all the data loaded and the results are a faster
test suite since we’re using less queries!
Updates coming in Rails 7 for strict loading
In the upcoming Rails 7 release, there are more options around strict loading we
can use to fine tune our application. Most notably, the n_plus_one_only
mode that
allows us to lazily load belongs_to
and has_many
associations that are
fetched through a single query, but does not raise an error unless it is a true N+1
query. This will be mostly likely be the default setting I will use since I think it’s a
nice trade off between convenience and performance.
user.activities # Does not raise an error anymore
user.activities.first.client # Raises StrictLoadingViolationError
We can also set the strict loading option on an associations-by-association basis. This can be handy if we’re working with an existing app and we want to convert to strict loading over time.
class User < ApplicationRecord
has_many :activities, strict_loading: true
end
I encourage you to try making strict loading the default in your Ruby on Rails applications. Your databases will thank you.