Levelwise
فارسی
ASP.NET Core

محدود کردن درخواست (Rate Limiting)

هر مشتری فقط تعداد مشخصی درخواست در یک بازه زمانی می‌تواند بفرستد. درخواست اضافه با کد 429 رد می‌شود. محدودکننده داخلی ASP.NET Core در حافظه هر سرور می‌شمارد، پس روی چند سرور به یک شمارنده مشترک نیاز داریم.

بازبینی نشدهبا کمک AI نوشته شدهزمان خواندن: ۱۴ دقیقهمثال API عمومی فروشگاهکد C# و .NET 10

نویسنده: bezzad

مشکل: یک مشتری، همه را کند می‌کند

فروشگاه ما یک API عمومی دارد. فروشگاه‌های همکار با آن قیمت و موجودی را می‌خوانند. طبق قرارداد، هر همکار حداکثر ۱۰۰ درخواست در دقیقه مجاز است.

یک روز، اسکریپت یکی از همکارها خراب می‌شود و در یک حلقه، دقیقه‌ای ۵۰۰۰ درخواست می‌فرستد. نتیجه:

  1. دیتابیس شلوغ می‌شود. همه کوئری‌ها کند می‌شوند.
  2. بقیه همکارها و مشتری‌های سایت هم کند می‌شوند. تقصیر آن‌ها نیست.
  3. هزینه سرور بالا می‌رود. برای کاری که هیچ ارزشی ندارد.

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

جواب درست: کد 429

وقتی درخواست رد می‌شود، جواب درست این است:

  • کد وضعیت 429 با اسم Too Many Requests.
  • هدر Retry-After. به کلاینت می‌گوید چند ثانیه بعد دوباره تلاش کند.

با این دو، یک کلاینت خوب می‌داند چه کند. صبر می‌کند و دوباره می‌فرستد، به جای اینکه پشت سر هم تلاش کند.

چهار الگوریتم

فریم‌ورک ASP.NET Core چهار محدودکننده آماده دارد:

  1. پنجره ثابت (Fixed Window). زمان را به بازه‌های یک‌دقیقه‌ای تقسیم می‌کند. در هر بازه تا ۱۰۰ می‌شمارد. اول بازه بعد، شمارنده صفر می‌شود. ساده‌ترین روش است.
  2. پنجره لغزان (Sliding Window). هر بازه را به چند تکه کوچک تقسیم می‌کند. شمارش، تکه‌های قدیمی را آرام‌آرام کنار می‌گذارد. دقیق‌تر از پنجره ثابت است.
  3. سطل ژتون (Token Bucket). هر مشتری یک سطل ژتون دارد. سطل با سرعت ثابت پر می‌شود. هر درخواست یک ژتون برمی‌دارد. اگر سطل خالی بود، درخواست رد می‌شود.
  4. همزمانی (Concurrency). زمان را نمی‌شمارد. فقط می‌گوید «حداکثر ۱۰ درخواست همزمان». برای endpoint های سنگین، مثل ساخت گزارش، مناسب است.

مشکل پنجره ثابت: لبه پنجره

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

پنجره لغزان و سطل ژتون این مشکل را ندارند. سطل ژتون یک فایده دیگر هم دارد: کمی انفجار (Burst) را اجازه می‌دهد. یعنی اگر مشتری مدتی آرام بوده، سطلش پر است و می‌تواند چند درخواست را پشت سر هم بفرستد.

مثال زنده: سطل ژتون

سطل این مثال حداکثر ۵ ژتون دارد. هر ثانیه یک ژتون اضافه می‌شود. چند درخواست پشت سر هم بفرست و ببین چه می‌شود:

