پیاده‌سازی الگوهای Rate Limiting پیشرفته در دات‌نت بدون افت عملکرد API

توسط: محسن درم بخت | منتشر شده در 1405/04/26 | بازدید : 3 بار | زمان مطالعه : 15 دقیقه

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

راهکار اساسی برای حل این معضل، استفاده از تکنیکی به نام محدودسازی نرخ یا Rate Limiting است. محدودسازی نرخ فرآیندی است که در آن سقف مشخصی برای تعداد درخواست‌های مجاز یک کلاینت در یک بازه زمانی معین تعریف می‌شود. در این مقاله جامع، به صورت گام‌به‌گام و کاربردی با انواع الگوریتم‌های محدودسازی نرخ، مکانیزم بومی دات‌نت برای اعمال این محدودیت‌ها، و ترفندهای بهینه‌سازی عملکرد آن بدون کاهش کارایی کلی سیستم آشنا خواهیم شد.

جایگاه Rate Limiting در امنیت وب

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

مکانیزم Rate Limiting مکمل بسیار خوبی برای سیستم‌های امنیتی و احراز هویت سنتی است که در مقالاتی مانند افزایش امنیت برنامه‌های وب با ASP.NET Core و پیاده‌سازی‌های نوین‌تر مثل معماری Zero Trust در احراز هویت ASP.NET Core به آن‌ها پرداخته‌ایم. در واقع، ریت لیمیتینگ تضمین می‌کند که منابع سیستم حتی در برابر درخواست‌های کاربران معتبر اما خرابکار یا برنامه‌نویسی‌شده با لوپ‌های اشتباه، محافظت می‌شود.

الگوریتم‌های متداول محدودسازی نرخ

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

  1. پنجره ثابت (Fixed Window): در این الگوریتم، زمان به بازه‌های ثابت و مجزا (مثلاً بازه‌های ۱ دقیقه‌ای) تقسیم می‌شود. هر کلاینت مجاز است در هر بازه تعداد مشخصی درخواست (مثلاً حداکثر ۶۰ درخواست) ارسال کند. هنگامی که یک بازه به پایان می‌رسد، شمارنده بازنشانی شده و مقدار آن صفر می‌شود. پیاده‌سازی این الگوریتم بسیار ساده و کم‌هزینه است، اما یک عیب بزرگ دارد؛ اگر کلاینتی تمام درخواست‌های خود را در ثانیه‌های پایانی یک بازه و ثانیه‌های ابتدایی بازه بعدی ارسال کند، در عمل ترافیکی معادل دو برابر حد مجاز را در یک بازه زمانی کوتاه به سرور تحمیل کرده است (معضل ترافیک لبه).
  2. پنجره لغزان (Sliding Window): این الگوریتم برای رفع معضل ترافیک لبه در الگوریتم پنجره ثابت طراحی شده است. در پنجره لغزان، به جای تقسیم زمان به بازه‌های ثابت، زمان دقیق هر درخواست کلاینت به صورت یک لیست در حافظه (مانند Redis یا In-Memory Cache) ذخیره می‌شود. با هر درخواست جدید، تعداد درخواست‌های ثبت شده در طول بازه زمانی گذشته (مثلاً ۶۰ ثانیه گذشته) محاسبه می‌شود. این الگوریتم دقت بسیار بالایی دارد و جلوی نفوذ ناگهانی ترافیک را در مرز پنجره‌ها می‌گیرد، اما به دلیل نیاز به ذخیره زمان تک‌تک درخواست‌ها، مصرف حافظه و هزینه محاسباتی بسیار بالاتری دارد.
  3. سطل توکن (Token Bucket): در این الگوریتم، یک سطل مجازی با ظرفیت مشخص (مانند ۲۰ توکن) تصور می‌شود. توکن‌ها با نرخ ثابتی (مثلاً ۵ توکن در هر ۱۰ ثانیه) به سطل اضافه می‌شوند تا زمانی که سطل کاملاً پر شود. با ارسال هر درخواست توسط کلاینت، یک توکن از سطل برداشته می‌شود. اگر سطل خالی باشد، درخواست بلافاصله رد می‌شود. این الگوریتم به دلیل انعطاف‌پذیری بالا بسیار محبوب است؛ زیرا به کلاینت‌ها اجازه می‌دهد که در صورت نیاز، ترافیک ناگهانی (Burst) را تا سقف ظرفیت سطل به صورت آنی ارسال کنند و در عین حال، میانگین ترافیک آن‌ها در بلندمدت کنترل می‌شود.
  4. محدودکننده همزمانی (Concurrency Limiter): این الگوریتم بر خلاف سه الگوریتم قبلی، هیچ حساسیتی به فاکتور زمان ندارد. وظیفه اصلی آن، محدود کردن تعداد کل درخواست‌های فعال و در حال پردازشی است که به صورت همزمان روی سرور در حال اجرا هستند. برای مثال، شما می‌توانید مشخص کنید که کاربر "A" در هر لحظه حداکثر می‌تواند ۳ درخواست فعال داشته باشد. این الگو برای محافظت از منابع سنگین مانند کوئری‌های پیچیده دیتابیس یا پردازش‌های طولانی‌مدت CPU بسیار حیاتی است تا از پر شدن Thread Pool و خفگی سرور جلوگیری کند.

