目次


Ruby on Rails を高速化する

N+1 クエリー問題を解消する

Comments

Ruby 言語でよく引き合いに出されるのは、その柔軟性です。Dick Sites が言うように、Ruby を使えば「プログラムを作成するためのプログラムを作成」することができます。Ruby on Rails はコアとなる Ruby 言語を拡張するものの、その拡張性を可能にしているのは Ruby そのものです。Ruby 言語の柔軟性を利用する Ruby on Rails では、ボイラープレート・コードをあまり使わずに、また追加でコードを作成することもそれほどなく、極めて構造化されたプログラムを簡単に作成することができます。つまり一般的な振る舞いをするプログラムを作成する場合には、そのほとんどで追加の作業が必要ありません。このように手をかけずに作成できるプログラムの振る舞いが常に完璧であるとは限らないものの、数々の優れたアーキテクチャーを簡単な作業で実現することができます。

例えば、Ruby on Rails はモデル・ビュー・コントローラー (MVC) パターンをベースにしていることから、大半の Rails アプリケーションは 3 つの構成部分にはっきりと分かれています。このうち、アプリケーションのデータを管理するために必要な振る舞いが含まれるのはモデルの部分です。一般的に、Ruby on Rails アプリケーションではモデルとデータベース・テーブルの間に 1 対 1 の関係があります。モデルのデータベース操作を管理するのは、Ruby on Rails がデフォルトで使用する ActiveRecord というオブジェクト・リレーション・マッピング (ORM) です。つまり、平均的な Ruby on Rails プログラムに SQL コードがあったとしても、その量は極めて限られています。ビューの部分には、ユーザーに送信される出力を作成するコードが含まれます。このコードは通常、HTML、JavaScript などで構成されます。そしてコントローラーが、ユーザーからの入力を正しいモデルの呼び出しに変換し、適切なビューを使用してレスポンスをレンダリングします。

Rails の支持者たちは、Rails が使いやすくなっている理由としてこの MVC パラダイムを (Ruby と Rails 両方の利点とともに) 挙げ、Rails ではより少人数のプログラマーでより多くの機能をより短時間に生み出すことができると主張します。これは、ソフトウェア開発費あたりのビジネス価値が増大することに他なりません。このことから、Ruby on Rails 開発がさらに大きな人気を博したというわけです。

けれども、初期開発費だけがすべてではありません。他にも保守にかかる費用、アプリケーションを実行するためのハードウェアの費用など、継続的に必要となってくる費用があります。Ruby on Rails 開発者は多くの場合、テストやその他のアジャイル開発手法を使って保守にかかる費用を抑えていますが、膨大な量のデータを使用する Rails アプリケーションを効率的に実行することに関しては見逃されがちです。Rails はデータベースへのアクセスを容易にするものの、必ずしも効率的にアクセスしているというわけではありません。

Rails アプリケーションの実行速度が遅くなる理由とは

Rails アプリケーションは、いくつかの根本的な理由から実行に時間がかかる場合があります。理由の 1 つは単純なもので、Rails では開発者が迅速に開発できるようにすることを前提としているからです。通常はこれらの前提は正しく、有効に働きますが、パフォーマンス面でメリットがあるとは限りません。場合によっては、リソースを非効率的に使用する結果になることもあります。これは特に、データベース・リソースに関して言えることです。

例えばデフォルトでは、ActiveRecordSELECT * に相当する SQL 文を使用して、すべてのフィールドをクエリーで選択します。大きなサイズの VARCHARBLOB フィールドが含まれている場合は尚更のこと、列の数がたくさんあるとしたら、この振る舞いはメモリー使用量とパフォーマンスという点で大きな問題になってしまいます。

もう 1 つの重大な問題は、この記事で詳しく検討する N+1 問題です。N+1 問題は基本的に、1 つの大きなクエリーではなく、小さなクエリーが数多く実行されるという結果をもたらします。ActiveRecord には、例えば親レコードの各セットに対して子レコードが要求されていることを知る手段はありません。そのため、親レコードのそれぞれに対して 1 つの子レコードのクエリーを生成します。クエリーごとのオーバーヘッドにより、この振る舞いは極めて重大なパフォーマンス問題を引き起こす可能性があります。

さらに、Ruby on Rails 開発者の開発スタイルと開発スタンスに密接に関連する問題もあります。ActiveRecord は多くのタスクを極めて簡単に行えるようにするため、Rails 開発者は「反 SQL」のスタンスを取りがちです。このようなスタンスの Rails 開発者は、SQL のほうが理にかなっているとしても、SQL を使いたがりません。けれども、大量の ActiveRecord オブジェクトを作成して操作するとなると、必然的に実行速度は低下します。場合によってはオブジェクトをまったくインスタンス化しない SQL クエリーをそのまま作成したほうが、実行速度が遥かに向上する結果となります。

Ruby on Rails は多くの場合、開発チームの規模を縮小するために使用されます。そして、本番環境でのアプリケーションのデプロイメントおよび保守に必要なシステム管理タスクの一部は、Ruby on Rails 開発者が行うのが通例です。この 2 つの理由から、Ruby on Rails 開発者が本番環境について十分に理解していないと、問題が発生する可能性があります。その一例は、オペレーティング・システムやデータベースが正しく設定されないことです。MySQL の my.cnf が最適な設定になっていないにも関わらず、このデフォルト設定が Ruby on Rails のデプロイメントでそのまま使われていることがよくあります。さらに、パフォーマンスを長期的に向上させるための監視ツールやベンチマーク・ツールが十分に揃えられていないことも珍しくありません。もちろん、Ruby on Rails 開発者を非難しているのではなく、単に、専門知識を持っていないと起こり得る問題だと言っているのです。場合によっては、Rails 開発者が開発環境および本番環境の両方でのエキスパートであることもあります。

最後に挙げる問題は、Ruby on Rails はプログラマーにローカル環境で開発を行うことを推奨している点です。ローカル環境での開発にはさまざまなメリットがあります。例えば、開発の遅延が少なくなり、ディストリビューション数の増大につながります。けれどもこれは同時に、ワークステーションの規模が小さいことから、限られたデータ・セットでしか作業できないことを意味します。開発者の開発方法と、コードが実際にデプロイされる場所との違いは、大きな問題になり得ます。負荷の少ないローカル・サーバーで小さなデータ・サイズの処理を長時間行ってパフォーマンスに優れているとしても、負荷の大きなサーバーで大きなデータ・サイズの処理を行えば、途端にアプリケーションに大きなパフォーマンス問題があることが発覚するだけです。

もちろん上記の他にも、Rails アプリケーションのパフォーマンス問題には多くの原因があります。Rails アプリケーションの潜在的なパフォーマンス問題を見つける最善の方法は、正確で繰り返し測定できる診断ツールに目を向けることです。

パフォーマンス問題の検出

パフォーマンス問題を検出するための最高のツールの 1 つとなるのは、Rails 開発ログです。各開発マシンの log/development.log ファイルにある このログには、リクエストに応答するまでの合計所要時間、データベースで費やされた時間のパーセンテージ、ビューを生成するのに費やされた時間のパーセンテージなど、さまざまなメトリックの総計があります。このログは、development-log-analyzer などのツールを使用して自動的に分析することができます。

本番環境では、mysql_slow_log を調べることによって貴重な情報を見つけることができます。このログについての詳しい説明はこの記事ではしませんが、「参考文献」セクションに詳細を調べられるリンクを記載してあります。

