php-memcachedの高速かつ安定した運用方法

「今更需要ねえよ」「APC/APCuじゃダメなの？」「Redis使えば？」というマサカリが飛んできそうですが、php-memcachedの教導について細かく調べる機会があったので、ここにまとめておきます。

## インストール方法
```bash
apt-get install php5-memcached
echo "extension=memcached.so" >> /path/to/php.ini
```

## 接続プーリング
　php-memcachedにはコネクションをプーリングしておく機能が用意されています（http://php.net/manual/ja/memcached.construct.php ）。
　そこそこのリクエストがあるサーバーの場合、都度接続にしていると期待通りのパフォーマンスを発揮しないことがあります（参考：http://faultier.blog.jp/archives/1732537.html ）。
　AWSのElasticacheも最大接続数は65000くらいなので、webサーバー100台で1台24個のphpプロセスを立ち上げるとしても、接続数は2400。適切にスケールしてネットワークI/O負荷を分散していれば、理論的には接続可能なコネクション数です。

```php
$memcached = new Memcached('connection-pool');

// コネクションプーリングを行う場合、php-fpmプロセスが再起動するまでサーバーやオプションが維持されるため、最初のnewの時だけaddServersとsetOptionsを行うようにする
if (count($memcached->getServerList()) === 0) {
    $memcached->addServers($confid['servers']);
    $memcached->setOptions($confid['options']);
}
```

## 再接続の設定
　上記の接続プーリングを行うと、特定のmemcachedに繋がらなくなった時に、そのプロセスについてそれ以降のリクエストも常にmemcachedに繋がらなくなってしまいます。

```php
$memcached = new Memcached('connection-pool');
$memcached->get('key1'); // false（接続不能）. 次同じphpプロセスにあたった場合、必ずfalseが返る.
$memcached->getResultCode(); // 35:指定されたmemcachedサーバーが接続不能状態 or 47:一時的にmemcachedサーバーに繋がらない
```

　この現象を回避するには、下記のような手段が有効です。

```php
$memcached = new Memcached('connection-pool');
if (count($memcached->getServerList()) === 0) {
    $memcached->addServers($confid['servers']);
    // 1
    $memcached->setOption(Memcached::OPT_SERVER_FAILURE_LIMIT, 3);
}

// 2
$data = $memcached->get('key1');
if ($memcached->getResultCode() === Memcached::RES_NOTFOUND) {
    return null; // データ発見できず
}
// 3
if ($data === false && $memcached->getResultCode() !== Memcached::RES_SUCCESS) {
    // $memcached->quit(); 不要
    return null; // 接続に失敗したので、キャッシュ以外から取り直してね
}

```

　上記のコードは以下のようなことしています。

### 1. OPT_SERVER_FAILURE_LIMITの設定
　Memcachedをnewしたあと、オプションにOPT_SERVER_FAILURE_LIMITを設定しています。
　このオプションを設定することで、~~quit()で切断/再接続した時に何回まで接続失敗を許容するかを指定できます。~~指定回数分だけは接続に失敗しても、自動で再接続してくれます（2016/02/17 12時修正）。
　この例の場合、3回までは接続に失敗しても再接続が可能になります。

### 2. RES_NOTFOUNDのハンドリング
　memcachedからデータをgetしたあと、ステータスコードがRES_NOTFOUNDなら、キーが登録されていないのでnullを返しています。
　これは厳密にいえば例外投げるなどでnullが登録されている時と、キーが見つからなかった時とを適切にハンドリングすべきですが、そのへんはプロダクトの開発ポリシーで決めていいかもしれません。

### 3. RES_SUCCESS、RES_NOTFOUND以外の時に接続を切断する
　処理が成功しなかった時のハンドリングです。
　接続に失敗した場合、OPT_SERVER_FAILURE_LIMITを設定していれば、自ら$memcached->quit()しなくても勝手に再接続してくれます。

　厳密に処理するなら、単純な接続失敗時に返ってくるレスポンスコードは `47 = MEMCACHED_SERVER_TEMPORARILY_DISABLED` が `OPT_SERVER_FAILURE_LIMIT` の設定回数続いたあと、`35 = MEMCACHED_SERVER_MARKED_DEAD` が返ってくるようになります。
　`35 = MEMCACHED_SERVER_MARKED_DEAD` が返ってくるようになると、もうmemcached-client側で「死んでるサーバー」と認定されている状態なので、phpプロセスが再起動するまで再接続ができなくなります。
　なので、より厳密にエラーハンドリングしたい方は `47 = MEMCACHED_SERVER_TEMPORARILY_DISABLED` だけをハンドリングするようにしたほうが安全かもしれません。

　あと、ここでもnullを返していますが、ここも接続エラーなどの例外を投げてcatchするほうが自然な感じがします。

## memcached切断時の単体テスト
　PHPUnitでmemcached切断時の単体テストを書こうと試みたのですが、結論から言うとダメでした。

### 試みた方法その１：runkitを使う
「runkitでMemcachedのgetResultCodeを書き換えればいけんじゃね？」と思ったのですが、runkitでは組み込み関数を書き替えることはできても、組み込みクラスやpeclクラスの書き換えができないらしく、失敗に終わりました。

### 試みた方法その２：memcachedが立ってないサーバーをaddServerする
「memcachedが立ってないport/hostとかをaddServerすれば、擬似的に接続失敗状態を再現できんじゃね？」と思って試みたのですが、半分成功して半分ダメでした。
　確かに期待通り接続失敗状態を擬似再現することは可能だったのですが、死んでるサーバーをaddした場合、resetServerListで不要なサーバーを消さないと、以降のmemcachedを使ったテストが通らなくなってしまいます。
　しかし、libmemcachedのバージョンによってはresetServerListを呼び出すとsegfaultが起きるらしく（https://github.com/php-memcached-dev/php-memcached/issues/79 ）、自分の環境依存問題にハマってしまって諦めました。

## 総括
長々書いた内容をまとめると、下記のようになります。

* memcachedには接続プーリング（というか、接続の永続化）を行うべき。めっちゃ速くなる。
* ↑のようなプーリングを行う場合、接続失敗時に再接続できるようにしておくこと。

Enjoy, PHP!