janigo — docker compose up --build
BUILD
$

Django · Python · Docker · Deploy

قصد همکاری داری؟ تماس بگیر

Redis Cache در Django | چی رو Cache کنیم و چی رو نه؟

امیرحسین علیجانی 05 شهریور، 1405 0 دیدگاه
Redis Cache در Django | چی رو Cache کنیم و چی رو نه؟

قبل از خرید سرور، ببین اصلاً چی باید Cache بشه

ردیس یکی از قدرتمندترین ابزارهاییه که می‌تونی برای بهینه‌سازی پروژه‌ات استفاده کنی و کمکت میکنه جلوی کارهای تکراری رو بگیره و اجازه نده سیستم برای تولید دوباره‌ی یه داده‌ی سنگین یا یه کوئری پیچیده، دوباره وقت بذاره. وقتی نتیجه‌ای رو یک‌بار محاسبه می‌کنی و توی کش نگه می‌داری، در واقع داری از منابع سیستم برای کارهای جدیدتر و مفیدتر استفاده می‌کنی

اما برای اینکه از ردیس بهترین استفاده رو ببری، باید هوشمندانه عمل کنی نکته‌ی حیاتی اینجاست که باید مرز بین داده‌های عمومی و داده‌های اختصاصی کاربر رو کاملاً مشخص کنی هدف اینه که داده‌ای رو توی کش نگه داری که هم قابل پیش‌بینی باشه و هم تکراری و در عین حال باید حواست باشه که دیتای شخصی هر کاربر رو با داده‌های اشتراکی قاطی نکنی تا اطلاعات هر کسی دقیقاً بر اساس همون هویت خودش مدیریت بشه اگر این تفکیک رو درست انجام بدی، ردیس می‌تونه سطح کارایی پروژه‌ات رو به یه جای دیگه برسونه

یه بار Cache اضافه کردیم و پروژه بدتر شد

من هم یه زمانی فکر می‌کردم هر Query گرونی رو باید بندازیم پشت Redis و خیال‌مون راحت بشه و یه بار روی یه صفحه لیست، خروجی محصولات رو Cache کردیم سرعت صفحه واقعاً بهتر شد، ولی بعد از تغییر قیمت، بعضی کاربرها هنوز قیمت قبلی رو می‌دیدند و تیم فروش هم فکر می‌کرد مشکل از دیتابیسه، نه از همون Cache قهرمان‌بازی‌درآور ما 😂

مشکل اصلی این بود که فقط ذخیره کردن داده رو دیده بودیم و برای invalidate کردنش تصمیمی نداشتیم نمی‌دونستیم وقتی محصول ویرایش شد، کدوم کلید باید پاک بشه، چه مدت داده معتبره و اگر چند مسیر مختلف از یک داده استفاده کنند، چطور همه‌شون با تغییر جدید هماهنگ می‌شن آخرش هم Debug کردن سخت‌تر شد، چون چیزی که توی دیتابیس می‌دیدیم با چیزی که کاربر می‌گرفت یکی نبود

Cache قرار نیست جای دیتابیس رو بگیره و قرار نیست Query بد رو با یه لایه مخفی بپوشونه اگر Query با select_related یا prefetch_related قابل اصلاحه، اول همون کار رو بکن؛ چون Cache فقط نتیجه رو موقتاً نگه می‌داره و با خودش مسئلهٔ انقضا، دادهٔ قدیمی و پاک‌سازی کلیدها رو هم میاره

جالبه که بعضی وقت‌ها حذف Cache، دیباگ کردن و حتی فهمیدن مشکل واقعی رو ساده‌تر می‌کنه قبل از اینکه چیزی رو Cache کنی، باید بدونی این داده چقدر تغییر می‌کنه، قدیمی شدنش چه هزینه‌ای داره و دقیقاً چه زمانی باید دوباره از منبع اصلی خوانده بشه؛ وگرنه فقط سرعت رسیدن به یک جواب اشتباه رو بیشتر کردی

Redis Cache در Django دقیقاً چه چیزی رو حل می‌کنه؟

Redis قرار نیست جای دیتابیس رو بگیره؛ قراره جلوی تکرار خوا=ندن‌های گران از دیتابیس رو بگیره 

دیتابیس منبع اصلی داده است و باید بتونه اطلاعات رو ماندگار، قابل جست‌وجو و قابل تراکنش نگه داره، اما Cache معمولاً یه کپی موقت از نتیجه‌ای هست که ساختنش هزینه داره و هر لحظه هم ممکنه حذف بشه

فرض کن صفحه اصلی هر بار لیست دسته‌بندی‌ها، تنظیمات سایت یا محصولات پرفروش رو از PostgreSQL میخونه اگر این داده برای همه کاربران تقریباً یکی باشه و هر چند دقیقه یک‌بار تغییر کنه، اجرای دوباره همان Query برای هر Request بیشتر شبیه مالیات اضافه است تا کار مفید؛ اینجا Redis میتونه نتیجه رو نزدیک‌تر و سریع‌تر تحویل بده

جای Redis معمولاً بین Django و دیتابیس قرار می‌گیره  نه داخل خود Django و نه به‌عنوان بخشی از PostgreSQL درخواست وارد View می‌شه و Django اول Cache رو چک می‌کنه؛ اگر داده پیدا شد همون‌جا برمی‌گرده و اگر نبود، از دیتابیس می‌خونه، نتیجه رو توی Redis می‌ذاره و پاسخ رو ارسال می‌کنه

