memcached/APCとネームスペース、トランザクション

PHPでmemcacheやAPCを使ったキャッシングを色々試してみて、気付いた事をメモ。

memcacheはクエリで取得した値のキャッシュに使い、それ以外の色々な事にはAPCを使うのが良いような気がする。APCはmemcacheより10倍程高速なようだが(Cache Performance Comparison | MySQL Performance Blog)、httpd再起動で消えてしまう為、大量にキャッシュして使いたいデータベースのキャッシュにはちょっと(PHP等の設定をよく変更するのでhttpdは結構再起動する)。memcacheはやや遅いが、データベースの負荷を軽減するのに良い感じ。

以下は、キャッシュ使ってて気付いた、ネームスペースとトランザクション絡みの考察。

ネームスペース

memcachedAPCは、ネームスペースのような機能をサポートしていない。あるオブジェクトをキャッシュしようと思えば、キーに紐付けて保存することになる訳だが、このままだと、例えば、あるテーブルに対するクエリの結果をキャッシュしようとした場合に、プライマリキーを利用してキーとし、クエリの結果オブジェクトをキャッシュに保存していく、といった使い方になる。ここで問題となるのが、複数のテーブルの行をキャッシュしていった場合に、あるテーブルに対応したキャッシュだけを削除したい場合にどうするのか?という点。キーを指定して保存/取得/削除する事しか出来なければ、複数のアプリケーションから同じmemcachedを使っていた場合等にも、キャッシュの特定の部分を纏めて削除する、といった事が出来ず使い勝手が悪い。

この問題を解決する為に、以下の方法で擬似的にネームスペースを実装してやることが出来る。

これにより、例えば、テーブル名に対応したネームスペース以下に、キーに対応したクエリ結果オブジェクトを保存してやり、削除時には、キーによる削除に加えて、ネームスペースを指定しての、ネームスペース以下の全削除も可能となる。

方法は、下記memcachedのFAQに載ってるそのまんま。

FAQ / memcached

PHPの場合、memcache用の下記のようなクラスを作れば、

class MyMemcache
{
    private $option = array('host' => 'localhost',
                            'port' => 11211,
                            'timeout' => null,
                            'cache_compress' => false,
                            'cache_expire' => 0);
    private $memcache = null;

    public function __construct()
    {
        $this->memcache = new Memcache();
        $this->memcache->connect($this->option['host'],
                                 $this->option['port'],
                                 $this->option['timeout']);
    }
    
    public function loadCache($namespace, $key)
    {
        $keyName = $this->createCacheKey($namespace, $key);
        return $this->get($this->memcache, $keyName);
    }

    public function saveCache($namespace, $key, $value, $option = null)
    {
        $keyName = $this->createCacheKey($namespace, $key);
        return $this->set($this->memcache,
                          $keyName,
                          $value,
                          $option !== null ? $option : $this->option);
    }

    public function deleteCache($namespace, $key = null)
    {
        if ($key === null) {
            return $this->increment($this->memcache, $namespace, 1);
        } else {
            $keyName = $this->createCacheKey($namespace, $key);
            return $this->delete($this->memcache, $keyName);
        }
    }

    public function flushCache()
    {
        $this->flush($this->memcache);
    }

    private function set(Memcache $memcache, $key, $item, $option)
    {
        return $memcache->set($key, $item,
                              $option['cache_compress'],
                              $option['cache_expire']);
    }

    private function get(Memcache $memcache, $key)
    {
        return $memcache->get($key);
    }

    private function delete(Memcache $memcache, $key)
    {
        return $memcache->delete($key);
    }

    private function increment(Memcache $memcache, $key, $value){
        return $memcache->increment($key, $value);
    }

    private function flush(Memcache $memcache){
        return $memcache->flush();
    }

    private function createCacheKey($namespace, $key)
    {
        $namespaceKey = $this->get($this->memcache, $namespace);
        if ($namespaceKey === false) {
           
            $namespaceKey = 0;
            $this->set(
                $this->memcache,
                $namespace,
                $namespaceKey,
                $this->option);
        }
        $k = is_object($key) ? sha1(serialize($key)) : sha1($key);
        return "$namespace:$namespaceKey:$k";
    }
}

$m = new MyMemcache();
$m->saveCache('hogeNamespace', 'fooKey', 'foo'); //hogeNamespaceにfooKeyで'foo'保存
$m->saveCache('hogeNamespace', 'barKey', 'bar'); //同様に'bar'保存
$m->saveCache('fugaNamespace', 'fooKey', 'foofoo'); //fugaNamespaceにfooKeyで'foofofo'保存
$foo = $m->loadCache('hogeNamespace', 'fooKey'); //hogeNamespaceのfooKeyの値を取得
$bar = $m->loadCache('hogeNamespace', 'barKey'); //同様にbarKeyの値を取得
var_dump($foo); //string 'foo'
var_dump($bar); //string 'bar'
$m->deleteCache('hogeNamespace'); //hogeNamespaceを削除
$foo = $m->loadCache('hogeNamespace', 'fooKey'); //削除されたnamespaceから値を取得
$bar = $m->loadCache('hogeNamespace', 'bar');
var_dump($foo); //boolean false
var_dump($bar); //boolean false
$foofoo = $m->loadCache('fugaNamespace', 'fooKey'); //fugaNamespaceのfooKeyの値を取得
var_dump($foofoo); //string 'foofoo'
$m->deleteCache('fugaNamespace', 'fooKey'); //fugaNamespaceからキーを指定して削除
$foofoo = $m->loadCache('fugaNamespace', 'fooKey');
var_dump($foofoo); //boolean false

