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して「ようこそ○○さん」を表示するだけのものだ。
ログイン前:
サインアップ:
良くあるでしょ? こういうしょうもないアプリケーション。ほとんど全ての画面の共通ヘッダにこういうログイン名表示があって、だから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
それは今日は作成が間に合わなかったので、「 後半に続く 」。