# settings.py
import os

CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": os.environ["REDIS_CACHE_URL"],
        "TIMEOUT": 300,
        "OPTIONS": {
            "db": 1,
        },
        "KEY_PREFIX": "janigo",
    },
}

حالا یک الگوی ساده برای خوندن داده داریم؛ cache.get اول Redis رو بررسی می‌کنه و فقط در Cache Miss سراغ دیتابیس می‌ریم 

مقدار Timeout هم عمداً محدود هست، چون Cache قرار نیست تا ابد زنده بمونه و بعد از تغییر داده، با اعتمادبه‌نفس اطلاعات پنج روز قبل رو نمایش بده

# services/products.py
from django.core.cache import cache

from shop.models import Product


PRODUCTS_CACHE_KEY = "homepage:popular-products:v1"
PRODUCTS_CACHE_TIMEOUT = 60 * 5


def get_popular_products():
    products = cache.get(PRODUCTS_CACHE_KEY)

    if products is None:
        products = list(
            Product.objects.filter(
                is_active=True,
                is_featured=True,
            )
            .only("id", "name", "slug", "price")
            .order_by("-priority", "-created_at")[:12]
        )

        cache.set(
            PRODUCTS_CACHE_KEY,
            products,
            timeout=PRODUCTS_CACHE_TIMEOUT,
        )

    return products

ارزش Cache وقتی مشخص می‌شه که هزینه تولید داده از هزینه خوندنش بیشتر باشه و همون نتیجه چند بار مصرف بشه Query سنگین، محاسبه آماری، پاسخ API بیرونی یا داده‌ای که برای تعداد زیادی کاربر یکسانه و معمولاً کاندید خوبیه؛ ولی برای یک Query ساده که سریع اجرا می‌شه و فقط یک بار مصرف داره، اضافه کردن Redis ممکن است فقط پیچیدگی و یک نقطه خرابی جدید بسازه

یه نکته مهم هم این است که Cache رو منبع حقیقت فرض نکنی. Redis ممکنه Restart شه، کلیدها Expire بشن یا حتی به‌خاطر فشار حافظه حذف بشن؛ کدی که فقط با وجود Cache کار می‌کنه، دیر یا زود در Production زمین می‌خوره. الگوی درست اینه که نبودن Cache همیشه قابل مدیریت باشه و سیستم بتونه دوباره داده رو از دیتابیس بسازه، حتی اگر اون لحظه کمی کندتر جواب بده

قبل از اضافه کردن Redis، یک بار با ابزارهایی مثل Django Debug Toolbar یا لاگ Queryها ببین واقعاً چه چیزی تکراری و پرهزینه است. اگر صفحه فقط چند Query سبک اجرا می‌کنه، Cache کردنش احتمالاً راه حل نیست؛ اول باید Query اضافه، نبود Index یا N+1 رو درست کنی، چون Redis قرار نیست روی یک Query بد، روکش طلا بکشه !

چه چیزهایی معمولاً کاندید خوب Cache هستند؟

هر داده‌ای که خوندنش گرونه ولی تغییراتش کم اتفاق میفته، معمولاً کاندید خوبی برای Cache محسوب میشه. تنظیمات عمومی سایت، لیست دسته‌بندی‌ها، منوها، لیست شهرها و کشورها یا هر داده‌ای که صدها بار خوانده میشه اما هر چند ساعت یک‌بار تغییر میکنه، گزینه‌های کم‌ریسکی هستند؛ چون خروجی برای بیشتر کاربران یکیه و لازم نیست هر بار دوباره از دیتابیس گرفته بشه

یه بار روی صفحه فروشگاه، لیست دسته‌بندی‌ها از چند جدول مختلف ساخته میشد و با هر درخواست دوباره Query می‌خورد. خود Query خیلی عجیب نبود، اما وقتی صفحه پرترافیک شد، همین خواندن‌های تکراری فشار اصلی رو ساخت؛ اینجا Cache کردن نتیجه، نه خود QuerySet، تصمیم منطقی‌تری بود

# catalog/services.py

from collections.abc import Callable
from typing import Any

from django.core.cache import cache
from django.db.models import QuerySet

from .models import Category


PUBLIC_CATEGORIES_CACHE_KEY = "catalog:public-categories:v1"
PUBLIC_CATEGORIES_CACHE_TTL = 60 * 15  # 15 minutes


def _load_public_categories() -> list[dict[str, Any]]:
    """
    QuerySet را عمداً به list تبدیل می‌کنیم تا نتیجه قبل از Cache شدن
    کامل evaluate شده باشد و آبجکت lazy داخل Cache نرود.
    """
    return list(
        Category.objects
        .filter(is_active=True)
        .order_by("sort_order", "name")
        .values("id", "name", "slug")
    )


def get_public_categories() -> list[dict[str, Any]]:
    """
    خروجی یکسان برای همه کاربران؛ مناسب برای Cache عمومی.
    """
    cached_categories = cache.get(PUBLIC_CATEGORIES_CACHE_KEY)

    if cached_categories is not None:
        return cached_categories

    categories = _load_public_categories()

    cache.set(
        PUBLIC_CATEGORIES_CACHE_KEY,
        categories,
        timeout=PUBLIC_CATEGORIES_CACHE_TTL,
    )

    return categories

