Solving N+1’s with Rails `exists?` queries
Learn how to solve your Rails applications N+1’s caused by `exists?` queries.
We recently had a performance issue in our Rails application where we had several N+1 .exists?
queries on a single API endpoint. In the following query, we check to see if the "data_imports" feature is enabled for a user:
user.beta_feature.where(name: "data_imports").enabled.exists?
Output:
BetaFeature Exists? (0.6ms) SELECT 1 AS one FROM `beta_feature` WHERE `beta_feature`.`name` = 'data_imports' AND `beta_feature`.`target_type` = 'User' AND `beta_feature`.`target_id` = 1 AND `beta_feature`.`enabled_at` IS NOT NULL LIMIT 1
This pattern initially worked well, but became an issue as we started to add more beta features. Each new beta feature added an additional query, and it started to impact the speed of the API endpoint.
Generally, in Rails applications, you can solve N+1's by using includes
to preload the data. This, however, doesn't work with exists?
queries. Rails will still execute the query.
Solving the N+1
Let's look at how we went about solving the N+1 problem for our Rails exists?
query. On our user model, we originally had this method for checking beta features:
def beta_feature_enabled?(name)
beta_features.where(name: name).enabled.exists?
end
This will always execute a query whether or not beta_features
is already loaded.
One way to avoid the query is by preloading all the records and then checking them in memory rather than executing a query.
# New method, allows us to preload beta_features
def beta_feature_enabled?(name)
if beta_features.loaded?
beta_features.any? { |f| f.name == name.to_s && f.enabled? }
else
beta_features.where(name: name.to_s).enabled.exists?
end
end
Now, in our controllers, if we preload beta_features
using includes
, it will already be loaded. Any calls to beta_feature_enabled?
won't execute additional queries.
# Executes 2 queries
@users = User.all.includes(:beta_features)
You can also use this technique for reducing queries when loading a single record.
@user = User.find(params[:id])
@user.beta_features.load # preload all beta features for the user
Preloading with a scope
With the above solution, we're loading every beta_feature
for every user. For our use case, this is what we want.
This could result in loading unnecessary records if your application is only checking a few though.
If that's you, here's a solution. You can set up a new association that only loads the records you need:
has_many :beta_features, as: :target, dependent: :destroy_async
PRELOADED_FLAGS = %w[dark_mode insights data_imports]
has_many :preloaded_beta_features, -> { where(name: PRELOADED_FLAGS) }, as: :target, class_name: "BetaFeature"
You can now replace beta_features
with preloaded_beta_features
to load in only the records you need.