#372 Bullet
- Download:
- source codeProject Files in Zip (55.7 KB)
- mp4Full Size H.264 Video (17.4 MB)
- m4vSmaller H.264 Video (7.75 MB)
- webmFull Size VP8 Video (10 MB)
- ogvFull Size Theora Video (16.6 MB)
Bulletは、Richard Huangが開発したgemで、アプリケーションで非効率なデータベースクエリー( N+1クエリーやmissing counter cache columnなど)が行なわれたときに、いろいろな方法で警告を出してくれます。今回のエピソードでは、このgemを使ってRailsアプリケーションの最適化をおこないます。
商品ページを最適化する
下に示したページは、カテゴリ別の商品一覧です。このページには2つのモデルが含まれています。一つがCategory
で、これは複数のProducts
を持つことができます。
このページには現在、N+1クエリ問題があり、これはRailsのログを見ることで確認できます。ログによると、カテゴリを取得するためにクエリが1回実行され、関連する各商品を取得する度ごとに別のクエリが実行されます。
Category Load (0.2ms) SELECT "categories".* FROM "categories" ORDER BY name Product Load (0.2ms) SELECT "products".* FROM "products" WHERE "products"."category_id" = 3 Product Load (0.2ms) SELECT "products".* FROM "products" WHERE "products"."category_id" = 1 Product Load (0.2ms) SELECT "products".* FROM "products" WHERE "products"."category_id" = 5 Product Load (0.2ms) SELECT "products".* FROM "products" WHERE "products"."category_id" = 4 Product Load (0.3ms) SELECT "products".* FROM "products" WHERE "products"."category_id" = 2
これがN+1クエリ問題といわれるもので、親を取得するクエリとそれ以外のレコードを取得するために子供の数だけのクエリが生成されることを意味します。この種の問題は見落とされがちなので、Bulletが役に立ちます。アプリケーションのgemfileのdevelopmentグループのみにgemを追加して、bundle
コマンドでインストールをおこないます。
gem 'bullet', group: :development
新規に初期化ファイルを作成してBulletを有効化します。すべての環境にはロードされないので、最初に定義されているかどうかを確認します。定義されていたらそれを有効化して、クエリの問題をどう通知してほしいかを指定します。alert
をtrue
に設定すると、ブラウザに警告が表示されます。
if defined? Bullet Bullet.enable = true Bullet.alert = true end
サーバを再起動してページをリロードすると、JavaScriptの警告で、BulletがN+1クエリを検知したこととそれを修正するためにとるべき方法が表示されます。
Bulletの推奨に従うことにして、カテゴリのデータと同時に商品も取得するようにします。
class CategoriesController < ApplicationController def index @categories = Category.order(:name).includes(:products) end end
いずれこのデータは必要になるので、eager loadingで商品情報を取得します。ページをリロードすると、データを効率的に取得しているので警告は表示されません。ログファイルを見ると、2つのクエリだけでデータを取得していることがわかります。一つ目でカテゴリを取得して、次にこれらのカテゴリに属する商品を取得しています。
Category Load (0.2ms) SELECT "categories".* FROM "categories" ORDER BY name Product Load (0.4ms) SELECT "products".* FROM "products" WHERE "products"."category_id" IN (3, 1, 5, 4, 2)
Bulletは、不必要なeager loadingを行なっている場合も教えてくれます。例えばこのindexページから商品リストをはずして、各商品のshowページに移すと決めたとします。indexテンプレートの商品リストのコードを削除して、カテゴリについての情報だけを表示します。
<h1>Categories</h1> <% @categories.each do |category| %> <div class="category"> <h2><%= link_to category.name, category %></h2> </div> <% end %>
ページをリロードすると、再度警告が表示されますが、Bulletが今度は使われていないeager loadingを検知したことを通知しています。
これを修正するために、CategoriesController
のincludes
の呼び出しを削除します。
class CategoriesController < ApplicationController def index @categories = Category.order(:name).includes(:products) end end
ページをリロードすると警告は消えました。
Counter Cache Column
Bulletはcounter cache columnの使用を検討した方がいい場合も通知してくれます。例えば各カテゴリ名の下に、そのカテゴリに属する商品数を表示したいとします。これを次のようにします。
<h1>Categories</h1> <% @categories.each do |category| %> <div class="category"> <h2><%= link_to category.name, category %></h2> <%= pluralize category.products.size, "product" %> </div> <% end %>
ページをリロードするとまた警告が表示されます。
今回はcounter cache columnを追加することを推奨しています。各カテゴリに属する商品数をカウントするためにデータベースに対してクエリを実行する必要があります。これは先に触れたN+1クエリ問題に似ています。これは、Product
モデルのbelongs_to
の呼び出しでcounter_cache
オプションを使用することで簡単に修正することが可能です。
class Product < ActiveRecord::Base belongs_to :category, counter_cache: true attr_accessible :name, :price, :category_id end
migrationを生成して、productsテーブルにcounter列を追加します。
$ rails g migration add_products_count_to_categories products_count:integer
migrationを実行する前に、counter_cache
列にデフォルト値を設定します。
class AddProductsCountToCategories < ActiveRecord::Migration def change add_column :categories, :products_count, :integer, default: 0, null: false end end
これを既存のProduct
レコードに適用させたい場合は、その列にデータを入れる必要があるので、もう一つ別のmigrationを作成して対応します。
$ rails g migration cache_product_count
このmigrationでActiveRecordを利用してこの列を埋めることもできますが、ここではそれは行なわず、SQLコードを書いて対応することにします。
class CacheProductsCount < ActiveRecord::Migration def up execute "update categories set products_count=(select count(*) from products where category_id=categories.id)" end def down end end
これによって、既存のカテゴリについて商品数が更新されます。これらのmigrationをrake db:migrate
で実行してページをリロードすると、警告は消えています。
その他の通知オプション
ここまでは、Bulletの通知方法として警告メッセージを介する方法のみを見てきましたが、その他の通知方法のオプションを選択することができます。これのためにUniform Notifierという別のgemを利用しますが、これ自体もとても興味深いプロジェクトです。アプリケーションでBulletの機能は一応試したので、通知メッセージをより邪魔にならないもの、例えばbullet_logger
に切り替えます。この方法でアプリケーションの開発を継続し、時々これをチェックしてクエリの問題がないかどうかを確認することにします。
このようなツールで重要なことは、その指示に盲目的に従わないということです。警告メッセージでeager loadingを追加するように言われたときに、その警告を消すために言われた通りに追加するべきではありません。eager loadingを追加することによって実際にはパフォーマンスが落ちてしまう場合がしばしばあるので、その場合はキャッシュを利用するなど他の最適化手法を検討する必要があるでしょう。疑わしい場合には、ベンチマークを行なって複数のソリューションのパフォーマンスを比較するのがいいでしょう。また常に考慮しなくてはいけないのが、本番稼働用サーバが設定された環境、例えばデータベース接続の待ち時間などにも影響を受けるという点です。