ratio - rational - irrational

« 鬼頭莫宏はむしろ高河ゆん | Main | 鬱 »

2007年07月16日

Railsの画面生成を10倍高速化する方法

RailsでPageキャッシュをより広く活用する方法を考えてみました。以下、ちょっと長く前置きが続きます。

* Rails遅杉

Railsは遅い。何が遅いって、Rubyが遅くてRoutingが遅くてRDBとRHTMLが遅い。RDBが遅いのは大抵のWebアプリケーションでは変わらない話、で、だからRailsなんかが評価される余地があるんだよね。RubyやRHTMLの遅さは柔軟性の代償として受け入れよう。なにしろRDBがもともと遅いんだから。ただ、Routingは無駄に高機能だったりして頭にくる。Rhino on RailsのSteve YeggeもRoutingは黒魔術だと言っていたし。私はActionPackの全てが黒魔術だと思うけど。

そういう訳で、RoutingをCで書き直すのはドリコムのみなさんがいつかやってくれると期待するとして(可能なら手伝いたいけどね)、当面の対応としてはキャッシュ、キャッシュ、キャッシュだ。

* Pageキャッシュ

Railsには3種類のキャッシュが備わっている。詳しいことは我らが舞波が遥か昔に通り過ぎた道なのでそちらを参照。

1つ言えるのは、Pageキャッシュは極めて効果的ということだ。何故か。答えは簡単で、Railsを通らないから。LighttpdにせよApache にせよ標準的な設定をしておけば、Pageキャッシュが存在するときにフロントのWWWサーバーはRailsプロセスを呼ばずに自分でキャッシュを読む。だから速い。Routingすら通らないから。これに対して他の2種類のキャッシュは少なくともRoutingのコストだけは掛かってしまう。だから、 Railsアプリケーションが遅かったら極力Pageキャッシュを使うのが定石だ。

* ログイン管理よ呪われよ

ところが、Pageキャッシュの使えない局面がある。ユーザーのログイン状態を反映してページのヘッダ部分に「ようこそ○○さん」とか書いてある場合だ。これをPageキャッシュしてしまうとおかしなことになる。ログインしてもキャッシュされているページだけは名前が表示されないとか、最悪なのはログイン状態がキャッシュされてしまって、誰がアクセスしても「ようこそ舞波さん」とか出力されてしまう場合だ。だから仕方がなくPageキャッシュを放棄する。

これが、ログインしないと見られないページならまだ諦めは付く。けれども、「ログイン」リンクと「ようこそ○○さん」が切り替わるだけだったらどうだろう。この数文字だけが動的で、他は静的なページ。そのためだけにRoutingのコストを支払うのか。

ここにサンプルで作ってみたアプリケーションがある。acts_as_authenticatedでログイン機能をscript/generateして「ようこそ○○さん」を表示するだけのものだ。

  • ログイン前: 20070716-without_name.png
  • サインアップ: 20070716-signup.png
  • ログイン後: 20070716-with_name.png

良くあるでしょ? こういうしょうもないアプリケーション。ほとんど全ての画面の共通ヘッダにこういうログイン名表示があって、だからapp/views/layout/application.rhtmlにログイン名表示ロジックが書いてあるの。前に仕事で作ったアプリケーションも全画面の半分ぐらいはこんなのだ。

<%- if logged_in? -%>
  <h1>ようこそ<%= current_user.login %>さん</h1>
<%- else -%>
  <p><%= link_to 'ログイン', :action => 'login'  %></p>
<%- end -%>
  <hr />
<%= @content_for_layout %>

こういうしょうもない3ヵ月で使い捨てられるアプリケーションこそRailsの得意領域だったはずなのに、情けない。

* PHP! PHP!

そういうわけで、そこはPHPで処理すれば良いんではないかと。可変部分が少ないのにわざわざRoutingするというのが問題であったのだ。可変部分が少ないならそこはRubyで書かなくてもたぶんそんなに大変じゃない。

Apache ━ (SSI) ┳ (mod_rewrite) ┳ (mod_proxy_balancer) ━ mongrel_cluster
               ┃               ┃                            │   
               ┃               ┗ キャッシュファイル         │   
               ┗ mod_php5                                   │   
                     │                                      │   
                     └───→ [Memcached] (session情報) ←──────┘

で、こんな風にしてみた。Apache 2系統だとRailsの出力結果にもSSIを適用できるから便利便利。

* apacheの設定

<Proxy balancer://mongrel>
  BalancerMember http://localhost:8000
  BalancerMember http://localhost:8001
  BalancerMember http://localhost:8002
  Allow from all
  SetOutputFilter INCLUDES
</Proxy>
 
NameVirtualHost *
<VirtualHost *>
        DocumentRoot /path/to/app/public
        # 以下、普通のvirtual hostの設定
        # (略)...

        <Directory />
         Options FollowSymLinks
         AllowOverride None
        </Directory>
        <Directory /path/to/app/public>
         Options Indexes FollowSymLinks MultiViews Includes
         AllowOverride None
         Order allow,deny
         allow from all
        </Directory>

        RewriteEngine on
        RewriteRule ^/?$ index.html [QSA]
        RewriteRule ^([^.]+?)/?$ $1.html [QSA]

        RewriteCond %{DOCUMENT_ROOT}%{REQUEST_FILENAME} !-f
        RewriteRule ^(.*)$ balancer://mongrel%{REQUEST_URI} [P,QSA]
        AddOutputFilter INCLUDES .html
