QA@IT

このActiveRecordの書き方のどこが悪いでしょうか?

10686 PV

現在、Rails3.2とBullet(SQLの悪い所を指摘するgem)を使ってWebサービスを開発しているのですが、Bulletが「N+1 Query detected」、「Unused Eager Loading detected」と警告を出しているのですが、どこが悪いのかわかりません。

具体的に現在の状況を説明させていただきます。

現在プログシステムを作っており、それにたいしてランキング機能(多く見られている記事を表示する)を実装しようと考えました。
またこのブログにはタグ機能があります。

ここで簡単に登場するモデルを説明すると、Blog、AccessConter、Tagという3つのモデルがあります。
AccessCounterはBlogと1:1の関係、TagはBlogと多対多の関係にあります。

まず私は、アクセスのカウントを事前に数えてAccessCounterに値を入れました。
AccessCounterのカラムは、yesterday_countというカラムがあり、ここに昨日どれだけアクセスされたか集計させています。
例えば100回アクセスされたなら、100という数値が入っています。

次に私は昨日のランキングをindexに表示するために、このようなコードを書きました。またindexにはkaminariを使って、ページネーションを実装しています。

Blog.includes("access_counter","tags").order("access_counters.yesterday_count desc").page page

このようなコードを書いた意図を説明すると、まずアクセス数をはかる為に、access_counterをインクルードしています。
またindexにはBlogのタイトルだけではなく、そのBlogに関連づけられているTagの一覧を表示しないといけないため、あらかじめTagをインクルードしておく事で、N+1を回避しようとしています。

しかしこのコードを実行すると、html.erb内でBulletが以下の様な警告を出します。

N+1 Query detected
  Blog => ["access_counter", "tags"]
  Add to your finder: :include => [:access_counter, :tags]
Unused Eager Loading detected
  Blog => [:access_counter, :tags, "access_counter", "tags"]
  Remove from your finder: :include => [:access_counter, :tags, :access_counter, :tags]

改め質問なのですが、なぜこのような警告がでるのでしょうか?
また私はどこを直せばいいでしょうか?
ご指摘いただけると幸いです。

  • コメントに返信を書きました。 -

回答

似たようなモデルを作って確認してみました。列は適当です。

class Blog < ActiveRecord::Base
  has_many :tags
  has_one :access_counter
end

class AccessCounter < ActiveRecord::Base
  belongs_to :blog
end

class Tag < ActiveRecord::Base
  has_many :blogs
end

yesterday_county_countにしています(タイプするのが面倒だっただけです)。

kaminariは使用せずに

Blog.includes("access_counter","tags").order("access_counters.y_count desc")

まででやってみたところ、N+1の警告はでませんでしたが、Unused Eager Loadingは出ました。

Unused Eager Loading detected
  Blog => [:access_counter, :tags, "access_counter", "tags"]
  Remove from your finder: :include => [:access_counter, :tags, :access_counter, :tags]2013-12-11 00:39:38[WARN] user: …

Eager Loading

N+1問題を回避するためにはEager Loading(一括読み込み)することになるんですが、 includeは条件などによってはLEFT OUTER JOINとなります。
外部結合ですので結合されない場合も片側は無条件で全件出力されることになります。

場合によってはこれは無駄となることがあり、おそらくこれが警告を受けているんではないかと思います。
rails consoleで作成されているSQLを確認してみると
以下のような左外部結合のSQLになっています。

SELECT 
    "blogs"."id" AS t0_r0
  , "blogs"."title" AS t0_r1
  -- ~ 10行程 省略 ~
  , "tags"."updated_at" AS t2_r3
  , "tags"."blog_id" AS t2_r4 
FROM "blogs" 
    LEFT OUTER JOIN "access_counters" 
    ON "access_counters"."blog_id" = "blogs"."id" 
    LEFT OUTER JOIN "tags" 
    ON "tags"."blog_id" = "blogs"."id" 
ORDER BY access_counters.y_count desc

このうち、access_counterは左外部結合でなくともよいので、これはjoinにして、必要な項目だけ取得するように変えてみます。

Blog.joins(:access_counter).select("access_counters.y_count As y_count").includes(:tags).order("access_counters.y_count desc")

# rails 4
Blog.joins(:access_counter).select("access_counters.y_count As y_count").includes(:tags).order("access_counters.y_count desc").references(:tags)

すると以下のSQLとなり、BulletのログにEager Loadingの警告はでなくなりました。

SELECT 
    "blogs"."id" AS t0_r0, 
    "blogs"."title" AS t0_r1, 
    "blogs"."created_at" AS t0_r2, 
    "blogs"."updated_at" AS t0_r3, 
    "blogs"."tag_id" AS t0_r4, 
    "tags"."id" AS t1_r0, 
    "tags"."name" AS t1_r1, 
    "tags"."created_at" AS t1_r2, 
    "tags"."updated_at" AS t1_r3, 
    "tags"."blog_id" AS t1_r4, 
    access_counters.y_count As y_count 
FROM "blogs" 
    INNER JOIN "access_counters" 
    ON "access_counters"."blog_id" = "blogs"."id" 
    LEFT OUTER JOIN "tags" 
    ON "tags"."blog_id" = "blogs"."id" 
ORDER BY access_counters.y_count desc

Bulletが実際にどういう判断を下しているのかはわかりませんが、
access_counterstagsも LEFT OUTER JOINだったことにより警告が出ていたのかなぁと思います。

ただ、警告が出たらなんでもかんでも内部結合にすればいいのかと言えば当然そんなことはありません(今回で言えばタグが設定されていないBlogがあるならtagsはjoinにできないですし、access_countersもAccessCounterの生成タイミングによってはjoinにするとマズイです)ので、目安として、警告がでたらSQLを見て問題がないならBulletの White Listに設定するという手もあると思います。

N+1の警告の方は出なかったのでわかりません。

編集 履歴 (2)
  • 丁寧なお返事ありがとうございます。
    恥ずかしながら、外部結合と内部結合の違いがわかっておらず、全てincludesでやっておりました。
    ご回答いただいた方法でうまくいきました。
    色々と勉強になりました、ありがとうございました。
    -
  • acceptした後で申し訳ないんですが、追加でちょっとした疑問というか確認なのですが、
    内部結合は片方が存在しない物は結果を返さない。つまり、access_counterが存在しないBlogは検索結果には返ってこないという認識であってますよね?
    -
  • そうですね、内部結合ですと結合できなかったものは省略されます。ですから、access_counterを作成するタイミングが『Blog作成時』ではなく、『初めてページにアクセスされたとき』に作成するような作りの場合はinner joinだとマズイですね(回答に書いた「生成タイミングによっては・・・」というのはこういうケースです) -
  • お返事ありがとうございます。なるほどです、合点がいきました。この当たりは注意して書くようにします。 -
ウォッチ

この質問への回答やコメントをメールでお知らせします。