Application Security

پیاده‌سازی محدودیت نرخ از صفر: الگوریتم‌های اصلی و مدیریت وضعیت برای برنامه‌های وب

محدودیت نرخ تنها یک ویژگی نیست؛ بلکه خط مقدم دفاع در امنیت برنامه‌های وب است. این مکانیزم از زیرساخت شما در برابر حملات رد سرویس (DoS) محافظت می‌کند، از سوءاستفاده از API جلوگیری کرده و تخصیص عادلانه منابع را بین کاربران تضمین می‌کند. اگرچه بسیاری از توسعه‌دهندگان به سرویس‌های شخص ثالث مانند Cloudflare یا AWS WAF متکی هستند، درک نحوه پیاده‌سازی محدودیت نرخ از صفر برای ساخت راه‌حل‌های سفارشی یا بهینه‌سازی میکروسرویس‌های سبک‌وزن حیاتی است.

در این پست، ما الگوریتم‌های اصلی پشت محدودیت نرخ را بررسی می‌کنیم، استراتژی‌های مدیریت وضعیت را بحث کرده و مثال‌های کد عملی را در جاوااسکریپت و Go ارائه می‌دهیم.

الگوریتم پنجره ثابت: سادگی در برابر اثر پرتگاه

الگوریتم پنجره ثابت ساده‌ترین رویکرد است. این روش زمان را به بازه‌های ثابت (مثلاً پنجره‌های ۱ دقیقه‌ای) تقسیم می‌کند و درخواست‌ها را در آن پنجره می‌شمارد. به محض اینکه شمارنده از حد مجاز فراتر رود، درخواست‌های بعدی تا زمان بازنشانی پنجره رد می‌شوند.

اگرچه این روش کارآمد است، اما از «اثر پرتگاه» رنج می‌برد. اگر کاربر در انتهای یک پنجره ۱۰۰ درخواست و در ابتدای پنجره بعدی نیز ۱۰۰ درخواست ارسال کند، عملاً با ارسال ۲۰۰ درخواست در یک بازه ۲ دقیقه‌ای، محدودیت ۲۰۰ درخواست در دقیقه را دور زده است. برای کاهش این مشکل، برخی سیستم‌ها پنجره‌های همپوشان را پیاده‌سازی می‌کنند، اما این کار پیچیدگی را افزایش می‌دهد.

پیاده‌سازی در جاوااسکریپت (Node.js)

در زیر یک مثال ساده از میدل‌ور (Middleware) با استفاده از رویکرد پنجره ثابت و یک ذخیره‌سازی حافظه‌ای (In-memory) آورده شده است. توجه داشته باشید که برای محیط تولید، باید ذخیره‌سازی حافظه را با Redis جایگزین کنید تا وضعیت توزیع‌شده را مدیریت نمایید.

const RATE_LIMIT = 100;
const WINDOW_MS = 60000; // 1 minute

const requests = new Map();

function fixedWindowLimit(req, res, next) {
  const ip = req.ip;
  const now = Date.now();
  const windowStart = Math.floor(now / WINDOW_MS) * WINDOW_MS;
  
  if (!requests.has(ip) || requests.get(ip).windowStart !== windowStart) {
    // New window, reset counter
    requests.set(ip, { count: 1, windowStart });
  } else {
    const current = requests.get(ip);
    if (current.count >= RATE_LIMIT) {
      return res.status(429).json({ error: "Too Many Requests" });
    }
    current.count++;
  }
  
  next();
}

لاگ پنجره لغزنده: دقت با هزینه

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

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

سطل توکن: جریان ترافیک روان

الگوریتم سطل توکن اغلب به عنوان استاندارد طلایی برای تعادل بین انصاف و عملکرد در نظر گرفته می‌شود. تصور کنید یک سطل با ظرفیت ثابت وجود دارد. توکن‌ها با نرخ ثابت به سطل اضافه می‌شوند. هر درخواست یک توکن مصرف می‌کند. اگر سطل خالی باشد، درخواست رد می‌شود.

این روش اجازه می‌دهد تا ترافیک به صورت پرتابی (Burst) ارسال شود (اگر توکن‌ها انباشته شده باشند) و در عین حال میانگین نرخ بلندمدت را به شدت محدود می‌کند. این الگوریتم از نظر ریاضی زیبا است و در سیستم‌های توزیع‌شده عملکرد خوبی دارد.

پیاده‌سازی در Go

مدل همزمانی Go آن را به گزینه‌ای عالی برای پیاده‌سازی سطل‌های توکن تبدیل می‌کند. در اینجا یک پیاده‌سازی قوی با استفاده از ساختاری محافظت‌شده با Mutex آورده شده است.

package main

import (
	"sync"
	"time"
)

type TokenBucket struct {
	tokens     float64
	maxTokens  float64
	refillRate float64
	lastRefill time.Time
	mu         sync.Mutex
}

func NewTokenBucket(maxTokens, refillRate float64) *TokenBucket {
	return &TokenBucket{
		tokens:     maxTokens,
		maxTokens:  maxTokens,
		refillRate: refillRate,
		lastRefill: time.Now(),
	}
}

func (tb *TokenBucket) Allow() bool {
	tb.mu.Lock()
	defer tb.mu.Unlock()

	now := time.Now()
	elapsed := now.Sub(tb.lastRefill).Seconds()
	tb.tokens += elapsed * tb.refillRate

	if tb.tokens > tb.maxTokens {
		tb.tokens = tb.maxTokens
	}
	tb.lastRefill = now

	if tb.tokens >= 1.0 {
		tb.tokens--
		return true
	}
	return false
}

مدیریت وضعیت: انتخاب بک‌اند شما

حیاتی‌ترین جنبه محدودیت نرخ در سطح تولید، مدیریت وضعیت است. نقشه‌های حافظه‌ای، همان‌طور که در مثال جاوااسکریپت نشان داده شد، تنها برای استقرار تک‌نسخه‌ای (Single-instance) کار می‌کنند. برای محیط‌های چندگره‌ای (Multi-node)، شما به یک وضعیت مشترک نیاز دارید.

  • Redis: استاندارد صنعت. از مجموعه‌های مرتب (Sorted sets) برای پنجره‌های لغزنده یا هش‌های ساده برای پنجره‌های ثابت استفاده کنید. سرعت و عملیات اتمی آن آن را برای APIهای با عبور داده بالا ایده‌آل می‌سازد.
  • Memcached: برای جستجوهای کلید-مقدار ساده سریع‌تر از Redis است، اما ساختارهای داده مورد نیاز برای پنجره‌های لغزنده را ندارد.
  • قفل‌های توزیع‌شده: اگر مجبور به استفاده از پایگاه داده هستید، مطمئن شوید که از قفل‌گذاری خوش‌بینانه (Optimistic locking) یا محدودیت‌های سطح پایگاه داده برای جلوگیری از شرایط رقابتی (Race conditions) در زمان همزمانی بالا استفاده می‌کنید.

نتیجه‌گیری

پیاده‌سازی محدودیت نرخ از صفر، کنترل دقیقی بر وضعیت امنیتی برنامه شما به شما می‌دهد. اگرچه الگوریتم پنجره ثابت درک آسانی دارد، سطل توکن انعطاف‌پذیری بهتری برای الگوهای ترافیک دنیای واقعی ارائه می‌دهد. صرف‌نظر از الگوریتمی که انتخاب می‌کنید، همیشه اولویت را به مدیریت وضعیت کارآمد با استفاده از ابزارهایی مانند Redis بدهید تا یکپارچگی را در سیستم‌های توزیع‌شده تضمین نمایید. با تسلط بر این الگوریتم‌های اصلی، زیربنایی مقاوم می‌سازید که از برنامه شما در برابر سوءاستفاده محافظت می‌کند و در عین حال تجربه‌ای روان را برای کاربران قانونی حفظ می‌نماید.

Share: