Importance of inverse_of for N+1 prevention
Most Rails developers know about includes
and how it prevents N+1 queries problem. However includes
is not always the best option so let’s explore when you’d want to use something else.
The setup
Imagine you have a blog with a list of comments on the blog page and you want to include country code next to the author of comment unless they are from the same country as the author of the blog post.
How many SQL queries do you think this page will generate if the post has 10 comments?
The problem
The answer is this page makes 32 queries. Perhaps you guessed 13:
- 1 to find post
- 1 to find comments
- 10 to find comment authors
- 1 to find post author
But that’s not correct, we actually have:
- 1 to find post
- 1 to find comments
- 10 to find comment authors
- 10 to find post of the comment
- 10 to find author of the post found previously
And while we can solve 10 queries for comments’ authors with includes
, solving the other 20 is better done using different approach.
Why did it happen?
The reason it happened is because Rails was not able to automatically determine that :blog_post
and :comments
are inverse associations of each other. In that case @post
and @post.comments.first.blog_post
are actually different objects and have different object_id
.
Thus each comment has to load the post from the database when we call comment.blog_post
and then each post has to load its author when we call blog_post.author
. It doesn’t matter that they’re same object, Rails doesn’t know that.
The fix
In order to fix our problem we need to make Rails aware that comments
and blog_post
associations are inverse of each other. Often Rails automatically determines the inverse association automatically and we don’t see this problem with simple associations, but there are multiple reasons why rails is unable to do that though, e.g. specifying foreign_key
option.
In cases like these you have to specify inverse association manually, e.g. using has_many :comments, foreign_key: :blog_post_id, inverse_of: :blog_post
. In fact this one small change fixes our N+1 problem for comment.blog_post
and comment.blog_post.author
.
Keep in mind though that inverse_of
often needs to be manually specified on both sides of association, e.g. comment.object_id.in?(comment.blog_post.comments.map(&:object_id))
will still return false, until we fix the other side using belongs_to :blog_post, class_name: "Post", inverse_of: :comments
.
The side effects
Specifying inverse_of
for associations can have a side effect. It changes how this behaves:
Before inverse_of
this would return []
, but now it returns [new_comment]
. Thus in bigger codebases this might result in breaking some expectations.
Conclussions
Not all N+1 problems can simply be solved with includes
. One of the other common problems Rails code bases have is lack of inverse associations. So make sure all your associations have inverse association set - you can quickly check that Model.reflect_on_association(:association_name).inverse_of
doesn’t return nil
to make sure. And why not write a test that makes sure all your models have their associations defined with inverses?