この記事はBEAR.Sunday Advent Calendar 2017 - Qiita 20日目の記事、@kaliboraさんのBEAR.Sunday のリソースキャッシュを試してみたの続きです。

デフォルトのキャッシュエンジン

と、その前に私がハマったところ

内部で使用されるキャッシュ機構のデフォルトがDoctrine\Common\Cache\ArrayCache

キャッシュストレージのデフォルトは確かにPHPのArrayCacheですが、これはprod-stage-ではない、開発のコンテキストで実行されているためです。

ProdModuleでのデフォルトはProdCacheProviderが束縛されていて、ProdCacheProviderではAPC+ファイルキャッシュのチェーンキャッシュが束縛されています

変更の多い開発時にはキャッシュ無効にしています。リソースキャッシュは複数のWebサーバー使用時にはRedisやmemcacheなどのストレージが必要ですが、1つの場合にはAPC+ファイルキャッシュでも機能します。

キャッシュ戦略

コンピュータサイエンスには、キャッシュ無効化と名前付けという2つの困難な問題があります。

-- フィル・カールトン

キャッシュの無効化の制御で一番ポピュラーなのは時間による制御(TTL)です。

アクセスの多いコンテンツは時間キャッシュが有効に機能します。秒間100アクセスのあるサイトに1秒間のキャッシュを設定するとヒット率は99%、コンテンツのディレイも最大1秒ですみます。しかし時間を10倍の10秒にするとタイムラグは10倍になりますが、ヒット率は99.9%と0.9%しか上がりません

アクセスは少ないが数が多くなるロングテールのコンテンツは時間によるキャッシュがうまく機能しません。コンテンツに変更がないのにも関わらず、アクセスがないとコンテンツが再生性されてしまいます。その結果むしろキャッシュが無い方がパフォーマンスがよくなるともあります。

RESTメソッドを使ったキャッシュ更新は「更新系のメソッドが呼ばれたタイミングでパージされるという、とても理にかなった挙動」が可能になります。

onGet()メソッドはGETリクエストでは実行されないのが理想

更新系のメソッドで必ずキャッシュが生成されるなら、GETリクエストではonGet()メソッドの中身は実行されません。キャッシュが返るため、onGet()中のメソッドが実行されないからです。 POSTやPUTのリクエストで読み込み用のコンテンツを作るためにOnGet()メソッドの中身が実行されます。

キャッシュの理想

理想的なリソースキャッシュを考えてみましょう。

  1. 変更があった時だけそれを検知して再生性される
  2. 揮発性は低く、データ構造が変わらない限り(deployなどしても)破壊されない
  3. キャッシュ生成の日付を知ることができる(Last-Modifiedヘッダー)
  4. HTTPクライントがすでにそのコンテンツを持っている時は、キャッシュで返すのではなく変更が無いということを知らせることができる。(304 Not Modified)
  5. キャッシュは本質的な関心事ではないため、アプリケーションロジックのコードの中には出現しない。(AOP)

2は以下のようにリソースキャッシュのバージョンの指定をしてキャッシュの破壊を指定できます。詳しくはマニュアルのproductionをご覧ください。

$this->install(new CacheVersionModule('1')); 

3 HTTP/1.1 RFC 2616ではHTTP/1.1 serversは可能な限りLast-Modifiedヘッダーを付加すべきとしていますが(それはそうだと思います。ファイルにファイルスタンプがあるように)多くのダイナミックなページが対応できていません。それを実現します。

An origin server SHOULD obtain the Last-Modified value of the entity as close as possible to the time that it generates the Date value of its response. This allows a recipient to make an accurate assessment of the entity's modification time, especially if the entity changes near the time that the response is generated.
HTTP/1.1 servers SHOULD send Last-Modified whenever feasible.

(キャッシュというより、永続的な読み込み専用のコンテンツデータ、つまりCQRSのクエリーリポジトリです。
1-5は現在のBEAR.Sundayで対応しています。

キャッシュの理想(2)

この機能はまだ実現されていません。アイデアレベルのものです。

コンテンツに変更が無い時にフレームワークレベルで304 Not Modifiedを返すことができたり、GETリクエストの時には期限の無いキャッシュを返すだけというのはパワフルな機能ですが、究極的にはコンテンツに変更が無い時にはPHPが動作しないことが理想です。

そのためにPHPの前段にVarnishなどのcache機能を持つリバースプロキシを立てます。インターセプターでリソースキャッシュを生成する代わりに(あるいは同時に)Varnishにコンテンツを生成するようにします。コンテンツは(ログイン時のユーザー情報などを含んだ)動的な構成も含まれるでしょうからその場合には、ESI(Edge Side Includes)を使いリソースを合成します。

これは例えばTwitterのタイムラインのもののような更新度が極端に高いものには不可能です。しかしブログやECサイトなど個別ページではアクセスの少ない(しかし全体としては多い)ロングテールのコンテンツでもフルに活用できれば、従来の単なるTTL制御のキャッシュと合わせてパフォーマンス的には最上なシステムになると思います。

任意のタイミングでのキャッシュのパージ

例えばクーポンというリソースがあり、これに有効期限があるとします。
このリソースに isExpired という readonly な boolean のフィールドがあった場合、
このフィールドに変更があるのは有効期限を過ぎたタイミングです。
ですから、そのタイミングでキャッシュをパージして欲しい。

「isExpired という な boolean のフィールド」をセットするリソースがあればメソッド内でisExpired=trueの時にインジェクトしたQueryReposityを使ってpurgeすればどうでしょか?

class Foo
{
    private $queryRepository;

    public function __construct(QueryRepositoryInterface $queryRepository)
    {
        $this->queryRepository = $queryRepository;
    }

    /**
     * isExpiredの変更をするリソースのメソッド
     */
    public function onPut()
    {
        if ($isExpired) {
            $this->queryRepository->purge(new Uri('app://self/coupon/?id={id}', ['id' => 1]));
            $this->queryRepository->purge(new Uri('page://self/coupon/?id={id}', ['id' => 1]));
        }
    }
}
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.