در تصویر زیر می‌توانید نحوه عملکرد پنجره زمانی و فیلتر شدن درخواست‌ها در سیستم محدودکننده نرخ را مشاهده کنید:

نمودار جریان الگوریتم محدودکننده نرخ

پیاده‌سازی نیتیو Rate Limiting در ASP.NET Core

تا قبل از دات‌نت ۷، توسعه‌دهندگان معمولاً از کتابخانه‌های ترد‌پارتی مانند AspNetCoreRateLimit استفاده می‌کردند. اما از دات‌نت ۷ به بعد، مایکروسافت میان‌افزار بومی و بسیار بهینه‌ای را در فضای نام Microsoft.AspNetCore.RateLimiting ارائه کرده است. برای درک بهتر نحوه کارکرد میان‌افزارها، مرور مقاله آشنایی با مفهوم میان‌افزار و سرویس در asp.net core توصیه می‌شود.

گام اول: پیکربندی و تعریف سیاست‌ها در Program.cs

برای فعال‌سازی سیستم محدودسازی نرخ، ابتدا باید سرویس‌ها و سیاست‌های مورد نظر را ثبت کرده و سپس میان‌افزار آن را در پایپ‌لاین درخواست‌ها قرار دهیم. در پروژه‌های بزرگ، معمولاً نیاز داریم ترافیک کاربران لاگین‌شده را از کاربران ناشناس تفکیک کنیم. برای این کار می‌توانیم از قابلیت قدرتمند PartitionedRateLimiter استفاده کنیم تا به صورت پویا کلیدهای پارتیشن را بر اساس ادعاهای هویت کاربر (Claims) یا آدرس آی‌پی ایجاد کند:

using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;
using System.Net;

var builder = WebApplication.CreateBuilder(args);

// تعریف سیاست‌های ریت لیمیتینگ
builder.Services.AddRateLimiter(options =>
{
    // تعریف سیاست سطل توکن (Token Bucket) به صورت عمومی
    options.AddTokenBucketLimiter("TokenPolicy", tokenOptions =>
    {
        tokenOptions.TokenLimit = 20; // حداکثر ظرفیت سطل
        tokenOptions.QueueLimit = 2;  // تعداد درخواست‌های منتظر در صف
        tokenOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        tokenOptions.ReplenishmentPeriod = TimeSpan.FromSeconds(10); // دوره زمانی شارژ
        tokenOptions.TokensPerPeriod = 5; // تعداد توکن‌های شارژ شده در هر دوره
        tokenOptions.AutoReplenishment = true;
    });

    // تعریف سیاست پنجره لغزان (Sliding Window) برای جلوگیری از هجوم ترافیک در مرز پنجره‌ها
    options.AddSlidingWindowLimiter("SlidingPolicy", slidingOptions =>
    {
        slidingOptions.PermitLimit = 30;
        slidingOptions.Window = TimeSpan.FromMinutes(1);
        slidingOptions.SegmentsPerWindow = 3; // تقسیم پنجره به سه بخش ۲۰ ثانیه‌ای جهت محاسبه لغزان
        slidingOptions.QueueLimit = 0;
    });

    // پیاده‌سازی پارتیشن‌بندی پیشرفته برای تفکیک کاربران لاگین‌شده از ناشناس
    options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
    {
        // در صورت لاگین بودن کاربر، او را بر اساس نام کاربری لیمیت می‌کنیم
        if (httpContext.User.Identity?.IsAuthenticated == true)
        {
            var username = httpContext.User.Identity.Name ?? "Authenticated";
            return RateLimitPartition.GetTokenBucketLimiter(username, _ => new TokenBucketLimiterOptions
            {
                TokenLimit = 100,
                ReplenishmentPeriod = TimeSpan.FromSeconds(10),
                TokensPerPeriod = 20,
                AutoReplenishment = true
            });
        }

        // برای کاربران ناشناس، محدودیت سخت‌گیرانه‌تری بر اساس آدرس آی‌پی اعمال می‌شود
        var clientIp = httpContext.Connection.RemoteIpAddress?.ToString() ?? "Anonymous";
        return RateLimitPartition.GetFixedWindowLimiter(clientIp, _ => new FixedWindowLimiterOptions
        {
            PermitLimit = 5,
            Window = TimeSpan.FromSeconds(10),
            QueueLimit = 0
        });
    });
});

builder.Services.AddControllers();

var app = builder.Build();

