Object Cacheは難しい

インフラ担当のHARAIです。WordPressには Object Cache というキャッシュのAPIが用意されています。このAPI、仕様は比較的単純なのですが、正しく使うのは難しいものとなっています。本記事では、Object Cacheの何が問題なのか、どう使うべきかを解説します。

キャッシュは難しい

キャッシュの難しさについて語る際には、Phil Karltonの言葉がよく引き合いに出されます。

There are only two hard things in computer science: cache invalidation and naming things.

計算機科学で難しいのは、キャッシュの無効化とネーミングだけらしいです。

実際、多数のプロセスから同時に利用されるキャッシュは競合状態 (race condition) により予想外の挙動を示すことがあり、正しく実装するのは容易ではありません。

キャッシュは正しく動作しているかどうか確認することも困難です。多くのバグは複数のプロセスがあるタイミングで同時アクセスした場合にのみ起こり、再現性はありません。ログから調査しようにも、キャッシュはアクセスが多すぎて、すべてのログを保管するのは不可能です。

加えて、問題が起こった際には調査よりも復旧が優先されることが多いのも原因究明を難しくしています。

弊社が管理するWordPress環境下でも、キャッシュが関係していると見られる問題を経験したことが何度かあり、中には原因究明ができず調査を断念したケースもあります。

対象とする構成

この記事では、つぎのような構成のWordPress環境を考えます。

本記事で前提とする構成

クライアントから送信されたリクエストは、Nginxを経由してPHP-FPM上で動いているWordPressに渡されます。WordPressは必要に応じてデータベースのMySQLとObject Cacheの実装(バックエンド)であるRedisを読み書きします。PHP-FPMのプロセスは複数動いているので、MySQLやRedisも複数のプロセスから同時に読み書きされる可能性があります。

cache-asideパターン

Object Cacheはいろいろな用途に使えますが、この記事では特にデータベースのクエリ結果を保持する役割に焦点を当てたいと思います。

Object Cacheが想定するキャッシュの利用のしかたは、cache-asideパターンと呼ばれています。このパターンの特徴は、アプリケーション(Object Cacheの場合はWordPressコアやプラグイン等)が直接データベースとキャッシュの双方と接続することです。

cache-asideパターン

キャッシュとデータベースをどう利用するかは、すべてプログラムに委ねられています。Object Cacheはいろいろな用途に使えると述べましたが、それはまさにcache-asideパターンを採用しているためです。多くの場合、キャッシュはデータベースへのクエリー結果を利用しやすい形式で保持しますが、データベースとは関係なくキャッシュ単独で利用することも可能です。逆に、あえてキャッシュは利用せずデータベースに直接問い合わせることも可能です。cache-asideパターンはこのようにアプリケーション側で使い方を細かく制御できる反面、誤った使い方により問題を起こす危険性も高くなっています。

ちなみに、cache-asideパターンではないキャッシュとしてもっとも一般的なのはCDNでしょう。CDNは直接オリジンと接続し、キャッシュが存在しない場合には自らリクエストを転送します。これはread-throughパターンと呼ばれます。

read-throughパターン

不変キャッシュ

キャッシュは難しいと書きましたが、難しくないキャッシュがあります。それは値が不変 (immutable) なキャッシュです。一度あるキーに値が設定されると二度と値を書き換えないような使い方のキャッシュでは、キャッシュの状態は「値が存在する」か「キャッシュがない」の2種類しかなく、競合状態とは無縁です。

実は、不変キャッシュという概念はWeb技術者の間ですでに普及しています。JavaScriptやCSSなどのリソースファイルのURLに ?v=123456 のようなバージョンを示す文字列を付加することがありますが、これはCDNやローカルのキャッシュを不変キャッシュとして扱っているともいえます。URLというキーに対するレスポンス値は変化することがない(厳密には、変化するときにはもう参照元のページは存在しないので変化しないと考えてよい)ため、キャッシュの中身をどう変更、無効化するかということを考える必要がありません。

不変キャッシュは理想的ですが、すべての場面で使えるわけではありません。

可変キャッシュ

キーに対する値を固定する不変キャッシュは多くの場合都合が悪く、後から値を書き換える可変 (mutable) のキャッシュのほうがずっと一般的です。