特に強力で有用なツールとして挙げられるのは、query_reviewer プラグインです (「参考文献」を参照)。このプラグインは、ページで実行されているクエリーの数、ページの生成に費やされた時間を示すだけでなく、ActiveRecord が生成する SQL コードを自動的に分析して潜在的な問題を見つけてくれます。例えば、MySQL インデックスを使用していないクエリーなどです。したがって、重要な列にインデックスを付け忘れたためにパフォーマンス問題が発生しているとしても、このことを簡単に突き止めることができます (MySQL インデックスについての詳細は、「参考文献」を参照してください)。このプラグインはこうしたすべての情報を、ポップアップ表示される <div> に含めて表示しますが、この <div> は開発モードでのみ表示されます。

最後に、Firebug、yslow、Ping、tracert などのツールを使用して、パフォーマンス問題の原因がネットワークにあるのか、それともアセットのロード問題にあるのかを突き止めることも忘れないでください。

次のセクションでは、Rails に特有のパフォーマンス問題とそのソリューションに取り組みます。

N+1 クエリー問題

N+1 クエリー問題は、Rails アプリケーションに伴う最大の問題の 1 つです。例えば、リスト 1 のコードが生成するクエリーの数がわかりますか?このコードは、一時的に作成した posts テーブル全体を単純にループ処理して、投稿のカテゴリーと本文を表示するコードです。

リスト 1. 最適化される前の Post.all コード
<%@posts = Post.all(@posts).each do |p|%> 
	<h1><%=p.category.name%></h1>
	<p><%=p.body%></p>
<%end%>
 

答え: このコードは 1 つのクエリーを生成するのに加え、@posts に含まれる行ごとに 1 つのクエリーを生成します。クエリーごとのオーバーヘッドにより、このコードは極めて重大な問題となる可能性があります。問題の原因は、p.category.name の呼び出しです。この呼び出しは @posts 配列全体ではなく、この特定の投稿オブジェクトにしか適用されません。幸い、この問題は Eager Loading という手段によって解決することができます。

Eager Loading とは、Rails が自動的に、指定された子オブジェクトのオブジェクトをロードするために必要なクエリーを行うという意味です。Rails はその際、JOIN SQL 文や、複数のクエリーが実行されるストラテジーを使用しますが、使用する予定のすべての子オブジェクトを指定すれば、ループの繰り返し処理ごとにクエリーが追加で生成されるという N+1 の状況に陥ることはありません。N+1 問題を回避するためにリスト 1 のコードで Eager Loading を使用した場合、リスト 2 に記載するコードになります。