</VirtualHost>

* app/views/layout/application.rhtml

<html>
  <head>
    <title>test</title>
  </head>
  <body>
    <!--#include virtual="/header.php"-->
    <%= @content_for_layout %>
  </body>
</html>

* public/header.php

<?php
  $memcache = new Memcache;
  $memcache->pconnect('localhost', 11211) or die('cannot connect');
  $json = $memcache->get("session:" . $_COOKIE['_simple_layout_session_id']);
  $hash = json_decode($json);
  $user_name = $hash->user_name;
  if ($user_name) {
?>
   <h1>ようこそ<?php echo($user_name) ?>さん</h1>
<?php } else { ?>
  <p><a href="/account/login">ログイン</a></p>
<?php } ?>
<hr />

* lib/authenticated_system.rb

acts_as_authenticatedが生成したもの。current_user=を次のように改変。

def current_user=(new_user)
   if new_user.nil? || new_user.is_a?(Symbol)
     session[:user_name] = session[:user] = nil
   else
     session[:user] = new_user.id
     session[:user_name] = new_user.login
   end
   @current_user = new_user
 end

* lib/memcache_json.rb

gem 'ruby-json' rescue nil
require 'memcache'
require 'json/objects'
require 'json/lexer'

class MemCache
  module Marshal
    def self.load(key)
      value = JSON::Lexer.new(key).nextvalue
      if value.kind_of?(Hash)
        if value.key?('flash')
          value['flash'] = ActionController::Flash::FlashHash.new.update(value['flash'])
        end
        value.update(value.symbolize_keys)
      end
      value
    end

    def self.dump(value)
      value.to_json
    end
  end
end

ポイントは最後のmemcache_json.rbで、これをconfig/environments.rbから読み込んでる。通常、sessionに情報を保存したときmemcachedにはMarshal.dumpした文字列が入る。けれども、Marshal.dump形式ではPHPと共有が難しいのでJSONで保存することにした。 この代償として数値、文字列、ハッシュ、配列以外は保存できなくなってしまったけれども、私はどうせPlainなデータ以外Sessionに入れないからOK。あんまり複雑なデータ構造をSessionに突っ込むとライフサイクル管理が面倒だからたぶんこのほうがいい。

* ベンチマーク

まあ、そういう訳で、種も仕掛けもございません。ログイン状態のクッキーを付けてでApache Benchに掛けてみた。

* キャッシュなし

キャッシュしないで普通にLayoutしたもの。

Concurrency Level:      100
Time taken for tests:   52.251849 seconds
Complete requests:      10000
Failed requests:        6782
   (Connect: 0, Length: 6782, Exceptions: 0)
