ویژگی تصویر

پیاده‌سازی API Rate Limit در Node.js

  /  Node.js   /  پیاده سازی API Rate Limit در Node.js
بنر تبلیغاتی الف
NodeJS - Node.js

محدودسازی نرخ درخواست (API Rate Limit) یکی از اجزای حیاتی در طراحی سرویس‌های وب است. هدف اصلی جلوگیری از سوءاستفاده، محافظت در برابر DoS ساده، مدیریت مصرف منابع و تضمین کیفیت سرویس برای کاربران واقعی است. در Node.js روش‌ها و ابزارهای متنوعی وجود دارد که در این مقاله به مفاهیم، الگوریتم‌ها و نمونه‌های عملی با تمرکز بر پیاده‌سازی در Express و محیط‌های توزیع‌شده می‌پردازیم.

چرا Rate Limiting مهم است؟

  • حفاظت از منابع سرور (CPU، حافظه، بانک‌اطلاعاتی)
  • جلوگیری از سوءاستفاده یا حملات خودکار
  • ایجاد عدالت در مصرف از طریق محدودیت برای کاربران مختلف
  • قابلیت تنظیم SLA و سیاست‌های عملکردی

الگوریتم‌های مرسوم

  • Fixed Window: ساده اما مستعد انفجار همزمان در مرز بازه‌ها
  • Sliding Window: دقیق‌تر از Fixed Window، اما نیاز به نگهداری تایم‌استمپ‌ها
  • Token Bucket: انعطاف‌پذیر برای burstها؛ توکن‌ها به تناسب نرخ پر می‌شوند
  • Leaky Bucket: جریان ثابت از خروج درخواست‌ها، مناسب برای هموارسازی ترافیک

مقایسه مختصر الگوریتم‌ها

الگوریتممزایامعایب
Fixed Windowساده و سریعنیمه‌کارایی در مرز بازه‌ها
Sliding Windowدقیق‌تر در نرخ‌گیریپیاده‌سازی پیچیده‌تر و حافظه‌بر
Token Bucketپذیرای burst و تنظیم نرخنیاز به مدیریت توکن‌ها

نمونه عملی ۱ — استفاده از express-rate-limit (ساده و سریع)

const express = require('express');
const rateLimit = require('express-rate-limit');

const app = express();

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // limit each IP to 100 requests per windowMs
  standardHeaders: true, // Return rate limit info in the RateLimit-* headers
  legacyHeaders: false,
});

app.use(limiter);

app.get('/', (req, res) => {
  res.send('Hello, world!');
});

app.listen(3000);

این کد از بسته express-rate-limit استفاده می‌کند تا برای هر IP، در بازهٔ ۱۵ دقیقه حداکثر ۱۰۰ درخواست مجاز باشد. گزینه standardHeaders=true باعث می‌شود هدرهای مرتبط (RateLimit-Limit, RateLimit-Remaining) به کلاینت بازگردانده شوند. این پیاده‌سازی برای برنامه‌های تک‌سروری مناسب است اما در محیط‌های چندین نمونه (cluster یا چند سرور) نیاز به store مشترک دارد.

مشکل پیاده‌سازی حافظه محلی و راه‌حل توزیع‌شده

نگهداری شمارنده‌ها در حافظهٔ محلی (in-memory) در node.js ساده ولی در مقیاس‌پذیری و در صورت راه‌اندازی چندین instance مشکلاتی ایجاد می‌کند. راه‌حل معمول استفاده از یک ذخیره‌ساز مشترک مثل Redis است که اتمیک‌بودن و TTL را در مقیاس توزیع‌شده فراهم می‌کند.

نمونه عملی ۲ — Redis-backed با rate-limiter-flexible

const express = require('express');
const { RateLimiterRedis } = require('rate-limiter-flexible');
const Redis = require('ioredis');

const redisClient = new Redis();
const rateLimiter = new RateLimiterRedis({
  storeClient: redisClient,
  points: 10, // 10 requests
  duration: 1, // per second
  blockDuration: 60 // block for 60 seconds if consumed
});

const app = express();

app.use(async (req, res, next) => {
  try {
    await rateLimiter.consume(req.ip);
    next();
  } catch (rejRes) {
    res.set('Retry-After', String(Math.ceil(rejRes.msBeforeNext / 1000)));
    res.status(429).send('Too Many Requests');
  }
});

app.listen(3000);

