PredisとRedisではじめるPHPアプリのキャッシュ設計入門

概要

PHPアプリケーションでデータベースへのアクセスが増えると、同じクエリを繰り返し実行してレスポンスが遅くなる、という課題に直面します。本記事では、PHP向けのRedisクライアント Predis を使って、Redisをキャッシュ層として組み込む基本を、コード例とともに解説します。

対象は「Redisの名前は知っているが、自分のPHPアプリへの入れ方はまだ試したことがない」という方です。キャッシュの導入方法に加え、TTL・キー設計・キャッシュ無効化といった、後から困りやすい設計判断まで踏み込みます。

環境

  • PHP 8.1 以上
  • Redis 6 以上(ローカルは Docker で起動)
  • Predis 2.x(Composer 導入)

PHPからRedisを使うクライアントには2系統あります。

方式 特徴
phpredis(C拡張) 高速。PHP拡張のインストールが必要
Predis(純PHP) 拡張不要。Composerのみで導入。手軽

拡張のビルドが不要で環境を選ばないため、学習や小〜中規模ではまず Predis が扱いやすい選択です。パフォーマンスを追い込む段階になったら phpredis を検討する、という順序で問題ありません。

発生した問題

キャッシュ導入前の典型的なコードは次のようになります。

// 毎回DBを叩く
function getPopularProducts(PDO $pdo): array
{
    $stmt = $pdo->query(
        'SELECT id, name, price FROM products
         WHERE is_published = 1
         ORDER BY view_count DESC
         LIMIT 20'
    );
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

「人気商品一覧」のように 更新頻度は低いのに参照は多い データを、リクエストのたびにDBから取得しています。アクセスが増えるほどDB負荷が上がり、レスポンスも悪化します。

原因

  • 参照が多く更新が少ないデータをキャッシュしていない
  • 同一結果を返すクエリを毎回実行している
  • DBがボトルネックになりやすい構造になっている

このようなデータこそ、キャッシュの効果が最も出ます。

対応方法

Predis を導入します。

composer require predis/predis

Redisはローカルでは Docker で起動するのが手軽です。

# docker-compose.yml
services:
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

接続はホストとポートを渡すだけです。

use Predis\Client;

$redis = new Client([
    'scheme' => 'tcp',
    'host'   => '127.0.0.1',
    'port'   => 6379,
]);

echo $redis->ping(); // PONG

実装例

1. キャッシュ・アサイド(Cache-Aside)パターン

最も基本的で、まず覚えるべきパターンです。「キャッシュを見て、無ければDBから取り、キャッシュに書き戻す」流れです。

use Predis\Client;

class ProductRepository
{
    public function __construct(
        private Client $redis,
        private PDO $pdo,
    ) {}

    public function getPopularProducts(): array
    {
        $key = 'products:popular';

        // 1. キャッシュを見る
        $cached = $this->redis->get($key);
        if ($cached !== null) {
            return json_decode($cached, true);
        }

        // 2. 無ければDBから取得
        $stmt = $this->pdo->query(
            'SELECT id, name, price FROM products
             WHERE is_published = 1
             ORDER BY view_count DESC
             LIMIT 20'
        );
        $products = $stmt->fetchAll(PDO::FETCH_ASSOC);

        // 3. TTL付きで書き戻す(300秒)
        $this->redis->setex($key, 300, json_encode($products));

        return $products;
    }
}

ポイントは setex で TTL(有効期限)を必ず付ける ことです。TTLの無いキャッシュはメモリを圧迫し続け、古いデータが残り続ける原因になります。

2. キー設計

キーは運用であとから効いてくる部分です。名前空間をコロンで区切る 階層命名が慣例です。

// 良い例
$key = "user:{$userId}:profile";
$key = "products:category:{$categoryId}:page:{$page}";

// 避けたい例
$key = "data123";

パラメータが多い場合はハッシュ化して安定したキーにします。

function cacheKey(string $prefix, array $params): string
{
    ksort($params); // 順序を固定し、同じ条件で同じキーになるようにする
    return $prefix . ':' . md5(json_encode($params));
}

3. キャッシュ無効化(Invalidation)

キャッシュ設計で最も難しいのが「いつ消すか」です。データを更新したら、関連するキャッシュを削除します。

public function updateProduct(int $id, array $data): void
{
    $stmt = $this->pdo->prepare(
        'UPDATE products SET name = :name, price = :price WHERE id = :id'
    );
    $stmt->execute([
        'name'  => $data['name'],
        'price' => $data['price'],
        'id'    => $id,
    ]);

    // 関連キャッシュを削除
    $this->redis->del("product:{$id}");
    $this->redis->del('products:popular');
}

複数キーをまとめて消す場合、KEYS は本番で使うと全キー走査でRedisをブロックするため避けます。代わりに SCAN を使います。

$cursor = 0;
do {
    [$cursor, $keys] = $this->redis->scan(
        $cursor,
        ['MATCH' => 'products:*', 'COUNT' => 100]
    );
    if ($keys) {
        $this->redis->del($keys);
    }
} while ($cursor != 0);

4. アトミックなカウンタ

Redisはキャッシュ以外にも、アトミックなカウンタとして便利です。閲覧数のような更新頻度の高い値を、毎回DBに書かずRedisで加算し、定期的にまとめて反映する使い方があります。

$views = $this->redis->incr("product:{$id}:views");

確認方法

redis-cli で実際にキーが入っているか確認できます。

$ redis-cli
127.0.0.1:6379> ttl products:popular
(integer) 287   # 正の値ならTTLが効いている
127.0.0.1:6379> ttl nonexistent
(integer) -2    # -2 はキーが無い / -1 はTTL未設定

ヒット率は INFO stats の keyspace_hits と keyspace_misses から算出できます。

注意点

  • TTLを必ず設定する。無期限キャッシュはメモリリークと古いデータの温床になる
  • 本番で KEYS * を使わない。全走査でRedisがブロックする。列挙は SCAN を使う
  • キャッシュが消えても正しく動く設計にする。Redisが落ちてもDBから取れるようにする
  • Cache Stampede に注意。人気キーが同時に期限切れすると大量リクエストが一斉にDBへ流れる。TTLに少しランダム幅を持たせると緩和できる
$this->redis->setex($key, 300 + random_int(0, 60), json_encode($products));

まとめ

  • Predis は Composer だけで入る純PHPのRedisクライアントで、導入コストが低い
  • まずは Cache-Aside パターン と TTL必須 の2点を押さえる
  • キー設計は 種別:識別子 の階層命名で、運用が楽になる
  • 無効化(いつ消すか)が設計の肝。更新時削除や SCAN で整理する
  • キャッシュは「消えても動く」前提で組む

小さく setex と get から始めて、必要に応じてキー設計・無効化・カウンタへと広げていくのがおすすめです。

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください。