天然パーマです。

Webアプリにおけるキャッシュ。オレオレ事例

Webアプリにおいて、アクセスやデータ量が多く/大きくなってくると、 バックエンドのパフォーマンスが低下しがちです。 MySQLなどのRDBMSにデータを置いている場合は適切に クエリーを改善する、インデックスを張る、といった策で解決する場合もありますが、 キャッシュを効果的に利用することでより高負荷に対応できる可能性があります。 また、外部APIへの問い合わせなど、どうしてもネットワークや他のリソースのレスポンスタイムに 引きずられる部分に関しては情報を手元にキャッシュしておくと何かとよいでしょう。

今回はWebアプリケーションのレイヤーで最近僕がどのようにキャッシュを使っているのか? の事例を紹介しつつまとめてみたいと思います。



キャッシュについてとその基本

そもそもキャッシュとは、簡単にふわっと表現するならば、 「一時的に情報を手元の近い場所に置いておいて利用する手法、もしくはその一時データ」 と捉えることができます。 拙作の「Webサービスのつくり方」にも書きましたが、 例で説明するのがてっとり早いし、具体的な使い方が分かるでしょう。

この「ゆーすけべー日記」のAtomフィードをどこかのアプリケーションで利用するとして以下のようなコードを書きます。parse_feed っていうのが出て来ますがこれはフィードをパースするためのメソッドが定義されていると仮定してください。

use LWP::Simple qw/get/;

...;

my $content = get('http://yusukebe.com/atom.xml');
my $data = parse_feed($content);
print $data->title . "\n";

もし、更新を逐一知りたかったらこのコードを改良していけばいいのですが、 例えば、エントリーのタイトルをとあるWebアプリのサイドバーに一覧表示させたいなんて時があります。 アクセスの度にユーザーエージェントが(この場合はLWP::Simpleのgetメソッドを使って行っている)、 僕のブログにアクセスしてAtomフィードをとってくることになるのですが以下のことが分かります。

  • ネットワークを使って外部に、つまり yusukebe.com にアクセスするので時間がかかる
  • 短い頻度で更新されているわけではないので、毎回情報を更新させる必要がない

そこで、キャッシュを使います。例えば30分間情報をキャッシュさせるとなるとこのようなフローになります。

  1. フィードコンテンツがキャッシュされているか?をチェックする
  2. キャッシュされていればそのデータをそのまま使って、終わり
  3. キャッシュが無ければ yusukebe.com からフィードを取得
  4. 有効期限を30分として、キャッシュをセットする
  5. 取って来たデータを使って、終わり

キャッシュを実装する時にはまずは「get」「set」「delete」辺りのメソッドを覚えておけばいいのですが、 それを使って疑似コードで表現するとこんな感じになります。

use LWP::Simple qw//; # get メソッドが紛らわしいのでエクスポートしない

...;

my $key = 'key_of_feed'; # キャッシュのキーを予め変数に
my $data = $cache->get($key); # getメソッド
unless($data) {
    # キャッシュが無ければ
    my $content = LWP::Simple::get('http://yusukebe.com/atom.xml'); 
    $data = parse_feed($content); # フィードをフェッチしてパースする
    $cache->set($key, $data, 60 * 30); # キー、値、有効期限の秒数、この順番でset
}
do_something($data); # 何かする

memcachedなどのをバックエンドにしたキャッシュでは、このようにキャッシュを保持する有効期限を決めることが出来、上記のフローはキャッシュを使うに当たっての定石かと言えます。キャッシュについてとその実装を軽く説明したところで事例に入りましょう。

何をキャッシュするか?それが重要だ

キャッシュは上記のように簡単に実装できる反面、少しでも複雑なシステムだと「何をキャッシュするか?」を 賢く設計しないと痛い目にあいます。本来のデータがDBに入っているとして、DBのデータとキャッシュとの整合性が取れなくなるのです。例えば、アプリ側で表示しちゃいけない情報があって「DB側で消してるのになんで出てくるんだこれ...」と思ったらキャッシュされてたなんてことに陥ります。

こうした悩みを持たないためにも考慮しているのは、キャッシュの粒度です。 なるべく大きなオブジェクトをキャッシュすることで、キャッシュの管理コストを下げるという方針ですね。

これもアプリケーションの性質にだいぶ左右されてしまう問題ですが、とあるエロサイトでは更新が少ないために、 SQLのクエリーで言う

SELECT * FROM entry WHERE actress = '成瀬心美' ORDER BY created_on DESC LIMIT 20;

の結果をそのまま有効期限付きでキャッシュに突っ込んでおります。 サイトの特性上これで十分ですし、サーバリソースを減らせることになります。

また更新がある程度多いサイトでは、 キャッシュするオブジェクトを精査し、ここはMySQLから引っ張って来てあれはキャッシュだなと 区分しています。例えばソートが必要になるケースでは

  • 「SELECT id FROM 〜」の部分を有効期限付きでキャッシュ
  • idに対応するRowをDBと一部キャッシュから取得する
  • Rowが参照している他のRowを取得して利用可能なさらに大きいオブジェクトにする
  • 同時に随所でinflateがかかるようにする
  • 配列にしてコントローラやテンプレートに渡す

