ひとことで言うと#
よく使うデータをDBより速い場所(メモリ等)に一時保存し、同じデータへの繰り返しアクセスを高速化する技術。適切に使えばレスポンスタイムが数十倍改善し、DBの負荷も劇的に下がる。
押さえておきたい用語#
- Cache-Aside
- アプリがキャッシュを確認し、なければDBから取得してキャッシュに保存する最も一般的なパターン。
- TTL(Time To Live)
- キャッシュの有効期限。設定した時間が経過すると自動的にデータが破棄される。
- キャッシュヒット率
- リクエストのうちキャッシュから応答できた割合。90%以上が理想。低いとキャッシュ導入の効果が薄い。
- Thundering Herd(雷鳴問題)
- キャッシュ期限切れ直後に大量のリクエストが同時にDBに殺到する現象。ロック機構や事前更新で防ぐ。
- キャッシュ無効化
- データが更新された際に古いキャッシュを削除または更新する処理。キャッシュ設計で最も難しい部分。
キャッシング戦略の全体像#
こんな悩みに効く#
- ページの読み込みが遅く、同じクエリが何度もDBに発行されている
- トラフィックが増えるとDBがボトルネックになりスローダウンする
- APIのレスポンスタイムを改善したいが、DBのチューニングだけでは限界がある
基本の使い方#
すべてをキャッシュするのではなく、効果が高いものを選ぶ。
- 読み取り頻度が高く、書き込み頻度が低いデータが最適
- 例: 商品マスタ、カテゴリ一覧、ユーザープロフィール、設定値
- 計算コストが高い集計結果(ランキング、レコメンドなど)も効果的
ポイント: 「このデータが数秒古くても問題ないか?」が判断基準。リアルタイム性が必要なデータはキャッシュに向かない。
用途に応じてキャッシュの読み書きパターンを決める。
- Cache-Aside: アプリがキャッシュを確認→なければDBから取得→キャッシュに保存。最も一般的
- Write-Through: 書き込み時にDBとキャッシュを同時に更新
- Write-Behind: 書き込みをキャッシュに行い、非同期でDBに反映
- Read-Through: キャッシュミス時にキャッシュ自身がDBからデータを取得
ポイント: 迷ったらCache-Asideから始める。シンプルで理解しやすい。
キャッシュの鮮度をどう保つかを設計する。
- TTL(Time To Live): 一定時間後に自動的に期限切れにする
- イベント駆動の無効化: データ更新時にキャッシュを明示的に削除する
- タグベースの無効化: カテゴリやユーザーIDでグループ化して一括削除
ポイント: TTLは短すぎるとキャッシュの効果が薄く、長すぎると古いデータが表示される。最初は5分で試す。
複数のレイヤーでキャッシュを構成し、効果を最大化する。
- ブラウザキャッシュ: HTTP Cache-Controlヘッダーで制御
- CDN: 静的アセットやAPIレスポンスをエッジでキャッシュ
- アプリケーションキャッシュ: Redis、Memcachedでアプリ層にキャッシュ
- DBキャッシュ: クエリキャッシュ、マテリアライズドビュー
ポイント: ユーザーに近い場所でキャッシュするほど効果が高い。
具体例#
問題: トップページのリクエストごとに「人気記事ランキング(10件)」「カテゴリ一覧」「最新記事(20件)」を3つのクエリでDBから取得。レスポンスタイム800ms。1日あたり50万PV。
キャッシュ設計:
- 人気記事ランキング: Redis にキャッシュ。TTL 10分。10分に1回だけ集計クエリが走る
- カテゴリ一覧: Redis にキャッシュ。TTL 1時間。カテゴリ変更時にイベント駆動で無効化
- 最新記事: Redis にキャッシュ。TTL 1分。新記事公開時にキャッシュ無効化
- ページ全体のHTML: CDN にキャッシュ。TTL 30秒。ログインユーザーはCDNを通さない
結果: レスポンスタイムがCDNヒット時20ms、Redisヒット時50msに改善。DB負荷が1/100以下になり、DBインスタンスのスケールダウンで月額5万円のコスト削減も実現した。
状況: 通常時のAPIリクエスト: 毎秒500。ブラックフライデーは毎秒5,000に急増。DBの処理能力は毎秒800クエリが上限。
キャッシュ戦略:
- 商品検索結果: Redis にCache-Aside。TTL 30秒。人気キーワード上位100件は事前ウォーミング
- 商品詳細: Redis にCache-Aside。TTL 5分。価格変更時にイベント駆動で無効化
- 在庫数: キャッシュしない(リアルタイム性が必須)
- Thundering Herd対策: Redisのロック機構で同一キーへの並列DB問い合わせを1つに制限
結果: キャッシュヒット率94%を達成。DB負荷は毎秒5,000リクエスト中300のみDBに到達。ブラックフライデー当日のレスポンスタイムp99が380msで安定し、サービスダウンゼロだった。
問題: 管理ダッシュボードに「過去30日の売上推移」「顧客数推移」「解約率」の3つのグラフ。各クエリに3〜4秒かかり、ページ表示に10秒以上。ユーザー200名が毎朝9:00〜9:30にアクセスし、DBのCPU使用率が90%に。
キャッシュ設計:
- 集計結果をRedisにキャッシュ。TTL 15分
- 毎時0分にバッチジョブで集計を実行し、キャッシュを事前更新(Read-Throughに近い運用)
- ダッシュボードに「データ更新時刻」を表示し、ユーザーに鮮度を明示
結果: ダッシュボード表示が10秒→0.5秒に改善。朝のDBのCPU使用率ピークが90%→25%に低下。ユーザーから「ダッシュボードが速くなった」の声が15件寄せられた。
やりがちな失敗パターン#
- キャッシュの無効化を設計しない — 古いデータが表示され続けてユーザーからクレームが来る。「データが更新されたらキャッシュをどう消すか」を必ず設計する
- キャッシュに依存しすぎる — Redisが落ちるとシステム全体が停止する。キャッシュがなくても動く(ただし遅い)設計にする。サーキットブレーカーも検討
- Thundering Herd(雷鳴問題)を考慮しない — キャッシュの期限切れ直後に大量のリクエストがDBに殺到する。ロック機構やキャッシュの事前更新で対策する
- キャッシュキーの設計が不適切 — ユーザーIDやロケールを含めずに全ユーザー共通のキーにすると、他人のデータが表示される重大バグに。キーにコンテキスト情報を必ず含める
まとめ#
キャッシングは「正しく使えば劇的に効果がある」が 「間違えるとデータ不整合の温床になる」 両刃の剣。キャッシュ対象の選定、パターンの選択、無効化戦略の3つを意識して設計することが重要。まずはCache-Asideパターン + TTLの組み合わせで、最もアクセスが多いエンドポイントから導入してみよう。