可変キャッシュは柔軟性が高い半面、使い方が難しくさまざまな問題を引き起こします。

本記事の議論の中心となるのは可変キャッシュですので、特に断りがない限りキャッシュといえば可変キャッシュを指すこととします。

キャッシュにおける「正しさ」

本記事では、キャッシュの「正しさ」を、「キャッシュの状態とデータベースの状態が短時間で整合すること」とします。一時的にキャッシュとデータベースが異なる状態になったとしても、数秒から数十秒以内に整合するのなら正しいとします。

set/deleteパターン(正しさ: ×)

Object Cacheで採用されているのはcache-asideパターンであると書きましたが、このパターンはさらに細かく分類が可能です。その中でもっとも一般的なのが、MySQL(データベース)読み取り時にRedis(キャッシュ)に値をセットし、MySQL更新時にRedisの値を削除するパターンです。特に名前は付いていないようですので、ここではset/deleteパターンと呼ぶことにします。

このパターンはWordPressコアでも多用されているパターンです。データ取得時と更新時のコードは以下のようになります。

取得時

データの取得時は、まずRedisに問い合わせます。Redisに値があった場合にはその値を利用し、なければMySQLに問い合わせます。

MySQLに問い合わせた場合には、値を利用する前にRedisの値を更新します。こうすることで、次回はMySQLに問い合わせる必要がなくなりパフォーマンスの向上が期待できます。

function get_a_value( $key ) {
    $value = wp_cache_get( $key, 'group' );
    if ( false !== $value ) {
        // Redisに値があった
        return $value;
    }

    // Redisに値がなかったのでMySQLから取得
    $value = get_from_database( $key );
    // Redisに値を設定
    wp_cache_set( $key, $value, 'group' );

    return $value;
}
キャッシュに値が入っていた場合の流れをシーケンス図で表すとつぎのようになります。

set/deleteパターンで取得時、Redisに値が入っていた場合

青色のマーカーは値を保持している状態を意味します。この図では、 wp_cache_get() によってRedisから値を取得したので、その後PHP-FPMプロセスでは値を保持し続けるという意味になります。

RedisやMySQLが長期間値を保持し続けるのに対し、PHP-FPMではリクエストの処理が終わるとプロセス内の値はすべて削除されます。したがって、PHP-FPM内の値の生存期間はRedisやMySQLよりも短くなっています。

Redisに値が入っていない場合の流れは、少し複雑になります。

set/deleteパターンで取得時、Redisに値が入っていなかった場合

MySQLから取得した値を、PHP-FPMプロセスを介してRedisにセットしています。この処理により、PHP-FPMでリクエストの処理が終了した後もRedis内で値が保持され続けるようになります。

更新時

データの更新時は、まずMySQL内の値を更新し、続いてRedis内の値を削除します。

function update_a_key( $key, $value ) {
    // MySQLの値を更新
    update_database( $key, $value );
    // Redisの値を削除
    wp_cache_delete( $key, 'group' );
}

シーケンス図は以下のようになります。

set/deleteパターンの更新時

橙色のマーカー青色のマーカーとは異なる値を保持している状態を意味します。PHP-FPM内の値(橙色)はすでに更新済みなので、最初からRedisやMySQLに格納されている値(青色)とは異なります。update_database() により、MySQLの値はPHP-FPMと同じ値(橙色)に書き換わります。また、wp_cache_delete() により、Redisの値(青色)は削除されます。

問題点

一見正しく思えるこの処理ですが、実は問題があります。

取得と更新の処理がほぼ同じタイミングで行われた際に、

  1. 取得側でMySQLから値を取得した直後に
  2. 更新側でMySQLの更新とRedisの値の削除が行われ
  3. その後取得側でRedisに値が設定された

場合、MySQLには新しい値が格納されているのにRedisには古い値が格納されたままという現象が起こります。

シーケンス図で示すと以下のようになります。

set/deleteパターンで起こりうる競合状態

一度この問題が起こると、再度値が更新されるか何らかの理由でRedisの値が削除さるれまで、RedisとMySQLの整合性が取れない状態は続きます。set/deleteパターンは正しくないわけです。

