ویژگی تصویر

پیاده‌سازی کش (Cache) در Node.js — راهنمای عملی و نکات کلیدی

  /  Node.js   /  پیاده سازی کش (Cache) در Node.js
بنر تبلیغاتی الف
NodeJS - Node.js

کش یا حافظهٔ نهان یکی از ابزارهای اصلی برای بهبود عملکرد اپلیکیشن‌های Node.js است. با کش می‌توان پاسخ‌های پرهزینه را نگه داشت، تأخیر را کاهش داد و بار دیتابیس یا سرویس‌های خارجی را پایین آورد. در این مقاله به مفاهیم، الگوها، پیاده‌سازی‌های متداول (in-memory، Redis)، مشکلات رایج و راهکارهای عملی می‌پردازیم.

چرا و چه زمانی از کش استفاده کنیم؟

  • پاسخ‌های محاسباتی یا I/O پرهزینه را سریع‌تر کنید.
  • محدودیت نرخ (rate limits) سرویس‌های خارجی را مدیریت کنید.
  • افزایش توان لود سرور و کاهش زمان پاسخ (latency).
  • اما مراقب باشید: کش باعث پیچیدگی عملیات، ناسازگاری داده (staleness) و مصرف حافظه می‌شود.

الگوهای معمول کش

  • Cache-Aside (Lazy Loading): قبل از خواندن از دیتابیس، چک کن؛ اگر نبود بارگذاری و کش کن.
  • Write-Through / Write-Behind: هنگام نوشتن، داده هم در کش ذخیره می‌شود (همزمان یا ناهمزمان).
  • Stale-While-Revalidate: پاسخ قدیمی را سریع برگردان، در پس‌زمینه آن را بازسازی کن.

مقایسهٔ راهکارها

مکان ذخیرهمزایامعایب
In-memory (مثلاً lru-cache)بسیار سریع، سادهمحدود به حافظهٔ هر نمونه، از دست رفتن کش در ریستارت
Redis / Memcachedقابل اشتراک بین چند سرور، پایداری بیشتر، eviction بهترنیاز به سرویس جدا، latency شبکه
CDNبهبود برای محتوای استاتیک و HTTPمناسب محتوای عمومی، پیچیدگی invalidation

مثال ۱ — کش درون‌حافظه با lru-cache

const LRU = require('lru-cache');

const options = {
  max: 500,
  ttl: 1000 * 60 * 5 // 5 minutes
};
const cache = new LRU(options);

async function getUser(id) {
  const key = `user:${id}`;
  if (cache.has(key)) {
    return cache.get(key);
  }
  const user = await db.getUserById(id); // فرض دیتابیس
  cache.set(key, user);
  return user;
}

توضیح: این قطعه از بستهٔ lru-cache برای نگهداری تا ۵۰۰ آیتم با TTL پنج دقیقه استفاده می‌کند. ابتدا چک می‌کند آیا مقدار در کش هست؛ در صورت نبود، از دیتابیس می‌خواند و در کش قرار می‌دهد. این الگو Cache-Aside است و برای اپلیکیشن‌های تک‌نمونه مناسب است.

مشکلات In-memory و راهکارها

  • محدودی حافظه: از eviction و محدود کردن اندازه استفاده کنید.
  • مهاجرت و چندنمونه‌ای: In-memory روی هر نمونه جدا است؛ برای بار متعادل یا چند سرور از Redis استفاده کنید.

مثال ۲ — استفاده از Redis با الگوی Cache-Aside

const Redis = require('ioredis');
const redis = new Redis();

async function getProduct(id) {
  const key = `product:${id}`;
  const cached = await redis.get(key);
  if (cached) {
    return JSON.parse(cached);
  }
  const product = await db.getProductById(id);
  await redis.set(key, JSON.stringify(product), 'EX', 60 * 5); // 5 minutes
  return product;
}

توضیح: این مثال از ioredis استفاده می‌کند. ابتدا در Redis دنبال کلید می‌گردیم و اگر نبود از دیتابیس می‌خوانیم و نتیجه را با TTL پنج دقیقه ذخیره می‌کنیم. Redis مناسب محیط‌های توزیع‌شده است و می‌تواند بار را بین سرورها به اشتراک بگذارد.

