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.