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 から始めて、必要に応じてキー設計・無効化・カウンタへと広げていくのがおすすめです。