اینجا چند نکته کوچک جلوی چند دردسر بزرگ رو می‌گیره کلید Cache نسخه داره، پس اگر ساختار خروجی عوض شد می‌تونیم v1 رو به v2 تغییر بدیم و منتظر انقضای کلید قبلی نمونیم؛ ضمن اینکه list(...) باعث میشه QuerySet تنبل و وابسته به اتصال دیتابیس داخل Redis ذخیره نشه

نتایج Queryهای سنگین هم کاندید خوبی هستند، مخصوصاً وقتی گزارش یا آمار برای ورودی‌های یکسان بارها درخواست میشه. مثلاً تعداد سفارش‌های یک روز، مجموع فروش یک فروشگاه یا خروجی یک داشبورد عمومی را میشه با کلیدی که پارامترهای گزارش را داخل خودش دارد Cache کرد؛ فقط باید حواست باشه کلید برای هر ترکیب ورودی یکتا باشد، وگرنه گزارش یک کاربر را تحویل کاربر دیگری میدی که از نظر فنی هم جذاب نیست

داده‌های محاسباتی هم همین وضعیت را دارند. اگر تولید خروجی نیاز به چند Query، پردازش فایل، فراخوانی API بیرونی یا محاسبه سنگین دارد و لازم نیست نتیجه در همان میلی‌ثانیه تغییر کند، Cache ارزش خودش را نشان میدهد؛ نمونه‌اش نرخ‌های تبدیل، امتیازهای تجمیعی، آمار صفحه اصلی یا خروجی یک الگوریتم پیشنهاد ساده است

البته «کم‌تغییر» یعنی واقعاً کم‌تغییر، نه اینکه فقط امیدوار باشیم کسی آن را آپدیت نکند. اگر تنظیمات یا دسته‌بندی بعد از ذخیره شدن باید فوری در سایت دیده شود، یا TTL کوتاه انتخاب کن یا هنگام تغییر، کلید مربوطه را پاک کن؛ Cache بدون برنامه invalidation خیلی زود از ابزار Performance تبدیل میشه به موزه داده‌های قدیمی

چه چیزهایی را Cache نکنیم که فردا دنبال باگ نگردیم؟

هر داده‌ای که به کاربر، نقش کاربر یا وضعیت همین لحظه وابسته است، کاندید خوبی برای Cache نیست؛ حداقل نه بدون طراحی دقیق کلید Cache و زمان انقضا. من هم یه زمانی خروجی صفحه پروفایل را Cache کردم چون Queryهایش زیاد بود، بعد کاربرها اطلاعات همدیگر را می‌دیدند و تازه فهمیدیم مشکل از Redis نیست، از این بوده که کلید Cache فقط اسم View را شامل می‌شده

داده‌های شخصی مثل پروفایل کاربر، سفارش‌ها، آدرس‌ها، فاکتورها و اعلان‌های خصوصی را به شکل عمومی Cache نکن. اگر واقعاً لازم است Cache شوند، کلید باید حداقل شناسه کاربر، Tenant و گاهی نقش یا سطح دسترسی را پوشش بدهد؛ با این حال برای خیلی از این داده‌ها، نگه‌داشتن Cache ارزش ریسکی که ایجاد می‌کند را ندارد، چون یک اشتباه کوچک در Key می‌تواند اطلاعات کاربر دیگری را برگرداند

# views.py
from django.contrib.auth.decorators import login_required
from django.shortcuts import render

from .models import Order


@login_required
def account_orders(request):
    # این داده به کاربر فعلی وابسته است و عمومی نیست؛ Cache نمی‌کنیم.
    orders = (
        Order.objects
        .filter(user=request.user)
        .select_related("payment")
        .order_by("-created_at")
    )

    return render(
        request,
        "account/orders.html",
        {"orders": orders},
    )

داده‌های حساس مثل Access Token، Refresh Token، کدهای بازیابی، اطلاعات پرداخت، محتوای پیام خصوصی و هر چیزی که اگر لو برود دردسر امنیتی درست می‌کند را هم وارد Cache نکن. Redis را فقط چون داخل شبکه داخلی است امن فرض نکن؛ ممکن است لاگ، مانیتورینگ، بکاپ یا دسترسی اشتباه باعث شود چیزی که نباید، جای دیگری دیده شود. Cache محل نگه‌داری موقت داده‌های قابل بازسازی است، نه گاوصندوق پروژه

داده‌های لحظه‌ای هم معمولاً انتخاب بدی هستند؛ موجودی کالا، وضعیت پرداخت، اعتبار کیف پول، ظرفیت باقی‌مانده یک رویداد و وضعیت پردازش یک Job را اگر بدون استراتژی دقیق Cache کنی، کاربر عدد قدیمی می‌بیند و سیستم هم تصمیم اشتباه می‌گیرد. مثلاً اگر موجودی محصول را ۶۰ ثانیه Cache کنی، ممکن است دو نفر آخرین واحد را هم‌زمان بخرند، در حالی که Cache هنوز می‌گوید موجودی وجود دارد؛ اینجا چند میلی‌ثانیه تأخیر، تبدیل به مشکل واقعی کسب‌وکار می‌شه

