Django上級 #4 キャッシング — per-view / template fragment / low-level
#3 クエリ最適化 が クエリを減らす 道だったとすれば、キャッシングは 計算結果を保存して次は作らない 道です。2 つは補完関係。キャッシュヒット率が高い場面は ORM 最適化の効果がより大きく、ミス時にも速くないとキャッシュは意味がありません。
Django は 複数のレベル のキャッシングをすべて提供します。細かい方から大きい方へ:
| レベル | デコレータ / ツール | 場面 |
|---|---|---|
| Low-level | cache.set/get | 任意のオブジェクト |
| テンプレート断片 | {% cache %} | サイドバー、ヘッダーなど |
| Per-view | @cache_page | ビュー全体のレスポンス |
| Per-site | ミドルウェア | サイト全体 |
| 条件付き | condition (ETag) | クライアント側キャッシュ |
キャッシュバックエンド #
settings.CACHES に定義します。
locmem — デフォルト (開発用) #
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "unique-per-process",
}
}プロセス単位のメモリ。ワーカーが複数あればワーカーごとにキャッシュが別 — 運用には不適切。開発 / テストの場面。
Redis — 標準的な運用バックエンド #
Django 5.x から ビルトイン のバックエンド。
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/1",
}
}より豊富な機能 (分散ロックなど) が必要なら django-redis が事実上の標準。
pip install django-redisCACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/1",
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"PARSER_CLASS": "redis.connection._HiredisParser",
"CONNECTION_POOL_KWARGS": {"max_connections": 50},
"SOCKET_CONNECT_TIMEOUT": 5,
"SOCKET_TIMEOUT": 5,
"IGNORE_EXCEPTIONS": True, # Redis ダウン時に例外の代わりに None
},
"KEY_PREFIX": "myproject",
}
}
DJANGO_REDIS_IGNORE_EXCEPTIONS = TrueIGNORE_EXCEPTIONS=True が運用で決定的 — Redis が一時的に落ちたとき、サイト全体が 500 にならず キャッシュミスとして fallback。
Memcached #
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
"LOCATION": "127.0.0.1:11211",
}
}最も軽くて速いです。ただし TTL ベースの単純な KV のみ — 分散ロック、sorted set、pub/sub のようなものが必要なら Redis。
DB / ファイル #
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.db.DatabaseCache",
"LOCATION": "my_cache_table",
}
}インストール後 python manage.py createcachetable を一度。特別なインフラなしに キャッシングが必要なら。ただし DB の負荷が増えるので運用ではあまり推奨されません。
filebased.FileBasedCache も同じ場面。
Per-view キャッシュ — @cache_page
#
最も簡単なキャッシング。レスポンス全体を丸ごと。
from django.views.decorators.cache import cache_page
@cache_page(60 * 15) # 15 分
def article_list(request):
articles = Article.objects.published().order_by("-created_at")[:50]
return render(request, "articles/list.html", {"articles": articles})内部動作:
- キャッシュキー:
(リクエストメソッド、URL、クエリ文字列、Accept-Language、...)のハッシュ - ミス: view 実行 → レスポンスをキャッシュ → 返す
- ヒット: view を経ずレスポンスだけ返す (DB も見ない)
GET / HEAD リクエストのみキャッシュ。POST/PUT/DELETE は無視。
クラスベースビュー #
from django.utils.decorators import method_decorator
from django.views.generic import ListView
@method_decorator(cache_page(60 * 15), name="dispatch")
class ArticleListView(ListView):
model = Articleまたは URL 登録の箇所で:
from django.views.decorators.cache import cache_page
urlpatterns = [
path("articles/", cache_page(60 * 15)(ArticleListView.as_view())),
]ユーザーごとに違うキャッシュ #
@cache_page だけでは すべてのユーザーに同じレスポンス を返します。ログインユーザーごとに違うなら危険 — 別のユーザーのページが見えるかもしれません。
from django.views.decorators.vary import vary_on_cookie
from django.views.decorators.cache import cache_page
@cache_page(60 * 5)
@vary_on_cookie
def my_dashboard(request):
...vary_on_cookie が クッキーごとに違うキャッシュキー を作ります。ただしユーザーが多いとキャッシュヒット率が下がって効率が落ちます。ユーザーごとのページは普通 fragment キャッシング の方が向いています。
Per-site ミドルウェアキャッシュ #
サイト全体をキャッシュしたいとき。
MIDDLEWARE = [
"django.middleware.cache.UpdateCacheMiddleware", # ← 最上部
"django.middleware.common.CommonMiddleware",
...
"django.middleware.cache.FetchFromCacheMiddleware", # ← 最下部
]
CACHE_MIDDLEWARE_ALIAS = "default"
CACHE_MIDDLEWARE_SECONDS = 600
CACHE_MIDDLEWARE_KEY_PREFIX = "myproject"順序が重要 — UpdateCacheMiddleware が上 (レスポンス時)、FetchFromCacheMiddleware が下 (リクエスト時)。
ほぼ静的なマーケティングサイトやブログなどに似合います。動的なサイトには view/fragment 単位のキャッシュの方が精密。
Template fragment キャッシュ #
ビューの一部だけキャッシュ。サイドバー、ヘッダー、メニュー、人気記事のような場面。
{% load cache %}
{% cache 500 sidebar request.user.username %}
<aside>
<h3>人気の記事</h3>
{% for post in popular_posts %}
<a href="{{ post.url }}">{{ post.title }}</a>
{% endfor %}
</aside>
{% endcache %}文法: {% cache <秒> <キー名> [追加キー引数...] %}
追加キー引数は キャッシュ分岐の基準。request.user.username を入れればユーザーごとに違うキャッシュ。LANGUAGE_CODE を入れれば言語ごとに。
{% cache 600 article_card article.id article.updated_at user.is_authenticated %}
...
{% endcache %}article.updated_at をキーに入れるのが 手動無効化を避けるよくあるトリック — 投稿が変わるとキーが自動で変わって新しいキャッシュが作られます。明示的な invalidate が要りません。
Low-level キャッシュ API #
3 行要約:
from django.core.cache import cache
cache.set("key", value, timeout=300) # 5 分
value = cache.get("key", default=None)
cache.delete("key")よく使うメソッド #
cache.set("a", 1, timeout=60)
cache.add("a", 2) # すでにあれば何もしない (set-if-not-exists)
cache.get_or_set("a", lambda: compute(), timeout=60) # なければ作って set
cache.set_many({"x": 1, "y": 2}, timeout=60)
cache.get_many(["x", "y"]) # {"x": 1, "y": 2}
cache.delete_many(["x", "y"])
cache.incr("counter", 1) # アトミックなインクリメント
cache.decr("counter", 1)
cache.has_key("a") # bool
cache.clear() # すべて (危険)
cache.set("a", 1, timeout=None) # 無制限
cache.set("a", 1, timeout=0) # 即時失効 (= キャッシュしない)get_or_set が最もよく使うパターン。
def get_dashboard_stats(user_id):
return cache.get_or_set(
f"dashboard_stats:{user_id}",
lambda: compute_dashboard_stats(user_id),
timeout=300,
)複数のキャッシュバックエンドを併用 #
CACHES = {
"default": {"BACKEND": "...redis...", "LOCATION": "redis://r1:6379/1"},
"sessions": {"BACKEND": "...redis...", "LOCATION": "redis://r1:6379/2"},
"long": {"BACKEND": "...redis...", "LOCATION": "redis://r2:6379/0"},
}from django.core.cache import caches
caches["long"].set("annual_report", data, timeout=86400 * 7)キャッシュキー設計 — 無効化戦略 #
キャッシュには 2 つの難しいことがあると言われます。命名、キャッシュ無効化。あと off-by-one。
1) TTL だけで — 最もシンプル #
cache.set("popular_posts", qs, timeout=60)データが多少古くてもよい場面。シンプルさは大きな美徳。 60 秒の stale を受け入れられるなら、95% のキャッシュ問題は解けます。
2) キーにバージョンを入れる — 自動無効化 #
key = f"article:{article.id}:{int(article.updated_at.timestamp())}"
cache.set(key, rendered, timeout=86400 * 7)updated_at が変わればキーが変わるので自動的に新しいキャッシュ。古いキャッシュは TTL で自然に失効。
3) 明示的 delete — 最も正確だが難しい #
from django.db.models.signals import post_save
from django.dispatch import receiver
@receiver(post_save, sender=Article)
def invalidate_article_cache(sender, instance, **kwargs):
cache.delete(f"article:{instance.id}")
cache.delete("article_list:popular") # 影響を受けるキー問題は 影響するキーをすべて把握する必要があること。大きなプロジェクトではすぐに漏れが出ます。可能なら 1 番か 2 番で解決し、明示的 delete は最小限に。
4) パターン削除 — django-redis の強み #
from django_redis import get_redis_connection
cache.delete_pattern("article:*") # django-redis のみサポートビルトイン Redis バックエンドには delete_pattern がありません。パターン無効化が必要なら django-redis。
Cache-Control / ETag — クライアント側キャッシュ #
サーバーが結果を作った後でも ブラウザ / CDN が再度尋ねないようにすればもっと速いです。
cache_control — Cache-Control ヘッダー
#
from django.views.decorators.cache import cache_control
@cache_control(public=True, max_age=3600)
def static_ish_view(request):
...レスポンスに Cache-Control: public, max-age=3600 が付いて、ブラウザ / CDN が 1 時間再度尋ねません。
@cache_control(private=True, max_age=300)
def user_view(request):
...private は 共有キャッシュ (CDN) はキャッシュ禁止、ブラウザだけ OK。
condition — ETag / Last-Modified
#
from django.views.decorators.http import condition
def article_etag(request, pk):
return Article.objects.filter(pk=pk).values_list("updated_at", flat=True).first()
def article_last_modified(request, pk):
return Article.objects.filter(pk=pk).values_list("updated_at", flat=True).first()
@condition(etag_func=article_etag, last_modified_func=article_last_modified)
def article_detail(request, pk):
article = Article.objects.get(pk=pk)
return render(request, "article_detail.html", {"article": article})流れ:
- クライアントが
If-None-Match: <etag>またはIf-Modified-Since: <date>ヘッダーでリクエスト - Django が
etag_func/last_modified_funcを呼んで比較 - 同じなら
304 Not Modifiedだけレスポンス (本文を送らない) - 違えば view 実行
etag_func が軽くないと意味がありません。updated_at を 1 カラム照会する程度なら OK。
キャッシュ stampede #
キャッシュが同時に失効してすべてのリクエストが一斉にミス → 同じ高価な計算を同時に N 回。これが stampede。
シンプルなロック #
from django_redis import get_redis_connection
def get_expensive(key):
val = cache.get(key)
if val is not None:
return val
redis = get_redis_connection("default")
lock = redis.lock(f"lock:{key}", timeout=10)
if lock.acquire(blocking=True, blocking_timeout=5):
try:
val = cache.get(key) # ダブルチェック
if val is not None:
return val
val = compute_expensive()
cache.set(key, val, timeout=300)
return val
finally:
lock.release()
# ロックを取れなかったら stale を返すか、短く待って再試行
return cache.get(key) or compute_expensive()最初のリクエストだけ高価な計算を行い、残りは それが終わるのを待ってからキャッシュから読みます。
事前計算 / 更新時間の分散 #
大トラフィックのサイトは TTL 失効直前にバックグラウンドで先に更新 します。Celery beat のようなツールで。または TTL に ランダムな jitter を加えて同時失効を分散。
import random
cache.set(key, val, timeout=300 + random.randint(0, 60))キャッシュ vs メモ化 vs CDN — 場面の使い分け #
メモ化 (functools.cache) | Django キャッシュ | CDN | |
|---|---|---|---|
| 範囲 | プロセス | ワーカー間で共有 (Redis) | グローバル |
| 速度 | 最速 | 速い (ネットワーク 1 ホップ) | 最速 (リージョン) |
| 無効化 | プロセス再起動 | API 呼び出し | URL/ヘッダー |
| 場面 | pure 関数の結果 | ユーザー別データ、クエリ結果 | 静的 / 公開レスポンス |
3 つのレベルを併用します。静的なレスポンスは CDN、ユーザー別の動的は Django キャッシュ、小さな pure 関数は lru_cache。 DRF #3 のページネーションと結合する場面もあります。
キャッシュしない場面 #
- POST/PUT/DELETE
- 認証トークンの検証そのもの
- ユーザーごとにすべてのページが完全に違う SaaS ダッシュボード — ヒット率が低すぎる
- 決済、残高 — stale が危険なデータ
- 非常に頻繁に変わるデータ — TTL 失効よりも変更が多い
よく出会う落とし穴 #
1) ユーザーページに @cache_page だけ
#
上で見たそれ — 別のユーザーに 他人のページ が見えるかもしれません。vary_on_cookie または fragment キャッシング。
2) 大きなオブジェクトをキャッシュ #
QuerySet そのものではなく list(qs) または直列化結果をキャッシュ。そして大きすぎるものはいっそキャッシュしない方がいいことも (シリアライズ / デシリアライズのコスト > 節約)。
3) Redis が落ちるとサイトも #
IGNORE_EXCEPTIONS=True と cache.get(..., default=None) パターン。
4) キャッシュに機密情報 #
トークン、パスワードハッシュなどはキャッシュ禁止。キャッシュは 再計算可能なデータ の場面。
5) キーの衝突 #
KEY_PREFIX を環境ごとに違うように (dev、prod) — 同じ Redis を共有するなら必須。また #7 で扱う settings 分離と一緒に。
まとめ #
今回つかんだもの:
- バックエンド: locmem (開発)、Redis (運用標準)、Memcached、DB
django-redis+IGNORE_EXCEPTIONS=Trueが運用推奨- Per-view:
@cache_page、ユーザー別はvary_on_cookieまたは fragment - Per-site:
UpdateCacheMiddleware+FetchFromCacheMiddleware - Template fragment:
{% cache <秒> <キー名> <分岐引数たち> %} - Low-level:
cache.set/get/delete、get_or_set、incr、set_many、alias - 無効化: TTL > キーバージョン (
updated_at) > 明示 delete > パターン (django-redis) - クライアントキャッシュ:
cache_control、condition(ETag/Last-Modified) - Stampede: lock + double-check、jitter、事前計算
- キャッシュ vs メモ化 vs CDN — 場面の使い分け
- 落とし穴: ユーザー別レスポンスに cache_page、大きなオブジェクト、Redis ダウン、機密情報、キー衝突
次回 (#5 Signals の深さとトランザクション後処理) では 中級 #3 のシグナルの上に transaction.on_commit、savepoint、custom signal、Celery との結合 を載せます。「シグナルの中で外部システムを呼ぶときに何が怖いのか」 への答えが入ります。