キャッシュ戦略
どれだけクエリを最適化しても、データベースへのアクセス自体にはコストがかかります。読み取りが多く更新が少ないデータには、 クエリキャッシュ? 同じSELECT文の結果をメモリに保存して再利用する仕組み。まったく同じクエリが繰り返し来る場合に高速化できる。アプリ層(Redis等)でのキャッシュが一般的。 を超えたアプリケーション層のキャッシュが有効です。このエピソードでは Redis を中心にキャッシュ戦略を体系的に学びます。
なぜキャッシュが必要か
データベースへのクエリは、どれだけ最適化しても数ミリ秒〜数十ミリ秒かかります。一方、Redis などのインメモリキャッシュはマイクロ秒オーダーでデータを返せます。また以下のような場面ではキャッシュが特に効果的です。
- 同じデータを何度も読み取るが、更新頻度が低い(マスタデータ、設定情報)
- 集計や複雑なJOINの結果をキャッシュしてDBの負荷を下げたい
- データベースへの同時接続数を減らして コネクションプール? データベース接続を使い回す仕組み。接続確立には時間がかかるため、あらかじめ接続を作っておき、リクエスト毎に貸し出す。接続数の上限管理にも役立つ。 の枯渇を防ぎたい
アプリケーション層キャッシュ(Redis)
Redis は文字列・ハッシュ・リストなど多様なデータ型をサポートするインメモリデータストアです。TTL(有効期限)付きのキャッシュストアとして広く使われています。
基本的なキャッシュ実装(PHP/疑似コード)
function getUserById(int $userId): array
{
$cacheKey = "user:{$userId}";
// キャッシュを確認
$cached = $redis->get($cacheKey);
if ($cached !== null) {
return json_decode($cached, true); // キャッシュヒット
}
// キャッシュミス: DBから取得
$user = DB::query("SELECT id, name, email FROM users WHERE id = ?", [$userId]);
// 5分間キャッシュ
$redis->setex($cacheKey, 300, json_encode($user));
return $user;
}
キャッシュのパターン
Cache-Aside(Lazy Loading)
最も広く使われるパターンです。アプリケーションがキャッシュとDBを両方管理します。
読み取り:
キャッシュを確認
→ ヒット: キャッシュから返す
→ ミス: DBから取得 → キャッシュに保存 → 返す
書き込み:
DBを更新
キャッシュを削除(または更新)
// 書き込み時のキャッシュ無効化
function updateUser(int $userId, array $data): void
{
DB::query("UPDATE users SET name = ?, email = ? WHERE id = ?",
[$data['name'], $data['email'], $userId]);
// キャッシュを削除(次のアクセス時に再生成される)
$redis->del("user:{$userId}");
}
メリット: シンプル、読み取りが多い場面で効果的 デメリット: キャッシュミス時はレイテンシが増加する(DBアクセス+キャッシュ書き込み)
Read-Through
キャッシュレイヤーがDB読み取りを自動的に行うパターンです。アプリケーションはキャッシュにしか問い合わせません。
アプリ → キャッシュ(ミスならDBから自動取得してキャッシュ) → 返す
ORM のキャッシュ機能や専用のキャッシュライブラリが内部的にこのパターンを実装していることがあります。
Write-Through
データ更新時にDBとキャッシュを同時に更新するパターンです。
function updateUser(int $userId, array $data): void
{
// DBとキャッシュを同時に更新
DB::query("UPDATE users SET name = ?, email = ? WHERE id = ?",
[$data['name'], $data['email'], $userId]);
$updatedUser = ['id' => $userId, 'name' => $data['name'], 'email' => $data['email']];
$redis->setex("user:{$userId}", 300, json_encode($updatedUser));
}
メリット: キャッシュが常に最新、読み取り時のミスが起きない デメリット: 書き込みが少し遅くなる、書き込み頻度が高い場合はキャッシュ効果が薄い
キャッシュ無効化の難しさ
There are only two hard things in Computer Science: cache invalidation and naming things. — Phil Karlton
「キャッシュの無効化とネーミングだけが難しい」という有名な格言があります。どのタイミングでキャッシュを削除・更新するかを誤ると、古いデータが表示されるキャッシュの不整合が発生します。
典型的な失敗パターン:
// NG: 更新前にキャッシュを削除すると、別プロセスが古いDBデータをキャッシュする
$redis->del("user:{$userId}");
DB::query("UPDATE users ...");
// OK: 更新後にキャッシュを削除する
DB::query("UPDATE users ...");
$redis->del("user:{$userId}");
TTL の設計
TTL(Time To Live)はデータの性質に合わせて設計します。
| データの種類 | TTL の目安 |
|---|---|
| 商品マスタ(変更頻度低) | 1時間〜1日 |
| ユーザープロフィール | 5〜15分 |
| セッション情報 | 30分〜数時間 |
| リアルタイム在庫 | 数秒〜30秒 |
| 認証トークン | トークンの有効期限と同じ |
TTL が短すぎるとキャッシュミスが増え恩恵が減ります。長すぎると古いデータが表示されるリスクが増えます。
MySQL のクエリキャッシュについて
MySQL 5.7以前にはquery_cacheというクエリ結果をキャッシュする機能がありましたが、MySQL 8.0で廃止されました。
理由は設計上の問題です。テーブルに対して1件でも更新(INSERT/UPDATE/DELETE)が発生すると、そのテーブルに関わるキャッシュがすべて無効化されます。書き込みが頻繁なテーブルでは常にキャッシュが無効化され、むしろロック競合でパフォーマンスが低下することがありました。
現代的なアプリケーションではアプリケーション層でキャッシュを制御する設計が正しいアプローチです。次のエピソードはシリーズの最終回——コネクションプールとシリーズ全体のまとめです。