جلوگیری از Cache Stampede (طوفان کش)

وقتی چند درخواست هم‌زمان به کلیدی که کش آن منقضی شده حمله می‌کنند، همه به دیتابیس می‌روند؛ این مسئله را stampede می‌نامند. چند راهکار:

  • Locking: قبل از بارگذاری از سرویس دیتابیس، یک قفل کوتاه ایجاد کنید (مثلاً SETNX در Redis یا Redlock).
  • Probabilistic early expiration: بعضی آیتم‌ها زودتر منقضی شوند تا بار تعادل یابد.
  • Stale-while-revalidate: مقدار قدیمی را برگردان و در پس‌زمینه رفرِش کن.

نمونه با قفل ساده در Redis

async function getWithLock(id) {
  const key = `item:${id}`;
  const lockKey = `lock:${key}`;
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  const acquired = await redis.set(lockKey, '1', 'NX', 'EX', 5);
  if (!acquired) {
    // اگر قفل گرفته نشده، صبر کوتاهی کن و دوباره تلاش کن
    await new Promise(r => setTimeout(r, 50));
    return getWithLock(id);
  }

  try {
    const item = await db.getItemById(id);
    await redis.set(key, JSON.stringify(item), 'EX', 60);
    return item;
  } finally {
    await redis.del(lockKey);
  }
}

توضیح: این پیاده‌سازی از SET … NX برای گرفتن قفل استفاده می‌کند. اگر قفل گرفته نشود، تابع کمی صبر کرده و دوباره سعی می‌کند. پس از به‌دست‌آوردن قفل، داده خوانده و در کش قرار می‌گیرد و قفل آزاد می‌شود. برای تولیدی، باید از پیاده‌سازی‌هایی مثل Redlock یا کتابخانه‌های آماده برای قفل توزیع‌شده استفاده کنید.

نکات عملی و بهینه‌سازی‌ها

  • برای منابع حجیم فقط فیلدهای مورد نیاز را کش کنید (avoid over-caching).
  • برای داده‌های حساس و تغییرپذیر TTL کوتاه تعیین کنید یا از invalidation دستی پس از به‌روزرسانی استفاده کنید.
  • مراقب serialization و memory bloat باشید: از Buffer/MsgPack برای داده‌های بزرگ استفاده کنید یا compression در Redis.
  • مانیتورینگ: نرخ hit/miss، حجم کش و latency را پایش کنید تا پارامترها را تنظیم کنید.

نمونهٔ کش HTTP در Express (با Cache-Control و ETag)

const express = require('express');
const app = express();

app.get('/api/resource/:id', async (req, res) => {
  const id = req.params.id;
  const resource = await getProduct(id); // فرضاً از کش یا db می‌آید
  res.set('Cache-Control', 'public, max-age=300, stale-while-revalidate=60');
  res.json(resource);
});

app.listen(3000);

توضیح: این کد هدر Cache-Control را اضافه می‌کند تا مرورگر یا CDN پاسخ را برای ۵ دقیقه نگه دارد و با استفاده از stale-while-revalidate، بازه‌ای برای بازسازی پس‌زمینه فراهم می‌کند. این روش برای مقیاس‌بندی لایهٔ HTTP مفید است.

نتیجه‌گیری و راهنمای تصمیم‌گیری

برای انتخاب استراتژی کش در Node.js ابتدا نیازمندی‌ها را مشخص کنید: آیا اپلیکیشن شما چند رایانه‌ای است؟ آیا داده‌ها سریع تغییر می‌کنند؟ برای توسعهٔ سریع و نمونه‌سازی از in-memory استفاده کنید، اما برای محیط توزیع‌شده و تولیدی Redis یا Memcached مناسب‌تر است. همیشه الگوی Cache-Aside را به عنوان پایه در نظر بگیرید و برای جلوگیری از مشکلاتی مانند stampede و stale بودن، از قفل، TTL مناسب و مانیتورینگ استفاده کنید.

در نهایت، کش یک ابزار قدرتمند ولی دو لبه است؛ طراحی درست، تست عملکرد و مانیتورینگ مداوم کلید استفادهٔ موفق آن در پروژه‌های Node.js هستند.

آیا این مطلب برای شما مفید بود ؟

خیر
بله
موضوعات شما در انجمن: