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

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