#07 データベースの高速化

キャッシュ戦略

どれだけクエリを最適化しても、データベースへのアクセス自体にはコストがかかります。読み取りが多く更新が少ないデータには、 クエリキャッシュ? 同じSELECT文の結果をメモリに保存して再利用する仕組み。まったく同じクエリが繰り返し来る場合に高速化できる。アプリ層(Redis等)でのキャッシュが一般的。 を超えたアプリケーション層のキャッシュが有効です。このエピソードでは Redis を中心にキャッシュ戦略を体系的に学びます。

キャッシュ層の構造 クライアント / ブラウザ HTTP リクエスト アプリケーションサーバー Laravel / Rails / Express / etc. ①キャッシュ確認 Redis インメモリキャッシュ 応答: マイクロ秒〜 ②ヒット: 返却 ③ミス時: DBへ MySQL 永続化ストレージ 応答: ミリ秒〜 ④Redisにキャッシュ保存 応答速度の比較 Redis(キャッシュヒット) 〜 100μs MySQL(最適化済み) 〜 5ms MySQL(未最適化) 〜 数百ms Redisは50〜1000倍速い Cache-Aside パターンの流れ ① Redisを確認 → ② ヒット: 即返却 / ミス: DBへ → ③ DBから取得 → ④ Redisに保存 → ⑤ 返却 TTLが切れると自動的にキャッシュ失効(次回アクセス時にDBから再取得)
図1: キャッシュ層の構造(アプリ→Redis→DB)

なぜキャッシュが必要か

データベースへのクエリは、どれだけ最適化しても数ミリ秒〜数十ミリ秒かかります。一方、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) 読み取り アプリ → Cache確認 HIT: キャッシュから返す MISS: DB→Cache保存→返す 書き込み DB更新 → Cache削除 (次のアクセスで再生成) メリット • 実装がシンプル • 読み取り頻度が高い場面に最適 • キャッシュ障害時DBで継続可能 注意点 • Cache Miss時に遅延が増加 • スタンピード問題(同時miss) • DB更新→Cache削除の順番重要 Read-Through (キャッシュ層が透過的に処理) 読み取り アプリ → キャッシュ層のみ問い合わせ HIT: キャッシュから返す MISS: Cache層がDB取得して保存 書き込み DBのみ(Cacheは自動失効) または書き込みも透過的に処理 メリット • アプリのコードがシンプル • Cache Miss処理が自動化 • キャッシュの一貫性を保ちやすい 注意点 • キャッシュ層の実装が必要 • ORMの機能に依存することが多い • 初回アクセスは必ずDBへ Write-Through (書き込み時に同時更新) 読み取り アプリ → Cache確認 常にキャッシュが最新 MISS率が低い 書き込み DB更新 + Cache同時更新 (書き込みのたびに両方更新) メリット • Cacheが常に最新を保持 • Cache Miss がほぼ発生しない • 一貫性が高い 注意点 • 書き込みが少し遅くなる • 書き込み頻度が高いと効果薄 • 滅多に読まないデータも保存
図2: キャッシュパターン(Read-Through / Write-Through)

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)が発生すると、そのテーブルに関わるキャッシュがすべて無効化されます。書き込みが頻繁なテーブルでは常にキャッシュが無効化され、むしろロック競合でパフォーマンスが低下することがありました。

現代的なアプリケーションではアプリケーション層でキャッシュを制御する設計が正しいアプローチです。次のエピソードはシリーズの最終回——コネクションプールとシリーズ全体のまとめです。