はじめに
キャッシュってよく聞きますし、Redisも技術記事でよく出てきます。でも「なぜ速くなるのか」「DBを更新したらキャッシュはどうなるのか」と聞かれると、意外と仕組みまで説明しづらいです。
そこで、バックエンドでよく使うキャッシュを基本から調べて整理してみました。
TL;DR
- キャッシュは、正しい元データから作り直せる一時コピーとして考える
- 「どれくらい古くてもよいか」と、キャッシュが無いときのDB負荷を一緒に考える
- 速く返せるときだけでなく、更新・期限切れ・障害時まで見る
1. キャッシュは「速いDB」ではなく「一時コピー」
キャッシュを調べる前は、「メモリに置くからDBより速い」という説明だけでも分かった気になりやすいです。ただ、それだけだとTTLやキャッシュ削除の話が急に別の論点に見えてしまいます。そこで最初に分けた方が分かりやすかったのが、正しいデータを持つ場所と、速く返すためのコピーです。
たとえば商品詳細を表示するたびにDBへ同じクエリを投げているとします。同じ商品が何度も読まれるなら、そのたびに同じDB処理をやり直す必要はありません。一度取得した結果を別の場所へ置いて再利用すれば、DBまで進むリクエストを減らせます。これがキャッシュの基本的な役割です。
ここで大事なのは、キャッシュ側のデータを「最終的に正しいデータ」とは扱わないことです。たとえば商品価格の正しい値はDBにあり、キャッシュはそこから作ったコピーとして扱います。こうした、最終的に正しいとみなすデータの置き場所を Source of Truth と呼びます。この記事ではDBをSource of Truthとし、キャッシュはそこから作り直せる一時コピーとして考えます。
この見方をすると、「キャッシュが全部消えたらどうなる?」という問いが効いてきます。消えてもDBから再生成できるなら、少なくとも元データを失ったわけではありません。逆に、消えたら復元できないデータを置いているなら、それは単なる一時コピーとして扱ってよいのかを考え直す必要があります。
2. 読み込みはかなり単純だった
キャッシュとDBの役割を分けると、通常の読み込みはかなり素直です。最初のリクエストではキャッシュに何もなく、DBへ取りに行きます。次のリクエストからは、そのコピーを使えます。
たとえば product:100 というキーで商品データをキャッシュするとします。
1回目はキャッシュにデータがありません。この状態をキャッシュミス(Cache Miss) と呼びます。DBから商品を取得し、その結果をキャッシュへ保存してからレスポンスを返します。
2回目はキャッシュに product:100 があるので、DBまで進まずに返せます。
こちらがキャッシュヒット(Cache Hit) です。
Python風に書くと、かなりシンプルです。
def get_product(product_id):
key = f"product:{product_id}"
product = cache.get(key)
if product is not None:
return product
product = db.find_product(product_id)
cache.set(key, product)
return product
このように、アプリケーションが先にキャッシュを確認し、無ければDBから読み、その結果をキャッシュへ入れる方式を Cache-aside と呼びます。
ここまでは分かりやすいです。難しくなるのは、このコピーを持ち続けたあとでした。
3. コピーは時間が経つと古くなる
キャッシュに商品データを置いたあと、DB側の商品価格が変わったらどうなるでしょうか。キャッシュは自動でDBの変更を知るわけではないので、古い値が残ることがあります。
たとえば、DBでは12,000円に更新されたのに、キャッシュには10,000円が残っている状態です。
| 保存先 | 商品価格 |
|---|---|
| DB | 12,000円 |
| キャッシュ | 10,000円 |
キャッシュからは一瞬で返せても、ユーザーへ10,000円と表示してしまえば正しいレスポンスとは言えません。
こうした、元データより古くなったキャッシュを Stale(古い状態) と呼びます。逆に、まだそのまま使ってよい新しい状態が Fresh です。
では、いつまでも同じコピーを使わないようにするにはどうするのか。そこで出てくるのが TTL(Time To Live) です。キャッシュの各データに「この時間を過ぎたら期限切れ」という寿命を持たせます。
たとえばTTLを60秒にすると、キャッシュした値は永遠には残りません。期限切れになったあと、次の読み込みでDBから最新値を取り直します。
ただ、TTLは短ければ短いほどよいわけではありません。
| TTL | データの新しさ | キャッシュから返せる割合 | DB負荷 |
|---|---|---|---|
| 短い | 新しくなりやすい | 下がりやすい | 上がりやすい |
| 長い | 古さを抱えやすい | 上がりやすい | 下がりやすい |
キャッシュから返せた割合はヒット率(Hit Rate) と呼ばれます。TTLを短くすれば古いデータは減らせますが、期限切れも増えるのでDBへ戻るリクエストも増えます。
調べてみて、TTLは単なる「パフォーマンス用の秒数」ではないと分かりました。まず「この画面では、何秒くらい古くても許せるか」があり、その範囲でヒット率やDB負荷とのバランスを取ります。
同じ在庫データでも、商品一覧に出す「残りわずか」という表示と、購入確定時の在庫判定では許容できる古さが違います。データ名だけではTTLは決まらず、どこで使うかまで見る必要があります。
4. TTLを待てない変更では、古いコピーを消したくなる
TTLがあれば古いキャッシュはいずれ期限切れになります。でも、価格を変更したあと60秒間ずっと古い価格を返してよいとは限りません。そこで、DBを更新したタイミングで古いキャッシュをどう扱うか、という次の問題が出てきます。
DBとキャッシュを両方更新する難しさ
一見すると、DBとキャッシュを両方12,000円へ更新すればよさそうです。ただ、この2回の更新は別々の処理です。DBの更新は成功したのにキャッシュ更新だけ失敗すると、DBは12,000円、キャッシュは10,000円という状態が残ります。
このように、複数の処理の一部だけが成功することを Partial Failure(部分的な失敗) と呼びます。
そこでよく使われるのが、DBを更新したあとにキャッシュの値まで書き換えるのではなく、古いキャッシュを削除する考え方です。
- DBを12,000円へ更新する
-
product:100のキャッシュを削除する - 次の読み込みでDBから12,000円を取得する
- 取得した新しい値をキャッシュへ保存する
古いキャッシュを使えない状態にすることを Cache Invalidation(キャッシュの無効化) と呼びます。値を正しく書き換え続けるより、「古いコピーは捨てて、次の読み込みで作り直す」と考える方が、キャッシュ側で最新値を維持し続ける責務は小さくなります。
もちろん、削除処理そのものが失敗することもあります。DB更新後にキャッシュ削除が失敗すれば古い値は残ります。ここでもTTLがあれば、少なくともその値が無期限に残り続けることは防ぎやすくなります。
DELETEしてもRaceは残る
削除まで成功すれば終わりかというと、そうでもありません。読み込みと更新が同じタイミングで走ると、古い値が削除後に戻ってくる場合があります。
時系列だけ抜き出すとこうなります。
A: キャッシュミス
A: DBから 10,000円を取得
B: DBを 12,000円へ更新
B: キャッシュ削除
A: 10,000円をキャッシュへ保存 ← 古い値が復活
複数の処理が時間的に重なって進むことを 並行実行(Concurrency) と呼びます。その実行順によって結果が変わる問題が Race Condition です。
ここまで追うと、「キャッシュを入れれば速くなる」だけでは設計を決められない理由が見えてきます。どれだけ古い値を許せないのかによって、TTLを短くする、データに世代番号を持たせる、ロックを使う、といった対策の重さも変わります。
5. 古いコピーを消すと、今度は「無い瞬間」が生まれる
古いキャッシュを削除するところまでは自然です。でも、削除が成功した直後はキャッシュに何もありません。TTLが切れたときも同じで、次の読み込みはキャッシュミスになります。
キャッシュ内の1件のデータを、ここでは Entry(エントリ) と呼びます。Entryが無くなる理由は1つではありません。TTLが切れて期限切れになることもあれば、容量不足で追い出されることもありますし、更新に合わせて明示的に削除する場合もあります。
TTLによって期限切れになることが Expiration、容量などの都合でキャッシュから追い出されることが Eviction です。整理すると次のようになります。
| 原因 | 何が起きたか |
|---|---|
| Expiration | TTLが切れて期限切れになった |
| Eviction | 容量不足などでキャッシュから追い出された |
| Invalidation | 更新に合わせて明示的に削除した |
理由は違っても、いずれも結果として「次の読み込みではキャッシュから返せない」状態を作り得ます。
問題になるのは、人気のあるキーでその瞬間が起きたときです。
たとえば product:100 へ同時に大量のリクエストが来ているとします。そのキャッシュがちょうど期限切れになれば、全リクエストがほぼ同時にキャッシュミスになり、同じ商品をDBへ取りに行く可能性があります。
このように、同じキーへの多数のキャッシュミスが元データ側へ集中する現象を Cache Stampede と呼びます。
対策の1つは、「同じキーを今まさに誰かが再生成しているなら、他の処理は同じ再生成を繰り返さない」ことです。single-flightのように、同じキーの進行中の処理をまとめる方法があります。
ただし、まとめられる範囲には限りがあります。同じアプリケーションの1プロセス内なら進行中の処理を共有しやすいですが、アプリケーションが複数インスタンスに分かれていれば、各インスタンスから1本ずつDBへ行くことはあり得ます。
複数インスタンスをまたいで調整するなら、共有ロックなど全インスタンスから見える仕組みが必要です。つまり「1000リクエストなら必ずDB 1回」ではなく、同時処理をまとめられる範囲の中で、重複した再生成を減らすという理解が近いです。
キャッシュ自体が落ちた場合は別の問題
エントリが無いのではなく、キャッシュサーバーそのものへアクセスできないケースもあります。
アプリケーション側で「キャッシュ接続に失敗したらDBへ取りに行く」という Fallback(フォールバック、代替経路) を実装していれば、サービスを継続できる可能性があります。ただし、普段キャッシュが受け止めていたリクエストが一気にDBへ流れるので、DBがその負荷に耐えられるとは限りません。
キャッシュを入れるときは、ヒットしたときの速さだけではなく、キャッシュが無くても元のシステムがどこまで耐えられるかも見ておきたいところです。
存在しないデータへのキャッシュミスが繰り返される場合は、「Not Found」という結果を短時間だけキャッシュする方法もあります。これはNegative Cachingと呼ばれます。
大量のキーが同時に期限切れになって負荷が集中する現象はCache Avalancheと呼ばれることがあります。また、1つの人気キーにアクセスが偏るHot Keyは別系統の問題です。ここでは用語だけに留めます。
6. 何でもキャッシュすればよいわけではなかった
ここまで見てくると、キャッシュは無料の高速化ではありません。コピーを持てば古さを考える必要があり、消せばキャッシュミスが生まれ、障害時にはDB負荷まで考えることになります。なので「キャッシュできるか」より、その複雑さを引き受けてでもキャッシュする価値があるかで考える方がしっくりきました。
自分なら、まず次の4つを確認します。
- 同じ結果を何度も読むか
- 元の取得処理は重いか
- どれくらい古くても許せるか
- キャッシュが消えても元データから作り直せるか
たとえば「在庫」という同じデータでも、使い方で判断は変わります。
| 使い方 | キャッシュとの相性を考えるときの観点 |
|---|---|
| 商品一覧の「残りわずか」表示 | 数秒のズレを許容できるなら候補になりやすい |
| 購入確定時の在庫判定 | 古い値で購入可否を決めてよいか慎重に考える |
| 商品説明 | 更新頻度が低く、繰り返し読まれるなら候補になりやすい |
| 権限判定 | 古い権限を使った場合の影響を先に確認したい |
キャッシュ導入後も、ヒット率だけを見ればよいわけではありません。応答時間がどう変わったか、DB負荷はどうなったか、Eviction(追い出し)が増えていないか、古いデータをどの程度返しているかも一緒に見る必要があります。
おわりに
キャッシュを調べる前は「速い場所に置けば速くなる」という話が中心に見えます。でも仕組みを追うと、難しいのはコピーを持った後でした。
コピーは古くなり、TTLやキャッシュの無効化で消え、消えればキャッシュミスが起きます。同じキーへミスが集中すればDB負荷も増えます。キャッシュを「速いメモリ」ではなく、古くなり、消え、元データから作り直される一時コピーとして見ると、個々の用語がつながって理解しやすくなりました。