これで、上記のように、ネームスペースを指定して保存/取得/削除が出来るようになる。

ポイントは、ネームスペースに対応したキーに適当な整数値を保存しておく点。これをバージョンナンバーとして使い、キャッシュにアクセスする際に、キーの一部にネームスペースとバージョンナンバーを含めておく。そして、あるネームスペースを削除したい場合は、当該ネームスペースのバージョンナンバーをインクリメントする。これにより、取得時に新たなバージョンナンバーがキーに含まれるため、古いキーの値にはアクセス出来なくなる。実際にキャッシュからデータが削除される訳ではないが、キャッシュの容量が一杯になれば、使用されていないキーから消されいくので、放って置けば良い。

以上はmemcachedの場合だが、APCでも同様にネームスペースを実装出来る。(APCにincrementメソッドは無いので、一旦取得して値をインクリメント後、保存するようなincrementメソッドを自前で用意すればOK。)

トランザクション

キャッシュにクエリの結果を保存するような使い方をしている場合、更新系クエリの後に、キャッシュの値も追加/更新/削除するように実装すると思うのだが、当たり前ながら、トランザクション中で更新を行っている場合、キャッシュの方もコミット後にまとめて更新しなければならない。ここで少し気を付けないといけないのが、トランザクション中で更新を行った時点からコミットまでの間の、影響の受けるキャッシュの値に扱いについて。

例えば、あるテーブルのid=2の値をキャッシュしてあったとして、これをトランザクション内で更新したとする。そのトランザクション内で、再度id=2の値を検索した場合、更新後の値を取得出来なければならないが、コミットまでの間、トランザクションの外からは古い値が見えないといけない。この為には、コミットまでの間、キャッシュには更新後の値も更新前の値も残っていないようにするか、もしくは、キャッシュには更新前の値を残すが、トランザクション内からは更新後の値が取得出来るように細工しなければいけない。後者はやや複雑になるのに比べ(キャッシュを扱うクラス内でバッファリングしておいてゴニョゴニョするとか)、前者は更新時点で影響を受けるキャッシュを一旦削除するだけで済み簡単である。キャッシュを削除するので、コミットまでの間、トランザクション内/外どちらからもデータベースへのアクセスが必要になってしまうが、これは仕方ないものとする。


整理すると、

  • 更新系クエリ発行
    1. 影響を受けるキャッシュを削除する。
    2. 更新内容をバッファリングする。
  • 参照系クエリ発行
    1. キャッシュが存在すればそれを使用する。
    2. さもなければ、データベースにアクセスして値を取得し、取得内容をバッファリングする。
  • コミット後
    1. バッファリングした処理をまとめてキャッシュに書き出す。


先程のMyMemcacheのコードに下記のように追加/変更を行う。saveCache()内ではsetは行わず、delete後バッファに処理を追加している。また、deleteCache()内でも、delete/increment後にバッファに処理を追加する。これで、トランザクション開始前にclearBuffer()、コミット後にwriteBackCache()を行えばOK。ちなみに、delete/incrementも(一旦実際に削除したのにも関わらず)バッファリングしておかなければならない。さもないと、例えばあるキーをsaveCache()後にdeleteCache()した場合に、writeBackCache()時点で、saveCache()の分の書き戻しでキャッシュに値が保存されてしまう。

    private $buffer = array();

    public function saveCache($namespace, $key, $value)
    {
        $keyName = $this->createCacheKey($namespace, $key);
        $this->delete($this->memcache, $keyName);
        $this->buffer[] = array('method' => 'save',
                                'key' => $keyName,
                                'value' => $value);
    }

    public function deleteCache($namespace, $key = null)
    {
        if ($key === null) {
            $deleted = $this->increment($this->memcache, $namespace, 1);
            $this->buffer[] = array('method' => 'increment', 'key' => $namespace, 'value' => 1);
            return $deleted;
        } else {
            $keyName = $this->createCacheKey($namespace, $key);
            $deleted = $this->delete($this->memcache, $keyName);
            $this->buffer[] = array('method' => 'delete', 'key' => $keyName);
            return $deleted;
        }
    }

    public function clearBuffer()
    {
        $this->buffer = array();
    }

    public function writeBackCache()
    {
        $result = true;
        foreach ($this->buffer as $buf) {
            if ($buf['method'] == 'save') {
                $saved = $this->set($this->memcache,
                                    $buf['key'],
                                    $buf['value'],
                                    $this->option);
                if(!$saved) {
                    $result = false;
                }
            } elseif ($buf['method'] == 'delete') {
                $this->delete($this->memcache, $buf['key']);
            } elseif ($buf['method'] == 'increment') {
                $this->increment($this->memcache, $buf['key'], $buf['value']);
            }
        }
        $this->buffer= array();
        return $result;
    }