は、一度得た結果を手元に取っておき、次回はそれを返す仕組みです。効果は大きく、桁で速くなることも珍しくありません。
同時に、元のデータと食い違う可能性を買うことになります。導入で難しいのは保存する側ではなく、いつ捨てるかの設計です。この回はそこを中心に扱います。
1. 入れる前に確認すること
キャッシュは強力なので、本来やるべき改善を覆い隠します。入れる前に確認する項目があります。
- そもそも処理が無駄に重くないか — EP.3〜5 の改善で足りるなら、そちらが先
- 同じ問い合わせが本当に繰り返されているか — 毎回違う条件なら効かない
- 古い情報を返してよいか — ここが No なら設計が一段難しくなる
- 食い違ったときに誰が困るか — 表示だけか、判断や金額に影響するか
1番目は特に重要です。遅いSQLをキャッシュで隠すと、初回アクセスや期限切れの直後だけ極端に遅い、という状態になります。しかもその遅さは平均に埋もれるため、 として現れます。根本を直さずに蓋をすると、問題が見えにくい形に変わるだけです。
入れた瞬間に数字が改善するため、その先の調査が止まります。数か月後にキャッシュが効かない条件が見つかったとき、元の遅さがそのまま出てきます。根本を直せるなら直してから入れてください。
2. どの層に置くか
置ける場所は複数あります。利用者に近いほど効果が大きく、制御は難しくなります。
| 層 | 効果 | 捨てやすさ | 向く対象 |
|---|---|---|---|
| 利用者の端末 | 最大 | 最も難しい | 変わらない静的なもの |
| 配信網(CDN) | 大きい | 難しい | 全員に同じ内容を返すもの |
| アプリの外部(共有) | 中 | 容易 | 複数台で共有したいデータ |
| アプリ内(プロセス内) | 中 | 容易 | 1台で完結する計算結果 |
| データベース内 | 小 | 自動 | 同じクエリの繰り返し |
捨てやすさの列が判断の中心です。利用者の端末に置いたものは、こちらから捨てられません。間違ったものを配ってしまうと、期限が切れるまで回収できないわけです。だから端末側には、変わらないと確信できるものだけを置きます。
実務で最初に手を付けやすいのは、アプリの外部に共有のキャッシュを置く形です。自分たちで明示的に捨てられ、複数台で共有できます。プロセス内は最も速いものの、台ごとに内容がずれるため、更新のあるデータには向きません。台数を増やすと「更新したのに古い情報が見える端末がある」という再現しにくい不具合になり、調査に時間を取られます。
3. いつ捨てるか
の方式は、実質的に3つです。
| 方式 | 仕組み | 利点 | 欠点 |
|---|---|---|---|
| 期限切れ | 一定時間で自動的に捨てる | 単純で堅い | その時間は古い情報を返す |
| 更新時に捨てる | 元データを変えたら明示的に消す | 常に最新 | 消し忘れが起きる |
| 更新時に書き直す | 元データと同時に書き換える | 常に最新で高速 | 書き込み経路が複雑になる |
迷ったら期限切れです。単純で、間違えても一定時間で自然に回復します。「更新時に捨てる」は正確ですが、元データを変える経路が複数あると必ず漏れます。管理画面から、バッチから、直接SQLから ── すべての経路で消す必要があり、1つ漏れると古い情報が残り続けます。
import time
class TTLCache: def __init__(self, ttl_sec: float): self.ttl = ttl_sec self.store = {} self.hit = 0 self.miss = 0
def get_or_set(self, key, build): now = time.monotonic() entry = self.store.get(key) if entry and now - entry["at"] < self.ttl: self.hit += 1 return entry["value"]
self.miss += 1 value = build() self.store[key] = {"value": value, "at": now} return value
@property def hit_rate(self): total = self.hit + self.miss return self.hit / total if total else 0.0命中率を必ず測れるようにしてください。キャッシュを入れたのに速くならない場合、その大半は命中していません。鍵が細かすぎる、期限が短すぎる、そもそも同じ問い合わせが来ていない ── 命中率が分かれば、どれなのか切り分けられます。
4. 鍵の設計
何を鍵にするかで、命中率と正しさの両方が決まります。細かすぎれば命中せず、粗すぎれば別人のデータを返します。
- 利用者ごとに違う内容なら、鍵に利用者を含める — 含め忘れると他人のデータが見える
- 権限で見える範囲が変わるなら、権限も鍵に含める — 最も危険な取りこぼし
- 表示に関係しない条件は鍵に含めない — 命中率が落ちるだけ
- 鍵に含める要素は明示的に列挙する — 引数をそのまま鍵にすると、無関係な差で外れる
利用者や権限を鍵に含め忘れると、他人の情報が表示されます。性能改善のつもりが情報漏洩になるため、キャッシュの導入では鍵の設計をレビューの対象にしてください。ここは速さの問題ではありません。
5. 期限切れが重なる問題
運用で顕在化しやすいのが、多数のキャッシュが同時に期限切れになる現象です。同時に作り直しが始まり、元のデータベースに負荷が集中します。
起きやすいのは、一斉に作られたキャッシュが一斉に切れる場合です。再起動直後や、キャッシュを全消しした直後に発生します。対処は単純で、期限に少しばらつきを持たせることです。
import random
BASE_TTL = 300 # 基本は5分JITTER = 0.2 # ±20% のばらつき
def ttl_with_jitter(base: float = BASE_TTL) -> float: """期限を散らして、同時に切れるのを防ぐ。""" return base * (1 + random.uniform(-JITTER, JITTER))
# 1000件のキャッシュを同時に作っても、切れる時刻は 240〜360 秒に散るsamples = [ttl_with_jitter() for _ in range(1000)]print(f"最短 {min(samples):.0f}秒 / 最長 {max(samples):.0f}秒")あわせて、同じ鍵の作り直しが同時に走らないようにする制御も有効です。1件だけ作り直し、他は待つか古い値を返す。作り直しが同時多発すると、キャッシュがない状態より悪化することがあります。
6. 入れたあとに残すもの
キャッシュは時間が経つと、なぜ入っているか分からなくなる種類の仕組みです。EP.1 で書いた「払った代償を残す」が特に効く領域です。
- 1何を速くするために入れたか — 元の遅さの数字
- 2どれだけ古い情報を許容する設計か — 期限の根拠
- 3どの経路で捨てられるか — 更新時に消す場合、すべての経路を列挙する
- 4命中率の目安 — 下がったときに気づけるように
3番目を書き残さないと、新しい更新経路が追加されたときに消し忘れます。これは時間差で発生し、原因が分かりにくい種類の不具合になります。
キャッシュは、入れた直後がいちばん安全で、時間が経つほど危うくなる仕組みです。元データの構造が変わり、更新経路が増え、担当者が代わる。そのどれもがキャッシュの前提を静かに崩します。 の観点で言えば、命中率と、古い情報を返した回数が観測できる状態にしておくと、崩れ始めたことに気づけます。
キャッシュは速度と整合性の交換。入れる前に根本を直せないかを確認する(隠すと テールレイテンシ になる)。層は捨てやすさで選ぶ。無効化は迷ったら期限切れ。鍵に利用者と権限を含め忘れると事故になる。命中率は必ず測れるようにしておく。
この記事の感想を教えてください
あなたの 1 クリックで、本当にこの記事は更新されます。「もっと詳しく」「続編希望」が一定数集まった記事は、 ふくふくが 実際に内容を拡充したり続編記事を公開 します。 送信したリアクションはお使いのブラウザに記録され、再カウントされません。