なお、 wp_cache_set()wp_cache_add() で置き換えた実装もWordPressコア内のコードで見かけます。ここではadd/deleteパターンと呼びましょう。 wp_cache_set() が無条件で値をセットするのに対し、 wp_cache_add() は値が存在しない場合にのみセットする関数です。こちらもset/deleteパターンと同様の問題を抱えています。

もしかしたら、add/deleteパターンでは「キャッシュに手が加えられていない場合」保存するという条件を付けたかったのかもしれません。これは後述するように発想としては正しいのですが、この実装では残念ながらうまく行きません。なぜかというと、Redisにまったく手が加えられていない場合と、Redisの値が明示的に削除された場合の区別ができないからです。これはABA問題に似ています。

set/setパターン(正しさ: ×)

set/deleteやadd/deleteとよく似たパターンでset/setパターンというものもあります。こちらは、更新時にRedisの値を削除するのではなく、新しい値で上書きします。また、set/deleteに対してadd/deleteがあったように、set/setに対してadd/setというパターンもあります。

set/set, add/setパターンもWordPressコアで使われています。ここまで紹介した各パターンは、何かしらの基準で使い分けられているわけではないようです。

取得時のコードはset/deleteパターンと同一ですので、更新時の処理のみ紹介します。

更新時

データの更新時は、まずMySQLの値を更新し、続いて同じ値でRedisを更新します。Redisの値を同時に更新することで、MySQLの値を更新した後も古い値が返され続けることがないようにします。

function update_a_key( $key, $value ) {
    // MySQLの値を更新
    update_database( $key, $value );
    // Redisの値も更新
    wp_cache_set( $key, $value, 'group' );
}

シーケンス図はつぎのようになります。

set/setパターンの更新時

問題点

set/set, add/setパターンもset/delete, add/deleteパターンと同じ問題を抱えていますが、それだけではなく複数の更新処理が同じタイミングで発生しただけでも問題が起こる可能性があります。

あるPHP-FPMプロセスがMySQLの値を更新した後、Redisの値を更新するまでの間に別のPHP-FPMプロセスがMySQLの値の更新とRedisの値の更新を実行すると、MySQLとRedisの値が整合しないままになります。

茶色のマーカー橙色のマーカー青色のマーカーとは異なる値を保持している状態を意味します。

特に更新が頻繁に起こるキーの場合、問題となる可能性があるでしょう。これは、取得が頻繁にあるキーの場合はキャッシュにヒットする可能性も高いためリスクが上がらないのとは対照的です。

どちらもキャッシュの使い方としては正しくありませんが、set/deleteとset/setであればset/deleteのほうがまだ安全性が高い印象を受けます。

placeholderパターン(正しさ: ◯、Object Cache APIの拡張が必要)

それでは、どうすれば安全にキャッシュを書き換えられるのでしょうか? 残念ながら、現在のObject Cache APIの枠組みでは、100%安全にキャッシュを書き換える方法は存在しないようです。

しかしながら、 wp-content/object-cache.php に手を加えAPIを拡張することで、安全な書き換えを実現する方法がありましたのでご紹介します。WordPressコアやサードパーティのプラグイン等は安全になりませんが、自身で作成するプラグインについては応用可能だと思います。

※以下のことがらは独自研究となります。TLA+を用いて最低限の検証をおこなったのみであり、正しさや実用性は保証できません。

placeholderパターンと名付けたこのパターンでは、Redisにデータを追加する前に必ずプレースホルダーとなるプロセス固有のダミー値をセットします。これにより楽観的排他制御を行い整合性を保ちます。

取得時

取得時の流れは、Redisに値が存在した場合は今までのパターンと同じです。

Redisに値が存在しない場合には、まずプレースホルダーとなるダミー値をセットします。この値は、キーのメタデータとして、実際に格納する値とは区別して扱う必要があります。プレースホルダーにより各々のPHP-FPMプロセスを区別できなければいけないので、例えばPIDをダミー値として用いるとよいでしょう。プレースホルダーがセットされたキーは、標準のObject Cache APIでは存在しないキーと同様に扱います。

プレースホルダーをセットしたら、続いてMySQLから実際のデータを取得します。