といった具合です。例を出せないので分かりにくいかもですが、更新が早いサイトでは、 どこでキャッシュをするのか?を判断するのが難しく、またキャッシュをしなくても良いくらい パフォーマンスを出せるクエリーを考える必要が出てきそうです。

参考ページ

ページのHTMLをキャッシュする

キャッシュする粒度の話をしましたが、その対象をより大きく捉えるという意味で アプリで生成するHTML自体をキャッシュするのは非常に有効だと思います。 例えばサイトのサイドバーなど更新の少なく共通で使われる部分は積極的にHTMLにしてキャッシュした方がいいでしょう。

僕がWeb Application Frameworkとして使っているMojoliciousでは、 フックとテンプレートレンダリングの機能を使って実現することが出来ます。 例えば「MyApp::Web.pm」などで

package MyApp::Web;

sub startup {
    my $self = shift;

    ...;

    $self->hook(
        before_dispatch => sub {
            my $c = shift;
            my $key = "key_of_sidebar";
            my $html = $cache->get($key);
            unless($html) {
                ...;
                $html = $c->render_partical('layouts/right_container');
                $cache->set($html, 60 * 30);
            }
            $self->stash->{right_container} = $html;
        }
    );
}
...;

とし、テンプレート内では「right_container」という名の変数をエスケープせずそのまま展開すればOKです。 今回はアプリケーションレイヤーでHTMLを読み込んでますが、書き出してフロントに近いところでインクルードしてもいいですね。

透過的キャッシュを自作する

DBとのやり取りを吸収してくれる「O/R Mapper」に「意識せずともキャッシュされる」機能が付いてくる場合もあります。「透過キャッシュ」という言葉がふさわしいか自信ないですが、今回はそう呼ばせてください。

こうした透過キャッシュは便利ですが、上記で述べたキャッシュによる不整合を招く弊害があるので、一概にオススメできません。ただ、エロサイトの例のような更新が少ない簡単なアプリケーションには有効です。僕はこの透過的なキャッシュを自作して利用しています。O/R Mapperなどに投げるクエリーをハッシュ関数へ通してキーにして、結果をキャッシュするという戦略です。これも疑似コードを見てみましょう。get_entries がエントリー一覧を取得するためのメソッドで「条件」と「オーダーなどのオプション」を引数に取ります。make_key サブルーティンでハッシュ関数に通してキーを生成しています。

use Digest::SHA1 qw/sha1_hex/;
use Storable qw/nfreeze/;
...;

sub get_entries {
    my ($cond, $attr) = @_;

    my $key = make_key('entry', $cond, $attr);
    my $rows = $cache->get($key);
    return $rows if $rows;

    $rows = $db->search('entry', $cond, $attr);
    $cache->set($key, $rows, 60 * 30);
    return $rows;
}

sub make_key {
    my @data = @_;
    return sha1_hex(nfreeze({ data => \@data }));
}

...;

ちなみに、DBIx::Class::Cursor::Cachedというモジュールを参考にしてキー作成等を実装しています。

遅延キャッシュ

Webアプリケーションからソート済みの「エントリー」一覧を取得したいとします。 ソート結果を出すには数秒かかってしまうために、キャッシュを利用して結果を30分間有効にします。 一番最初のアクセスはしかたないにしろ、「数秒」かかってしまいます。 そして当然ですが、 30分の有効期限が切れた瞬間の次にアクセスしたクライアントにはまた「数秒」待ってもらうことになります。

サービスによってはこの「数秒」が大きくなるかもしれませんし、そもそも運悪く遅いページを見てしまうユーザーもいるかもしれません。また複数のアクセスに対してキャッシュ生成ロジックが無駄に平行して走る可能性もあります。

この辺り、まだ「ベストプラクティス」と言えないのですが、JobQueueと呼ばれる仕組みを使い遅延させてキャッシュを生成する方法を試しています。こんな流れです。

  • クエリーを実行するためのワーカーをスタンバイさせておく
  • サイトにアクセスが来る
  • キャッシュがあれば返却
  • 無ければ、ワーカーに生成を依頼
  • ワーカーが作ったオブジェクトはマスターに加えて「もう一つのキー」で保持させる
  • Web側は「もう一つのキー」で取っておいたキャッシュの結果を返却
  • 生成中のフラグ処理等...

つまり、ワーカー側では結果をマスターとスレーブの様な感じのキー2つにキャッシュさせておき、 マスターは有効期限を付けておく。そして、有効期限が切れたときにはすぐさまワーカーに生成を依頼すると同時に、スレーブにあるキャッシュを返すのです。

このキャッシュを遅延させる手法、ある程度うまくいっている感じはするものの、ぶっちゃけ、cronなどを使ったバッチでいいんじゃないか?という気がしていますw



まとめ

最近どのようにキャッシュと戯れているかを書いてみました! やはり管理が大変なので、使いどころをわきまえるのが大切ですね。 とはいえ、あからさまに「そこはキャッシュしておくところだ...」というツッコミたい時もあったりするので、 参考にしてください!