PHP
Cache

Cache利用時にCache揮発直後のスパイクを避ける方法

More than 1 year has passed since last update.

Cache利用ポリシーについての一般論

適切なCache利用はシステム全体の性能を上げることに非常に効果的です。また、うまくCacheを利用することによって、スケール対応しにくい部分の負荷を逃がすこともできるようになります。
CDNの利用も含めて、積極的にCacheを利用したいケースとして以下のような例が挙げられます。

  • [前提条件]Cacheのヒット率が高いケース

    • マスタデータの参照など、実行時にほとんど結果が変わらないケース
    • どのユーザーに対してもほぼ同じページを返答して良いケース
  • [前提条件]Cacheが古くても大きな問題とならないケースまたは古い状態のCacheの更新が出来るケース

    • Cache保持期間を短くすることである程度解消可能
    • DBを更新した時にCacheの内容も書き換える事ができるケース(Read Through Cacheとして利用している場合は、Cacheの削除だけで良い。)
  • リソース負荷が大きい箇所に適用するケース

    • レイテンシの高いシステムとの結合が発生するケース
    • スループットの低いシステムとの結合が発生するケース
    • CPUリソースを大きく消費する処理が発生するケース

前提条件として、Cacheのヒット率が高いケースとしましたが、逆にCacheの保持期間を長くすることでCacheのヒット率を上げるというアプローチは多くの場合アンチパターンですので注意して下さい。

  • 保持期間を長くしても、線形にしかヒット率は上昇しない
  • ヒット率の低い無駄なデータでシステムリソース(Memory/Storage)が占有されてしまう
  • Cacheのミスヒット時にもCacheの参照コスト、更新コストは発生してしまう
  • 保持期間を長くすることで古いデータにアクセスする不整合状態が発生しやすくなる

逆に、ヒット率が高いデータを対象とする場合は、1秒~10秒間といった非常に短い時間のCache保持でも十分な効果が発生しますので、アプリケーションの設計時にはいかにしてCacheのヒット率を上げるかが重要になります。

例: 秒間1,000アクセスのある処理を1秒間保持するCache利用に変更した場合、(適切な利用をしていれば)該当処理の実行回数は1,000rps→1rpsへと激減する。

Read through Cacheにおける、Cacheミスヒットのスパイク対策について

Cacheを利用することでシステムの特定部分の負荷を分散させ構成を組んだとします。
しかしタイムアウト設定などでChacheが揮発してしまった瞬間に大量のアクセスがそれまで迂回させていた重い処理の部分に流れ込むことがあります。

例: 秒間1,000アクセスのある処理のレイテンシが1秒かかる場合に、Cacheが揮発して利用できなかった場合には該当処理の実行回数は1,000rpsのリクエストが流れ込む。
もし、上記の処理が10秒かかる場合には同時に10,000リクエストが流れ込む

Cacheが適切に利用されていた場合には、同時に1リクエストしか発生しないリクエストが、10,000リクエストが流入してしまいシステムがハングアップするといったことが発生します。

こういった症状が発生した場合には、Cacheの更新作業は同時に1リクエストしか発生しないようにCache更新処理に入る前に該当のキーをロックする機構をアプリケーション側で追加することを検討して下さい。
また、Cacheの保持期限をCacheのデフォルトのものとは別に設定することにより、その時間を経過した最初のリクエストのみCacheの更新を試みて、他のリクエストは既にあるCacheのデータを利用するということを実装可能です。

cache_1.png
cache_2.png
cache_3.png
cache_4.png

PHPからMemcacheを利用するサンプルだと以下のとおりです。

MemcacheWithLockSample.php

<?php
    /**
     * レイテンシの大きな処理をCache利用することで迂回するサンプル
     */

    // Cacheの生存期間は20秒であるが、10秒経過した時点でCacheの更新を行うことができるようになる
    $mc = new MemcacheWithLock("localhost:11211", 20, 10);


    // まずCacheから取得
    if(!$value = $mc->get("random_value")){
        // Cacheに無かった場合は関数実行
        $value = someHighLatencyFunc();
        // Cacheに格納
        $mc->set("random_value", $value);
    }
    echo $value."\n";


    /**
     * レイテンシの大きな処理
     */
    function someHighLatencyFunc(){
        sleep(5);
        return rand(0,10000);
    }

    /**
     * Memcacheラッパークラス
     * 仮想的なロック機構対応版、ロックを取得できなかったプロセスはキャッシュ上のデータを参照する
     * ロック時間を設けることで、ロックし続ける事を防止する
     * このサンプルでは、falseの値をCacheするとキャッシュにヒットしなかったことになることに注意
     */
    class MemcacheWithLock{
        private static $mc = null;
        private $expire_sec = 0;
        private $lock_sec   = 0;

        function __construct($memcache_hosts, $expire_sec, $lock_sec){
            $this->expire_sec = $expire_sec;
            $this->lock_sec   = $lock_sec;

            if(!self::$mc){
                $mc = new Memcache();
                $memcache_hosts = explode(",", $memcache_hosts);
                foreach($memcache_hosts as $memcache_host){
                    list($host,$port) = explode(":", $memcache_host);
                    $mc->addServer(trim($host), trim($port));
                }
                self::$mc = $mc;
            }
        }

        public function set($key, $value){
            // memcacheに格納するデータに、ロック時間を付加する
            $_with_lock_value = array(
                'unlock_time' => time() + $this->lock_sec,
                'value'       => $value,
            );
            return self::$mc->set(
                $key,
                $_with_lock_value,
                0,
                time() + $this->expire_sec
            );
        }

        public function get($key){
            if($_with_lock_value = self::$mc->get($key)){
                if(
                   isset($_with_lock_value['value']) &&
                   isset($_with_lock_value['unlock_time'])
                ){
                    $unlock_time = $_with_lock_value['unlock_time'];
                    $value       = $_with_lock_value['value'];

                    if ($unlock_time > time()) {
                        // ロック中はそのまま値を返答する
                        return $value;
                    } else {
                        // ロックが終了していたら、再ロックをかけるが、そのプロセスにおいては取得失敗とし、再度データを取得する
                        // 他のプロセスは残りのlock期間は継続してデータ取得が可能
                        $this->set($key, $value);
                        return false;
                    }
                }
            }
            // 最初は空のロックだけを作成する
            $this->set($key, false);
            return false;
        }
        public function del($key){
            return self::$mc->delete($key);
        }
    }

※このサンプルプログラムにおいても、Cacheそのものが揮発して存在しなかったケースにおいては、複数のリクエストが同時にキャッシュデータの再取得処理に流れますが、それはその前に一定時間該当のキャッシュの更新が発生しなかったケースにおいて発生しますので、その状態から急に大量アクセスが発生するというケースをレアケースとして許容しています。
このケースが問題になる場合には、ロック中でデータ取得ができなかった時に一定時間Sleepして再度キャッシュからの取得を試みるSpin Lock機構を実装して下さい。