こんにちは、鈴木です。
Rails の便利なライブラリをシリーズでご紹介してみたいと思います。
今回は「N+1 問題」を検出してくれるライブラリ、bullet です。
- bullet (http://github.com/flyerhzm/bullet)
N+1 問題
N+1 問題とは、OR マッパーを使用しているときに発生しがちな問題です。
何かの一覧画面を作成しているときに、
- 一覧に表示するデータを取得するために SELECT を 1 回実行(N レコード返される)
- 各データの関連データを取得するために SELECT を N 回実行
- データベースアクセス(SELECT)が合計 N+1 回も実行される(JOIN して 1 回の SQL で取得した方が効率的)
というものです。
具体的なコードで考えてみましょう。
例として店舗の一覧画面を作成しているとします。
関係するモデルは以下の通りです。
| 1 2 3 4 5 6 7 8 | # 都道府県. class Prefecture < ActiveRecord::Base end # 店舗. class Shop < ActiveRecord::Base   belongs_to :prefecture end | 
コントローラは以下のようになっているとします。
| 1 2 3 4 5 | class ShopsController < ApplicationController   def index     @shops = Shop.order(:id)   end end | 
店舗一覧の View では、店舗名と店舗のある都道府県名を表示します。
| 1 2 3 4 5 | <h1>店舗一覧<h1> <% @shops.each do |shop| %>   <div><%= shop.name %> (<%= shop.prefecture.name %>)</div> <% end %> | 
作成した店舗一覧を表示してみると、、、なんと店舗の一覧が表示されています!普通ですね・・(^^;
ログファイルを覗いてみると prefectures (都道府県テーブル) への SELECT が大量に行なわれています!これは大変ですね・・(^^;
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |   Shop Load (0.7ms)  SELECT "shops".* FROM "shops" ORDER BY id LIMIT 20   Prefecture Load (0.3ms)  SELECT "prefectures".* FROM "prefectures" WHERE "prefectures"."id" = 73 LIMIT 1   CACHE (0.0ms)  SELECT "prefectures".* FROM "prefectures" WHERE "prefectures"."id" = 73 LIMIT 1   Prefecture Load (0.4ms)  SELECT "prefectures".* FROM "prefectures" WHERE "prefectures"."id" = 126 LIMIT 1   Prefecture Load (0.2ms)  SELECT "prefectures".* FROM "prefectures" WHERE "prefectures"."id" = 36 LIMIT 1   Prefecture Load (0.2ms)  SELECT "prefectures".* FROM "prefectures" WHERE "prefectures"."id" = 26 LIMIT 1   Prefecture Load (0.2ms)  SELECT "prefectures".* FROM "prefectures" WHERE "prefectures"."id" = 96 LIMIT 1   Prefecture Load (0.2ms)  SELECT "prefectures".* FROM "prefectures" WHERE "prefectures"."id" = 52 LIMIT 1   Prefecture Load (0.2ms)  SELECT "prefectures".* FROM "prefectures" WHERE "prefectures"."id" = 34 LIMIT 1   Prefecture Load (0.2ms)  SELECT "prefectures".* FROM "prefectures" WHERE "prefectures"."id" = 47 LIMIT 1   Prefecture Load (0.2ms)  SELECT "prefectures".* FROM "prefectures" WHERE "prefectures"."id" = 56 LIMIT 1   CACHE (0.0ms)  SELECT "prefectures".* FROM "prefectures" WHERE "prefectures"."id" = 26 LIMIT 1   Prefecture Load (0.2ms)  SELECT "prefectures".* FROM "prefectures" WHERE "prefectures"."id" = 12 LIMIT 1   Prefecture Load (0.2ms)  SELECT "prefectures".* FROM "prefectures" WHERE "prefectures"."id" = 70 LIMIT 1   Prefecture Load (0.2ms)  SELECT "prefectures".* FROM "prefectures" WHERE "prefectures"."id" = 31 LIMIT 1   Prefecture Load (0.1ms)  SELECT "prefectures".* FROM "prefectures" WHERE "prefectures"."id" = 11 LIMIT 1   Prefecture Load (0.2ms)  SELECT "prefectures".* FROM "prefectures" WHERE "prefectures"."id" = 109 LIMIT 1   CACHE (0.0ms)  SELECT "prefectures".* FROM "prefectures" WHERE "prefectures"."id" = 34 LIMIT 1   Prefecture Load (0.2ms)  SELECT "prefectures".* FROM "prefectures" WHERE "prefectures"."id" = 107 LIMIT 1   Prefecture Load (0.2ms)  SELECT "prefectures".* FROM "prefectures" WHERE "prefectures"."id" = 114 LIMIT 1   Prefecture Load (0.1ms)  SELECT "prefectures".* FROM "prefectures" WHERE "prefectures"."id" = 24 LIMIT 1 | 
これが N+1 問題が発生した現場です。
機能的な不具合ではないところが N+1 問題の嫌なところです。
- 機能的には問題が無い
- 開発時の少ないデータ量では N+1 問題に気付きにくい
- データ量の多い本番環境にリリース後に発覚してショック・・
インデックスの張り忘れと同じくらいに嫌です。
bullet の導入
bullet を利用するには、Gemfile に以下のように記述します。
| 1 | gem 'bullet', :group => :development | 
開発中のみ使用することを想定しているため、:group => :development を指定しています。
bundle install します。
| 1 | bundle install | 
config/environments/development.rb に設定を記述します。
| 1 2 3 4 5 6 7 | config.after_initialize do   Bullet.enable = true   Bullet.alert = true   Bullet.bullet_logger = true   Bullet.console = true   Bullet.rails_logger = true end | 
(※2013/07/04 追記: bullet-4.4.0 から disable_browser_cache オプションが削除されたので、上記コードからも削除しました。)
設定項目は他にもいくつかあるので、詳細は bullet のサイトをご確認ください。
もう一度店舗一覧を表示する
bullet を導入した状態で、もう一度店舗一覧画面を表示すると、ポップアップで以下のようなメッセージが表示されました。
| 1 2 3 4 5 6 | user: caracal N+1 Query detected   Shop => [:prefecture]   Add to your finder: :include => [:prefecture] N+1 Query method call stack ... | 
曰く、「N+1 問題を検出したので、検索時に :include => [:prefecture] を追加するように」とのことです。
Rails3 を使っているので「:include => [:prefecture]」ではなく「include(:prefecture)」を追加します。
| 1 2 3 4 5 6 7 8 | class ShopsController < ApplicationController   def index     # @shops = Shop.order(:id)     @shops = Shop.order(:id).includes(:prefecture)   end  end | 
再度、店舗一覧を表示すると、今度は bullet によるポップアップは表示されなくなりました。
ログファイルを見ると、今度は以下のような SQL が実行されていることを確認できました。
| 1 2 |   Shop Load (0.3ms)  SELECT "shops".* FROM "shops" ORDER BY id   Prefecture Load (0.5ms)  SELECT "prefectures".* FROM "prefectures" WHERE "prefectures"."id" IN (73, 126, 36, ...) | 
ホワイトリスト
(※2013/07/04 追記: bullet-4.5.0 でホワイトリストを指定できるようになりました。)
bullet を利用すると N+1 問題を検出してくれるので非常に便利ですが、部分的に bullet による通知をオフにしたい場合もあります。
例えば次のような場合です。
- 外部ライブラリの中で N+1 問題が発生しているため、対応することが困難。
- bullet によるチェックが入ったことで実行速度が極端に低下してしまったためチェックをオフにしたい。
bullet 4.5.0 からホワイトリストを指定できるようになりました。
| 1 2 3 4 5 6 7 8 9 10 11 12 | config.after_initialize do   Bullet.enable = true   Bullet.alert = true   Bullet.bullet_logger = true   Bullet.console = true   Bullet.rails_logger = true   # ホワイトリストを指定.   Bullet.add_whitelist type: :n_plus_one_query, class_name: 'User', association: :prefecture   Bullet.add_whitelist type: :unused_eager_loading, class_name: 'User', association: :prefecture   Bullet.add_whitelist type: :counter_cache, class_name: 'User', association: :comments end | 
type には「n_plus_one_query (N+1 問題が発生したクエリ)」、「unused_eager_loading (include による不要な先読み)」、「counter_cache (関連データ件数を取得しているが Counter Cache が使われていない)」のいずれかを指定します。
そして、class_name と association にはホワイトリストに入れるクラスとアソシエーションを指定します。
まとめ
ミスをしない人はいないので、bullet のようにミスがあったことを知らせてくれるライブラリがあることは非常に心強いです。

 
						