RailsでPageキャッシュをより広く活用する方法を考えてみました。以下、ちょっと長く前置きが続きます。
Railsは遅い。何が遅いって、Rubyが遅くてRoutingが遅くてRDBとRHTMLが遅い。RDBが遅いのは大抵のWebアプリケーションでは変わらない話、で、だからRailsなんかが評価される余地があるんだよね。RubyやRHTMLの遅さは柔軟性の代償として受け入れよう。なにしろRDBがもともと遅いんだから。ただ、Routingは無駄に高機能だったりして頭にくる。Rhino on RailsのSteve YeggeもRoutingは黒魔術だと言っていたし。私はActionPackの全てが黒魔術だと思うけど。
そういう訳で、RoutingをCで書き直すのはドリコムのみなさんがいつかやってくれると期待するとして(可能なら手伝いたいけどね)、当面の対応としてはキャッシュ、キャッシュ、キャッシュだ。
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で処理すれば良いんではないかと。可変部分が少ないのにわざわざRoutingするというのが問題であったのだ。可変部分が少ないならそこはRubyで書かなくてもたぶんそんなに大変じゃない。
Apache ━ (SSI) ┳ (mod_rewrite) ┳ (mod_proxy_balancer) ━ mongrel_cluster
┃ ┃ │
┃ ┗ キャッシュファイル │
┗ mod_php5 │
│ │
└───→ [Memcached] (session情報) ←──────┘
で、こんな風にしてみた。Apache 2系統だとRailsの出力結果にもSSIを適用できるから便利便利。
<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>
<html>
<head>
<title>test</title>
</head>
<body>
<!--#include virtual="/header.php"-->
<%= @content_for_layout %>
</body>
</html>
<?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 />
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
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)
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)
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キャッシュのほうが遅いのはご愛嬌。
ま、厳密な測定じゃないけど、確実に速くはなるらしい。ということで、近いうちに会社でも試してみる。
SSIのincludeを使ってphpを併用させてる.rhtmlで直接phpを吐き出して処理する方法を模索したいのです。
それは今日は作成が間に合わなかったので、「後半に続く」。
tumblrの方法てどうですか?
たぶん、ページ全体はページキャッシュを使いつつ、ログインユーザにだけ表示される動的なところだけiframeで読み込みしてると思うのですけど。
> yamazさん
今コンパイル中なのでしばしお待ちを。今日明日ぐらいには何か書きます。
> のりおさん
それもいいですし、スクリプトで書き換えてもいいんですけど、今回はこだわりとしてクライアントの環境に依存したくないんですよ。Lynxでもおk、みたいな。
そして何より、mongrelに行くリクエストの数をとにかく減らしたい。ものすごく私固有の話ですけど、今mongrelの負荷を減らせばサーバーの台数減らせそうなので。
mod_php5の資料がHandler経由のやつばっかりだったので、こいつをFilterにしてやれば良いのかと思った訳でした。
で、ソースを見てみるとexperimentalだけど既にapache2filterは実装されていて、という訳で、これでうまく行くかどうか、現在、mod_php5をコンパイル中なのです。
一箇所パッチ当てればうまく行く予感。
おいらは良くcache do のお世話になっております。
phpは使わない方法で、loginname等の動的な場所以外を
cache "hogehoge" do
end
等とくるんでしまってますねぇ。
これでも結構高速化。
expireを忘れるとちょと悲しい事になるけど。
> shachiさん
それをやったのがベンチマークの2番目で、cache do end内を生成するのにRHTMLロジックやましてSQLクエリが走る場合は効くんですけど、今回みたいなのは、まあ意味ないですよね。
で、PHPにしてみたらそれより1桁速かったよ、という話です。わかりにくくてすみません。
実験乙!ログイン後のホームページとかでは確かに有効だけど、さて、具体的なCRUD操作になったときはどうしよう、というのが課題かな。つまり、もしも実際に実行されるアクションの比率が、ホーム : CRUD = 1:9 ぐらいだったら、頑張った割に総合的には報われなかった、というのが怖い気がしまんた。「いやいや、そこでこれでつよ!」という後半の日記はこの後すぐ!禿げ上がるほど期待age!!
だとすれば、単純にRailsのsessionの遅さかもー。
よくわからない理由により、コメントが即座には反映されないかもしれませんか゛、ボタンを押して元の画面に戻ってきたならたぶん正しく送信されています。
一歩進めてrhtmlをphpとして出力して,フロントのapacheでキャッシュ+phpの処理をさせる方法を模索したいのです.