March 19, 2024
The N+1 problem is a common performance issue that’s present in most rails apps I’ve worked in. It occurs when listing a collection of objects and, for each object, fetching associations from the database is required.
In this post, I’ll focus on a specific variation: displaying the count of associated objects.
Consider having a User model and a Post model, where a User has many Posts. The goal is to display the number of posts each user has in a list of users.
# app/models/user.rb
class User < ApplicationRecord
has_many :posts
end
# app/models/post.rb
class Post < ApplicationRecord
belongs_to :user
end
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def index
@users = User.all
end
end
<!-- app/views/users/index.html.erb -->
<% @users.each do |user| %>
<p><%= user.name %>, <%= user.posts.count %> posts</p>
<% end %>
Rails’ counter_cache feature is a straightforward solution for storing a running count of posts for each user, eliminating the need to query the database for this information.
# app/models/post.rb
class Post < ApplicationRecord
belongs_to :user, counter_cache: true
end
<!-- app/views/users/index.html.erb -->
<% @users.each do |user| %>
<p><%= user.name %>, <%= user.posts_count %> posts</p>
<% end %>
posts_count column to the users table.counter_cache: true option to the belongs_to association in the Post model.posts_count column in the users table when posts are created, updated, or destroyed.This method is often the first solution considered for addressing this issue.
While counter caches are effective in many scenarios, they have limitations, such as:
counter_cache uses a callback in the model to update the count)includes and sizeAn alternative solution involves using includes to eager load associated objects and then counting them in memory with the size method.
class User < ApplicationRecord
has_many :posts
delegate :size, to: :posts, prefix: true
end
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def index
@users = User.includes(:posts)
end
end
<!-- app/views/users/index.html.erb -->
<% @users.each do |user| %>
<p><%= user.name %>, <%= user.posts_size %> posts</p>
<% end %>
delegate method to create a posts_size method that returns the count of posts for each user.posts_size method in the view to display the count.This solution is great because it doesn’t require any extra database columns, and it’s more flexible than counter_cache. But it has a downside: it’s not as performant as counter_cache because it needs to load all the associated objects into memory.
includes and sizeThe potential downside of this approach, is when there are many associated records, which leads to a large amount of data being loaded into memory.
For situations where counter_cache falls short, such as needing to filter counts of associated objects, the counter_culture gem offers a robust solution. However, embracing simplicity and minimizing dependencies is a valuable strategy. A Rails-centric approach can be just as effective
counter_culture solves this problem really well. It provides a counter_culture method that takes a block to define the conditions for the counter. It also provides a reset_counters method to update the counters in bulk without triggering callbacks.
After re-reading the rails ActiveRecord documentation, I figured, that solving this is also dead-simple:
class User < ApplicationRecord
has_many :posts
has_many :published_posts, -> { where.not(published_at: nil) }, class_name: 'Post'
delegate :size, to: :posts, prefix: true
delegate :size, to: :published_posts, prefix: true
end
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def index
@users = User.all.includes(:posts, :published_posts)
end
end
<!-- app/views/users/index.html.erb -->
<% @users.each do |user| %>
<p><%= user.name %>, <%= user.posts_size %> posts, <%= user.published_posts_size %> published posts</p>
<% end %>
published_posts that filters the posts that have a published_at date.delegate method to create a published_posts_size method that returns the count of published posts for each user.published_posts_count method in the view to display the count.Well, for a long time, I had a misconception about size method in ActiveRecord::Relation. I thought that calling size on an association would always load the associated objects into memory and count them there everytime.
However, this is not the case. When you call size on an association, it will check if the association has already been loaded into memory (in this case, by the includes method), and if it has, it will count the elements in memory. If it hasn’t, it will execute a SELECT COUNT(*) ... query to the database.
So, in this case, the code is not triggering N+1 queries because the associated objects are eager-loaded into memory with includes(:posts, :published_posts).
The N+1 counters problem is a common issue when working with associations in rails. There are different solutions to the problem, and the best one depends on the specific requirements of your application.
counter_cache is the go-to solution for the problem, but it has some limitations. Also, if you need to filter the count of associated objects or update records in bulk without triggering callbacks, it may not be the best solution.
counter_culture gem is a great solution for the filtered counter problem, but it adds a dependency to your project, and also it will still be affected by the callback limitations.
includes + size is a great solution for the problem, but it’s not as performant as counter_cache (for reads!) because it needs to load all the associated objects into memory. If your collection is small, this is not a problem.
Maybe try to keep things simple in the beginning, use the tools that Rails provides, like counter_cache or includes + size. You can always refactor later if the need arises. Don’t overcomplicate things from the start.