Write errors:           0
Non-2xx responses:      6782
Total transferred:      11815306 bytes
HTML transferred:       9829388 bytes
Requests per second:    191.38 [#/sec] (mean)
Time per request:       522.519 [ms] (mean)
Time per request:       5.225 [ms] (mean, across all concurrent requests)
Transfer rate:          220.82 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.3      0       3
Processing:     1  518 343.9    476    6106
Waiting:        1  518 343.9    476    6106
Total:          1  518 343.9    477    6106

Percentage of the requests served within a certain time (ms)
  50%    477
  66%    665
  75%    790
  80%    847
  90%    980
  95%   1083
  98%   1232
  99%   1298
 100%   6106 (longest request)

* Fragmentキャッシュ

@content_for_layoutの生成部分はまるまるFragmentキャッシュにして、layoutだけ掛けたもの。今回はロジックがほとんど存在しないのであまり変わらない。
Concurrency Level:      100
Time taken for tests:   54.66335 seconds
Complete requests:      10000
Failed requests:        6680
   (Connect: 0, Length: 6680, Exceptions: 0)
Write errors:           0
Non-2xx responses:      6680
Total transferred:      12000400 bytes
HTML transferred:       9999080 bytes
Requests per second:    184.96 [#/sec] (mean)
Time per request:       540.663 [ms] (mean)
Time per request:       5.407 [ms] (mean, across all concurrent requests)
Transfer rate:          216.75 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.3      0       3
Processing:     2  536 435.9    446    6897
Waiting:        1  536 435.9    446    6897
Total:          2  536 436.0    446    6897

Percentage of the requests served within a certain time (ms)
  50%    446
  66%    609
  75%    753
  80%    880
  90%   1137
  95%   1358
  98%   1583
  99%   1758
 100%   6897 (longest request)

* Pageキャッシュ+PHP

今回のトリックを施した結果。
Concurrency Level:      100
Time taken for tests:   7.530995 seconds
Complete requests:      10000
Failed requests:        0
Write errors:           0
Total transferred:      22410000 bytes
HTML transferred:       20630000 bytes
Requests per second:    1327.85 [#/sec] (mean)
Time per request:       75.310 [ms] (mean)
Time per request:       0.753 [ms] (mean, across all concurrent requests)
Transfer rate:          2905.86 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.7      0       9
Processing:    12   74  26.9     71    1133
Waiting:       11   73  26.2     71    1133
Total:         16   74  26.8     71    1133

Percentage of the requests served within a certain time (ms)
  50%     71
  66%     74
  75%     76
  80%     77
  90%     85
  95%     94
  98%    106
  99%    137
 100%   1133 (longest request)

* 結論

Request per secondが 191.38 [#/sec] => 184.96 [#/sec] => 1327.85 [#/sec]。Fragmentキャッシュのほうが遅いのはご愛嬌。

ま、厳密な測定じゃないけど、確実に速くはなるらしい。ということで、近いうちに会社でも試してみる。

* 追記

To: yamaz
SSIのincludeを使ってphpを併用させてる.rhtmlで直接phpを吐き出して処理する方法を模索したいのです。

それは今日は作成が間に合わなかったので、「後半に続く」。



トラックバック

この記事のトラックバックpingのURL:
http://idm.s9.xrea.com/blog/mt-tb.cgi/640

Ping元:Your RoR(rails) app’s view x10 faster

概要:
I found usefull info to tune your Rails app. Yugui suggests the way to accelarate rails’ view x10 faster.(Railsの画面生成を10倍高速化する方法) He says Ruby on Rails generates views slowly because of Routing. So he proposes this comb...
Weblog:
hidetox.com blog
日時:
2007年09月25日 05:01

Ping元:続・Railsの画面生成を10倍高速化する方法: フィルタ編

概要:
さて、昨日はSSIとの組み合わせでPageキャッシュの適用範囲を広げる話をした...
Weblog:
ratio - rational - irrational
日時:
2007年09月25日 11:10

コメント

一歩進めてrhtmlをphpとして出力して,フロントのapacheでキャッシュ+phpの処理をさせる方法を模索したいのです.

  • Posted by: yamaz at 2007年07月16日 23:45

tumblrの方法てどうですか?
たぶん、ページ全体はページキャッシュを使いつつ、ログインユーザにだけ表示される動的なところだけiframeで読み込みしてると思うのですけど。

  • Posted by: のりお at 2007年07月17日 02:27

> yamazさん
今コンパイル中なのでしばしお待ちを。今日明日ぐらいには何か書きます。

> のりおさん
それもいいですし、スクリプトで書き換えてもいいんですけど、今回はこだわりとしてクライアントの環境に依存したくないんですよ。Lynxでもおk、みたいな。

そして何より、mongrelに行くリクエストの数をとにかく減らしたい。ものすごく私固有の話ですけど、今mongrelの負荷を減らせばサーバーの台数減らせそうなので。

  • Posted by: Yugui at 2007年07月17日 03:08

mod_php5の資料がHandler経由のやつばっかりだったので、こいつをFilterにしてやれば良いのかと思った訳でした。

で、ソースを見てみるとexperimentalだけど既にapache2filterは実装されていて、という訳で、これでうまく行くかどうか、現在、mod_php5をコンパイル中なのです。

一箇所パッチ当てればうまく行く予感。

  • Posted by: Yugui at 2007年07月17日 03:47

おいらは良くcache do のお世話になっております。
phpは使わない方法で、loginname等の動的な場所以外を
cache "hogehoge" do
end
等とくるんでしまってますねぇ。
これでも結構高速化。
expireを忘れるとちょと悲しい事になるけど。

  • Posted by: shachi at 2007年07月17日 09:22

> shachiさん
それをやったのがベンチマークの2番目で、cache do end内を生成するのにRHTMLロジックやましてSQLクエリが走る場合は効くんですけど、今回みたいなのは、まあ意味ないですよね。

で、PHPにしてみたらそれより1桁速かったよ、という話です。わかりにくくてすみません。

  • Posted by: Yugui at 2007年07月17日 10:21

 実験乙!ログイン後のホームページとかでは確かに有効だけど、さて、具体的なCRUD操作になったときはどうしよう、というのが課題かな。つまり、もしも実際に実行されるアクションの比率が、ホーム : CRUD = 1:9 ぐらいだったら、頑張った割に総合的には報われなかった、というのが怖い気がしまんた。「いやいや、そこでこれでつよ!」という後半の日記はこの後すぐ!禿げ上がるほど期待age!!

  • Posted by: 舞波 at 2007年07月17日 11:18

だとすれば、単純にRailsのsessionの遅さかもー。

  • Posted by: shachi at 2007年07月17日 11:53

新しくコメントをつける

よくわからない理由により、コメントが即座には反映されないかもしれませんか゛、ボタンを押して元の画面に戻ってきたならたぶん正しく送信されています。




blog操作

検索


カテゴリー

このブログについて

あわせて読みたい

follow yugui at http://twitter.com
© 2007 Yugui

Powered by Movable Type 3.2-ja-2