Django上級 #4 キャッシング — per-view / template fragment / low-level

読了 8分

#3 クエリ最適化クエリを減らす 道だったとすれば、キャッシングは 計算結果を保存して次は作らない 道です。2 つは補完関係。キャッシュヒット率が高い場面は ORM 最適化の効果がより大きく、ミス時にも速くないとキャッシュは意味がありません。

Django は 複数のレベル のキャッシングをすべて提供します。細かい方から大きい方へ:

レベルデコレータ / ツール場面
Low-levelcache.set/get任意のオブジェクト
テンプレート断片{% cache %}サイドバー、ヘッダーなど
Per-view@cache_pageビュー全体のレスポンス
Per-siteミドルウェアサイト全体
条件付きcondition (ETag)クライアント側キャッシュ

キャッシュバックエンド #

settings.CACHES に定義します。

locmem — デフォルト (開発用) #

settings.py
CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
        "LOCATION": "unique-per-process",
    }
}

プロセス単位のメモリ。ワーカーが複数あればワーカーごとにキャッシュが別 — 運用には不適切。開発 / テストの場面。

Redis — 標準的な運用バックエンド #

Django 5.x から ビルトイン のバックエンド。

ビルトイン Redis
CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": "redis://127.0.0.1:6379/1",
    }
}

より豊富な機能 (分散ロックなど) が必要なら django-redis が事実上の標準。

インストール
pip install django-redis
django-redis 設定
CACHES = {
    "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 = True

IGNORE_EXCEPTIONS=True が運用で決定的 — Redis が一時的に落ちたとき、サイト全体が 500 にならず キャッシュミスとして fallback

Memcached #

memcached
CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
        "LOCATION": "127.0.0.1:11211",
    }
}

最も軽くて速いです。ただし TTL ベースの単純な KV のみ — 分散ロック、sorted set、pub/sub のようなものが必要なら Redis。

DB / ファイル #

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 #

最も簡単なキャッシング。レスポンス全体を丸ごと。

views.py
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 は無視。

クラスベースビュー #

CBV
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 登録の箇所で:

urls.py で
from django.views.decorators.cache import cache_page

urlpatterns = [
    path("articles/", cache_page(60 * 15)(ArticleListView.as_view())),
]

ユーザーごとに違うキャッシュ #

@cache_page だけでは すべてのユーザーに同じレスポンス を返します。ログインユーザーごとに違うなら危険 — 別のユーザーのページが見えるかもしれません。

✅ Vary: Cookie
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 ミドルウェアキャッシュ #

サイト全体をキャッシュしたいとき。

settings.py
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 キャッシュ #

ビューの一部だけキャッシュ。サイドバー、ヘッダー、メニュー、人気記事のような場面。

templates/_sidebar.html
{% 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")

よく使うメソッド #

set/get/delete + alpha
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,
    )

複数のキャッシュバックエンドを併用 #

settings.py — 複数 alias
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"},
}
alias 指定
from django.core.cache import caches

caches["long"].set("annual_report", data, timeout=86400 * 7)

キャッシュキー設計 — 無効化戦略 #

キャッシュには 2 つの難しいことがあると言われます。命名、キャッシュ無効化。あと off-by-one。

1) TTL だけで — 最もシンプル #

短い TTL
cache.set("popular_posts", qs, timeout=60)

データが多少古くてもよい場面。シンプルさは大きな美徳。 60 秒の stale を受け入れられるなら、95% のキャッシュ問題は解けます。

2) キーにバージョンを入れる — 自動無効化 #

updated_at または version
key = f"article:{article.id}:{int(article.updated_at.timestamp())}"
cache.set(key, rendered, timeout=86400 * 7)

updated_at が変わればキーが変わるので自動的に新しいキャッシュ。古いキャッシュは TTL で自然に失効。

3) 明示的 delete — 最も正確だが難しい #

signal で無効化
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 の強み #

pattern delete
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 #

ETag
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})

流れ:

  1. クライアントが If-None-Match: <etag> または If-Modified-Since: <date> ヘッダーでリクエスト
  2. Django が etag_func / last_modified_func を呼んで比較
  3. 同じなら 304 Not Modified だけレスポンス (本文を送らない)
  4. 違えば view 実行

etag_func が軽くないと意味がありません。updated_at を 1 カラム照会する程度なら OK。

キャッシュ stampede #

キャッシュが同時に失効してすべてのリクエストが一斉にミス → 同じ高価な計算を同時に N 回。これが stampede。

シンプルなロック #

django-redis の lock
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 を加えて同時失効を分散。

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=Truecache.get(..., default=None) パターン。

4) キャッシュに機密情報 #

トークン、パスワードハッシュなどはキャッシュ禁止。キャッシュは 再計算可能なデータ の場面。

5) キーの衝突 #

KEY_PREFIX を環境ごとに違うように (devprod) — 同じ 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/deleteget_or_setincrset_many、alias
  • 無効化: TTL > キーバージョン (updated_at) > 明示 delete > パターン (django-redis)
  • クライアントキャッシュ: cache_controlcondition (ETag/Last-Modified)
  • Stampede: lock + double-check、jitter、事前計算
  • キャッシュ vs メモ化 vs CDN — 場面の使い分け
  • 落とし穴: ユーザー別レスポンスに cache_page、大きなオブジェクト、Redis ダウン、機密情報、キー衝突

次回 (#5 Signals の深さとトランザクション後処理) では 中級 #3 のシグナルの上に transaction.on_commit、savepoint、custom signal、Celery との結合 を載せます。「シグナルの中で外部システムを呼ぶときに何が怖いのか」 への答えが入ります。

X