2026年6月1日

2026年6月1日

RedisのキャッシュパターンとCache-Asideの実装:PHPとWordPressでのコード例と無効化の注意点

Redis をキャッシュとして使うときにもっとも広く使われているのが Cache-Aside(キャッシュアサイド)パターンです。アプリケーションがまず Redis を見て、なければデータベースから読んで Redis に書き込む、という単純な仕組みですが、データ更新時の消し方や、キャッシュが切れた瞬間にアクセスが集中する問題への対策を入れないと、古いデータが残ったりデータベースに負荷が集中したりします。この記事では、主なキャッシュパターンの違いを整理したうえで、PHP(phpredis)と WordPress のオブジェクトキャッシュでの Cache-Aside の実装と、運用で気をつける点を説明します。

主なキャッシュパターンの違い

キャッシュの読み書きの方法には、いくつかの定番の型があります。

  • Cache-Aside(Lazy Loading):読み込み時にアプリケーションがキャッシュを確認し、なければデータベースから読んでキャッシュに入れる。更新時はデータベースを書き換えてキャッシュを削除する
  • Read-Through:アプリケーションはキャッシュ層にだけ問い合わせ、なければキャッシュ層(ライブラリやミドルウェア)がデータベースから読み込む。考え方は Cache-Aside に近いが、読み込みの処理をキャッシュ層が受け持つ
  • Write-Through:書き込み時に、データベースとキャッシュの両方を同時に更新する。読み込みは常にキャッシュに当たりやすいが、読まれないデータまでキャッシュに載る
  • Write-Behind(Write-Back):書き込みをまずキャッシュに入れ、データベースへは後でまとめて反映する。書き込みは速いが、反映前にキャッシュが失われるとデータを失う

Redis 自体には「なければデータベースから読む」機能はないので、Redis を使う場合はアプリケーション側でこのいずれかを実装することになります。中でも Cache-Aside は、キャッシュが止まってもデータベースから読めばアプリケーションが動き続けるため、障害に強く、最初に選ばれることが多い方式です。この記事でもこれを扱います。

なお、Redis はバージョン 7.4 以降でライセンスが変わり、その後のバージョンでは選べるライセンスがさらに見直されています。互換のフォークとして Valkey も広く使われるようになりました(2026年10月現在)。この記事のパターンとコマンドは、どちらでも同じように使えます。

基本のCache-Aside(PHP + phpredis)

商品の情報を1件読み込む関数を例にします。データベースは PDO、Redis との接続は PHP 拡張の phpredis を使う想定です。

<?php
function get_product( Redis $redis, PDO $pdo, int $id ): ?array {
    $key = 'product:v1:' . $id;

    // 1. まずキャッシュを見る(キーがなければ false が返る)
    $cached = $redis->get( $key );
    if ( false !== $cached ) {
        return json_decode( $cached, true );
    }

    // 2. なければデータベースから読む(値はプレースホルダーで渡す)
    $stmt = $pdo->prepare( 'SELECT id, name, price FROM products WHERE id = ?' );
    $stmt->execute( [ $id ] );
    $row = $stmt->fetch( PDO::FETCH_ASSOC ) ?: null;

    // 3. キャッシュに書く。存在しない ID も短時間だけ覚えておく
    $ttl = ( null === $row ) ? 60 : 300 + random_int( 0, 60 );
    $redis->set( $key, json_encode( $row ), [ 'ex' => $ttl ] );

    return $row;
}

この短いコードに、いくつかの工夫を入れています。

  • キーに v1 のような版を入れておくと、保存する形式を変えたときに、キーの版を上げるだけで古い形式のキャッシュを読まずに済む
  • 有効期限(TTL)をすべて同じ値にせず、random_int で少しばらつかせている。同時に作られた大量のキャッシュが同じ瞬間に切れて、データベースへの問い合わせが集中するのを避けるため
  • 存在しない ID の結果(null)も短い期限でキャッシュしている。存在しないデータを繰り返し要求されたときに、毎回データベースまで届くのを防ぐ(ネガティブキャッシュ)

TTL は「どのくらい古いデータを見せてもよいか」で決めます。価格や在庫のように正確さが求められるデータは短く、変更の少ない設定情報などは長くします。

更新時は「書き換え」ではなく「削除」

データを更新したときは、データベースを更新した後に、対応するキャッシュを削除します。次に読まれたときに、新しい値でキャッシュが作り直されます。

<?php
function update_product_price( Redis $redis, PDO $pdo, int $id, int $price ): void {
    $stmt = $pdo->prepare( 'UPDATE products SET price = ? WHERE id = ?' );
    $stmt->execute( [ $price, $id ] );

    // データベースの更新が確定してからキャッシュを消す
    $redis->del( 'product:v1:' . $id );
}

更新時にキャッシュへ新しい値を書き込む方法もありますが、同時に複数の更新が走ると、書き込みの順番が入れ替わって古い値がキャッシュに残ることがあります。削除であれば、次の読み込みでデータベースの最新の値が使われるため、食い違いが起きにくくなります。

順番も大切です。トランザクションを使っている場合は、コミットした後に削除します。コミットの前に削除すると、その間にほかのリクエストが更新前の値を読んでキャッシュに入れ直してしまうことがあります。それでも、まれなタイミングで古い値が残る可能性はゼロではないため、TTL を設定して「いずれは期限切れで正しい値に戻る」状態にしておくことが安全網になります。

アクセスが集中したときの対策(キャッシュスタンピード)

