世界線航跡蔵

Mad web programmerのYuguiが技術ネタや日々のあれこれをお送りします。

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を吐き出して処理する方法を模索したいのです。

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

トラックバック

http://yugui.jp/articles/642/ping
Your RoR(rails) app&#8217;s view x10 faster (hidetox.com blog)
I found usefull info to tune your Rails app. Yugui suggests the way to accelarate rails&#8217; view x10 faster.(Railsの画面生成を10倍高速化する方法) He says Ruby on Rails generates views slowly because of Routing. So he proposes this comb...
続・Railsの画面生成を10倍高速化する方法: フィルタ編 (ratio - rational - irrational)
さて、昨日はSSIとの組み合わせでPageキャッシュの適用範囲を広げる話をした...

コメント

yamaz (2007年07月16日 23時45分37秒)
<p>一歩進めてrhtmlをphpとして出力して,フロントのapacheでキャッシュ+phpの処理をさせる方法を模索したいのです.</p>
のりお (2007年07月17日 02時27分57秒)
<p>tumblrの方法てどうですか?<br />たぶん、ページ全体はページキャッシュを使いつつ、ログインユーザにだけ表示される動的なところだけiframeで読み込みしてると思うのですけど。</p>
Yugui (2007年07月17日 03時08分45秒)
<p>&gt; yamazさん<br />今コンパイル中なのでしばしお待ちを。今日明日ぐらいには何か書きます。</p> <p>&gt; のりおさん<br />それもいいですし、スクリプトで書き換えてもいいんですけど、今回はこだわりとしてクライアントの環境に依存したくないんですよ。Lynxでもおk、みたいな。</p> <p>そして何より、mongrelに行くリクエストの数をとにかく減らしたい。ものすごく私固有の話ですけど、今mongrelの負荷を減らせばサーバーの台数減らせそうなので。</p>
Yugui (2007年07月17日 03時47分16秒)
<p>mod_php5の資料がHandler経由のやつばっかりだったので、こいつをFilterにしてやれば良いのかと思った訳でした。</p> <p>で、ソースを見てみるとexperimentalだけど既にapache2filterは実装されていて、という訳で、これでうまく行くかどうか、現在、mod_php5をコンパイル中なのです。</p> <p>一箇所パッチ当てればうまく行く予感。</p>
shachi (2007年07月17日 09時22分44秒)
<p>おいらは良くcache do のお世話になっております。<br />phpは使わない方法で、loginname等の動的な場所以外を<br /> cache &quot;hogehoge&quot; do<br /> end<br />等とくるんでしまってますねぇ。<br />これでも結構高速化。<br />expireを忘れるとちょと悲しい事になるけど。</p>
Yugui (2007年07月17日 10時21分30秒)
<p>&gt; shachiさん<br />それをやったのがベンチマークの2番目で、cache do end内を生成するのにRHTMLロジックやましてSQLクエリが走る場合は効くんですけど、今回みたいなのは、まあ意味ないですよね。</p> <p>で、PHPにしてみたらそれより1桁速かったよ、という話です。わかりにくくてすみません。</p>
舞波 (2007年07月17日 11時18分23秒)
<p> 実験乙!ログイン後のホームページとかでは確かに有効だけど、さて、具体的なCRUD操作になったときはどうしよう、というのが課題かな。つまり、もしも実際に実行されるアクションの比率が、ホーム : CRUD = 1:9 ぐらいだったら、頑張った割に総合的には報われなかった、というのが怖い気がしまんた。「いやいや、そこでこれでつよ!」という後半の日記はこの後すぐ!禿げ上がるほど期待age!!</p>
shachi (2007年07月17日 11時53分15秒)
<p>だとすれば、単純にRailsのsessionの遅さかもー。</p>
blog comments powered by Disqus

ご案内

前の記事
次の記事

タグ一覧

過去ログ

  1. 2016年07月
  2. 2016年01月
  3. 2015年09月
  4. 2015年08月
  5. 過去ログ一覧

フィード

フィードとは

その他

Powered by "rhianolethe" the blog system