پرش به محتوای اصلی
زیرساخت و لینوکس
۴ دقیقه مطالعه

کش دولایه با Nginx و Redis برای ترافیک ناگهانی کمپین‌ها

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

برنامه‌نویس فول‌استک

وقتی یک کمپین تبلیغاتی یا استوری پربازدید منتشر می‌شود، در چند دقیقه چند برابر ترافیک عادی به سایت می‌رسد. اگر هر درخواست تا دیتابیس برود، سرور زودتر از کمپین تمام می‌شود و بدترین زمان ممکن برای کند شدن سایت است: وقتی بیشترین مشتری را دارید. کش دولایه این فشار را جذب می‌کند. اما کش اشتباه هم خطرناک است: قیمت قدیمی، موجودی‌ای که تمام شده، یا بدتر، سبد خرید یک کاربر که به کاربر دیگر نشان داده شود. این مقاله پیکربندی‌ای است که هر دو طرف را در نظر می‌گیرد.

اول تصمیم بگیرید چه چیزی کش نشود

قبل از هر تنظیمی، فهرست چیزهایی را بنویسید که هرگز نباید کش شوند. این مهم‌تر از خود پیکربندی است:

بقیه، یعنی صفحه اصلی، دسته‌بندی‌ها، صفحه محصول برای کاربر مهمان و مقالات، برای همه کاربران یکسان‌اند و می‌توانند چند ثانیه کهنه باشند.

نکات کلیدی

  • سبد خرید، پرداخت، پنل کاربر و هر صفحه‌ای که به ورود کاربر وابسته است.
  • هر پاسخی که کوکی نشست یا Authorization دارد.
  • APIهای ثبت (POST، PUT، DELETE).
  • موجودی و قیمت در مرحله نهایی سفارش؛ این‌ها همیشه از دیتابیس خوانده می‌شوند.

لایه اول: میکروکش Nginx

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

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

nginx
proxy_cache_path /var/cache/nginx/micro levels=1:2 keys_zone=micro:20m max_size=1g inactive=10m use_temp_path=off;

map $http_cookie $skip_cache {
    default            0;
    ~*session          1;   # هر کاربر واردشده
    ~*cart             1;
}

server {
    location / {
        proxy_pass http://app;
        proxy_cache micro;
        proxy_cache_key "$scheme$host$request_uri";
        proxy_cache_valid 200 5s;
        proxy_cache_lock on;
        proxy_cache_lock_timeout 5s;
        proxy_cache_use_stale updating error timeout http_500 http_502 http_503;
        proxy_cache_bypass $skip_cache;
        proxy_no_cache $skip_cache;
        add_header X-Cache $upstream_cache_status always;   # HIT / MISS / BYPASS
    }

    location ~ ^/(cart|checkout|account|api)/ {
        proxy_pass http://app;          # بدون کش
    }
}

کلید کش: جایی که اشتباه‌ها پنهان‌اند

کلید کش تعیین می‌کند دو درخواست «یکسان» حساب شوند یا نه. اگر کلید فقط مسیر باشد و صفحه بر اساس چیز دیگری (کوکی زبان، نوع دستگاه، شهر) فرق کند، کاربر نسخه اشتباه را می‌بیند. اگر کلید همه پارامترها را داشته باشد، پارامترهای بی‌معنی (مثل utm کمپین) هر درخواست را منحصربه‌فرد و کش را بی‌اثر می‌کنند.

برای کمپین‌ها این دومی خیلی مهم است: لینک‌های تبلیغاتی پارامتر utm دارند و دقیقاً همان ترافیکی هستند که می‌خواهید کش شوند. پارامترهای ردیابی را از کلید کش حذف کنید (یا در سمت کاربر بخوانید) تا همه بازدیدکنندگان کمپین از یک نسخه کش‌شده جواب بگیرند.

لایه دوم: کش داده در Redis

بعضی بخش‌ها نمی‌توانند در سطح کل صفحه کش شوند، اما داده‌هایشان سنگین و پرتکرار است: درخت دسته‌بندی، منو، تنظیمات سایت، فهرست برندها، محصولات پیشنهادی. این‌ها با الگوی cache-aside در Redis نگهداری می‌شوند: اول Redis را نگاه کن، اگر نبود از دیتابیس بخوان و با زمان انقضا در Redis بگذار.

برای جلوگیری از داده کهنه، دو مکانیزم با هم کار می‌کنند: زمان انقضا (برای اطمینان) و حذف صریح کلید هنگام تغییر داده در پنل مدیریت (برای تازگی). وقتی مدیر قیمت یا دسته‌بندی را عوض می‌کند، کلیدهای مربوط حذف می‌شوند و درخواست بعدی نسخه تازه را می‌سازد.

typescript
const inflight = new Map<string, Promise<unknown>>();

/** cache-aside با جلوگیری از هجوم هم‌زمان: درخواست‌های هم‌زمان یک کلید، یک کوئری مشترک دارند. */
export async function cached<T>(key: string, ttlSec: number, load: () => Promise<T>): Promise<T> {
  const hit = await redis.get(key);
  if (hit) return JSON.parse(hit) as T;
  if (inflight.has(key)) return inflight.get(key) as Promise<T>;
  const p = load()
    .then(async (value) => {
      await redis.set(key, JSON.stringify(value), 'EX', ttlSec);
      return value;
    })
    .finally(() => inflight.delete(key));
  inflight.set(key, p);
  return p;
}

// هنگام ویرایش دسته‌بندی در پنل:
export const invalidateCategories = () => redis.del('categories:tree', 'menu:main');

قبل از کمپین: گرم کردن و اندازه‌گیری

دو کار ساده قبل از هر کمپین بزرگ: اول، صفحه‌هایی که در تبلیغ لینک شده‌اند را چند دقیقه قبل از انتشار یک بار باز کنید تا کش گرم باشد. دوم، با سرآیند X-Cache در پیکربندی بالا، نسبت HIT به MISS را در لاگ ببینید. اگر در اوج ترافیک بیشتر درخواست‌ها MISS یا BYPASS هستند، یعنی کلید کش یا قاعده bypass درست کار نمی‌کند.

یک تست بار ساده (حتی با ابزارهایی مثل ab یا autocannon روی نسخه آزمایشی) قبل از کمپین نشان می‌دهد سرور با و بدون کش چند درخواست در ثانیه را تحمل می‌کند. این عدد را بدانید، نه اینکه در روز کمپین کشفش کنید.

جمع‌بندی

کش خوب یعنی اول بدانید چه چیزی را نباید کش کنید: سبد خرید، پرداخت، هر چیز وابسته به کاربر و قیمت و موجودی نهایی. بقیه با میکروکش چندثانیه‌ای Nginx (با cache_lock و use_stale) و کش داده در Redis (با انقضا و حذف صریح هنگام تغییر) می‌تواند سایت را در اوج ترافیک زنده نگه دارد. کلید کش را دقیق تعریف کنید و پارامترهای ردیابی را از آن بیرون بگذارید. اگر سایت شما در کمپین‌ها کند یا از دسترس خارج می‌شود، در سرور و زیرساخت و سئوی فنی و سرعت سایت کمکتان می‌کنم.

زیرساخت و لینوکس
۴ دقیقه مطالعه

چند سرویس روی یک پورت ۴۴۳: مسیریابی SNI با Nginx Stream

پیکربندی ماژول stream در Nginx با ssl_preread برای هدایت ترافیک TLS چند دامنه به سرویس‌های جدا روی یک IP و پورت ۴۴۳، حفظ IP واقعی کاربر با Proxy Protocol، و خطاهای رایج مثل default_server ضمنی.

مطالعه مقاله#Nginx