アクセスの多いキーが期限切れになった瞬間、多数のリクエストが同時にキャッシュのない状態に当たり、全員がデータベースに同じ問い合わせを送ることがあります。これをキャッシュスタンピードと呼びます。対策として、キャッシュを作り直す処理を1つのリクエストだけに任せるロックを使います。

<?php
function get_report( Redis $redis, callable $build, string $key, int $ttl ) {
    $cached = $redis->get( $key );
    if ( false !== $cached ) {
        return json_decode( $cached, true );
    }

    $lock_key = $key . ':lock';
    $token    = bin2hex( random_bytes( 16 ) );

    // NX: キーがないときだけ作る / EX: 10秒で自動的に外れる
    if ( $redis->set( $lock_key, $token, [ 'nx', 'ex' => 10 ] ) ) {
        try {
            $data = $build();
            $redis->set( $key, json_encode( $data ), [ 'ex' => $ttl ] );
            return $data;
        } finally {
            // 自分が取ったロックだけを外す
            $unlock = "if redis.call('get', KEYS[1]) == ARGV[1] then "
                    . "return redis.call('del', KEYS[1]) else return 0 end";
            $redis->eval( $unlock, [ $lock_key, $token ], 1 );
        }
    }

    // ロックを取れなかった場合は少し待ってから再確認する
    usleep( 200000 );
    $cached = $redis->get( $key );
    return ( false !== $cached ) ? json_decode( $cached, true ) : $build();
}

ロックには期限(EX)を付けています。処理中にプロセスが落ちても、期限が来ればロックが外れて次のリクエストが作り直せるようにするためです。ロックを外すときに、値が自分のトークンと一致するかを Lua スクリプトで確かめているのは、期限切れの後にほかのリクエストが取り直したロックを誤って消さないためです。

待っても値ができていない場合は、この例ではデータベースから直接読んでいます。ここで待ち続けるとリクエスト全体が遅くなるので、どこまで待つかはアクセス量とデータベースの余裕で決めます。もう1つの方法として、期限より少し前にバックグラウンドで作り直しておく方式もあります。

WordPressでのCache-Aside

WordPress には、wp_cache_get() と wp_cache_set() によるオブジェクトキャッシュの仕組みがあります。標準ではリクエストの間だけメモリに保持されますが、Redis Object Cache などのプラグインで永続オブジェクトキャッシュ(object-cache.php というドロップイン)を入れると、保存先が Redis になり、リクエストをまたいで使えます。

人気記事の ID の一覧をキャッシュする例です。プラグインか子テーマの functions.php に書きます。

<?php
function myprefix_get_popular_post_ids(): array {
    $found = false;
    $ids   = wp_cache_get( 'popular_ids_v1', 'myprefix', false, $found );
    if ( $found ) {
        return $ids;
    }

    $query = new WP_Query( array(
        'post_type'      => 'post',
        'posts_per_page' => 10,
        'meta_key'       => 'myprefix_views',
        'orderby'        => 'meta_value_num',
        'fields'         => 'ids',
        'no_found_rows'  => true,
    ) );
    $ids = $query->posts;

    wp_cache_set( 'popular_ids_v1', $ids, 'myprefix', 10 * MINUTE_IN_SECONDS );
    return $ids;
}

// 投稿が保存されたらキャッシュを消す
add_action( 'save_post_post', function () {
    wp_cache_delete( 'popular_ids_v1', 'myprefix' );
} );

wp_cache_get() は、キャッシュがない場合に false を返します。保存した値そのものが空の配列や false のこともあるので、4番目の引数 $found で「見つかったかどうか」を判定しています。第2引数のグループ名には、ほかのプラグインと重ならない接頭辞を付けます。

永続オブジェクトキャッシュが入っていない環境では、この関数は毎回データベースに問い合わせます。どちらの環境でも期限付きで保存したい場合は、Transients API(get_transient() と set_transient())を使う方法もあります。永続オブジェクトキャッシュがあれば Transients も Redis に保存されます。

運用で気をつける点

  • メモリの上限と追い出し方:maxmemory を設定し、キャッシュ専用のインスタンスなら maxmemory-policy を allkeys-lru などにする。既定の noeviction のままだと、上限に達したときに書き込みがエラーになる
  • キャッシュが止まったとき:Redis に接続できない場合も、例外を捕まえてデータベースから読むようにしておけば、遅くはなるがサービスは続けられる
  • 大きな値を入れない:1つのキーに大きな値を入れると、読み書きのたびに転送量が増え、ほかの処理を待たせる。必要な項目だけを保存する
  • 個人情報の扱い:ログインユーザーごとのデータをキャッシュする場合は、キーにユーザー ID を含めて混ざらないようにし、Redis を外部から接続できない場所に置いてパスワード(ACL)を設定する

設定の変更やメモリ上限の調整は、ステージング環境で負荷をかけて確認してから本番に反映してください。

まとめ

  • Cache-Aside は、読み込み時にキャッシュを確認し、なければデータベースから読んでキャッシュに入れる方式。キャッシュが止まっても動き続けられる
  • 更新時はデータベースのコミット後にキャッシュを削除する。TTL を付けて、古い値が残っても期限で戻るようにする
  • TTL のばらつき、ネガティブキャッシュ、ロックで、アクセス集中時のデータベースへの負荷を抑える
  • WordPress では wp_cache_get() の $found 引数でヒットを判定し、永続オブジェクトキャッシュで Redis に保存する
  • maxmemory と追い出し方を設定し、Redis を外部に公開しない

お気軽にご相談ください

お見積りへ お問い合わせへ
▲