最後に、取得したデータをRedisに書き込んで終わりとなるわけですが、このとき、「自身がセットしたプレースホルダーがまだ存在する場合のみ」値をセットするという条件を加えます。この比較と更新はアトミックに行わなければいけません。Redisでは条件付きの更新を実現する単独のコマンドはないのですが、Luaスクリプト内で複数のコマンドを実行することにより、アトミックな処理が行えるようになっています。

function get_a_value( $key ) {
    $value = wp_cache_get( $key, 'group' );

    if ( false !== $value ) {
        return $value;
    }

    // Object Cache APIには存在しない関数。
    // プレースホルダーとなるダミー値をRedisにセットする。
    // プレースホルダーは、PIDなど各々のPHP-FPMプロセスを区別できる値を用いる。
    // なお、プレースホルダーが設定されたキーは、
    // 他のObject Cache APIでは値が存在しないキーとして扱う必要がある
    wp_cache_prepare( $key );

    $value = get_from_database( $key );

    // 同じプレースホルダーがRedis上にまだ残っている場合にのみ $value をセットする
    wp_cache_set_prepared( $key, $value, 'group' );
    
    return $value;
}

この処理の意味は、MySQLからの値の取得とRedisへの値の追加の排他制御です。排他制御というとmutexのようなロックを取得するイメージがあるかもしれませんが、ここで問題にしているのはキャッシュの値ですので、キャッシュに値を追加しないのであれば複数の処理が同時に実行されても問題はありません。また、まれにキャッシュに値を追加できないことがあったとしても大きな問題にはなりません。このようなキャッシュ特有の「ゆるさ」を利用し、排他処理を再試行のない楽観的な排他制御で実現しています。

placeholderパターン

更新時

データの更新時は、まずMySQLの値を更新し、続いて同じ値でRedisの値を更新します。set/deleteパターンと同じです。

function update_a_key( $key, $value ) {
    // MySQLの値を更新
    update_database( $key, $value );
    // Redisの値を削除(プレースホルダーも削除する)
    wp_cache_delete( $key, 'group' );
}

取得と更新の処理が同じタイミングで動いても問題は起こりません。なぜなら、 wp_cache_delete() によってプレースホルダーも削除されてしまい、 wp_cache_set_prepared() は必ず失敗するからです。

競合状態にはならないplaceholderパターン

問題点

正しいと思われるplaceholderパターンですが、問題がないわけではありません。途中で処理が異常終了した場合には、MySQLとRedisの値が整合しない状態が長く続く可能性があります。

もっとも可能性として高いのは、PHP-FPMのfatal errorでしょう。MySQLの更新後、Redisの値を削除する前にメモリ不足等でfatal errorになってしまうと、削除前の古いデータがRedis内に残り続けます。

placeholderパターンでfatal errorが発生した場合

MySQLやRedisとのネットワーク接続が意図せず切断した場合や、動作中のPHP-FPMプロセスをKILLした場合にも同様の問題が起こる可能性があります。

これらの問題に対処するのであれば、PHP-FPMプロセスの状態を監視し、問題があったらキャッシュ全体をクリアするような別の機能が必要になるでしょう。

Object Cacheをどう使うか?

これらを踏まえたうえで、Object Cacheはどう使うべきでしょうか? パフォーマンスが大きく向上するという確証がない限り「使わない」のが一番シンプルでよいと私は思います。

使う場合には、まず不変キャッシュとして使えないか考えてみるのがよいでしょう。不変キャッシュであれば、競合状態について考える必要はありません。

やむを得ず可変キャッシュとして使う場合は、整合性が取れなくなった場合にどんな影響が出るか、タイミング的に整合性が取れない状態に陥る危険性はどの程度あるかを意識しつつ使うことになります。

最後のplaceholderパターンは、本記事を書いている最中に見つけたもので、社内でも十分な検証ができていません。Object Cacheの実装にも手を加える必要があり、決して気軽に試せるものではありませんが、絶対に整合性を保たなければいけない場合や、Object Cache APIの改善の提案までを視野に入れている場合には、試みる価値はあるかもしれません。

終わりに

キャッシュは難しいです。ここでまとめた内容も誤りを含んでいる可能性があります。間違いを見つけた方は、問い合わせフォームからご指摘をお願いします。また、弊社の求人にもぜひご応募ください。

参考文献