// قرار دادن میان‌افزار ریت لیمیت در پایپ‌لاین (باید بعد از Routing و قبل از Map endpoints باشد)
app.UseRouting();
app.UseRateLimiter();

app.MapControllers();
app.Run();

گام دوم: اعمال سیاست‌ها روی کنترلرها

پس از تعریف سیاست‌ها، می‌توانیم با استفاده از اتریبیوت [EnableRateLimiting] آن‌ها را بر روی کنترلرها یا اکشن‌های خاصی اعمال کنیم. این سازمان‌دهی در پروژه‌های تمیز دات‌نت که از معماری Clean در ASP.NET Core بهره می‌برند، باعث تفکیک کامل لایه کنترلرها و بهینه‌سازی دسترسی‌ها می‌شود:

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;

[ApiController]
[Route("api/[controller]")]
[EnableRateLimiting("TokenPolicy")] // اعمال سیاست سطل توکن روی کل کنترلر
public class ProductsController : ControllerBase
{
    [HttpGet]
    public IActionResult GetProducts()
    {
        return Ok("لیست محصولات با موفقیت دریافت شد.");
    }

    [HttpPost]
    [EnableRateLimiting("IpPolicy")] // لغو سیاست کنترلر و اعمال سیاست محدودتر آی‌پی برای ایجاد محصول جدید
    public IActionResult CreateProduct()
    {
        return Created("", "محصول جدید ثبت شد.");
    }

    [HttpGet("public-data")]
    [DisableRateLimiting] // غیرفعال کردن محدودیت نرخ برای این متد خاص
    public IActionResult GetPublicData()
    {
        return Ok("اطلاعات عمومی رایگان بدون محدودیت نرخ.");
    }
}

گام سوم: شخصی‌سازی پاسخ در صورت عبور از حد مجاز (Too Many Requests)

به صورت پیش‌فرض، دات‌نت در صورت رد شدن درخواست به دلیل محدودیت نرخ، وضعیت HTTP 503 Service Unavailable را باز می‌گرداند. با این حال، طبق استانداردهای وب، کد وضعیت صحیح HTTP 429 Too Many Requests است. ما می‌توانیم این رفتار و متن پاسخ را به صورت زیر شخصی‌سازی کنیم:

builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    options.OnRejected = async (context, token) =>
    {
        context.HttpContext.Response.ContentType = "application/json";
        var message = new { error = "تعداد درخواست‌های شما بیش از حد مجاز است. لطفا کمی بعد تلاش کنید." };
        await context.HttpContext.Response.WriteAsJsonAsync(message);
    };
});

ترفندهای بهینه‌سازی عملکرد Rate Limiting بدون افت کارایی API

اعمال محدودیت نرخ نیازمند ذخیره تعداد درخواست‌ها در حافظه و انجام محاسبات ریاضی روی هر درخواست است. برای اینکه این لایه خود به گلوگاه API تبدیل نشود، تکنیک‌های زیر را رعایت کنید:

  • پارتیشن‌بندی بهینه (Partitioning): شمارنده درخواست‌ها را بر اساس ویژگی‌های سبک مانند شناسه کاربری (اگر لاگین شده است) یا ترکیبی از آی‌پی و User-Agent پارتیشن‌بندی کنید تا از قفل‌های بیهوده روی حافظه مشترک جلوگیری شود.
  • استفاده از Memory Cache کارآمد: دات‌نت به طور پیش‌فرض شمارنده‌ها را در حافظه موقت اپلیکیشن نگهداری می‌کند. برای جلوگیری از هدررفت حافظه، زمان انقضای کش‌ها را هوشمندانه انتخاب کنید تا رکوردهای کلاینت‌های غیرفعال سریعاً از رم پاک شوند.
  • پیاده‌سازی توزیع‌شده با Redis در وب‌فارم‌ها: اگر اپلیکیشن شما روی چندین سرور اجرا می‌شود، کش محلی کارساز نخواهد بود، زیرا هر سرور شمارنده خود را دارد. در این سناریو باید از ساختارهای کش توزیع‌شده مبتنی بر Redis استفاده کنید تا آمار درخواست‌های هر کاربر به صورت متمرکز و یکپارچه در کل کلاستر بررسی شود.
  • مدیریت صف‌ها (Queue Optimization): صف‌ها (QueueLimit) را در پیکربندی سیاست‌ها بیش از حد طولانی نکنید. صف‌های طولانی باعث مصرف بی‌مورد نخ‌های پردازشی (Threads) سرور و افزایش تاخیر پاسخ‌دهی (Latency) به کلاینت‌های مسدودشده می‌شوند.

جمع‌بندی و منابع خارجی

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

برای مطالعه بیشتر و عمیق‌تر در این زمینه، منابع زیر مراجع مناسبی هستند:

دوره‌های آنلاین برنامه‌نویسی لیست دوره‌ها