عنوان:

‫مدیریت نرخ درخواست در ASP.NET Core: پیکربندی‌هایی که زیر بار ترافیکی شکست می‌خورند


نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۶/۰۵ ۱۰:۳۵
آدرس: www.dntips.ir
در نگاه اول، پیاده‌سازی Rate Limiting در ASP.NET Core یکی از بی‌دردسرترین کارها برای ایمن‌سازی سرویس‌ها در محیط پروداکشن به نظر می‌رسد. فریم‌ورک از نسخه .NET 7 به بعد، میان‌افزار داخلی و کارآمدی را بر پایه فضای‌نام System.Threading.RateLimiting ارائه داده است؛ بنابراین نیازی به نصب پکیج‌های متفرقه نیست. کافی است یک Policy ساده ثبت کنید، عددی معقول برای آن در نظر بگیرید و آن را به Endpoint متصل کنید تا به درخواست‌های اضافی پاسخ 429 Too Many Requests داده شود:
builder.Services.AddRateLimiter(options =>
{
    options.AddFixedWindowLimiter("api", limiter =>
    {
        limiter.PermitLimit = 100;
        limiter.Window = TimeSpan.FromSeconds(10);
        limiter.QueueLimit = 0;
    });
});

// ...

app.UseRateLimiter();

app.MapGet("/api/orders", GetOrders)
   .RequireRateLimiting("api");
ظاهر تنظیمات کاملاً اطمینان‌بخش است و اندپوینت، محدود شده‌است؛ اما این پیکربندی لزوماً سرویس را در برابر پیک‌های ناگهانی ترافیک (Traffic Spikes) محافظت نمی‌کند. مرز باریک و پرخطایی میان کنترل نرخ ورودی (Rate Limiting) و محافظت از ظرفیت پردازشی (Capacity Protection) وجود دارد که نادیده گرفتن آن منشأ قطعی سرویس‌ها در شرایط اوج مصرف است.

محدودیت تعداد درخواست در برابر مصرف واقعی منابع

سیاست‌های معمول Rate Limiting صرفاً بر اساس تعداد درخواست‌ها، مجوز ورود صادر می‌کنند؛ در حالیکه محافظت از ظرفیت سیستم، مستلزم ارزیابی میزان مصرف منابع اشباع‌پذیر (مانند Thread Pool، Memory Allocations، Connection Pool دیتابیس و سوکت‌های خارجی) به‌ازای هر درخواست مجاز است.
یک سرویس ممکن است بدون افت کارایی به ۵۰۰ درخواست در ثانیه که مستقیماً از حافظه موقت (Cache) خوانده می‌شوند پاسخ دهد، اما با دریافت تنها ۸۰ درخواست هم‌زمان که نیازمند باز کردن اتصال دیتابیس، اجرای کوئری‌های سنگین، ساخت گراف‌های بزرگ شیء و انتظار برای وب‌سرویس‌های پایین‌دستی هستند، دچار کمبود نخ در تردپول یا اشباع استخر اتصالات دیتابیس شده و از کار بیفتد. سیاست‌های مبتنی بر شمارش صِرف، تفاوتی میان هزینه این عملیات‌ها قائل نمی‌شوند.

شکل ترافیک و خطای پنجره‌های زمانی ثابت

توزیع درخواست‌ها در طول زمان اهمیت حیاتی دارد. ورود ۱۰۰ درخواست که به‌صورت یکنواخت در طول یک بازه ۱۰ ثانیه‌ای پخش شده‌اند، از نظر عملیاتی هیچ شباهتی به ورود همان ۱۰۰ درخواست در ۵۰ میلی‌ثانیه ابتدایی پنجره زمانی ندارد.
در سناریوی ورود انفجاری (Micro-Bursting)، با اینکه سقف ۱۰۰ درخواست در بازه ۱۰ ثانیه‌ای رعایت شده، اما افزایش ناگهانی هم‌زمانی لحظه‌ای (Instantaneous Concurrency) می‌تواند در همان کسری از ثانیه سرور را دچار افت شدید کارایی یا توقف کامل کند. برای مدیریت این الگوها، استفاده از الگوریتم‌هایی نظیر Token Bucket یا Sliding Window اولویت دارد، چرا که تفکیک ظرفیت جهشی از نرخ بازسازی را ممکن می‌سازند.

تله صف‌ها: تبدیل بار لحظه‌ای به تأخیر انباشته

یکی از تنظیمات پرریسک، مقداردهی به QueueLimit است. نگه‌داشتن درخواست‌ها در صف در ظاهر به هموارسازی بار ترافیکی کمک می‌کند، اما در عمل فشار را به تأخیر انباشته (Accumulated Latency) تبدیل می‌کند.
وقتی درخواست‌ها برای دریافت مجوز در صف معلق می‌مانند، زمان پاسخ‌دهی افزایش می‌یابد. کلاینت‌ها معمولاً با گذشت زمان Timeout داده و تلاش مجدد (Retry) می‌کنند؛ پدیده‌ای که به ایجاد طوفان تکرار درخواست (Retry Storm) و هدررفت منابع سرور برای پردازش اتصالاتی که کلاینت پیش‌تر آن‌ها را رها کرده، ختم می‌شود. رویکرد مناسب در شرایط اشباع، رد کردن سریع درخواست (Fail-Fast) با مقدار QueueLimit = 0 است.

راهکارهای معماری برای مقابله با شکست در ASP.NET Core

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

رویکرد فنیهدف محافظتنحوه پیاده‌سازی در ASP.NET Core
محدودسازی هم‌زمانی (Concurrency Limiting)کنترل حداکثر پردازش‌های هم‌زمان جهت محافظت از CPU و اتصالات دیتابیسoptions.AddConcurrencyLimiter(...)
توزیع پنجره‌ای شناور یا سطل توکنجلوگیری از جهش‌های لحظه‌ای در مرز بازه‌های زمانیoptions.AddSlidingWindowLimiter(...) یا options.AddTokenBucketLimiter(...)
پارتیشن‌بندی محدودیت‌ها (Partitioned Limiter)ایزوله‌سازی سهمیه کلاینت‌ها و جلوگیری از اثر همسایه پر سر و صدا (Noisy Neighbor)PartitionedRateLimiter.Create
رد سریع بدون بافر (Fail-Fast Rejection)جلوگیری از حبس حافظه و تجمع تاخیر در صفQueueLimit = 0 به همراه RejectionStatusCode = 429

// نمونه پیکربندی کنترل هم‌زمانی پارتیشن‌بندی‌شده بر اساس کاربر یا IP
builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;

    options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
    {
        // استخراج شناسه کاربر، کلید اختصاصی API یا IP کلاینت
        var partitionKey = httpContext.User.Identity?.Name 
            ?? httpContext.Connection.RemoteIpAddress?.ToString() 
            ?? "anonymous";

        return RateLimitPartition.GetConcurrencyLimiter(
            partitionKey,
            _ => new ConcurrencyLimiterOptions
            {
                PermitLimit = 20, // سقف دقیق درخواست‌های هم‌زمان به ازای هر کاربر
                QueueLimit = 0    // شکست سریع و عدم تشکیل صف در حافظه
            });
    });
});
پیکربندی کارآمد Rate Limiting مستلزم پاسخ به این پرسش نیست که «این اندپوینت مجاز به دریافت چند درخواست است؟»، بلکه باید مشخص شود که «هنگام پیشی گرفتن تقاضا از ظرفیت واقعی منابع سخت‌افزاری و دیتابیس، دقیقاً قصد داریم از وقوع کدام حالت شکست (Failure Mode) جلوگیری کنیم؟»
مطالب مشابه

نظرات

  • وحید نصیری در ۱۴۰۵/۰۶/۰۵ ۱۰:۴۵
    الگوریتم Fixed Window به دلیل سادگی مفهومی، نخستین انتخاب اکثر توسعه‌دهندگان دات‌نت برای اعمال محدودیت نرخ درخواست است: یک بازه زمانی مشخص آغاز می‌شود، تعداد معینی درخواست در آن بازه مجوز ورود می‌گیرند و به محض اتمام سهمیه، درخواست‌های بعدی با خطای 429 Too Many Requests رد می‌شوند.
    این الگوریتم برای تعریف سهمیه‌های کلی (Business Quotas) کاملاً معقول است، اما اشتباه بزرگ زمانی رخ می‌دهد که تصور کنیم یک پنجره ثابت، ورود یکنواخت و روان درخواست‌ها را تضمین می‌کند.

    پیکربندی زیر را در نظر بگیرید:
    options.AddFixedWindowLimiter("orders", limiter =>
    {
        limiter.PermitLimit = 1_000;
        limiter.Window = TimeSpan.FromMinutes(1);
        limiter.QueueLimit = 0;
    });
    در نگاه اول، توسعه‌دهنده این عدد را در ذهن خود به حدود ۱۶ یا ۱۷ درخواست در ثانیه (60 / 1000) ترجمه می‌کند. اما Policy بالا هرگز چنین تعهدی نمی‌دهد؛ این سیاست صرفاً می‌گوید در طول یک بازه ۶۰ ثانیه‌ای، ورود حداکثر ۱۰۰۰ درخواست مجاز است، بدون توجه به اینکه این درخواست‌ها با چه ترتیبی یا در چه لحظاتی می‌رسند.

    مسئله بحرانی مرز پنجره‌ها (Boundary Burst Phenomenon)

    بزرگ‌ترین پاشنه آشیل Fixed Window در مرز تغییر بازه‌های زمانی رخ می‌دهد؛ جایی که اکثر تست‌های بار استاندارد (Load Tests) متوجه آن نمی‌شوند.
    اگر یک کلاینت یا گروهی از کاربران در ۲ ثانیه پایانیِ پنجره اول، ۱۰۰۰ درخواست ارسال کنند و بلافاصله با شروع پنجره دوم در ۲ ثانیه ابتدایی نیز ۱۰۰۰ درخواست دیگر بفرستند، در یک بازه زمانی ۴ ثانیه‌ای، ۲۰۰۰ درخواست به وب‌سرور شلیک شده است!
    [ پنجره ۱ (دقیقه اول) ]                   [ پنجره ۲ (دقیقه دوم) ]
    |-----------------------||||||||| | |||||||||-----------------------|
                          ثانیه ۵۸ تا ۶۰   ثانیه ۰ تا ۲
                          (۱۰۰۰ درخواست)  (۱۰۰۰ درخواست)
                          
    >>> نتیجه در مرز پنجره: ۲۰۰۰ درخواست در ۴ ثانیه (۵۰۰ درخواست بر ثانیه واقعی!)
    میانگین یک‌دقیقه‌ای از نظر محاسباتی کاملاً معتبر است، اما نرخ لحظه‌ای در مرز پنجره‌ها به شکل انفجاری افزایش یافته است.

    چرا وابستگی‌های شما (Dependencies) به میانگین‌ها اهمیت نمی‌دهند؟

    پایگاه‌های داده و سرویس‌های خارجی بر اساس «میانگین در دقیقه» کار نمی‌کنند؛ آن‌ها با «هم‌زمانی لحظه‌ای» (Instantaneous Concurrency) اشباع می‌شوند.
    فرض کنید هر درخواست مجاز اندپوینت بالا، چنین عملیاتی را روی دیتابیس اجرا می‌کند:
    var orders = await dbContext.Orders
        .AsNoTracking()
        .Where(x => x.CustomerId == customerId)
        .OrderByDescending(x => x.CreatedAt)
        .Take(100)
        .ToListAsync(cancellationToken);
    اگر کسری از این ۱۰۰۰ یا ۲۰۰۰ درخواست به طور هم‌زمان وارد خط لوله اجرای ASP.NET Core شوند:
    • اشباع Connection Pool: تعداد کانکشن‌های فعال دیتابیس در چند میلی‌ثانیه به حداکثر مجاز (مثلاً سقف ۱۲۸ پیش‌فرض SqlClient) می‌رسد و درخواست‌های بعدی در صف انتظار کانکشن بلاک می‌شوند (Connection Timeout).
    • فشار حافظه و CPU: بار سنگین تخصیص حافظه (Materialization) و سریال‌سازی هم‌زمان ۱۰۰ شیء به‌ازای هر درخواست، حافظه موقت Gen 0/Gen 1 را اشباع کرده و باعث راه‌اندازی فرآیند Garbage Collection فشرده می‌شود.
    • رشد تاخیر زنجیره‌ای (Tail Latency): با درگیر شدن تردها، زمان اتمام درخواست‌ها طولانی‌تر شده و تقاضای نخ‌های پردازشی بالا می‌رود که نتیجه آن، آغاز پدیده ThreadPool Starvation در دات‌نت خواهد بود.

    مقایسه رفتار الگوریتم‌ها در مواجهه با ترافیک مرزی

    برای جلوگیری از این سناریو، ASP.NET Core دو الگوریتم جایگزین کارآمد در دل فضای‌نام System.Threading.RateLimiting قرار داده است:

    الگوریتمنحوه رفتار در مرز بازه زمانیمورد کاربرد ایده‌آل
    Fixed Windowمستعد جهش‌های ۲ برابری ترافیک در مرز پنجرهسهمیه‌بندی‌های تجاری، مسدودسازی ساده ابزارهای Scraping
    Sliding Windowتقسیم پنجره به بخش‌های کوچک‌تر (Segments) برای هموارسازی مرزهاAPIهایی که پایداری زمان پاسخ‌دهی (Latency) در آن‌ها حیاتی است
    Token Bucketاعطای توکن با نرخ ثابت در کنار تعیین ظرفیت انفجاری مشخص (Burst Capacity)کنترل دقیق جریان داده و جلوگیری از ترافیک جهشی ناگهانی

    راهکار عملی: استفاده از Sliding Window یا Token Bucket

    اگر قصد دارید بازه زمانی یک‌دقیقه‌ای را حفظ کنید اما اجازه ندهید ترافیک در مرز منفجر شود، الگوریتم Sliding Window با تقسیم زمان به قطعات کوچک‌تر (مثلاً ۶ قطعه ۱۰ ثانیه‌ای) میانگین متحرک را محاسبه می‌کند و جهش مرزی را از بین می‌برد:
    // جایگزینی Fixed Window با Sliding Window برای حذف جهش مرزی
    options.AddSlidingWindowLimiter("orders-sliding", limiter =>
    {
        limiter.PermitLimit = 1_000;
        limiter.Window = TimeSpan.FromMinutes(1);
        limiter.SegmentsPerWindow = 6; // تقسیم هر پنجره به ۶ سگمنت ۱۰ ثانیه‌ای
        limiter.QueueLimit = 0;
    });
    همچنین اگر هدف، کنترل دقیق رفتار انفجاری است، الگوریتم Token Bucket گزینه‌ای مطمئن‌تر است:
    options.AddTokenBucketLimiter("orders-token", limiter =>
    {
        limiter.TokenLimit = 50;              // حداکثر ظرفیت انفجاری مجاز در یک لحظه
        limiter.TokensPerPeriod = 16;          // اضافه شدن ۱۶ توکن در هر ثانیه
        limiter.ReplenishmentPeriod = TimeSpan.FromSeconds(1);
        limiter.QueueLimit = 0;
    });
    الگوریتم Fixed Window ذاتاً ناقص نیست؛ بلکه «۱۰۰۰ درخواست در دقیقه» صرفاً یک شاخص مصرف ماهانه یا تجاری است، نه یک مدل مهندسی برای حفظ ظرفیت زیرساخت. پیکربندی Rate Limiter همیشه باید از سقف تحمل منبع تحت فشار (مانند حداکثر Concurrency قابل تحمل توسط SQL Server یا Redis) آغاز شود، نه از اعدادی که روی کاغذ جذاب و رُند به نظر می‌رسند.
  • وحید نصیری در ۱۴۰۵/۰۶/۰۵ ۱۰:۴۷
    پاسخ طبیعی بسیاری از تیم‌ها برای حل مشکل جهش‌های ناگهانی در مرز پنجره‌های ثابت (Fixed Window)، مهاجرت به الگوریتم Sliding Window است. این تغییر بی‌تردید یک گام مثبت به جلو است؛ زیرا با تقسیم پنجره زمانی به چند بخش کوچک‌تر (Segments) و محاسبه میانگین متحرک، رفتار پذیرش درخواست‌ها را به شکلی پیوسته و هموار درمی‌آورد.

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

    تفاوت بنیادین زمان بقای درخواست (In-Flight Duration) و نرخ ورود

    فرض کنید اندپوینتی با میانگین ترافیک مشخصی به شکل پایدار کار می‌کند، اما زمان پردازش درخواست‌های آن نوسان بالایی دارد:
    • یک درخواست به دلیل دریافت پاسخ از حافظه موقت (Cache)، در ۲۰ میلی‌ثانیه به پایان می‌رسد.
    • درخواست دیگر به دلیل پاسخ‌دهی کند یک وب‌سرویس یا کوئری سنگین، ۲ ثانیه در سرور باز می‌ماند.

    الگوریتم‌های مبتنی بر زمان (مانند Sliding Window و Fixed Window) صرفاً «نرخ ورود درخواست جدید» را می‌شمارند، اما درکی از اینکه یک درخواست پس از تایید چه مدت‌زمانی منابع سرور را اشغال می‌کند، ندارند. این گسست مفهومی، درست در لحظه تضعیف وابستگی‌ها (Dependency Degradation) بحران‌آفرین می‌شود.

    سناریوی بحران: کندی پایگاه داده در شرایط ثبات نرخ ورودی

    تصور کنید نرخ ورودی کلاینت‌ها کاملاً مطابق با سقف مجازِ Sliding Window است و ماه‌ها بدون مشکل سرویس‌دهی کرده است. ناگهان پایگاه داده به دلیل قفل‌شدگی جداول (Lock Contention) یا نوسان سخت‌افزاری دچار افت سرعت می‌شود.
    از زاویه دید Rate Limiter هیچ ناهنجاری رخ نداده و ترافیک در محدوده استاندارد است، اما در عمق حافظه و ساختار ASP.NET Core اتفاقات زیر هم‌زمان رقم می‌خورد:
    • انباشت درخواست‌های معلق (In-Flight Requests): چون زمان پایان هر درخواست از ۲۰ میلی‌ثانیه به ۲ ثانیه جهش کرده، تعداد درخواست‌های هم‌زمان در حافظه به‌سرعت ۱۰۰ برابر می‌شود.
    • اشباع کانتینر DI و اشغال حافظه: سرویس‌های Scoped (مانند DbContext و ساختارهای ردیابی Entity Framework)، اشیای Activity مربوط به OpenTelemetry، بافرهای پاسخ و CancellationTokenها برای مدت طولانی‌تری زنده می‌مانند و تخصیص‌های Gen 1 و Gen 2 را به حداکثر می‌رسانند.
    • اشباع Connection Pool: اتصالات باز دیتابیس آزاد نمی‌شوند و درخواست‌های جدید به دلیل اتمام سقف اتصالات منتظر مانده و خطا می‌دهند.

    در چنین وضعیتی، سامانه به سقف بحرانی Concurrency برخورد می‌کند؛ در حالی که شمارنده Sliding Window هنوز سیگنال سبزی از وضعیت ترافیک نشان می‌دهد!

    نرخ پردازش (Throughput) در برابر هم‌زمانی (Concurrency)

    این دو مفهوم با یکدیگر پیوند خورده‌اند، اما جایگزین یکدیگر نیستند:
    • Request Rate Limiter: تعیین می‌کند که «درخواست‌های جدید با چه فرکانس و سرعتی اجازه ورود دارند؟»
    • Concurrency Limiter: تعیین می‌کند که «حداکثر چه تعداد از این درخواست‌ها می‌توانند به‌طور هم‌زمان در حافظه زنده و فعال باشند؟»

    ویژگیالگوریتم‌های مبتنی بر زمان (Time-Window)کنترل‌کننده هم‌زمانی (Concurrency Limiter)
    محور کنترلزمان و سهمیه ورودی (مثلاً ۱۰۰۰ در دقیقه)تعداد پردازش‌های موازی زنده (مثلاً ۲۰ اجرای هم‌زمان)
    محافظت در برابرمصرف بیش از حد سهمیه تجاری، بات‌ها و اسکرپرهاافت عملکرد دیتابیس، ThreadPool Starvation و کمبود حافظه
    پاسخ به کندی سیستمبی‌تفاوت به زمان اجرای درخواست‌هامسدودسازی فوری ورود ترافیک جدید در صورت انباشت درخواست‌های قبلی

    راهکار تکمیلی: معماری ترکیبی (Chained / Layered Strategy)

    اگر هدف، حفظ عدالت در سهمیه مصرفی کاربران در کنار محافظت قطعی از منابع سخت‌افزاری است، راه‌حل معماری مناسب ترکیب این دو سازوکار است. می‌توان با بهره‌گیری از CreateChained در PartitionedRateLimiter هر دو لایه را هم‌زمان اعمال کرد:
    builder.Services.AddRateLimiter(options =>
    {
        options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    
        // تعریف یک محدودکننده زنجیره‌ای: کنترل فرکانس ورود + سقف پردازش هم‌زمان
        options.GlobalLimiter = PartitionedRateLimiter.CreateChained(
            // لایه ۱: هموارسازی ورود درخواست‌ها به ازای کاربر/IP
            PartitionedRateLimiter.Create<HttpContext, string>(context =>
            {
                var key = context.User.Identity?.Name 
                    ?? context.Connection.RemoteIpAddress?.ToString() 
                    ?? "anonymous";
    
                return RateLimitPartition.GetSlidingWindowLimiter(key, _ => new SlidingWindowLimiterOptions
                {
                    PermitLimit = 120,
                    Window = TimeSpan.FromMinutes(1),
                    SegmentsPerWindow = 6,
                    QueueLimit = 0
                });
            }),
    
            // لایه ۲: سقف صلب هم‌زمانی کل برنامه جهت ایمن‌سازی دیتابیس و تردپول
            PartitionedRateLimiter.Create<HttpContext, string>(_ =>
            {
                return RateLimitPartition.GetConcurrencyLimiter("global-concurrency", _ => new ConcurrencyLimiterOptions
                {
                    PermitLimit = 50, // حداکثر ۵۰ درخواست باز در کل سیستم
                    QueueLimit = 0
                });
            })
        );
    });
    الگوریتم Rate Limiting صرفاً یک مقدار عددی در تنظیمات نیست؛ بلکه نمایانگر مدلی انتزاعی از ظرفیت قابل تحمل سیستم در شرایط بحرانی است. اگر هدف محافظت از اتصالات محدود دیتابیس یا سوکت‌های آسیب‌پذیر خارجی است، کنترل هم‌زمانی (Concurrency) بسیار حیاتی‌تر از کنترل میانگین درخواست در دقیقه خواهد بود.
  • وحید نصیری در ۱۴۰۵/۰۶/۰۵ ۱۰:۴۹
    استفاده از صف (Queue) در نگاه اول تصمیمی انسان‌دوستانه و منطقی به نظر می‌رسد. بدون صف، درخواست‌های مازاد بلافاصله با خطای 429 Too Many Requests پس زده می‌شوند؛ اما با وجود صف، به کلاینت‌ها این شانس داده می‌شود که چند لحظه منتظر بمانند تا ظرفیت خالی شود و درخواستشان با موفقیت انجام پذیرد.
    با این حال، همین تصمیم دلسوزانه می‌تواند دقیقاً عاملی باشد که یک جهش ترافیکی گذرا و چند ثانیه‌ای را به یک بحران پایدار تأخیر (Latency Incident) چند ده دقیقه‌ای تبدیل کند.

    پیکربندی زیر را در نظر بگیرید:
    options.AddConcurrencyLimiter("checkout", limiter =>
    {
        limiter.PermitLimit = 100;
        limiter.QueueLimit = 1_000;
        limiter.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
    });
    در نگاه اول، این سیاست کاملاً محافظه‌کارانه به نظر می‌رسد: سقف اجرای هم‌زمان روی ۱۰۰ درخواست مهار شده و ۱۰۰۰ درخواست دیگر به جای دریافت خطای آنی، فرصت انتظار پیدا می‌کنند. اما پشت این ظاهر فریبنده، واقعیت‌های عملیاتی سنگینی نهفته است.

    درخواست‌های صف‌بندی‌شده ناپدید نمی‌شوند؛ ذخیره می‌شوند

    درخواست‌هایی که در صف منتظر می‌مانند، ماهیت خود را از دست نمی‌دهند:
    • کلاینت‌ها، اپلیکیشن‌های موبایل و مرورگرها در آن سوی خط همچنان منتظر پاسخ هستند.
    • زمان ماندن در صف مستقیماً به زمان کلی پاسخ‌دهی (End-to-End Latency) افزوده می‌شود.
    • درخواست‌های معلق در صف، بخشی از سوکت‌های شبکه، ساختارهای داخلی وب‌سرور Kestrel و حافظه موقت را اشغال نگه می‌دارند.
    • با آزاد شدن مجوزها، بار پردازشی انباشته‌شده محو نمی‌شود، بلکه به‌تازگی وارد خط لوله اجرا و درگیر کردن دیتابیس می‌شود.

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

    اثر تشدیدکننده در زمان افت کارایی دیتابیس

    قابلیت صف‌بندی تنها زمانی مفید است که سیستم شانس واقعی برای تخلیه سریع صف (Drain) در کسری از ثانیه را داشته باشد. اما اگر گلوگاه سیستم ناشی از کندی دیتابیس، قفل‌شدگی جداول یا افت سرعت APIهای خارجی باشد، صف‌بندی به یک تله مرگبار تبدیل می‌شود. وقتی سرعت اجرای کوئری‌ها افت می‌کند، نرخ آزادسازی مجوزها (Permits) به‌شدت کاهش می‌یابد و صف با سرعت سرسام‌آوری پر می‌شود. افزایش سقف صف (QueueLimit) ظرفیت از دست رفته پایگاه داده را جبران نمی‌کند؛ صرفاً صف طویلی از درخواست‌های معطل در پشت درهای بسته ایجاد می‌کند.

    بودجه تأخیر (Latency Budget) و طوفان درخواست‌های مجدد (Retry Storm)

    در APIهای تعاملی و مدرن، هر درخواستی دارای یک بودجه زمانی مشخص است. بعد از گذشت مدت‌زمانی معین (مثلاً ۵ تا ۱۰ ثانیه):
    • وب‌سرورهای بالادستی و Reverse Proxyها (نظیر Nginx یا YARP) درخواست را Timeout می‌کنند.
    • اپلیکیشن‌های سمت کاربر به دلیل عدم دریافت پاسخ، درخواست را لغو کرده و مجدداً ارسال می‌کنند.
    • در نهایت، زمانی که مجوز به درخواست اول در صف اعطا می‌شود، سرور منابع گران‌بهای دیتابیس و پردازشی را صرف اجرای درخواستی می‌کند که کلاینت پیش‌تر اتصال آن را قطع کرده است؛ در حالی که درخواست جدیدِ تکرارشده (Retry) نیز هم‌اکنون به انتهای صف اضافه شده است!

    چرا خطای ۴۲۹ شکست نیست، بلکه ابزار بقاست؟

    پس زدن سریع درخواست مازاد (Fail-Fast) با ارسال بلادرنگ کد وضعیت 429، ایجادکننده فشار معکوسِ صریح (Explicit Backpressure) است. این سیگنال فوری به کلاینت‌ها و کلاسترهای بالادست اعلام می‌کند که سیستم در سقف ظرفیت است تا آن‌ها سازوکارهای عقب‌نشینی تدریجی (Exponential Backoff) یا نمایش پیام مناسب به کاربر را فعال کنند.

    رویکردرفتار در اوج ترافیکپیامد برای دیتابیس و حافظهرفتار در زمان بازیابی
    صف طولانی (QueueLimit > 0)تبدیل خطا به تأخیر بالا و انباشت کارهای معلقاشغال طولانی حافظه، خطر لغو کلاینت و تکرار درخواستادامه کندی شدید سیستم حتی پس از پایان جهش ترافیک
    رد فوری (QueueLimit = 0)شکست سریع (Fail-Fast) و ارسال بلادرنگ خطای ۴۲۹ایزوله ماندن دیتابیس در سقف ایمن و آزادسازی فوری منابعبازیابی بلادرنگ سیستم به محض فروکش کردن بار ورودی

    راهکار تکمیلی: کنترل مدت زمان انتظار در صف با Cancellation Token

    اگر در سناریوی خاصی ناچار به استفاده از صف‌های کوچک هستید، باید از نگه‌داشتن درخواست‌هایی که کلاینت آن‌ها را رها کرده جلوگیری کنید. هنگام فراخوانی دستی AcquireAsync یا استفاده از میان‌افزار، حتماً اتصال به HttpContext.RequestAborted را بررسی کرده و ترجیحاً QueueLimit را روی حداقل ممکن (صفر یا مقادیر تک‌رقمی بسیار محدود) تنظیم کنید:
    // استفاده از صف بسیار کوچک همراه با Fail-Fast برای حفظ بودجه تاخیر
    options.AddConcurrencyLimiter("checkout-defensive", limiter =>
    {
        limiter.PermitLimit = 50;  // سقف سخت‌گیرانه هم‌زمانی روی دیتابیس
        limiter.QueueLimit = 5;    // صف بسیار محدود صرفاً برای هموارسازی میلی‌ثانیه‌ای
        limiter.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
    });
    
    // بازگرداندن پاسخ شفاف با هدر Retry-After
    options.OnRejected = async (context, token) =>
    {
        context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
        context.HttpContext.Response.Headers.RetryAfter = "5"; // پیشنهاد عقب‌نشینی ۵ ثانیه‌ای به کلاینت
        await context.HttpContext.Response.WriteAsync("سرویس موقتاً در حداکثر ظرفیت است. لطفاً چند لحظه بعد مجدداً تلاش کنید.", token);
    };
    طول صف در تنظیمات Rate Limiting نباید ابزاری برای پنهان کردن خطای ۴۲۹ در داشبوردهای مانیتورینگ باشد. طول صف باید بر اساس توانایی واقعی سیستم در تخلیه آن و بودجه زمانی معتبر درخواست‌ها توجیه شود. در شرایط بحرانی، یک رد کردن سریع، ضامن بقای کل سرویس خواهد بود.
  • وحید نصیری در ۱۴۰۵/۰۶/۰۵ ۱۰:۵۲
    پارتیشن‌بندی ظرفیت (Partitioning)، یکی از قدرتمندترین قابلیت‌های میان‌افزار Rate Limiting در ASP.NET Core است. با تفکیک سهمیه‌ها بر اساس شناسه کاربر، مستأجر (Tenant)، کلید API یا آدرس IP، می‌توان از مصرف کل ظرفیت سیستم توسط یک کاربر پرمصرف یا مهاجم (Noisy Neighbor) جلوگیری کرد.
    با این حال، پارتیشن‌بندی درست در محیط توسعه می‌تواند در محیط پروداکشن به یک فاجعه عملیاتی تبدیل شود؛ به‌ویژه زمانی که کلید پارتیشن (Partition Key) نماینده هویت واقعی درخواست‌دهنده نباشد.

    کد زیر یک الگوی بسیار رایج است:
    options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
    {
        var key = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
    
        return RateLimitPartition.GetFixedWindowLimiter(
            partitionKey: key,
            factory: _ => new FixedWindowRateLimiterOptions
            {
                PermitLimit = 100,
                Window = TimeSpan.FromMinutes(1),
                QueueLimit = 0
            });
    });
    این تنظیمات روی سیستم توسعه‌دهنده (Localhost) بی‌نقص عمل می‌کند؛ اما در معماری واقعی پروداکشن، لایه‌های واسطی چون Reverse Proxyها (نظیر Nginx یا YARP)، اینگرس‌کنترلرهای Kubernetes، شبکه‌های توزیع محتوا (CDN)، گیت‌وی‌ها یا سامانه‌های NAT و Load Balancer حضور دارند.

    چالش نخست: مسدودسازی گروهی کاربران پشت پروکسی

    در صورت عدم پیکربندی صحیح، مقدار context.Connection.RemoteIpAddress در سرور Kestrel، آدرس IP همان لایه میانی (پروکسی یا لودبالانسر) خواهد بود.
    در این حالت:
    • هزاران کاربر مستقل و معتبر، همگی دارای یک کلید پارتیشن یکسان (IP پروکسی) می‌شوند.
    • سقف مجاز ۱۰۰ درخواست در دقیقه بین تمامی کاربران تقسیم می‌شود.
    • با اولین فعالیت سنگین یک کاربر، کل کاربران پشت آن مسیر با خطای 429 Too Many Requests مسدود خواهند شد.

    برای رفع این مشکل، باید میان‌افزار ForwardedHeadersOptions در ابتدای پایپ‌لاین به شکل امن رجیستر شود تا هدرهای X-Forwarded-For و X-Forwarded-Proto به‌درستی مقداردهی شوند:
    builder.Services.Configure<ForwardedHeadersOptions>(options =>
    {
        options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
        // افزودن شبکه‌ها یا لودبالانسرهای قابل اعتماد برای جلوگیری از جعل هدر
        options.KnownNetworks.Clear();
        options.KnownProxies.Clear();
    });
    
    // ...
    // این میان‌افزار حتماً باید قبل از UseRateLimiter فراخوانی شود
    app.UseForwardedHeaders();
    app.UseRateLimiter();

    چالش دوم: جعل کلید پارتیشن (Header Spoofing)

    اگر بدون اعتبارسنجی پروکسی‌های مورد اعتماد (Known Proxies/Networks)، هدرهای بازارسال‌شده را بپذیرید یا مستقیماً از هدر X-Forwarded-For به عنوان کلید پارتیشن استفاده کنید، مهاجم می‌تواند با ارسال مقادیر تصادفی در هر درخواست، کلید پارتیشن جدیدی بسازد و سیستم Rate Limiting را به‌سادگی دور بزند.

    انتخاب کلید پارتیشن بر اساس هویت و منبع کمیاب

    آدرس IP تنها برای اندپوینت‌های ناشناس (Anonymous) کاربرد دارد و برای بقیه سناریوها ضعیف‌ترین شناسه است. در سیستم‌های واقعی، کلید پارتیشن باید بر اساس «عاملی که می‌تواند منبع را اشباع کند» انتخاب شود:

    نوع سیستم / اندپوینتهویت اصلی درخواست‌دهندهکلید پارتیشن ایده‌آلعلت انتخاب
    اندپوینت عمومی / احرازشده نشدهکلاینت شبکهآدرس IP امن (از طریق Forwarded Headers)تنها داده در دسترس برای مسدودسازی بات‌ها و اسکرپرها
    سرویس‌های تجاری و B2Bمشتری / کلاینت مجازX-Api-Key یا شناسه اشتراکرعایت سهمیه قرارداد تجاری بدون تأثیر گرفتن از موقعیت مکانی
    سیستم‌های چندمستأجری (Multi-Tenant)کل سازمان / TenantTenantId (از Claim یا Header)جلوگیری از اشباع دیتابیس مشترک توسط یک سازمان با کاربران متعدد
    سامانه‌های تعاملی سازمانیکاربر نهاییClaimTypes.NameIdentifierتضمین عدالت مصرف میان اعضای مختلف یک سیستم

    راهکار تکمیلی: سیاست پارتیشن‌بندی چندلایه و هوشمند

    یک رویکرد دفاعی مناسب در ASP.NET Core، تشخیص نوع هویت درخواست (کاربر احراز هویت‌شده، شناسه سازمانی یا IP کلاینت) و تفکیک محدودیت‌ها بر این اساس است:
    options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
    {
        // ۱. بررسی شناسه سازمانی برای سیستم‌های چندمستأجری
        var tenantId = context.User.FindFirst("tenant_id")?.Value;
        if (!string.IsNullOrEmpty(tenantId))
        {
            return RateLimitPartition.GetSlidingWindowLimiter($"tenant_{tenantId}", _ => new SlidingWindowLimiterOptions
            {
                PermitLimit = 500,
                Window = TimeSpan.FromMinutes(1),
                SegmentsPerWindow = 6,
                QueueLimit = 0
            });
        }
    
        // ۲. بررسی شناسه کاربر احرازهویت‌شده
        var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        if (!string.IsNullOrEmpty(userId))
        {
            return RateLimitPartition.GetSlidingWindowLimiter($"user_{userId}", _ => new SlidingWindowLimiterOptions
            {
                PermitLimit = 120,
                Window = TimeSpan.FromMinutes(1),
                SegmentsPerWindow = 4,
                QueueLimit = 0
            });
        }
    
        // ۳. استفاده از IP امن برای کاربران ناشناس
        var ipAddress = context.Connection.RemoteIpAddress?.ToString() ?? "unknown_ip";
        return RateLimitPartition.GetFixedWindowLimiter($"ip_{ipAddress}", _ => new FixedWindowRateLimiterOptions
        {
            PermitLimit = 30,
            Window = TimeSpan.FromMinutes(1),
            QueueLimit = 0
        });
    });
    پارتیشن‌بندی اگر هویت واقعی عامل مصرف‌کننده را منعکس نکند، در شرایط اوج ترافیک یا کل ترافیک مجاز را به اشتباه قطع می‌کند، یا در برابر حملات هدفمند شکست می‌خورد. کلید پارتیشن باید همواره با هویت انحصاری دارنده حق مصرف هم‌راستا باشد.
  • وحید نصیری در ۱۴۰۵/۰۶/۰۵ ۱۰:۵۴
    استفاده از یک محدودکننده سراسری (Global Limiter) جذابیت زیادی دارد؛ زیرا پیکربندی آن بسیار ساده است: یک سیاست کلی برای تمام برنامه تعریف می‌کنید و دیگر درگیر پیچیدگی‌های مربوط به تک‌تک اندپوینت‌ها نمی‌شوید. این رویکرد برای میکروسرویس‌های بسیار کوچک و تک‌منظوره شاید پاسخگو باشد، اما در APIهای واقعی، فرض «برابری هزینه پردازش درخواست‌ها» توهمی بیش نیست.

    این چهار اندپوینت را در یک سیستم فرضی در نظر بگیرید:
    GET  /api/products/{id}
    POST /api/reports/export
    POST /api/orders
    GET  /api/health
    تفاوت اقتصادی و عملیاتی این درخواست‌ها از زمین تا آسمان است:
    • اندپوینت products یک مدل کوچک و کش‌شده در Redis را با کمترین بار پردازشی برمی‌گرداند.
    • اندپوینت reports/export یک کوئری تحلیلی سنگین را روی دیتابیس اجرا کرده، داده‌ها را مرتب‌سازی و به صورت یک فایل حجیم استریم می‌کند.
    • اندپوینت orders تراکنش‌های مالی، رزرو موجودی و قفل‌گذاری روی جداول پایگاه داده را انجام می‌دهد.
    • اندپوینت health صرفاً وضعیت زنده بودن پروسس را بررسی می‌کند و تقریباً هیچ منبعی مصرف نمی‌کند.

    اگر همه این درخواست‌ها از یک سهمیه مشترک سراسری (Global Quota) استفاده کنند، سیستم Rate Limiting تفاوتی میان هزینه این عملیات‌ها قائل نخواهد شد.

    ناهنجاری‌های ناشی از یکسان‌انگاری درخواست‌ها در شرایط پیک بار

    زمانی که بار ترافیکی روی سیستم افزایش می‌یابد، اعمال یک سقف عددی یکپارچه دو سناریوی شکست ایجاد می‌کند:
    • قربانی شدن درخواست‌های سبک توسط درخواست‌های سنگین: اگر جهشی در درخواست‌های صدور گزارش رخ دهد، کل سهمیه سراسری به سرعت مصرف می‌شود. در نتیجه، درخواست‌های سبکِ مشاهده محصول (GET products) که سرور به‌راحتی توانایی پاسخ به هزاران مورد از آن‌ها را دارد، بی‌دلیل با خطای 429 Too Many Requests رد می‌شوند.
    • اشباع و فلج شدن منابع زیرساختی: اگر سقف سراسری بر مبنای توان پاسخ‌دهی به درخواست‌های سبک تنظیم شده باشد (مثلاً ۱۰۰۰ درخواست در ثانیه)، ورود کسری از همین تعداد برای اندپوینت گزارش‌گیری سنگین، استخر اتصالات پایگاه داده (Connection Pool) و پردازنده را به طور کامل اشباع کرده و کل سرویس را زمین‌گیر می‌کند.
    • از دسترس خارج شدن بررسی سلامت (Health Check): اگر اندپوینت health پشت همان سهمیه سراسری قرار گیرد، پر شدن سهمیه منجر به رد درخواست‌های Health Probe توسط لودبالانسر یا Kubernetes شده و پروسس سالم به اشتباه Restart می‌شود!

    دسته‌بندی بار کاری (Workload Classes) به جای شمارش کلی

    بهترین راهبرد برای مدیریت ترافیک، تفکیک اندپوینت‌ها به چند دسته بار کاری (Workload Class) بر اساس هزینه پردازشی آن‌هاست.
    در ASP.NET Core می‌توانید به کمک قابلیت AddPolicy سیاست‌های نام‌گذاری‌شده بسازید و با متد RequireRateLimiting آن‌ها را به اندپوینت‌های متناظر متصل کنید:

    دسته بار کارینمونه اندپوینتراهبرد کنترل ظرفیتالگوریتم مناسب
    خواندن‌های سبک و کش‌شده/api/products/{id}سهمیه دست‌ودلبازانه همراه با قابلیت BurstToken Bucket یا Sliding Window
    عملیات تحلیلی و سنگین/api/reports/exportکنترل صلب هم‌زمانی (Concurrency) بدون صفConcurrency Limiter (QueueLimit = 0)
    تراکنش‌های حساس و نوشتنی/api/ordersترکیب سهمیه کاربر با سقف هم‌زمانیPartitioned Rate Limiting
    بررسی سلامت و متادیتا/api/healthمعاف از محدودیتDisableRateLimiting

    پیاده‌سازی عملی سیاست‌های چندگانه در ASP.NET Core

    به جای تعریف ده‌ها Policy غیرضروری، سیستم را به چند سیاست تمیز و هدفمند مجهز کنید:
    builder.Services.AddRateLimiter(options =>
    {
        options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    
        // ۱. سیاست درخواست‌های سبک و خواندنی: سهمیه بالا با مدیریت جهش
        options.AddTokenBucketLimiter("read-policy", limiter =>
        {
            limiter.TokenLimit = 200;
            limiter.TokensPerPeriod = 50;
            limiter.ReplenishmentPeriod = TimeSpan.FromSeconds(1);
            limiter.QueueLimit = 0;
        });
    
        // ۲. سیاست گزارش‌های سنگین: سقف سخت‌گیرانه هم‌زمانی روی منابع دیتابیس
        options.AddConcurrencyLimiter("expensive-report-policy", limiter =>
        {
            limiter.PermitLimit = 5; // حداکثر ۵ گزارش هم‌زمان در کل پروسس
            limiter.QueueLimit = 0;   // رد فوری درخواست‌های اضافه جهت حفظ دیتابیس
        });
    
        // ۳. سیاست تراکنش‌های ایجاد سفارش: تفکیک بر اساس شناسه کاربر
        options.AddPolicy("transactional-write-policy", context =>
        {
            var userId = context.User.Identity?.Name 
                ?? context.Connection.RemoteIpAddress?.ToString() 
                ?? "anonymous";
    
            return RateLimitPartition.GetSlidingWindowLimiter(userId, _ => new SlidingWindowLimiterOptions
            {
                PermitLimit = 10,
                Window = TimeSpan.FromMinutes(1),
                SegmentsPerWindow = 2,
                QueueLimit = 0
            });
        });
    });
    
    // ...
    
    app.UseRateLimiter();
    
    // اتصال صریح سیاست‌ها به اندپوینت‌ها
    app.MapGet("/api/products/{id}", GetProduct)
       .RequireRateLimiting("read-policy");
    
    app.MapPost("/api/reports/export", ExportReport)
       .RequireRateLimiting("expensive-report-policy");
    
    app.MapPost("/api/orders", CreateOrder)
       .RequireRateLimiting("transactional-write-policy");
    
    // معاف‌سازی اندپوینت‌های زیرساختی
    app.MapGet("/api/health", () => Results.Ok(new { status = "Healthy" }))
       .DisableRateLimiting();
    هدف از معماری Rate Limiting، ساخت ساختارهای پیچیده و نمایشی نیست؛ هدف این است که یک درخواست سنگین و پرهزینه نتواند منابع مشترک سرور را به گونه‌ای ببلعد که سایر تراکنش‌های حیاتی و سبک سیستم آسیب ببینند. یک محدودکننده هوشمند، توانایی سرویس را در انجام کارهای مفید در بحبوحه اوج بار ترافیکی تضمین می‌کند.
  • وحید نصیری در ۱۴۰۵/۰۶/۰۵ ۱۰:۵۶
    الگوریتم Token Bucket یکی از جذاب‌ترین الگوها برای کنترل ترافیک کلاینت‌هایی است که رفتار جهشی (Bursty) دارند. در این مدل، توکن‌ها با نرخ ثابتی در طول زمان تولید شده و تا سقف معینی در یک «سطل» ذخیره می‌شوند. هر درخواست ورودی یک توکن مصرف می‌کند. اگر کلاینت مدتی غیرفعال بماند، توکن‌ها جمع می‌شوند و کلاینت می‌تواند تمام آن‌ها را در یک بازه زمانی بسیار کوتاه و به‌صورت انفجاری مصرف کند.
    این رفتار دقیقاً همان چیزی است که برنامه‌های مبتنی بر رابط کاربری (UI) به آن نیاز دارند؛ مثلاً داشبوردی را تصور کنید که بلافاصله پس از باز شدن صفحه توسط کاربر، ۱۰ درخواست موازی برای دریافت ویجت‌های مختلف ارسال می‌کند. یک الگوریتم سخت‌گیر که اجازه ارسال سریع را ندهد، تجربه کاربری را کند می‌کند؛ اما Token Bucket با پذیرش این جهش اولیه، درخواست‌ها را سریع پردازش کرده و سپس ترافیک پیوسته (Sustained Traffic) را مهار می‌کند.

    تفاوت «مجاز بودن جهش» با «ارزان بودن پردازش»

    نکته‌ای که اغلب در تنظیمات نادیده گرفته می‌شود این است: الگوریتم Token Bucket جهش‌های ترافیکی را سبک و بی‌هزینه نمی‌کند، بلکه صرفاً ورود آن‌ها را مجاز می‌سازد.
    اگر زیرساخت شما نتواند ورود هم‌زمان کل توکن‌های ذخیره‌شده را در کسری از ثانیه هضم کند، ظرفیت سطل شما (TokenLimit) بزرگ‌تر از توان تاب‌آوری واقعی منبعی است که قصد محافظت از آن را دارید.
    پیکربندی زیر را در نظر بگیرید:
    options.AddTokenBucketLimiter("dashboard-burst", limiter =>
    {
        limiter.TokenLimit = 100;                 // سقف ظرفیت انفجاری
        limiter.TokensPerPeriod = 10;             // تولید ۱۰ توکن جدید
        limiter.ReplenishmentPeriod = TimeSpan.FromSeconds(1); // در هر ثانیه
        limiter.QueueLimit = 0;
    });
    اگر کلاینت ۱۰ ثانیه سکوت کند، سطل ۱۰۰ توکن پر خواهد داشت. با اولین کنش، ۱۰۰ درخواست در کمتر از چند میلی‌ثانیه به سمت سرور شلیک می‌شوند. اگر این اندپوینت عملیات سنگینی مانند نوشتن در دیتابیس مشترک، انتشار پیام در Message Broker یا فراخوانی سرویس‌های واسط را انجام دهد، این جهش ناگهانی می‌تواند سیستم را دچار قفل‌شدگی یا اشباع استخر اتصالات کند.

    تفکیک سهمیه تجاری از توان فیزیکی زیرساخت

    یکی از خطاهای متداول معماری، تنظیم پارامترهای Rate Limiting صرفاً بر اساس نیازمندی‌های بیزینس (Business Requirements) است:
    • تیم محصول می‌گوید: «هر مشتری مجاز است تا سقف ۶۰۰ عملیات در دقیقه انجام دهد.»
    • تیم فنی تنظیم می‌کند: یک Token Bucket با سقف ۶۰۰ توکن.

    اما این دو، دو مفهوم کاملاً مجزا هستند:
    • سهمیه تجاری (Commercial Quota): مشتری چقدر حجم کاری خریداری کرده است؟
    • ظرفیت لحظه‌ای زیرساخت (Instantaneous Infrastructure Capacity): سرور و پایگاه داده در همین ثانیه چه میزان فشار موازی را می‌توانند بدون افت کیفیت تحمل کنند؟

    راهکار معماری: زنجیره‌سازی سهمیه و محافظت از ظرفیت (Chained Limiting)
    به جای گنجاندن هر دو نیاز در یک عدد واحد، رویکرد استاندارد در ASP.NET Core استفاده از محدودکننده‌های چندلایه و زنجیره‌ای به کمک PartitionedRateLimiter.CreateChained است:
    builder.Services.AddRateLimiter(options =>
    {
        options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    
        options.GlobalLimiter = PartitionedRateLimiter.CreateChained(
            // لایه ۱: اعمال سهمیه تجاری و مجاز شمردن جهش‌های کوتاه‌مدت کاربر
            PartitionedRateLimiter.Create<HttpContext, string>(context =>
            {
                var userId = context.User.Identity?.Name 
                    ?? context.Connection.RemoteIpAddress?.ToString() 
                    ?? "anonymous";
    
                return RateLimitPartition.GetTokenBucketLimiter(userId, _ => new TokenBucketRateLimiterOptions
                {
                    TokenLimit = 60,               // حداکثر جهش مجاز به ازای کاربر
                    TokensPerPeriod = 10,
                    ReplenishmentPeriod = TimeSpan.FromSeconds(1),
                    QueueLimit = 0
                });
            }),
    
            // لایه ۲: سقف فیزیکی هم‌زمانی روی دیتابیس و منابع سرور
            PartitionedRateLimiter.Create<HttpContext, string>(_ =>
            {
                return RateLimitPartition.GetConcurrencyLimiter("db-capacity-guard", _ => new ConcurrencyLimiterOptions
                {
                    PermitLimit = 30,              // حداکثر ۳۰ اجرای واقعاً هم‌زمان روی منابع مشترک
                    QueueLimit = 0
                });
            })
        );
    });
    مفهومماهیتمتولی در معماریابزار در ASP.NET Core
    امکان جهش (Burst Allowance)یک ویژگی محصول و بیزینسلایه کاربر / سهمیهTokenBucketLimiter
    تحمل جهش (Burst Tolerance)یک ویژگی سخت‌افزاری و زیرساختیلایه محافظت از منبعConcurrencyLimiter
    تعریف امکان جهش، بدون سنجش و بنچمارک توانایی تحمل آن در شرایط پروداکشن، به این معناست که Rate Limiter با دقت بالا دقیقاً همان الگوی ترافیکی مخربی را تایید و وارد سیستم می‌کند که پایگاه داده را از پا درمی‌آورد.
  • وحید نصیری در ۱۴۰۵/۰۶/۰۵ ۱۰:۵۷
    سرویس‌های عملیاتی ASP.NET Core در دنیای واقعی به‌ندرت به‌صورت تک‌پردیس (Single-Instance) باقی می‌مانند. سیستم‌های امروزی در قالب چندین کانتینر یا پاد، پشت یک Load Balancer و در کنار مکانیزم‌های مقیاس‌پذیری خودکار (Autoscaling) اجرا می‌شوند.
    این توزیع‌شدگی، مفهوم و رفتار میان‌افزار داخلی دات‌نت (System.Threading.RateLimiting) را به کلی تغییر می‌دهد؛ زیرا این ابزار به‌طور پیش‌فرض درون‌حافظه‌ای (In-Memory) است و وضعیت آن درون همان تک پروسس مدیریت می‌شود.

    چالش مقیاس‌پذیری و تغییر سهمیه کلی (Fleet Capacity Multiplication)

    اگر در تنظیمات برنامه مقدار زیر را تعیین کرده باشید:
    options.AddFixedWindowLimiter("client-quota", limiter =>
    {
        limiter.PermitLimit = 100;
        limiter.Window = TimeSpan.FromMinutes(1);
        limiter.QueueLimit = 0;
    });
    زمانی که برنامه با یک نمونه (Instance) اجرا می‌شود، هر کلاینت حداکثر ۱۰۰ درخواست در دقیقه ارسال خواهد کرد. اما به محض اینکه سیستم تحت بار ترافیکی Scale Out کرده و به ۱۰ نود افزایش یابد، وضعیت متفاوتی شکل می‌گیرد:
    [ کلاینت / مهاجم ]
            │
            ▼ (ارسال درخواست‌ها با الگوی Round-Robin)
    [ Load Balancer ]
       ├───► نود ۱ (مجاز: ۱۰۰)  ──► مصرف: ۱۰۰
       ├───► نود ۲ (مجاز: ۱۰۰)  ──► مصرف: ۱۰۰
       │     ...
       └───► نود ۱۰ (مجاز: ۱۰۰) ──► مصرف: ۱۰۰
    ────────────────────────────────────────────────────
    >>> مجموع ترافیک مجاز واردشده به دیتابیس مشترک: ۱,۰۰۰ درخواست در دقیقه!
    در این حالت، با افزایش تعداد رپلیکاها، ظرفیت سهمیه ورودی هر کلاینت بدون تغییر در کد، ضرب در تعداد نودها شده و دیتابیس مشترک یا وب‌سرویس‌های پایین‌دستی را زیر بار سنگین له می‌کند.

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

    این رفتار ناشی از باگ در ASP.NET Core نیست؛ بلکه به تفاوت میان دو نیازمندی معماری مربوط می‌شود:
    • محافظت از نمونه محلی (Local Instance Protection): جلوگیری از اشباع پردازنده، حافظه و نخ‌های کاری همان یک کانتینر.
    • سهمیه‌بندی کلی و تجاری (Distributed Fleet-Wide Quota): اعمال دقیق سقف پلن خریداری‌شده توسط کاربر یا سازمان در کل سامانه.

    سطح اعمالهدف اصلیابزار و لایه پیاده‌سازی مناسب
    سراسری (Distributed)سهمیه تجاری، مهار بات‌ها، صورت‌حسابلایه API Gateway (نظیر YARP/Nginx/Kong) یا Redis متمرکز
    محلی (In-Process)حفظ ThreadPool، سقف کانکشن‌های دیتابیسمیان‌افزار داخلی ASP.NET Core (ConcurrencyLimiter)

    تله هماهنگی سراسری (Distributed Coordination Trap)

    پاسخ فوری برخی تیم‌ها، اتصال تک‌تک درخواست‌های ورودی به یک استخر مرکزی (مثل ذخیره توکن‌ها در Redis) است.
    اگرچه این کار سهمیه دقیق را تضمین می‌کند، اما در ترافیک‌های سنگین یک گلوگاه جدید و وابستگی شبکه (Network I/O) اضافه می‌سازد. اگر ردیس با نوسان یا کندی مواجه شود، کل خط لوله وب‌سرور Kestrel دچار تأخیر زنجیره‌ای خواهد شد. هماهنگی توزیع‌شده فقط باید برای سهمیه‌های تجاری استفاده شود، نه برای محافظت‌های ثانیه‌ای و محلی.

    راهکار معماری استاندارد: ترکیب دو لایه (Layered Defense)

    بهترین الگو در سیستم‌های ابری و میکروسرویسی، جداسازی این دو دغدغه در دو لایه متفاوت است:
    • لایه گیت‌وی یا ریورس پروکسی: سهمیه تجاری کاربر (مثلاً ۱۰۰۰ درخواست در دقیقه) را با استفاده از کش توزیع‌شده کنترل می‌کند.
    • لایه کانتینر ASP.NET Core: با یک ConcurrencyLimiter ساده و بدون نیاز به شبکه، سقف منابع فیزیکی همان پروسس را گارانتی می‌کند:
    // نمونه پیکربندی درون پروسس برای محافظت مستقل هر نود
    builder.Services.AddRateLimiter(options =>
    {
        options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    
        // کنترل سخت‌گیرانه هم‌زمانی روی همان نمونه محلی بدون وابستگی به شبکه
        options.AddConcurrencyLimiter("local-capacity-guard", limiter =>
        {
            limiter.PermitLimit = 40; // حداکثر ۴۰ عملیات هم‌زمان روی این نود
            limiter.QueueLimit = 0;   // رد آنی بدون ایجاد تأخیر در حافظه
        });
    });
    زمانی که سیستم Autoscaling فعال می‌شود، از خود بپرسید: «با افزایش تعداد پادها، آیا سقف مجاز این سیاست هم باید رشد کند؟» اگر پاسخ مثبت است (مثل ظرفیت پذیرش هم‌زمانی)، پیاده‌سازی محلی در دات‌نت بهترین گزینه است؛ اما اگر پاسخ منفی است (مثل سهمیه ماهانه یا تجاری کاربر)، محل اعمال این قانون خارج از حافظه محلی تک پروسس‌ها خواهد بود.
  • وحید نصیری در ۱۴۰۵/۰۶/۰۵ ۱۱:۲۶
    بدترین شیوه برای تست Rate Limiting این است که ترافیکی یکنواخت با نرخی اندکی بالاتر از سقف مجاز ارسال کنید و صرفاً خوشحال باشید که چند پاسخ با کد وضعیت 429 بازگردانده شده است. این آزمایش تنها یک موضوع را اثبات می‌کند: میان‌افزار در پایپ‌لاین رجیستر شده است. اما هرگز نشان نمی‌دهد که سرویس شما در شرایط اشباع واقعی زنده خواهد ماند یا خیر.
    تست بار کارآمد (Load Testing) تستی است که دقیقاً فرضیات پشت پیکربندی را به چالش بکشد و پیش از پروداکشن، محدودکننده را بشکند.

    طراحی الگوهای حمله بر اساس نوع الگوریتم (Core Attack Vectors)

    برای ارزیابی واقعی تاب‌آوری، باید آزمون بار را متناسب با الگوریتم انتخابی تنظیم کنید:

    استراتژی محدودکنندهالگوی اصلی حمله در تست بارخطای مورد انتظار برای سنجش و پیشگیری
    Fixed Windowترافیک انفجاری در مرز (Boundary Bursting): ارسال ترافیک با حجم دو برابر سقف مجاز در فاصله چند میلی‌ثانیه قبل و بعد از ریست شدن پنجره زمانی.جلوگیری از ورود ناگهانی موج دوبرابری ترافیک و اشباع اتصالات دیتابیس در مرز پنجره‌ها.
    Sliding Windowاشباع سگمنت‌ها: شلیک پالس‌های مقطعی ترافیک در بازه‌های کوچک خردشده (Segments).اطمینان از عملکرد صحیح میانگین متحرک و عدم مصرف بی‌رویه حافظه برای رهگیری وضعیت سگمنت‌ها.
    Token Bucketتخلیه آنی سطل + فشار مستمر: خالی کردن یکباره تمام توکن‌های انباشته و سپس ادامه بار سنگین بالاتر از نرخ پر شدن مجدد (Refill Rate).اثبات ثبات زمان‌بندی شارژ توکن‌ها و عدم وقوع ناهماهنگی تحت قفل‌های داخلی تردها.
    Concurrency Limiterتزریق تاخیر در وابستگی‌ها (Latency Injection): ایجاد تاخیر مصنوعی ۲ تا ۵ ثانیه‌ای در کوئری‌های پایگاه داده یا وب‌سرویس‌های خارجی.حصول اطمینان از اینکه تعداد پردازش‌های زنده هم‌زمان دقیقاً در سقف تعیین‌شده مهار می‌شود و تردپول آسیب نمی‌بیند.
    پارتیشن‌بندی چندمستأجریشبیه‌سازی همسایه پر سر و صدا (Noisy Neighbor): اشباع کامل سهمیه سازمان A و هم‌زمان ارسال ترافیک استاندارد روی سازمان‌های B و C.اطمینان از اینکه اشباع یک پارتیشن، عملکرد و سرعت پاسخ‌دهی به سایر کاربران معتبر را مختل نمی‌کند.
    تله صف: اتاق انتظار یا پس‌زدن بهنگام خطا؟

    فعال‌سازی صف (QueueLimit > 0) اغلب پاشنه آشیل سیستم‌های تحت فشار است:
    • انباشت حافظه موقت: هر درخواست معلق در صف، بافرهای HTTP، متادیتاها و سرویس‌های حوزه Scoped را در حافظه قفل نگه می‌دارد. در صورت تداوم فشار، صف‌های طولانی باعث رفتن آبجکت‌ها به Gen 2 و در نهایت فریز سیستم بر اثر Garbage collection یا خطای OutOfMemoryException می‌شوند.
    • لغو درخواست توسط کلاینت و کار بیهوده سرور: اگر کلاینت بعد از ۲ ثانیه Timeout دهد اما درخواست ۵ ثانیه در صف منتظر مانده باشد، سرور پردازش سنگین دیتابیس را برای کلاینتی انجام می‌دهد که پیش‌تر ارتباط خود را قطع کرده است.
    • تأخیر در بازیابی سیستم: پس از قطع ناگهانی ترافیک ورودی، اندازه‌گیری کنید چه مدت‌زمانی طول می‌کشد تا سرور صف‌های معوقه را تخلیه کرده و Latency را به سطح نرمال برگرداند.

    تله‌متری و سنجه‌های حیاتی حین آزمون بار

    تنها به شمارش پاسخ‌های ۴۲۹ اکتفا نکنید. هم‌زمان این چهار لایه را به صورت همبسته (Correlated) رصد کنید:
    سنجه‌های داخلی میان‌افزار دات‌نت (Microsoft.AspNetCore.RateLimiting):
    • مدت زمان نگه‌داشت مجوزها (rate-limiting.request.lease.duration).
    • زمان انتظار در صف (rate-limiting.request.time-in-queue).
    • نسبت درخواست‌های ردشده به درخواست‌های صف‌بندی‌شده.

    سنجه‌های ران‌تایم و منابع سیستمی:
    • وضعیت ThreadPool: شمارنده کارهای معلق (ThreadPool.PendingWorkItemCount) و مدت‌زمان رقابت بر سر قفل‌ها (Lock Contention).
    • رفتار حافظه و GC: نرخ رشد فضای Heap و زمان مکث فرآیند GC حین اشباع صف‌ها.

    مرز وابستگی‌ها و پایگاه داده:
    • مدت زمان انتظار برای دریافت اتصال در Connection Pool دیتابیس و بررسی پدیده Pool Starvation.
    • وضعیت مصرف استخر سوکت‌های خروجی HTTP.

    پروفایل دقیق تأخیر (End-to-End Latency Profiles):
    • رهگیری صدک‌های تأخیر (p95، p99 و p99.9) صرفاً برای درخواست‌های موفق (HTTP 200)؛ تا مطمئن شوید حتی با رد شدن درصد بالایی از ترافیک اضافی، کیفیت سرویس‌دهی به درخواست‌های مجاز افت نکرده است.

    آزمون با وابستگی‌های ضعیف‌شده (Degraded Dependencies)

    یک تست جامع باید آزمون بار را با تزریق خطای عمدی ترکیب کند:
    • ایجاد گلوگاه در I/O دیتابیس: ایجاد تاخیر مصنوعی ۵۰۰ میلی‌ثانیه‌ای روی کوئری‌ها تا اطمینان حاصل شود Concurrency Limiter قبل از بروز خطای Timeout در Connection Pool وارد عمل شده و جریان را قطع می‌کند.
    • توقف پاسخ وب‌سرویس‌های خارجی (Blackhole): قطع موقت پاسخ‌دهی سرویس‌های Downstream تا بررسی شود که آیا Rate Limiter مانع از تجمع درخواست‌ها پشت سوکت‌های معلق می‌شود یا خیر.

    یک تست ترافیک خوب باید سیستم را تا لبه شکست پیش ببرد و مرزهای عدم کارایی آن را شفاف سازد؛ چرا که ترافیک پروداکشن در آینده بدون هشدار قبلی همین آزمایش را اجرا خواهد کرد.
  • وحید نصیری در ۱۴۰۵/۰۶/۰۵ ۱۱:۲۸
    میان‌افزار Rate Limiting در ASP.NET Core بخش شکننده و ضعیف ماجرا نیست؛ بخش شکننده، فرضیات اشتباهی است که ما در قالب سیاست‌ها (Policies) کدنویسی و به سیستم تحمیل می‌کنیم.
    تمام ابزارهای موجود در فضای‌نام System.Threading.RateLimiting ابزارهایی معتبر و استاندارد هستند، اما هر کدام اگر در جای نادرست به کار گرفته شوند، منشأ شکست خواهند بود:
    • Fixed Window می‌تواند سهمیه تجاری را دقیق اعمال کند، اما جهش‌های مخرب دوبرابری در مرز پنجره‌ها به همراه بیاورد.
    • Sliding Window شمارنده ورود را هموار می‌کند، اما اگر پاسخ‌دهی به درخواست‌ها کند شود، در برابر رشد انفجاری هم‌زمانی (Concurrency) کاملاً ناتوان است.
    • Token Bucket ورود جهشی ترافیک را مجاز می‌سازد، در حالی که ممکن است این جهش فراتر از ظرفیت هضم دیتابیس یا سوکت‌های خارجی باشد.
    • Concurrency Limiter ظرفیت منابع حیاتی را مهار می‌کند، اما بدون پارتیشن‌بندی، با مسدود شدن توسط یک کاربر پرمصرف، عدالت را قربانی می‌کند.
    • صف‌ها (Queues) می‌توانند رقابت‌های میلی‌ثانیه‌ای را تعدیل کنند، اما با کمی تغییر در شرایط، فشار لحظه‌ای را به یک بحران پایدار تأخیر (Tail-Latency Incident) تبدیل می‌سازند.
    • پارتیشن‌بندی بر اساس IP کاربران ناشناس را تفکیک می‌کند، اما در غیاب پیکربندی Forwarded Headers، هزاران کاربر پشت یک پروکسی یا CDN را به اشتباه مسدود می‌سازد.
    • محدودکننده سراسری (Global) حجم ترافیک کل را مهار می‌کند، در حالی که اندپوینت‌های سنگین تحلیلی با سرعتی بسیار بالاتر از خواندن‌های ساده کش‌شده، منابع مشترک را می‌بلعند.

    هیچ‌یک از این مکانیزم‌ها ذاتاً غلط نیستند؛ شکست زمانی رخ می‌دهد که مسئله را اشتباه تعریف کنیم.

    ماتریس تطبیق منبع بحرانی با الگوی کنترل ظرفیت

    پیش از مقداردهی به پارامترهای میان‌افزار، باید مشخص کنید کدام منبع زیرساختی با اشباع شدن خود، سیستم را زمین‌گیر می‌کند:

    منبع تحت خطر / سناریوی بحرانبُعد اصلی محدودسازیالگوی پیشنهادی در ASP.NET Core
    اتصالات پایگاه داده (Connection Pool) / حافظهحداکثر اجرای هم‌زمان (Concurrency)ConcurrencyLimiter با QueueLimit = 0
    حملات بات، اسکرپینگ و اندپوینت‌های عمومینرخ ورود به ازای هویت شبکهPartitionedRateLimiter روی IP امن با SlidingWindow
    سهمیه‌بندی پلن‌های تجاری و مشتریان سازمانیحجم کل در بازه زمانی مشخصTokenBucket با کلید پارتیشن TenantId یا API-Key
    عملیات سنگین I/O و گزارش‌گیری تحلیلیایزوله‌سازی سهمیه پردازش پرهزینهسیاست اختصاصی اندپوینت با RequireRateLimiting مجزا
    سرویس‌های توزیع‌شده با Autoscalingتفکیک لایه‌های محلی از سراسریسهمیه تجاری در API Gateway + کنترل هم‌زمانی در کانتینر

    اصول پایدار برای طراحی استراتژی مقابله با اضافه‌بار

    • مدیریت محتاطانه صف‌ها: فعال‌سازی صف باید کاملاً آگاهانه و با ظرفیت‌های بسیار محدود باشد. رد سریع درخواست (Fail-Fast) از طریق ارسال آنی خطای 429، مکانیزمی حیاتی برای ایجاد فشار معکوس (Backpressure) است.
    • پارتیشن‌بندی مبتنی بر مالکیت منبع: کلید پارتیشن باید بر اساس موجودیتی تعیین شود که انحصار آن منبع را در اختیار دارد (مانند TenantId برای دیتابیس مشترک سازمانی).
    • تفکیک هزینه اندپوینت‌ها: با فرض برابری هزینه پردازش تمام درخواست‌های HTTP، سیستم را پیکربندی نکنید؛ برای عملیات سنگین سیاست‌های سخت‌گیرانه‌تری لحاظ کنید.
    • تست در شرایط افت کارایی: تاب‌آوری Rate Limiter را با تزریق تاخیر عمدی در دیتابیس و شبیه‌سازی جهش‌های ترافیکی بسنجید، نه صرفاً در شرایط عادی و سلامت زیرساخت.

    مهم‌ترین عدد در تنظیمات Rate Limiter، مقدار PermitLimit نیست؛ بلکه میزان ظرفیت پردازش مفیدی است که سرویس شما می‌تواند در اوج بحران ترافیک بدون افت کیفیت تحویل دهد. یک سیاست هوشمند، درخواست‌های مازاد را در اولین نقطه ممکن متوقف می‌کند تا سرویس‌دهی سالم به سایر کاربران حفظ شود؛ در حالی که یک سیاست اشتباه، جهش‌های مخرب را وارد سیستم کرده یا درخواست‌ها را پشت صف‌های طولانی پنهان می‌سازد تا در نهایت داشبوردها وضعیتی آرام را نشان دهند در حالی که اپلیکیشن در حال فروپاشی کامل است.
    اعمال Rate Limiting نباید چک‌لیستی تشریفاتی پیش از استقرار باشد؛ این ابزار قلب تپنده استراتژی تاب‌آوری و مدیریت اضافه‌بار (Overload Strategy) سیستم شماست. هنگامی که موج سهمگین ترافیک فرا می‌رسد، ASP.NET Core دقیقاً همان کدی را اجرا می‌کند که شما نوشته‌اید؛ و تنها پروداکشن است که نشان می‌دهد آیا آن پیکربندی، با ظرفیت واقعی منابع سیستم هم‌خوانی داشته است یا خیر.