سطل ژتون: حداکثر ۵ ژتون، هر ثانیه یک ژتون
قبول شده: ۰
رد شده با 429: ۰

    کد: محدودیت برای هر مشتری

    مهم‌ترین تصمیم، کلید محدودیت است. یعنی شمارش برای چه کسی جدا باشد؟ ما برای هر همکار جدا می‌شماریم. به هر کلید یک Partition می‌گوییم.

    builder.Services.AddRateLimiter(options =>
    {
        // The default is 503. 429 tells the client "slow down", not "server is broken".
        options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    
        options.AddPolicy("per-customer", context =>
        {
            // One bucket per customer. Fall back to IP only for anonymous calls.
            var key = context.User.FindFirstValue("sub")
                      ?? context.Connection.RemoteIpAddress?.ToString()
                      ?? "unknown";
    
            return RateLimitPartition.GetTokenBucketLimiter(key, _ => new TokenBucketRateLimiterOptions
            {
                TokenLimit = 20,                               // the biggest burst
                TokensPerPeriod = 10,
                ReplenishmentPeriod = TimeSpan.FromSeconds(6), // 10 tokens every 6 s = 100 per minute
                QueueLimit = 0,                                // reject at once, do not wait
                AutoReplenishment = true
            });
        });
    
        options.OnRejected = (context, ct) =>
        {
            if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
                context.HttpContext.Response.Headers.RetryAfter =
                    ((int)retryAfter.TotalSeconds).ToString();
    
            return ValueTask.CompletedTask;
        };
    });
    
    var app = builder.Build();
    
    app.UseRouting();
    app.UseAuthentication(); // the limiter needs to know the user
    app.UseRateLimiter();
    
    app.MapGet("/products", (ShopDb db, CancellationToken ct) => db.Products.ToListAsync(ct))
       .RequireRateLimiting("per-customer");

    چند نکته در این کد:

    1. کد پیش‌فرض رد شدن 503 است. باید خودت آن را 429 کنی. کد 503 یعنی «سرور خراب است» و کلاینت را گمراه می‌کند.
    2. محدودکننده بعد از احراز هویت است. وگرنه هنوز نمی‌داند کاربر کیست و همه را با IP می‌شمارد.
    3. کلید، شناسه مشتری است، نه IP. چند مشتری پشت یک NAT یک IP دارند. یک مشتری هم ممکن است چند IP داشته باشد.
    صف یا رد کردن؟ با تنظیم QueueLimit، درخواست اضافه می‌تواند کمی صبر کند تا ژتون برسد، به جای اینکه فوراً رد شود. برای API عمومی معمولاً رد فوری بهتر است. صف یعنی اتصال‌ها و حافظه سرور مدتی بیشتر نگه داشته می‌شوند.

    مشکل بزرگ: چند سرور

    در Production سرویس روی ۱۰ pod اجرا می‌شود. یک Load Balancer درخواست‌ها را بین آن‌ها پخش می‌کند. حالا حد واقعی چقدر است؟

    1. محدودکننده داخلی شمارنده را در حافظه همان pod نگه می‌دارد.
    2. هر pod جدا تا ۱۰۰ می‌شمارد. از بقیه خبر ندارد.
    3. پس حد واقعی ۱۰ × ۱۰۰ = ۱۰۰۰ است. در لپ‌تاپ با یک نسخه همه چیز درست بود، ولی در Production نه.
    شمارنده در حافظه هر نسخهLoad Balancerpod 1100pod 2100pod 10100...حد واقعی: ۱۰۰۰ در دقیقهشمارنده مشترکpod 1pod 2pod 10Rediscustomer:42:10:30 = 87حد واقعی: ۱۰۰ در دقیقه
    سمت راست: هر pod شمارنده خودش را دارد. سمت چپ: همه pod ها یک شمارنده مشترک را می‌خوانند.

    راه‌ها:

    1. شمارنده مشترک در Redis. ساده‌ترین نسخه پنجره ثابت است. کلید از شناسه مشتری و دقیقه فعلی ساخته می‌شود. با دستور INCR یکی اضافه می‌کنیم و بار اول با دستور EXPIRE انقضای ۶۰ ثانیه می‌گذاریم. این دو را در یک اسکریپت Lua بگذار تا با هم و اتمی اجرا شوند. اگر عدد از ۱۰۰ گذشت، 429 برگردان.
    2. محدودیت در لبه سیستم. یک API Gateway یا Ingress جلوی همه pod ها می‌شمارد. کد برنامه تغییر نمی‌کند.
    3. راه تقریبی. سهم هر pod را ۱۰۰ ÷ ۱۰ = ۱۰ بگذار. ساده است، ولی با عوض شدن تعداد pod ها باید عوض شود. اگر Load Balancer بار را مساوی پخش نکند هم دقیق نیست.

    اگر Redis از کار افتاد؟

    بستگی دارد محدودیت برای چیست:

    • برای محافظت از سیستم: معمولاً درخواست را قبول کن (Fail-open). همراه با یک محدودیت ساده محلی به عنوان پشتیبان. نمی‌خواهیم قطعی Redis کل API را بخواباند.
    • برای امنیت یا هزینه: شاید رد کردن (Fail-closed) درست باشد. مثلاً محدودیتی که جلوی حدس زدن رمز عبور را می‌گیرد.

    قانون‌های مهم

    1. کد 429 و هدر Retry-After برگردان. نه 503، نه 500.
    2. کلید را درست انتخاب کن. شناسه مشتری یا API Key، نه فقط IP.
    3. روی چند سرور، شمارنده باید مشترک باشد. یا در Redis، یا در Gateway.
    4. الگوریتم را با نیاز انتخاب کن. برای اجازه انفجار کوتاه، سطل ژتون. برای endpoint سنگین، همزمانی.
    5. برای endpoint های حساس، سقف جدا بگذار. مثلاً ورود و ارسال کد پیامکی سقف خیلی کمتری لازم دارند.
    6. رد شدن‌ها را اندازه بگیر. اگر یک مشتری خوب مدام 429 می‌گیرد، شاید سقف کم است.

    اشتباه‌های رایج

    اشتباه نتیجه راه درست
    محدودکننده داخلی روی چند pod حد واقعی چند برابر قرارداد می‌شود. شمارنده مشترک در Redis یا Gateway.
    کلید فقط IP مشتری‌های پشت یک NAT با هم رد می‌شوند. شناسه مشتری یا API Key.
    کد پیش‌فرض 503 کلاینت فکر می‌کند سرور خراب است. تنظیم کد 429.
    نبود هدر Retry-After کلاینت پشت سر هم تلاش می‌کند و بار بیشتر می‌شود. پر کردن Retry-After در OnRejected.
    محدودکننده قبل از احراز هویت کاربر هنوز شناخته نشده و همه با IP شمرده می‌شوند. اول احراز هویت، بعد محدودکننده.
    INCR و EXPIRE جدا از هم اگر بین آن دو خطا شود، کلید بدون انقضا می‌ماند. هر دو در یک اسکریپت Lua.

    خلاصه در شش خط

    1. محدودیت درخواست از سیستم و از مشتری‌های دیگر محافظت می‌کند.
    2. درخواست اضافه با کد 429 و هدر Retry-After رد می‌شود.
    3. پنجره ثابت ساده است ولی در لبه پنجره دو برابر اجازه می‌دهد. سطل ژتون انفجار کوتاه را کنترل می‌کند.
    4. کلید محدودیت شناسه مشتری است، نه IP.
    5. محدودکننده داخلی در حافظه هر pod می‌شمارد. روی چند pod، شمارنده مشترک لازم است.
    6. اگر شمارنده مشترک از کار افتاد، بر اساس هدف محدودیت بین قبول و رد تصمیم بگیر.