コンピュータサイエンスには「キャッシュ無効化」と「...」という2つの難しい問題しかないという古いジョークがあります。このジョークは真実であるため、長く続いています。キャッシュは簡単に追加できますが、正しく保つのは難しく、ほぼすべてのキャッシュバグは実際には無効化バグです。キャッシュはデータベースと一致しなくなったデータを返し続けます。この記事では、単純なTTLから明示的・イベント駆動型の無効化まで、キャッシュデータを正しく保つための戦略と、どの戦略を選ぶべきかについて解説します。
これはRedisマスタークラスの第13回で、refresh-aheadに続きます。
なぜ無効化が難しいのか
難しさは、キャッシュとデータベースが同じデータの2つのコピーであり、データベースが変更された瞬間、修正するまでキャッシュは誤った状態になる点です。すべての戦略は「キャッシュが基盤データの変更をどうやって知るか」という問いに対する異なる答えであり、それぞれが鮮度、複雑さ、正しさのバランスを異なります。完璧な答えはないというのが、このジョークの本質です。常にトレードオフを選ぶことになります。
戦略1: TTLによる期限切れ
最もシンプルな戦略は、積極的に無効化せず、エントリを期限切れにさせることです。TTLを設定し、経過後にデータを新しく再読み込みします。古さはTTLによって上限が決まります。60秒のTTLであれば、キャッシュは最大60秒しか遅れません。
SET user:1 "..." EX 60 # 最大60秒の古さを許容
Enter fullscreen mode Exit fullscreen mode
これは、ある程度の古さを許容できるデータに適した選択肢です。想定より多くのデータが該当します。30秒古い商品一覧、1分古いダッシュボード、5分以内に更新される設定など、TTLだけで十分であり、非常にシンプルです。TTLはデータが許容できる古さによって選びます。欠点は、完全に最新でありながら長くキャッシュを保持することができない点です。鮮度とヒット率は相反します。
戦略2: 書き込み時の明示的無効化
古さが許容できない場合は、明示的に無効化します。データベースに書き込むたびに、対応するキャッシュキーを削除し、次の読み込みで再読み込みさせます。これはcache-aside記事のdelete-on-writeルールです。
async function updateUser(id, data) {
await db.update('users', id, data);
await redis.del(`user:${id}`); // 即座に無効化
}
Enter fullscreen mode Exit fullscreen mode
これにより、データが変更された瞬間にキャッシュが修正され、TTLの数秒後ではなく即座に反映されます。課題は完全性です。書き込みによって影響を受けるすべてのキャッシュキーを無効化する必要があり、見落としやすい点です。ユーザーのデータがuser:1、users:listキャッシュ、search:resultsキャッシュに存在する場合、更新時にすべてを無効化しなければなりません。1つでも見落とすと古いエントリが残ります。明示的無効化は正確ですが、データがキャッシュされているすべての場所を追跡し、変更時にすべてを無効化する必要があります。
戦略3: タグベース/グループ化による無効化
1つの変更で多くのキーを無効化する必要がある場合、個別に追跡するのはエラーが発生しやすいです。タグベースの無効化では、関連するキーをグループ化して一括で無効化できます。1つの方法は、Redisのセットを使ってどのキーがグループに属するかを追跡します。
# キャッシュ時にキーをタグの下に記録
SADD tag:user:1 user:1 profile:1 posts:by:1
# user 1のすべてを無効化するには:
# セットを読み取り、キーをすべて削除し、セットを削除
Enter fullscreen mode Exit fullscreen mode
これにより、user 1の更新時にグループ全体を1回の操作で無効化でき、各書き込み箇所で手動でキーを列挙する必要がなくなります。1つのエンティティのデータが多くのキーに分散する複雑なキャッシュに対して、明示的アプローチをスケールさせます。タグセットの管理という簿記作業が増えますが、「すべてのキーを覚えていたか」というリスクを除去します。
戦略4: イベント駆動型無効化
複数のサービスやレプリカを持つシステムでは、書き込みを行うサービスが古いキャッシュを保持しているサービスとは限りません。イベント駆動型無効化では、変更をブロードキャストして、関係するすべてのパーティが無効化できるようにします。Redisのpub/sub(後述)や変更ストリームで「user 1 changed」メッセージを配信し、各サービスが購読して関連するキャッシュをクリアします。
# 書き込み側が変更時に発行
PUBLISH cache:invalidate '{"entity":"user","id":1}'
# 各サービスが購読して一致するキーを削除
Enter fullscreen mode Exit fullscreen mode
これにより、分散システム全体でキャッシュの一貫性を保てます。1つのノードでのDELだけでは不十分です。最も強力で最も複雑な戦略であり、メッセージング機構と各所でのサブスクライバが必要です。本当に分散したキャッシュで、ローカルな無効化がすべてのコピーに到達できない場合にのみ使用してください。
バージョン付きキー: 削除なしの無効化
キーを変更することで無効化を回避する賢い代替手段があります。キャッシュキーにバージョンを含め、バージョンを上げて「無効化」し、削除を避けます。
GET user:1:v5 # 現在のバージョン
# 変更時にバージョンポインタをインクリメントし、以降の読み込みはv6を使用
Enter fullscreen mode Exit fullscreen mode
古いバージョン付きキーは単に要求されなくなり、TTLで自然に期限切れになります。一方、新しい読み込みは新しいバージョンを使用します。これにより、削除と再読み込みが競合するレースコンディションを回避でき、正確に無効化するのがコストの高いキャッシュに便利です。代償は、古いバージョンが期限切れまでメモリに残ることです。
戦略の選択
判断はデータに従います。
- 古さを許容できるか? TTLのみ。最もシンプルで、多くのデータに十分です。
- 即時鮮度が必要で、単一サービスか? 明示的なdelete-on-write。変更が多くのキーに影響する場合はタグベースのグループ化を併用。
- サービス間で分散キャッシュか? pub/subや変更ストリームによるイベント駆動型無効化。
- 無効化の競合が問題になるか? バージョン付きキー。
実際のシステムの多くはこれらを組み合わせます。すべてにTTLを安全網として設定し(無効化の見落としでも最終的に自己修正)、鮮度が必要なデータには明示的無効化を追加します。この組み合わせは、明示的無効化が見落としたものをTTLが捕捉するため、よく機能します。
キャッシュ無効化が難しいのは、基本的に2つのコピーを同期させる問題であり、すべての戦略が鮮度と複雑さのトレードオフだからです。TTLから始め、鮮度が必要な箇所に明示的無効化を追加し、変更が広がる場合にはタグでキーをグループ化し、本当に分散した環境でのみイベント駆動型無効化を検討してください。常にTTLを安全網として残しておくことが重要です。忘れていた無効化が、結局出荷するバグになるからです。
次回は、いくつかのパターンが示唆していた特定の障害、ホットキーが期限切れになり、リクエストの群れが一斉にデータベースを叩くキャッシュスタンピードについて扱います。
要点
- キャッシュバグのほとんどは無効化バグです。キャッシュがデータベースと一致しなくなったデータを返し続けることです。
- TTLによる期限切れは、能動的な作業なしで古さに上限を設け、ある程度の古さを許容できるデータには十分です。
- 明示的なdelete-on-writeは即時の鮮度を提供しますが、変更が影響するすべてのキーを無効化する必要があります。
- タグベースのグループ化は、関連する多くのキーをまとめて無効化します。イベント駆動型無効化は、分散キャッシュの一貫性を保ちます。
- 明示的無効化の下にTTLを安全網として残しておくことで、見落とした無効化でも自己修正されます。
よくある質問
なぜキャッシュ無効化は難しいと考えられるのか?
キャッシュとデータベースが同じデータの2つのコピーであり、データベースが変更された瞬間にキャッシュが誤った状態になるためです。すべての戦略は、その変更をキャッシュがどう学習するかについての異なる不完全な答えであり、それぞれが鮮度と複雑さをトレードオフします。
最もシンプルなキャッシュ無効化戦略は何か?
TTLによる期限切れです。存続期間を設定し、エントリを期限切れにして再読み込みさせます。古さはTTLによって上限が決まり、能動的な無効化は不要です。データが少し古くなることを許容できる場合には十分です。
データ変更時にキャッシュを無効化するには?
データベースに書き込んだ直後に影響を受けるキャッシュキーを削除(delete-on-write)し、次の読み込みで新しい値を再読み込みさせます。変更が影響するすべてのキーを無効化する必要があり、多くのキーに影響する場合はタグベースのグループ化が役立ちます。
複数のサービス間でキャッシュの一貫性を保つには?
イベント駆動型無効化を使用します。Redisのpub/subや変更ストリームで変更メッセージをブロードキャストし、各サービスが購読して関連するキャッシュをクリアします。1つのローカル削除では、他のサービスが保持するキャッシュに到達できません。
明示的無効化でもTTLを使用すべきか?
はい。TTLを安全網として残しておくことで、書き込みパスでキーの無効化を忘れた場合でも、古いエントリは期限切れ時に自己修正されます。永続的なバグを一時的なものに変えます。
参考文献
この記事は元々 amanksingh.com/blog/redis-cache-invalidation で公開されました。
著者について
Aman Kumar Singhは、インドのノイダを拠点とするチームリード兼シニアソフトウェアエンジニアで、TypeScript、Next.js、NestJS、PostgreSQL、Redisを用いたフルスタックエンジニアリング、システム設計、本番SaaSについて執筆しています。
- ポートフォリオ: amanksingh.com
- GitHub: amansingh1501
- LinkedIn: amansingh1597
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.