Trick for fixing Rails `find_by` N+1's
Recently I had some code that was doing 100's of find_by
queries. Due to the way it was setup, a simple fix using includes
wasn't possible.
I was able to know which records would be called via find_by
though, meaning I should be able to preload all the objects needed and avoid the 100's of queries.
Creating a "cache"
I started by adding a "cache" for storing all of the objects my code needed. It implements two methods, preload
for pre-filling the cache and get
for retrieving objects from the cache.
I'm saying "cache" in quotes here because it's simply a hash. We aren't using memcached or redis to store this. We're keeping the objects in memory for the length of the request.
This file lives in app/models
.
# Allows us to preload FeatureFlags to avoid N+1's.
#
# Usage:
# cache = FeatureFlagCache.new
# cache.preload(keys)
# cache.get(key)
#
# This is useful when replacing many calls to FeatureFlag.find_by(key: key)
class FeatureFlagCache
def initialize
@cache = {}
end
def preload(flags)
flags.each do |flag|
flag = flag.to_s
@cache[flag] = @cache[flag] || nil
end
FeatureFlag.where(key: flags).find_each do |flag|
@cache[flag.key] = flag
end
end
def get(key)
key = key.to_s
if @cache.key?(key)
# Return from cache, even if nil (to avoid multiple hits for non-existent flags)
@cache[key]
else
@cache[key] = FeatureFlag.find_by(key: key)
end
end
end
Replacing find_by with the cache
Once I had this cache setup, I added it to my model so that it would be available for the life of any request.
def feature_flag_cache
@feature_flag_cache ||= FeatureFlagCache.new
end
Now, within that model, I can update all calls to find_by
with the following:
feature_flag_cache.get(key)
Once this is done, I can now read from the cache, and any previously duplicate find_by
calls will now only cause a single lookup since subsequent lookups will be cached. Good.
Preloading the cache
In this particular case, I was able to know which key
's would be used for doing all the lookups.
feature_flag_cache.preload(KEYS)
Calling this executes a single query and pulls in all of the objects that will needed. Then as my code calls get
to the cache, the object gets returned without making a trip to the database.
Performance win
In this case, the code I was working on optimizing went from running 100's of queries, to about 10. A nice improvement in a critical path of our app.
Learn more
This is just one way to solve a tricky N+1. If you find yourself in the same situation, I hope this technique is helpful for you.
Another potential option is preloading an entire association and using Ruby's find_by
instead. I wrote about how to do that on PlanetScale's blog here: Solving N+1's with Rails exists queries.