hnwの日記

PHPでAPC/APCuのCAS機能を使うときの注意点

PHPAPC/APCuエクステンションをKVSとして使うときの話題です。


APC/APCuにはCAS(楽観ロックに基づくアトミックな値の書き換え)の関数として次の3つが用意されています。

  • apc_cas ― 古い値を新しい値に更新する
  • apc_inc ― 保存した数値を増やす
  • apc_dec ― 保存した数値を減らす


memcachedにも同様の機能があるので一見すると悩まず使えそうですが、実は罠があります。APC/APCu自体は全ての型の変数値をストアできるのですが、これらの関数については整数しか扱えないため、この機能を使う場合に限っては最初にストアする値を整数型にしておく必要があります。整数型以外の値がストアされていた場合、型エラーとして扱われて失敗します。


これの何が罠かというと、これらの関数がfalseを返したときに、ロック競合だったのか型エラーだったのかの区別が付かないという点です。ロック競合は一時的なエラーなのでリトライする必要がありますが、型エラーは永続的なエラーですのでリトライしても無意味です。エラーへの対応が異なるにもかかわらず、その区別がつかないインターフェースなので、かなり使いにくいと感じます。

楽観ロックと悲観ロック

用語の説明もしておきます。ロック方式の分類として、楽観ロックと悲観ロックという2種類があります。悲観ロックは、要するにRDBで使われるロック方式です。トランザクション処理を行う場合に明示的・暗黙的にロックを取得し、同時に同じロックを取ろうとするとブロックされて待たされます。


一方、楽観ロックは概念的にロックを取得しない*1ような方式です。その代わりに、全員が「以前自分が値を取得したときから他の人が値を更新していなければ、この値に更新したい」というリクエストを投げます。他人と同時にこの処理を行うと、一方の処理が成功し、他方はノンブロッキングで失敗が返ってきます。たとえばmemcachedなどではCASと呼ばれる楽観ロックに基づくアトミックなデータ更新機能を提供しています。

APC/APCuの挙動

ではAPC/APCuの各関数の挙動を見てみましょう。


apc_cas関数は第1引数で指定されたキーに対応する値が第2引数の値と等しければ第3引数の値に書き換える関数です。ここでいう「等しい」とは、元の値が整数型かつ整数としての値が等しいということです。つまり、元の値が整数型でない場合は常に書き換えに失敗します。

<?php
apc_store("foobar", "1"); // 文字列の"1"をストアする
apc_cas("foobar", "1", "2");
echo apc_fetch("foobar"); // "1"のまま、書き換え失敗

apc_store("foobar", 3); // 数値の3をストアする
apc_cas("foobar", 3, 4);
echo apc_fetch("foobar"); // 4、書き換え成功


apc_inc関数やapc_dec関数も内部的に同じ仕組みで値を更新しているため、元の値を文字列にしているような場合は同様にエラーになります。

ハマった内容

memcachedのCAS関数を使う場合、次のようにリトライ処理を書くのが普通です。(以下はMemcached::cas関数のマニュアルにあるサンプルの引用です)

<?php
$m = new Memcached();
$m->addServer('localhost', 11211);

do {
    /* IP リストとそのトークンを取得します */
    $ips = $m->get('ip_block', null, $cas);
    /* リストが存在しない場合はまず作成してからアトミック名追加を行います。
       誰かがすでに追加している場合は失敗します */
    if ($m->getResultCode() == Memcached::RES_NOTFOUND) {
        $ips = array($_SERVER['REMOTE_ADDR']);
        $m->add('ip_block', $ips);
    /* それ以外の場合は、IP をリストに追加して、トークンによる「比較して入れ替え」
       方式で格納します。だれかがリストを更新している場合は失敗します */
    } else { 
        $ips[] = $_SERVER['REMOTE_ADDR'];
        $m->cas($cas, 'ip_block', $ips);
    }   
} while ($m->getResultCode() != Memcached::RES_SUCCESS);

僕はAPC/APCuの関数群も同じノリで使えると思い込んでいたので、成功するまでビジーループするような処理を書いていました。

<?php
// インクリメントを確実に1回行う(期待通りには動かない)
do {
  $status = apc_inc("foobar");
} while ($status === false);


しかし、初期値として数値文字列をストアしてしまっていたために無限ループさせてしまいました。PHPではクエリパラメータやDBから取得した値など整数の情報であっても文字列型で扱われることがあるので、十分起こりうるミスだと思います。


もっとも、このような処理はシステムグローバルのキャッシュ(別サーバのmemcachedなど)で行うのが普通でしょうから、WebサーバローカルのキャッシュでCAS関数が必要な状況というのは考えにくいかもしれません。とはいえ、もし使うなら注意が必要ですよ、という話題でした。

*1:実装上はごく短い区間のロックを取ります