محدود کردن درخواست (Rate Limiting)
هر مشتری فقط تعداد مشخصی درخواست در یک بازه زمانی میتواند بفرستد. درخواست اضافه با کد 429 رد میشود. محدودکننده داخلی ASP.NET Core در حافظه هر سرور میشمارد، پس روی چند سرور به یک شمارنده مشترک نیاز داریم.
نویسنده: bezzad
مشکل: یک مشتری، همه را کند میکند
فروشگاه ما یک API عمومی دارد. فروشگاههای همکار با آن قیمت و موجودی را میخوانند. طبق قرارداد، هر همکار حداکثر ۱۰۰ درخواست در دقیقه مجاز است.
یک روز، اسکریپت یکی از همکارها خراب میشود و در یک حلقه، دقیقهای ۵۰۰۰ درخواست میفرستد. نتیجه:
- دیتابیس شلوغ میشود. همه کوئریها کند میشوند.
- بقیه همکارها و مشتریهای سایت هم کند میشوند. تقصیر آنها نیست.
- هزینه سرور بالا میرود. برای کاری که هیچ ارزشی ندارد.
محدود کردن درخواست (Rate Limiting) یعنی برای هر مشتری یک سقف بگذاریم. درخواست بیشتر از سقف، قبل از رسیدن به کد اصلی رد میشود.
جواب درست: کد 429
وقتی درخواست رد میشود، جواب درست این است:
- کد وضعیت 429 با اسم Too Many Requests.
- هدر Retry-After. به کلاینت میگوید چند ثانیه بعد دوباره تلاش کند.
با این دو، یک کلاینت خوب میداند چه کند. صبر میکند و دوباره میفرستد، به جای اینکه پشت سر هم تلاش کند.
چهار الگوریتم
فریمورک ASP.NET Core چهار محدودکننده آماده دارد:
- پنجره ثابت (Fixed Window). زمان را به بازههای یکدقیقهای تقسیم میکند. در هر بازه تا ۱۰۰ میشمارد. اول بازه بعد، شمارنده صفر میشود. سادهترین روش است.
- پنجره لغزان (Sliding Window). هر بازه را به چند تکه کوچک تقسیم میکند. شمارش، تکههای قدیمی را آرامآرام کنار میگذارد. دقیقتر از پنجره ثابت است.
- سطل ژتون (Token Bucket). هر مشتری یک سطل ژتون دارد. سطل با سرعت ثابت پر میشود. هر درخواست یک ژتون برمیدارد. اگر سطل خالی بود، درخواست رد میشود.
- همزمانی (Concurrency). زمان را نمیشمارد. فقط میگوید «حداکثر ۱۰ درخواست همزمان». برای endpoint های سنگین، مثل ساخت گزارش، مناسب است.
مشکل پنجره ثابت: لبه پنجره
پنجره لغزان و سطل ژتون این مشکل را ندارند. سطل ژتون یک فایده دیگر هم دارد: کمی انفجار (Burst) را اجازه میدهد. یعنی اگر مشتری مدتی آرام بوده، سطلش پر است و میتواند چند درخواست را پشت سر هم بفرستد.
مثال زنده: سطل ژتون
سطل این مثال حداکثر ۵ ژتون دارد. هر ثانیه یک ژتون اضافه میشود. چند درخواست پشت سر هم بفرست و ببین چه میشود:
کد: محدودیت برای هر مشتری
مهمترین تصمیم، کلید محدودیت است. یعنی شمارش برای چه کسی جدا باشد؟ ما برای هر همکار جدا میشماریم. به هر کلید یک 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");
چند نکته در این کد:
- کد پیشفرض رد شدن 503 است. باید خودت آن را 429 کنی. کد 503 یعنی «سرور خراب است» و کلاینت را گمراه میکند.
- محدودکننده بعد از احراز هویت است. وگرنه هنوز نمیداند کاربر کیست و همه را با IP میشمارد.
- کلید، شناسه مشتری است، نه IP. چند مشتری پشت یک NAT یک IP دارند. یک مشتری هم ممکن است چند IP داشته باشد.
مشکل بزرگ: چند سرور
در Production سرویس روی ۱۰ pod اجرا میشود. یک Load Balancer درخواستها را بین آنها پخش میکند. حالا حد واقعی چقدر است؟
- محدودکننده داخلی شمارنده را در حافظه همان pod نگه میدارد.
- هر pod جدا تا ۱۰۰ میشمارد. از بقیه خبر ندارد.
- پس حد واقعی ۱۰ × ۱۰۰ = ۱۰۰۰ است. در لپتاپ با یک نسخه همه چیز درست بود، ولی در Production نه.
راهها:
- شمارنده مشترک در Redis. سادهترین نسخه پنجره ثابت است. کلید از شناسه مشتری و دقیقه فعلی ساخته میشود. با دستور INCR یکی اضافه میکنیم و بار اول با دستور EXPIRE انقضای ۶۰ ثانیه میگذاریم. این دو را در یک اسکریپت Lua بگذار تا با هم و اتمی اجرا شوند. اگر عدد از ۱۰۰ گذشت، 429 برگردان.
- محدودیت در لبه سیستم. یک API Gateway یا Ingress جلوی همه pod ها میشمارد. کد برنامه تغییر نمیکند.
- راه تقریبی. سهم هر pod را ۱۰۰ ÷ ۱۰ = ۱۰ بگذار. ساده است، ولی با عوض شدن تعداد pod ها باید عوض شود. اگر Load Balancer بار را مساوی پخش نکند هم دقیق نیست.
اگر Redis از کار افتاد؟
بستگی دارد محدودیت برای چیست:
- برای محافظت از سیستم: معمولاً درخواست را قبول کن (Fail-open). همراه با یک محدودیت ساده محلی به عنوان پشتیبان. نمیخواهیم قطعی Redis کل API را بخواباند.
- برای امنیت یا هزینه: شاید رد کردن (Fail-closed) درست باشد. مثلاً محدودیتی که جلوی حدس زدن رمز عبور را میگیرد.
قانونهای مهم
- کد 429 و هدر Retry-After برگردان. نه 503، نه 500.
- کلید را درست انتخاب کن. شناسه مشتری یا API Key، نه فقط IP.
- روی چند سرور، شمارنده باید مشترک باشد. یا در Redis، یا در Gateway.
- الگوریتم را با نیاز انتخاب کن. برای اجازه انفجار کوتاه، سطل ژتون. برای endpoint سنگین، همزمانی.
- برای endpoint های حساس، سقف جدا بگذار. مثلاً ورود و ارسال کد پیامکی سقف خیلی کمتری لازم دارند.
- رد شدنها را اندازه بگیر. اگر یک مشتری خوب مدام 429 میگیرد، شاید سقف کم است.
اشتباههای رایج
| اشتباه | نتیجه | راه درست |
|---|---|---|
| محدودکننده داخلی روی چند pod | حد واقعی چند برابر قرارداد میشود. | شمارنده مشترک در Redis یا Gateway. |
| کلید فقط IP | مشتریهای پشت یک NAT با هم رد میشوند. | شناسه مشتری یا API Key. |
| کد پیشفرض 503 | کلاینت فکر میکند سرور خراب است. | تنظیم کد 429. |
| نبود هدر Retry-After | کلاینت پشت سر هم تلاش میکند و بار بیشتر میشود. | پر کردن Retry-After در OnRejected. |
| محدودکننده قبل از احراز هویت | کاربر هنوز شناخته نشده و همه با IP شمرده میشوند. | اول احراز هویت، بعد محدودکننده. |
| INCR و EXPIRE جدا از هم | اگر بین آن دو خطا شود، کلید بدون انقضا میماند. | هر دو در یک اسکریپت Lua. |
خلاصه در شش خط
- محدودیت درخواست از سیستم و از مشتریهای دیگر محافظت میکند.
- درخواست اضافه با کد 429 و هدر Retry-After رد میشود.
- پنجره ثابت ساده است ولی در لبه پنجره دو برابر اجازه میدهد. سطل ژتون انفجار کوتاه را کنترل میکند.
- کلید محدودیت شناسه مشتری است، نه IP.
- محدودکننده داخلی در حافظه هر pod میشمارد. روی چند pod، شمارنده مشترک لازم است.
- اگر شمارنده مشترک از کار افتاد، بر اساس هدف محدودیت بین قبول و رد تصمیم بگیر.