Rails: Gutentag tag_names with N+1 problem
Before you go
GutenTag
is a handy gem for adding tags to your model. However, if don't use it with care, you might get yourself into unexpected N+1 problem.
GutenTag creates two tables:
- gutentag_taggings
- gutentag_tags
You can see its columns in your schema.rb
or via rails console
Gutentag::Tagging
# => Gutentag::Tagging(id: integer, tag_id: integer, taggable_id: integer, taggable_type: string, created_at: datetime, updated_at: datetime) Gutentag::Tag
# => Gutentag::Tag(id: integer, name: string, created_at: datetime, updated_at: datetime, taggings_count: integer)
What it actually does is that it associates your model as taggable
via polymorphism, having the below relations:
SomeModel has_many tags, through taggings, as taggable
Scenario
I have an API which renders with tag_names and I found it very slow. I’ve already included the tags association. I also double checked with Bullet and it didn’t report any problem.
The original setup was like:
class ArticlesController < ApplicationController
def index
@articles = Article.includes(:tags).all
end
endclass ArticleSerializer < ActiveModel::Serializer
attributes :id, :title
attributes :tags
def tags
object.tag_names
end
end
I went to see logs and found that even if the :tags
association was already included, ActiveRecord still fetches each tag on every iteration:
=> @articles = Article.includes(:tags).all
=> @articles.map{|article| article.tags.map(&:name)}Article Load (0.6ms) SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = 3446 LIMIT 1 [["user_id", 3446]]
Article Load (0.6ms) SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = 3446 LIMIT 1 [["user_id", 3446]] (0.7ms) SELECT "gutentag_tags"."name" FROM "gutentag_tags" INNER JOIN "gutentag_taggings" ON "gutentag_tags"."id" = "gutentag_taggings"."tag_id" WHERE "gutentag_taggings"."taggable_id" = 1238 AND "gutentag_taggings"."taggable_type" = 'Article' [["taggable_id", 1238], ["taggable_type", "Article"]] (0.7ms) SELECT "gutentag_tags"."name" FROM "gutentag_tags" INNER JOIN "gutentag_taggings" ON "gutentag_tags"."id" = "gutentag_taggings"."tag_id" WHERE "gutentag_taggings"."taggable_id" = 1238 AND "gutentag_taggings"."taggable_type" = 'Article' [["taggable_id", 1238], ["taggable_type", "Article"]] Article Load (0.5ms) SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = 3541 LIMIT 1 [["user_id", 3541]] Article Load (0.5ms) SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = 3541 LIMIT 1 [["user_id", 3541]] (0.7ms) SELECT "gutentag_tags"."name" FROM "gutentag_tags" INNER JOIN "gutentag_taggings" ON "gutentag_tags"."id" = "gutentag_taggings"."tag_id" WHERE "gutentag_taggings"."taggable_id" = 733 AND "gutentag_taggings"."taggable_type" = 'Article' [["taggable_id", 733], ["taggable_type", "Article"]] (0.7ms) SELECT "gutentag_tags"."name" FROM "gutentag_tags" INNER JOIN "gutentag_taggings" ON "gutentag_tags"."id" = "gutentag_taggings"."tag_id" WHERE "gutentag_taggings"."taggable_id" = 733 AND "gutentag_taggings"."taggable_type" = 'Article' [["taggable_id", 733], ["taggable_type", "Article"]] Article Load (0.7ms) SELECT "articles".* FROM "articles" WHERE
...
What to do ?
I came up with a naive yet quick approach, which is to access tag’s name from the included tags
association, instead of using the tag_names
instance method.
TL;DR
- Include
tags
- Map to
name
If we apply it to this scenario, it will look like this:
class ArticlesController < ApplicationController
def index
@articles = Article.includes(:tags).all
end
endclass ArticleSerializer < ActiveModel::Serializer
attributes :id, :title
attributes :tags
def tags
object.tags.map(&:name) # this won't cause n+1 anymore
end
end# access via iteration
@articles.map{|article| article.tags.map(&:name)}
After that nothing explodes anymore, and my API response time reduced 550ms .
@articles.map{|article| article.tags.map(&:name)}
# => [["sport"], ["news"], ["food"], [], ["programming"], ...]