リスト 2. Eager Loading による最適化後の Post.all コード
<%@posts = Post.find(:all, :include=>[:category]
	@posts.each do |p|%> 
	<h1><%=p.category.name%></h1>
	<p><%=p.body%></p>
<%end%>

上記のコードは、posts テーブルにいくつの行があったとしても、最大で 2 つのクエリーしか生成しません。

当然のことながら、すべてがすべて、このように単純なわけではありません。さらに複雑な N+1 クエリー問題に対処するには、さらに大きな労力を要します。それだけの労力をかける価値はあるのでしょうか。そこで、簡単なテストを行ってみます。

N+1 のテスト

リスト 3 のスクリプトを使用して、クエリーの実行にどれだけ時間がかかるか (あるいはかからないか) を調べることができます。リスト 3 は、スタンドアロンのスクリプトで ActiveRecord を使用してデータベース接続を確立し、テーブルを定義し、データをロードする例です。このスクリプトを実行した後、Ruby の組み込みベンチマーク・ライブラリーを使って、どの手法が速度に優れているのか、そして速度にどれだけの差があるのかを確認することができます。

リスト 3. Eager Loading のベンチマーク・スクリプト
require 'rubygems'
require 'faker'
require 'active_record'
require 'benchmark'

# This call creates a connection to our database.

ActiveRecord::Base.establish_connection(
	:adapter  => "mysql",
	:host     => "127.0.0.1",
	:username => "root", # Note that while this is the default setting for MySQL,
	:password => "",     # a properly secured system will have a different MySQL
                            # username and password, and if so, you'll need to
                            # change these settings.
	:database => "test")

# First, set up our database...	
class Category <  ActiveRecord::Base
end

unless Category.table_exists?
	ActiveRecord::Schema.define do
		create_table :categories do |t|
				t.column :name, :string
		end
	end	
end

Category.create(:name=>'Sara Campbell\'s Stuff')
Category.create(:name=>'Jake Moran\'s Possessions')
Category.create(:name=>'Josh\'s Items')
number_of_categories = Category.count

class Item <  ActiveRecord::Base	
	belongs_to :category	
end

# If the table doesn't exist, we'll create it.

unless Item.table_exists?
	ActiveRecord::Schema.define do
		create_table :items do |t|
				t.column :name, :string
				t.column :category_id, :integer	
		end
	end	
end

puts "Loading data..."

item_count = Item.count
item_table_size = 10000

if item_count < item_table_size
	(item_table_size - item_count).times do
		Item.create!(:name=>Faker.name, 
                 :category_id=>(1+rand(number_of_categories.to_i)))
	end
end

puts "Running tests..."

Benchmark.bm do |x| 
	[100,1000,10000].each do |size|	
		x.report "size:#{size}, with n+1 problem" do 
			@items=Item.find(:all, :limit=>size)
			@items.each do |i| 
				i.category
			end	
		end	
		x.report "size:#{size}, with :include" do 
			@items=Item.find(:all, :include=>:category, :limit=>size)
			@items.each do |i| 
				i.category
			end	
		end	
	end	
end

このスクリプトは、:include 節を使用した Eager Loading を使った場合と、使わなかった場合とで、100、1,000、および 10,000 のオブジェクトのループ処理速度をそれぞれテストします。このスクリプトを実行するには、スクリプトの先頭近くにあるデータベース接続パラメーターを、使用しているローカル環境に応じたパラメーターに置き換えなければならない場合があります。また、test という名前の MySQL データベースを作成することも必要です。さらに、ActiveRecord および fakerr gem も必要になるので、gem install activerecord faker を実行して取得してください。

私のマシンでこのスクリプトを実行したところ、リスト 4 の結果となりました。

リスト 4. Eager Loading のベンチマーク・スクリプト出力
-- create_table(:categories)
	 -> 0.1327s
-- create_table(:items)
	 -> 0.1215s
Loading data...
Running tests...
			user     system      total        real
size:100, with n+1 problem  0.030000   0.000000   0.030000 (  0.045996)
size:100, with :include  0.010000   0.000000   0.010000 (  0.009164)
size:1000, with n+1 problem  0.260000   0.040000   0.300000 (  0.346721)
size:1000, with :include  0.060000   0.010000   0.070000 (  0.076739)
size:10000, with n+1 problem  3.110000   0.380000   3.490000 (  3.935518)
size:10000, with :include  0.470000   0.080000   0.550000 (  0.573861)

すべてのパターンのなかで最も高速だったのは、:include を使用したテストです。具体的には、テストしたそれぞれのオブジェクト数で 5.02 倍、4.52 倍、6.86 倍速くなっています。正確な結果は具体的な状況に依存することは言うまでもありませんが、Eager Loading が顕著なパフォーマンス向上をもたらすことは確かです。

ネストされた Eager Loading

ネストされた関係、つまり関係の関係を参照する必要がある場合を考えてみてください。リスト 5 は、ネストされた関係を参照する一般的な例です。このコードではすべての投稿をループ処理して、投稿した人のアイコンを表示します。ここで、AuthorImage に対して belongs_to の関係を持ちます。

リスト 5. ネストされた Eager Loading の使用事例
@posts = Post.all	
@posts.each do |p|  
	<h1><%=p.category.name%></h1>
	<%=image_tag p.author.image.public_filename %> 
	<p><%=p.body%> 
 <%end%>

このコードには前と同じように N+1 問題がありますが、関係の関係を使用しているため、問題を修正するための構文はすぐにはわかりません。それでは、ネストされた関係にはどのように Eager Loading を適用するのでしょうか。

これに対する正しい答えは、:include 節にハッシュ構文を使用することです。リスト 6 に、ハッシュを使用して、ネストされた Eager Loading を行う例を示します。

リスト 6. ネストされた Eager Loading のソリューション
@posts = Post.find(:all, :include=>{ :category=>[],
                                       :author=>{ :image=>[]}} )
@posts.each do |p|  
	<h1><%=p.category.name%></h1>
	<%=image_tag p.author.image.public_filename %> 
	<p><%=p.body%> 
 <%end%>

上記のように、ハッシュおよび配列リテラルをネストすることができます。この場合のハッシュと配列の違いは、ハッシュではサブ項目をネストできる一方、配列にはそれが不可能であるという点だけです。この点を除けば、どちらも同じです。

間接的な Eager Loading

N+1 問題のインスタンスのなかには、簡単に認識できないものもあります。例えば、リスト 7 はいくつのクエリーを生成するでしょうか。

リスト 7. 間接的な Eager Loading の使用事例
  <%@user = User.find(5)
    @user.posts.each do |p|%>   
      <%=render :partial=>'posts/summary',  :locals=>:post=>p
     %> <%end%>

クエリーの数を判断するには当然、posts/summary の部分に関する知識が必要です。リスト 8 に、この部分を記載します。

リスト 8. 間接的な Eager Loading の部分: posts/_summary.html.erb
  <h1><%=post.user.name%></h1>

残念ながら、リスト 7リスト 8 は、ActiveRecord がすでにメモリー内にある User オブジェクトから自動的に post オブジェクトを生成したにも関わらず、post の行ごとに追加でクエリーを作成して、ユーザーの名前を検索します。要するに、このままの状態では、Rails は子レコードをそれぞれの親に関連付けません。

この問題を解決するには、自己参照 Eager Loading を使用します。基本的に、Rails は親レコードによって生成された子レコードをリロードします。そのため、親と子が全く関係していないかのように、親レコードの Eager Loading を行う必要があるというわけです。この場合のコードは、リスト 9 のようになります。

リスト 9. 間接的な Eager Loading のソリューション
  <%@user = User.find(5, :include=>{:posts=>[:user]})
  ...snip...

直観には反しているものの、この手法は前の手法とほとんど同じように機能します。けれどもこの手法を使うと、階層が複雑な場合をはじめ、過剰にネストしてしまいがちです。リスト 9 に記載したような単純な使用事例であれば問題ありませんが、過剰なネストは問題の原因になることがあります。場合によっては、Ruby オブジェクトを必要以上にロードすることによって、N+1 問題に対処した場合よりも時間がかかるようになるという事態も考えられます。これは特に、すべてのオブジェクトでツリー全体がトラバースされない場合に当てはまります。そのような場合には、N+1 問題に対する別のソリューションを使ったほうが適切かもしれません。

そうしたソリューションの 1 つが、キャッシング手法を使用したソリューションです。Rails V2.1 には単純なキャッシュ・アクセスが組み込まれています。Rails.cache.readRails.cache.write、および関連メソッドを使用すれば、独自の単純なキャッシュ・メカニズムを簡単に作成できるため、バックエンドを単純なメモリー・バック・エンド、ファイル・ベースのバックエンド、または memcached サーバーにすることが可能です。Rails に組み込まれているキャッシング・サポートについての詳細は、「参考文献」セクションを参照してください。ただし、独自のキャッシング・ソリューションを作成するまでのことはありません。事前ビルドされた Rails プラグインとして、Nick Kallen のcache money プラグインなどを使用することができます。ライトスルー・キャッシュを行うこのプラグインは、Twitter で使用されているコードをベースとしています。詳細については「参考文献」を参照してください。

当然のことながら、クエリーの数とは関係のない Rails 問題もあります。

Rails のグループ計算と集約計算

直面する可能性がある問題の 1 つは、データベースで行うべき処理を Ruby で行うことによって発生します。これは、Ruby がいかに強力であるかの証しです。人々が大きな動機もなしにデータベース・コードの一部を進んで C 言語で再実装するのは想像し難いことですが、Rails を使って同じような計算を ActiveRecord オブジェクトのグループで計算するのはわけありません。けれども残念ながら、Ruby はデータベース・コードよりも遅いのが常です。したがって、リスト 10 に記載するような純粋な Ruby 手法を使って計算することは避けてください。

リスト 10. 誤ったグループ計算の実行方法
 all_ages = Person.find(:all).group_by(&:age).keys.uniq
  oldest_age = Person.find(:all).max

Rails では代わりに、一連のグループ関数と集約関数を用意しています。これらの関数は、リスト 11 に記載するように使用します。

リスト 11. 正しいグループ計算の実行方法
 all_ages = Person.find(:all, :group=>[:age])  
  oldest_age = Person.calcuate(:max, :age)

ActiveRecord::Base#find には、SQL を真似るために使えるオプションがいくつもあります。詳細については、Rails のドキュメントを見るとわかります。注目すべき点は、calculate メソッドは、データベースがサポートする有効なあらゆる集約関数 (:min:sum:avg など) と一緒に使えることです。その上、calculate は :conditions などの多数の引数を取ることもできます。Rails のドキュメントで詳細を調べてください。

けれども SQL で実行できることはすべて Rails でも実行できるとは限りません。組み込み SQL で必要を満たせない場合には、カスタム SQL を使用してください。

Rails でのカスタム SQL

人と、その人の職業、年齢、そして過去 1 年間に関係した事故の件数をリストアップしたテーブルがあるとします。この情報を取得するには、リスト 12 に記載するカスタムの SQL 文を使用することができます。

リスト 12. ActiveRecord を使用したカスタム SQL の例
sql = "SELECT profession,
              AVG(age) as average_age,  
              AVG(accident_count) 
         FROM persons 
        GROUP 
           BY profession"

Person.find_by_sql(sql).each do |row|   
  puts "#{row.profession}, " <<
       "avg. age: #{row.average_age}, " <<
       "avg. accidents: #{row.average_accident_count}"
end

このスクリプトによって、リスト 13 のような結果が出力されます。

リスト 13. ActiveRecord を使用したカスタム SQL の出力
  Programmer, avg. age: 18.010, avg. accidents: 9
  System Administrator, avg. age: 22.720, avg. accidents: 8

もちろんこれは単純な例ですが、この例をどんなに複雑な SQL 文にでも拡張可能であることは想像できるはずです。また、例えば ALTER TABLE 文といった別のタイプの SQL 文でも、ActiveRecord::Base.connection.execute メソッドを使用して実行することができます (リスト 14 を参照) 。

リスト 14. ActiveRecord を使用した検索以外のカスタム SQL
  ActiveRecord::Base.connection.execute "ALTER TABLE some_table CHANGE COLUMN..."

列の追加や削除などのスキーマ操作のほとんどは、Rails の組み込みメソッドを使用して行うことができます。けれども必要な場合には、任意の SQL コードを実行することも可能です。

まとめ

あらゆるフレームワークと同じく、Ruby on Rails は然るべき配慮と注意を怠ると、パフォーマンス問題に苛まされる可能性があります。幸い、パフォーマンス問題を監視して修正するための適切な手法は比較的単純で、簡単に習得することができます。複雑な問題でも、ある程度の忍耐、そしてパフォーマンス問題の原因についての知識があれば、解決できるはずです。


ダウンロード可能なリソース


関連トピック


コメント

コメントを登録するにはサインインあるいは登録してください。

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=Open source
ArticleID=513821
ArticleTitle=Ruby on Rails を高速化する
publish-date=07272010