はじめに
GMO NIKKOの吉岡です。
今期は新しいウェブアプリをゼロから作成しましたが、各機能は1〜2日で作る必要があったため、タグ機能を作る際にはacts-as-taggable-onというGemを使いました。
初めは良いですが、タグが増えるにつれてパフォーマンスが気になってきます。
この原因は大体がN+1問題で、includesを使った先読みで改善されますが、細かい動作については忘れてしまうことも多いので、備忘録として残しておきます。
N+1問題とは?
acts-as-taggable-onは特定のモデルに関連テーブルとしてタグを追加する機能です。
今回は記事モデルに追加しましたが、記事に関連する一つのタグを取得するのに、1SQLが発行されてしまいます。
タグが含まれている複数記事を一覧表示する場合、無駄なSQLが大量に発行されてしまうでしょう。
この現象がN+1問題です。関連テーブルが増えれば増えるほど、SQLの発行も多くなり、パフォーマンスに問題が出てしまいます。
先読み
RailsにはActiveRecord::QueryMethodsとして、便利なメソッドが用意されています。
先読みを考えず、記事のタグ一覧を表示した時は次のようになります。
1 2 3 4 5 |
articles = Article.all articles.first.tag_list ActsAsTaggableOn::Tagging Load (1.7ms) SELECT `taggings`.* FROM `taggings` WHERE `taggings`.`taggable_id` = 10 AND `taggings`.`taggable_type` = 'SuggestQuestion' ActsAsTaggableOn::Tag Load (1.4ms) SELECT `tags`.* FROM `tags` INNER JOIN `taggings` ON `tags`.`id` = `taggings`.`tag_id` WHERE `taggings`.`taggable_id` = 10 AND `taggings`.`taggable_type` = 'SuggestQuestion' AND (taggings.context = 'tags' AND taggings.tagger_id IS NULL) |
- #preload
- SQLを分離してメモリに保持。acts-as-taggable-onが用意しているtag_listメソッドではなく、メモリに保持したtagsを使う必要がある。
-
12345678articles = Article.preload(:tags)Article Load (1.5ms) SELECT `articles`.* FROM `articles`ActsAsTaggableOn::Tagging Load (1.1ms) SELECT `taggings`.* FROM `taggings` WHERE `taggings`.`taggable_type` = 'SuggestQuestion' AND `taggings`.`context` = 'tags' AND `taggings`.`taggable_id` IN (2, 3, 4, 5, 6, 7, 8, 9, 10)ActsAsTaggableOn::Tag Load (0.7ms) SELECT `tags`.* FROM `tags` WHERE `tags`.`id` IN (1, 2, 4, 5, 6, 7, 8, 9)# SQLを分離してメモリに読み込み。articles.first.tags.first.name# はじめにメモリ保持しているため、タグ名にアクセスしてもSQLが発行されない。
-
- 分離してメモリに保持しているがJOINされていないので、preloadしたテーブルはwhereによる絞り込みができない。絞り込みする場合はjoinsを一緒に使う。
-
1articles = Article.preload(:tags).joins(:tags).where(tags: {name: "test"})
- #eager_load
- left joinしてメモリに保持。
-
123456articles = Article.eager_load(:tags)SQL (3.2ms) SELECT `articles`.`id` AS t0_r0, `articles`.`expires_at` AS t0_r17, `articles`.`created_at` AS t0_r18, `articles`.`updated_at` AS t0_r19, `tags`.`id` AS t1_r0, `tags`.`name` AS t1_r1, `tags`.`created_at` AS t1_r2, `tags`.`updated_at` AS t1_r3, `tags`.`taggings_count` AS t1_r4 FROM `articles` LEFT OUTER JOIN `taggings` ON `taggings`.`taggable_type` = 'Article' AND `taggings`.`context` = 'tags' AND `taggings`.`taggable_id` = `articles`.`id` LEFT OUTER JOIN `tags` ON `tags`.`id` = `taggings`.`tag_id`# LEFT JOINで一括読み込み。articles.first.tags.first.name# はじめにメモリに読み込んでいるため、タグ名にアクセスしてもSQLが発行されない。
- 関連情報も保持しているため、tagsテーブルの絞り込みも可能。
-
1articles = Article.eager_load(:tags).where(tags: {name: "test"})
- #includes
-
1articles = Article.includes(:tags).where(tags: {name: "test"})
- 対象テーブルにwhereでの参照がない場合はpreloadと同じ。
参照がある場合はeager_loadとなる。
参照できない状態ではreferences(:tags)を合わせて使う。
-
まとめ
今回はN+1問題のパフォーマンス改善について紹介しました。
テーブルの関連が深い場合はSQLも複雑になってくるので、上記の違いを理解して早い段階から先読みしておくと後々楽になると思います。
参考リンク
- mbleigh/acts-as-taggable-on: A tagging plugin for Rails applications that allows for custom tagging along dynamic contexts.
https://github.com/mbleigh/acts-as-taggable-on