در این نمونه از rate-limiter-flexible استفاده شده است تا ۱۰ درخواست در ثانیه مجاز باشد. Redis به عنوان store مورد استفاده قرار می‌گیرد که مناسب معماری توزیع‌شده است. اگر محدودیت رد شود، کد 429 بازگردانده و هدر Retry-After نیز برای اطلاع کلاینت فرستاده می‌شود.

پیاده‌سازی دستی: Token Bucket ساده در Node.js

class TokenBucket {
  constructor(tokens, refillRate) {
    this.capacity = tokens;
    this.tokens = tokens;
    this.refillRate = refillRate; // tokens per second
    this.lastRefill = Date.now();
  }

  _refill() {
    const now = Date.now();
    const elapsed = (now - this.lastRefill) / 1000;
    const add = elapsed * this.refillRate;
    this.tokens = Math.min(this.capacity, this.tokens + add);
    this.lastRefill = now;
  }

  tryRemove(count = 1) {
    this._refill();
    if (this.tokens >= count) {
      this.tokens -= count;
      return true;
    }
    return false;
  }
}

این کلاس یک کوزهٔ توکن ساده را شبیه‌سازی می‌کند که توکن‌ها به ازای زمان پر می‌شوند. متد tryRemove سعی می‌کند توکن‌ها را بردارد و در صورت موفقیت true برمی‌گرداند. برای استفاده در Express باید این آبجکت‌ها را برای هر کاربر (مثلاً بر اساس API key یا IP) ذخیره کنید. اما توجه داشته باشید که این پیاده‌سازی در حافظه محلی است و برای چندین سرور باید آن را به Redis یا LRU cache توزیع‌شده تبدیل کنید.

بهبودها و بهینه‌سازی‌ها

  • برای سیستم‌های توزیع‌شده از Redis یا دیتاستور اتمیک (مثلاً Lua script در Redis) استفاده کنید تا race condition از بین برود.
  • هدرهای استاندارد (RateLimit-Limit, RateLimit-Remaining, Retry-After) را ارسال کنید تا کلاینت‌ها رفتار مناسب داشته باشند.
  • قوانین متفاوت برای مسیرها (مسیری که منحصر به دانلود است و نیاز به محدودیت پایین‌تر دارد) اعمال کنید.
  • از whitelist/blacklist برای IPها یا API keyهای خاص استفاده کنید.

نمونه بهینه‌شده: استفاده از Lua در Redis برای atomicity

-- Redis Lua script (as string)
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])

local current = redis.call('GET', key)
if current and tonumber(current) >= limit then
  return redis.call('PTTL', key)
else
  current = redis.call('INCR', key)
  if current == 1 then
    redis.call('PEXPIRE', key, window)
  end
  return -1
end

این اسکریپت Lua اتمیک روی Redis اجرا می‌شود تا شمارنده را افزایش دهد و TTL مناسب را تنظیم کند. اگر محدودیت رسیده باشد، PTTL (باقیماندهٔ زمان) را برمی‌گرداند، در غیر این صورت -1 برمی‌گرداند که نشان‌دهندهٔ مجاز بودن است. استفاده از Lua از شرایط رقابتی جلوگیری می‌کند و عملکرد بالایی دارد.

نکات عملی و بهترین شیوه‌ها

  • میزان و دوره محدودیت را براساس الگوی مصرف واقعی کاربران و نوع API تعیین کنید.
  • برای endpoints حساس (مثل لاگین) محدودیت سخت‌تری اعمال کنید تا حملات brute-force کاهش یابد.
  • لوگینگ و متریک جمع‌آوری کنید تا سیاست‌ها را بر اساس داده واقعی تنظیم کنید.
  • برای تست، هم load testing و هم تست‌های رفتار نامعمول (spike) انجام دهید.

جمع‌بندی

Rate limiting در Node.js ترکیبی از انتخاب الگوریتم مناسب، ابزارهای درست (مثل redis، rate-limiter-flexible، یا express-rate-limit برای موارد ساده) و طراحی سیاست‌های مبتنی بر نیازهای کسب‌وکار است. در محیط‌های توزیع‌شده حتماً از یک ذخیرهٔ مشترک و مکانیزم‌های اتمیک استفاده کنید. پیاده‌سازی صحیح باعث افزایش پایداری، امنیت و رضایت کاربران خواهد شد.

در صورت نیاز می‌توانم یک پیاده‌سازی کامل برای حالت clustered با Redis و health checks تهیه کنم یا نمونه‌هایی برای نرخ‌بندی براساس API key و ترکیب با سیستم احراز هویت ارائه دهم.

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

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