داده‌های وابسته به Permission و دسترسی هم خطرناک‌اند، حتی اگر ظاهراً عمومی به نظر برسند. لیست پروژه‌ها، منوهای پنل، دکمه‌های عملیات، گزارش‌های سازمانی و خروجی‌هایی که بر اساس request.user فیلتر می‌شوند را نباید با یک کلید مشترک Cache کنی، چون ممکن است پاسخ ساخته‌شده برای ادمین به کاربر عادی برسد. بدترش این است که تست‌های معمولی احتمالاً این باگ را نمی‌بینند و فقط وقتی مشتری واقعی گزارش می‌دهد، سرنخ پیدا می‌شه

از طرفی، داده‌هایی که Invalid کردنشان سخته هم کاندید مناسبی نیستن. فرض کن صفحه محصول را Cache کردی، اما همان صفحه قیمت، تخفیف، موجودی، عنوان، تصویر و وضعیت انتشار رو با هم نشون می‌ده؛ حالا با تغییر هرکدوم باید چند کلید رو پاک کنی و اگر رابطه‌ها پیچیده باشن، احتمال داره بخشی از صفحه قدیمی بمونه. در پروژه‌های مشابه معمولاً بهتره بخش عمومی و کم‌ریسک مثل مشخصات ثابت محصول رو جدا Cache کنیم و اطلاعات لحظه‌ای مثل قیمت و موجودی رو از منبع اصلی بخونیم

# services/catalog.py
from django.core.cache import cache
from django.db.models import QuerySet

PRODUCT_CACHE_TTL = 300


def get_public_product(product_id: int) -> dict | None:
    """
    فقط اطلاعات عمومی و قابل بازسازی محصول را Cache می‌کند.
    قیمت نهایی، موجودی و Permission عمداً اینجا نیستند.
    """
    key = f"catalog:public-product:v1:{product_id}"

    return cache.get_or_set(
        key,
        lambda: _load_public_product(product_id),
        timeout=PRODUCT_CACHE_TTL,
    )


def _load_public_product(product_id: int) -> dict | None:
    from catalog.models import Product

    product: QuerySet = (
        Product.objects
        .filter(id=product_id, is_published=True)
        .values("id", "name", "slug", "description", "image_url")
        .first()
    )

    return product

یک نکته مهم اینه که کوتاه بودن timeout مشکل طراحی بد رو حل نمی‌کنه. اگر پاسخ اشتباه حتی برای ۳۰ ثانیه هم به کاربر نادرست برسه، همون ۳۰ ثانیه کافی هست تا اطلاعات خصوصی لو بره یا یک سفارش اشتباه ثبت بشه؛ TTL برای کنترل عمر داده است، نه جایگزین Permission و امنیت

برای داده‌های شخصی، حساس، لحظه‌ای یا وابسته به سطح دسترسی، اول از خودت بپرس اگر Cache خراب شد، بدترین اتفاق چیه؟ اگر جوابش «کاربر فقط چند ثانیه عدد قدیمی می‌بینه» هست، شاید Cache قابل بررسی باشه؛ اما اگر جوابش «اطلاعات کاربر دیگه نمایش داده می‌شه یا پول اشتباه جابه‌جا می‌شه» است، همون Query را نگه دار و سراغ بهینه‌سازی دیتابیس، select_related، prefetch_related یا ایندکس برو. Redis قرار نیست مسئولیت تصمیم‌های حساس سیستم رو به عهده بگیرد
اینارو بخون اگر میخوای ایندکس گذاری و یاد بگیری یا مشکل N + 1 Queries رو حل کنی :
 

پیاده‌سازی درست Cache در Django | از cache.get تا cache_page

برای Cache کردن نتیجه Query اول باید کلید درست بسازی، بعد مقدار رو بخونی و فقط وقتی نبود سراغ دیتابیس بری . کلید باید همه چیزهایی رو که روی خروجی اثر دارن داخل خودش داشته باشه؛ مثلاً زبان، صفحه‌بندی، فیلترها و نسخه‌ی داده اگر این بخش رو شل بگیری، ممکنه خروجی کاربر قبلی به کاربر بعدی تحویل داده بشه و Redis هم خیلی خونسرد فقط تماشا کنه

فرض کن یه لیست محصول داری که با دسته‌بندی، صفحه و زبان تغییر می‌کنه خود QuerySet رو مستقیم Cache نکن، چون هم رفتار Lazy اون دردسر درست می‌کنه و هم وابستگی به ساختار داخلی QuerySet برای Cache مناسب نیست؛ نتیجه‌ی نهایی مثل لیست dict یا IDها انتخاب قابل‌اعتمادتری است

# catalog/services.py

from __future__ import annotations

from typing import Any

from django.core.cache import cache
from django.db.models import QuerySet

from .models import Product


PRODUCT_LIST_TIMEOUT = 60 * 5  # پنج دقیقه


def product_list_cache_key(
    *,
    category_slug: str | None,
    page: int,
    language: str,
) -> str:
    category = category_slug or "all"

    # نسخه را عمداً داخل کلید گذاشته‌ایم تا بعداً بتوانیم
    # بدون cache.clear، همه‌ی کلیدهای این خانواده را بی‌اعتبار کنیم.
    return f"products:v2:{language}:{category}:page:{page}"


def get_product_list(
    *,
    category_slug: str | None,
    page: int,
    language: str,
    page_size: int = 24,
) -> list[dict[str, Any]]:
    key = product_list_cache_key(
        category_slug=category_slug,
        page=page,
        language=language,
    )

    cached_products = cache.get(key)
    if cached_products is not None:
        return cached_products

    products: QuerySet[Product] = Product.objects.filter(
        is_active=True,
    ).only(
        "id",
        "name",
        "slug",
        "price",
        "category_id",
    )

    if category_slug:
        products = products.filter(category__slug=category_slug)

    start = max(page - 1, 0) * page_size
    end = start + page_size

    value = list(
        products.order_by("-created_at")[start:end].values(
            "id",
            "name",
            "slug",
            "price",
            "category_id",
        )
    )

    cache.set(key, value, timeout=PRODUCT_LIST_TIMEOUT)
    return value

نکته‌ی مهم اینجا if cached_products is not None است، نه if cached_products. اگر نتیجه‌ی معتبرت یک لیست خالی باشه، شرط دوم اون رو Cache Miss حساب می‌کنه و هر بار دوباره Query می‌زنه. همین جزئیات ریزن که در Production تبدیل به Query اضافه و لاگ‌های عجیب می‌شن

برای خوندن و ساختن مقدار، cache.get_or_set هم گزینه‌ی تمیزی هست، مخصوصاً وقتی منطق Query کوتاه باشه. با این حال در بار خیلی زیاد ممکن است چند Request هم‌زمان یک Cache Miss بخورن و همه با هم Query رو اجرا کنن؛ اگر این Query سنگین هست، قفل کوتاه‌مدت یا الگوی single-flight هم لازم می‌شه، چون Cache قرار نیست مسابقه‌ی اجرای Query راه بندازه !

from django.core.cache import cache

def get_featured_products() -> list[dict]:
    key = "products:v1:featured"

    return cache.get_or_set(
        key,
        lambda: list(
            Product.objects.filter(
                is_active=True,
                is_featured=True,
            )
            .order_by("-updated_at")
            .values("id", "name", "slug", "price")[:12]
        ),
        timeout=60 * 10,
    )

Timeout رو بر اساس سرعت تغییر داده انتخاب کن، نه با عدد رندوم مثل «بذار یک روز بمونه، بعداً می‌بینیم» لیست پرفروش‌ها شاید چند دقیقه‌ای قابل Cache باشه، ولی موجودی و قیمت اگر روی خرید اثر مستقیم دارند، یا باید کوتاه Cache بشن یا هنگام تغییر دقیقاً Invalidate بشن وگرنه کاربر قیمت قدیمی می‌بینه و تیم پشتیبانی باید توضیح بده Redis چرا تصمیم گرفته حسابداری کنه

برای حذف Cache، همون کلیدی که موقع خوندن ساختی دوباره بساز و فقط همان و پاک کن cache.clear() در محیط واقعی تقریباً مثل خاموش کردن برق ساختمون برای پیدا کردن یک لامپ سوخته است؛ ممکن مشکل رو بپوشونه و پنهانش کنه، ولی همه‌ی Cacheهای unrelated رو هم می‌پرونه

# catalog/cache.py

from django.core.cache import cache

from .services import product_list_cache_key


def invalidate_product_list_cache(
    *,
    category_slug: str | None,
    language: str,
) -> None:
    # اگر صفحه‌های زیادی داری، بهتر است کلیدها با versioning
    # یا registry مشخص مدیریت شوند؛ اینجا فقط چند صفحه‌ی اول را پاک می‌کنیم.
    for page in range(1, 11):
        key = product_list_cache_key(
            category_slug=category_slug,
            page=page,
            language=language,
        )
        cache.delete(key)

حذف Cache رو بعد از Commit موفق دیتابیس انجام بده، نه وسط تراکنش. اگر تراکنش Rollback بشه ولی Cache رو پاک کرده باشی، فقط یک Cache Miss اضافه داری؛ اما اگر اول Cache رو پر کنی و بعد تراکنش شکست بخوره ممکنه داده‌ای رو Cache کرده باشی که اصلاً در دیتابیس نهایی نشده باشه

# catalog/signals.py

from django.core.cache import cache
from django.db import transaction
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver

from .cache import invalidate_product_list_cache
from .models import Product


def invalidate_product_cache_after_commit(
    *,
    category_slug: str | None,
) -> None:
    transaction.on_commit(
        lambda: invalidate_product_list_cache(
            category_slug=category_slug,
            language="fa",
        )
    )


@receiver(post_save, sender=Product)
def product_saved(
    sender,
    instance: Product,
    **kwargs,
) -> None:
    invalidate_product_cache_after_commit(
        category_slug=instance.category.slug if instance.category_id else None,
    )


@receiver(post_delete, sender=Product)
def product_deleted(
    sender,
    instance: Product,
    **kwargs,
) -> None:
    invalidate_product_cache_after_commit(
        category_slug=instance.category.slug if instance.category_id else None,
    )

البته Signal برای پروژه‌های بزرگ همیشه بهترین انتخاب نیست، چون تغییر قیمت ممکنه از چند مسیر مختلف بیاد و پیدا کردن همه‌ی مسیرهای Invalidation سخت بشه. در پروژه‌های جدی‌تر معمولاً یک Service مشخص برای تغییر محصول داری و همون‌جا بعد از Commit، Cacheهای مربوط به محصول و لیست‌ها رو پاک می‌کنی؛ حداقل همه می‌دونن این مسئولیت کجاست

برای Viewهایی که خروجی کامل‌شان بر اساس URL و چند Header مشخص تغییر می‌کنه، cache_page انتخاب ساده‌تری هست این Decorator کل پاسخ View رو Cache می‌کنه، پس نباید روی صفحه‌ای استفاده بشه که محتوای شخصی، Cookie حساس یا Permission وابسته به کاربر داره؛ مگر اینکه دقیقاً بدانی Vary و کلید Cache چطور ساخته می‌شن

# catalog/views.py

from django.views.decorators.cache import cache_page
from django.views.decorators.vary import vary_on_headers
from django.http import JsonResponse
from django.views import View

from .services import get_product_list


@cache_page(60 * 2, key_prefix="catalog-products")
@vary_on_headers("Accept-Language")
class ProductListView(View):
    def get(self, request):
        try:
            page = max(int(request.GET.get("page", "1")), 1)
        except ValueError:
            page = 1

        category_slug = request.GET.get("category")
        language = request.headers.get("Accept-Language", "fa").split(",")[0]

        products = get_product_list(
            category_slug=category_slug,
            page=page,
            language=language,
        )

        return JsonResponse(
            {
                "page": page,
                "results": products,
            }
        )

اگر از cache_page استفاده می‌کنی، حواست به چیزهایی باشه که واقعاً توی پاسخ اثر دارن؛ Query String معمولاً بخشی از کلید می‌شه، اما Header یا Cookie فقط وقتی درست مدیریت شده که Vary مناسب داشته باشی. برای endpointهایی که خروجی‌شون با request.user تغییر می‌کنه، Cache عمومی صفحه معمولاً ایده‌ی بدی است و بهتر است نتیجه‌ی غیرحساس رو با کلیدی شامل شناسه‌ی کاربر Cache کنی، یا اصلاً Cache نکنی

تنظیمات Backend هم باید از اول شفاف باشه تا در Production ناگهان بفهمی Cache روی حافظه‌ی هر Worker پخش شده و هیچ‌کس همدیگر رو نمی‌بینه برای چند Process یا چند سرور، Redis مشترک لازم داری و بهتر است نام‌گذاری کلیدها با Prefix و Version انجام بشه تا پاک‌سازی هدفمند بمونه

# settings.py

CACHES = {
    "default": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": env(
            "REDIS_CACHE_URL",
            default="redis://127.0.0.1:6379/1",
        ),
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
            "SOCKET_CONNECT_TIMEOUT": 2,
            "SOCKET_TIMEOUT": 2,
            "IGNORE_EXCEPTIONS": True,
        },
        "KEY_PREFIX": "janigo",
        "TIMEOUT": 300,
    },
}

IGNORE_EXCEPTIONS باعث میشه قطع شدن Redis کل سایت رو نخوابونه و برنامه همون لحظه بدون Cache به کارش ادامه بده، ولی این به این معنی نیست که باید خطا رو کلاً بی‌خیال بشی؛ قطع شدن Redis باید Metric و Alert داشته باشه. Cache یه لایه برای بهینه‌سازیه، نه یه دیتابیس دوم؛ اگه با حذف شدن Cache برنامه از نظر correctness خراب میشه، مشکل از یه جای دیگه‌ی طراحیته

اشتباه‌های رایج Redis Cache که توی Production دردسر می‌سازن

Redis خودش قرار نیست پروژه رو نجات بده؛ فقط سرعت خوندن یه سری داده‌ها رو بیشتر می‌کنه. اگه بدون قانون مشخص شروع کنی هر چیزی رو Cache کنی، یه مدت که بگذره با داده‌های قدیمی، کلیدهای درهم‌ریخته و باگ‌هایی طرف میشی که پیدا کردنشون حتی از کندی اولیه هم سخت‌تره

Cache Stampede؛ وقتی همه با هم می‌ریزن سر دیتابیس

فرض کن Cache مربوط به صفحه اصلی بعد از ده دقیقه منقضی میشه و دقیقاً همون لحظه هزار تا درخواست هم‌زمان به سرور می‌رسه. اگه همه‌شون Cache Miss بگیرن، هر هزار درخواست میرن سمت دیتابیس و همون داده رو دوباره تولید می‌کنن؛ این دقیقاً همون Cache Stampede ـه که Redis رو سالم نگه می‌داره، ولی دیتابیس رو به زانو درمیاره

برای داده‌های سنگین، فقط TTL گذاشتن کافی نیست. معمولاً باید از lock، بازسازی تدریجی Cache، jitter برای تصادفی کردن زمان انقضا یا stale-while-revalidate استفاده کنی تا همه درخواست‌ها هم‌زمان وارد مسیر محاسبه نشن؛ وگرنه Cache فقط انفجار رو چند دقیقه عقب انداخته

کلیدهای بی‌قاعده، کابوس پاک‌سازی

کلیدهایی مثل home_data یا user_123 اول کار خیلی ساده و تمیز به نظر میان، ولی وقتی نسخه API، زبان، tenant یا نوع کاربر وارد ماجرا میشه، خیلی سریع به مشکل می‌خوری. Key باید یه ساختار مشخص داشته باشه؛ مثلاً v2:tenant:{tenant_id}:user:{user_id}:dashboard:{locale} خیلی بهتر از یه رشته‌ایه که هر توسعه‌دهنده با سلیقه خودش ساخته

اگه الگوی نام‌گذاری نداشته باشی، invalidate کردن Cache تقریباً تبدیل میشه به حدس زدن. یه prefix مشخص برای پروژه و نسخه داده بذار، پارامترهای مؤثر رو داخل Key بیار و قبل از اینکه ساختار داده رو تغییر بدی، نسخه Key رو عوض کن؛ آخرش پاک کردن v1:* خیلی کم‌دردسرتر از شکار کردن یه مشت Key پراکنده‌ست

Timeout طولانی؛ داده‌ای که عملاً هیچ‌وقت تازه نمیشه

TTL دو ساعته برای یه لیست عمومی شاید کاملاً قابل قبول باشه، ولی برای موجودی کالا، وضعیت پرداخت یا سطح دسترسی کاربر می‌تونه فاجعه درست کنه. مشکل فقط این نیست که یه داده قدیمی به کاربر نشون بدی؛ گاهی کاربر بر اساس همون داده تصمیم می‌گیره و سیستم بعداً باید تاوانش رو بده

از اون طرف، TTL خیلی کوتاه هم فشار روی دیتابیس رو دوباره برمی‌گردونه سر جای اول. برای هر نوع داده باید میزان تغییر، هزینه محاسبه و حساسیت به تازه بودن داده رو جدا بررسی کنی و اگه داده با یه تغییر مشخص بی‌اعتبار میشه، فقط به انقضای زمانی تکیه نکنی؛ همون موقع Cache مرتبط رو invalidate کن

Cache کردن پاسخ خطادار

یکی از اشتباه‌های خطرناک اینه که نتیجه هر درخواست رو بدون توجه به status code بذاری داخل Cache. اگه یه پاسخ ۵۰۰ یا حتی ۴۰۴ موقتی Cache بشه، کاربر ممکنه تا آخر TTL همون خطا رو ببینه، در حالی که مشکل اصلی شاید چند ثانیه بعد برطرف شده باشه

معمولاً پاسخ‌های موفق، اون هم با شرط‌های مشخص، کاندید Cache هستن. خطاهای داخلی، پاسخ‌های وابسته به مجوز و نتیجه‌هایی که ممکنه به خاطر قطعی موقت یه سرویس خراب شده باشن، نباید همین‌جوری و بدون فکر وارد Cache بشن؛ مخصوصاً وقتی هیچ مسیر مشخصی برای پاک کردنشون ننوشتی

نادیده گرفتن Multi-tenant

توی پروژه‌های چندمستاجری، فراموش کردن tenant_id داخل Cache Key فقط یه باگ معمولی نیست؛ می‌تونه باعث بشه داده یه مشتری به مشتری دیگه نشون داده بشه. این اتفاق معمولاً با یه تست ساده توی محیط توسعه خودش رو نشون نمیده، چون لوکال همه با یه tenant کار می‌کنن و مشکل بعداً توی Production خودش رو نشون میده

هر داده‌ای که به tenant وابسته‌ست باید tenant رو هم داخل Key، هم داخل scope کوئری و هم توی منطق invalidate شدن داشته باشه. اگه کاربر هم روی داده اثر داره، user_id، نقش، زبان یا هر پارامتر مؤثر دیگه‌ای هم باید توی طراحی Key دیده بشه؛ Cache کردن داده بدون اینکه مرز مالکیتش رو مشخص کرده باشی، عملاً دعوت‌نامه رسمی برای نشت اطلاعاته

داده منقضی، حتی وقتی TTL هنوز تموم نشده

بعضی وقت‌ها داده از نظر Redis هنوز منقضی نشده، ولی از نظر کسب‌وکار دیگه معتبر نیست. مثلاً قیمت محصول با یه تغییر از سمت ادمین، وضعیت سفارش با دریافت webhook یا دسترسی کاربر با تغییر نقش باید همون لحظه بی‌اعتبار بشه، نه اینکه صبر کنی تایمر Cache بالاخره تموم بشه.

برای همین Cache رو نباید منبع اصلی حقیقت حساب کنی. دیتابیس یا سرویس اصلی باید مرجع باشه و Cache فقط نسخه سریع‌تر داده رو نگه داره؛ اگه قراره تغییرات مهم باعث پاک شدن Cache بشن، این رفتار رو همون جایی که داده تغییر می‌کنه پیاده کن و براش تست بنویس، وگرنه یه روز می‌بینی داده درست توی دیتابیسه ولی همه کاربرها دارن نسخه قدیمی رو می‌بینن

توی پروژه‌های مشابه، مشکل اصلی Redis معمولاً خود Redis نیست؛ مشکل اون قراردادیه که بین کد، دیتابیس و Cache درست تعریف نشده. قبل از اینکه هر Key جدیدی اضافه کنی باید معلوم باشه چه کسی اون رو می‌سازه، چه زمانی معتبره، چی منقضیش می‌کنه و اگه Redis در دسترس نبود، برنامه چطوری بدون اون به کارش ادامه میده

چک‌لیست تصمیم‌گیری قبل از Cache کردن هر داده

قبل از اینکه هر خروجی سنگینی رو بفرستی داخل Redis، اول از خودت بپرس: این داده واقعاً گرون تولید میشه یا فقط من حس می‌کنم گرونه؟ تعداد Queryها، زمان اجرای Query، تعداد رکوردهایی که خونده میشن و حجم پردازش رو اندازه بگیر؛ چون Cache کردن یه Query پنج‌میلی‌ثانیه‌ای معمولاً فقط پیچیدگی اضافه می‌کنه، نه Performance واقعی

۱. تولید این داده واقعاً هزینه‌بره؟

اگه برای ساختن خروجی چندین Query، Join، Aggregate یا پردازش پایتونی داری، Cache می‌تونه کاندید خوبی باشه. ولی اگه داده با یه Query ساده و از روی ایندکس خونده میشه، اول ایندکس و Query رو درست کن؛ Redis قرار نیست جای EXPLAIN رو بگیره

۲. داده چقدر تغییر می‌کنه؟

داده‌ای که هر چند دقیقه یا هر چند ساعت تغییر می‌کنه معمولاً Cache مناسبیه، چون یه مقدار ثابت رو چندین بار سرو می‌کنی. ولی اگه رکورد هر چند ثانیه تغییر می‌کنه، باید هزینه قدیمی بودن داده رو هم حساب کنی؛ وگرنه فقط داری نسخه اشتباه رو سریع‌تر تحویل کاربر میدی

۳. قدیمی شدن داده قابل قبوله؟

برای لیست دسته‌بندی‌ها، تنظیمات عمومی یا آمار تقریبی، چند دقیقه قدیمی بودن معمولاً فاجعه نیست. ولی برای موجودی کالا، وضعیت پرداخت، اعتبار حساب یا دسترسی کاربر، جواب قدیمی می‌تونه یه باگ واقعی و حتی یه مشکل مالی درست کنه؛ اینجا Cache فقط وقتی قابل قبوله که سیاست تازگی داده دقیق و قابل دفاع داشته باشی.

۴. داده عمومی یا وابسته به کاربره؟

داده‌ای که برای همه یکیه، ساده‌ترین گزینه برای Cache کردنه و معمولاً یه Key عمومی مثل homepage:featured براش کافیه. ولی وقتی خروجی به کاربر، سازمان، زبان، نقش یا Query String وابسته میشه، همه این وابستگی‌ها باید داخل Cache Key بیان؛ وگرنه ممکنه خروجی کاربر A به کاربر B نمایش داده بشه، که دیگه اسمش Cache Bug نیست، اسمش حادثه‌ست

۵. داده حساس داخلشه؟

توکن، اطلاعات پرداخت، ایمیل خصوصی، سطح دسترسی و هر چیزی که نباید به کاربر دیگه‌ای برسه، کاندید پیش‌فرض Cache عمومی نیست. حتی اگه برای هر کاربر Cache خصوصی بسازی، باید مطمئن بشی Keyها درست namespace شدن، TTL منطقی دارن و داده حساس بدون نیاز برای مدت طولانی توی Redis باقی نمی‌مونه

۶. برای Invalid کردن Cache چه برنامه‌ای داری؟

قبل از اینکه cache.set کنی، باید جواب cache.delete رو هم بدونی. اگه بعد از تغییر محصول، خروجی لیست محصولات Cache شده باید حذف بشه، این حذف دقیقاً کجا انجام میشه؟ داخل Signal، Service Layer، View یا Task؟ اگه جواب مشخصی برای این سؤال نداری، احتمالاً Cache بعد از یه مدت تبدیل میشه به انبار جواب‌های قدیمی که هیچ‌کس جرئت پاک کردنشون رو نداره

۷. TTL فقط یه عدد تصادفی نیست

TTL باید بر اساس رفتار داده و هزینه قدیمی شدنش انتخاب بشه، نه اینکه برای همه‌چی timeout=3600 بذاری و بری سراغ کار بعدی. برای داده کم‌اهمیت می‌تونی TTL کوتاه و قابل تحمل داشته باشی، ولی برای داده سنگین‌تر شاید ترکیب TTL طولانی با Invalid کردن دقیق انتخاب بهتری باشه؛ این دوتا رقیب هم نیستن و خیلی وقت‌ها کنار هم استفاده میشن

۸. اگه Redis خالی شد، برنامه هنوز کار می‌کنه؟

Cache باید شتاب‌دهنده باشه، نه تنها منبع حقیقت. اگه Redis Restart شد، Keyها Evict شدن یا اتصال موقتاً قطع شد، درخواست باید بتونه داده رو از Database بسازه و دوباره Cache کنه؛ مگر اینکه عمداً از Redis به عنوان Storage استفاده کرده باشی که اون دیگه بحث یه Cache ساده Django نیست

برای هر داده این چند سؤال رو روی یه کاغذ یا داخل Ticket بنویس: تولیدش چقدر هزینه داره، چند بار خونده میشه، چقدر تغییر می‌کنه، قدیمی شدنش قابل قبوله یا نه، به کاربر وابسته‌ست یا نه و دقیقاً چه زمانی باید Invalid بشه. اگه برای یکی از اینا جواب روشنی نداری، فعلاً Cache نکردنش تصمیم امن‌تریه؛ چون Debug کردن داده قدیمی معمولاً از کند بودن Query اعصاب‌خردکن‌تره

قبل از اینکه هر چیزی رو سریع‌تر کنی، مطمئن شو قرار نیست همون جواب قدیمی رو فقط با سرعت بیشتری تحویل بدی

امیرحسین علیجانی

امیرحسین علیجانی

برنامه‌نویس Django و Python

درباره نویسنده · نمونه کارها

ارسال دیدگاه

نام
ایمیل
متن دیدگاه