پست

طراحی محدودکننده نرخ درخواست توزیع‌شده

بررسی گام‌به‌گام طراحی Rate Limiter توزیع‌شده شامل جایگذاری در معماری، الگوریتم‌های محدودسازی، پیاده‌سازی Token Bucket با Redis، پاسخ 429، مقیاس‌دهی، تحمل خطا و مدیریت قوانین پویا

طراحی محدودکننده نرخ درخواست توزیع‌شده

درک مسئله

Rate Limiter (محدودکننده نرخ درخواست) تعداد درخواست‌های یک کلاینت را در بازه زمانی مشخص کنترل می‌کند. مثل یک مأمور کنترل ترافیک برای API شما عمل می‌کند. مثلاً اجازه می‌دهد یک کاربر در هر دقیقه 100 درخواست بفرستد و درخواست‌های اضافه را با پاسخ HTTP 429 «Too Many Requests» رد می‌کند. محدودکننده‌های نرخ جلوی سوءاستفاده را می‌گیرند، از سرورها در برابر هجوم ترافیک محافظت می‌کنند و استفاده منصفانه را برای همه کاربران تضمین می‌کنند.

نیازمندی‌های عملکردی

در این تحلیل، یک محدودکننده نرخ در سطح درخواست برای API یک پلتفرم شبکه اجتماعی طراحی می‌کنیم. یعنی درخواست‌های HTTP منفرد (مثل ثبت توییت، واکشی Timeline یا آپلود عکس) را محدود می‌کنیم، نه اقدام‌های سطح بالاتر یا عملیات کسب‌وکاری. روی یک پیاده‌سازی سمت سرور تمرکز می‌کنیم که ترافیک را کنترل می‌کند و از سیستم‌هایمان محافظت می‌کند. محدودسازی نرخ در سمت کلاینت هم به‌عنوان رویکرد مکمل ارزش دارد (بعداً درباره‌اش صحبت می‌کنیم)، اما محدودسازی سمت سرور برای امنیت و حفاظت از سیستم ضروری است، چون نمی‌توان به کلاینت‌ها برای تنظیم خودشان اعتماد کرد.

نیازمندی‌های اصلی:

  • سیستم باید کلاینت‌ها را با شناسه کاربر، آدرس IP یا کلید API شناسایی کند تا محدودیت‌های مناسب اعمال شود.
  • سیستم باید درخواست‌های HTTP را بر اساس قوانین قابل تنظیم محدود کند (مثلاً 100 درخواست API در دقیقه برای هر کاربر).
  • وقتی از محدودیت‌ها فراتر رفت، سیستم باید درخواست‌ها را با HTTP 429 رد کند و هدرهای مفید بفرستد (محدودیت باقی‌مانده، زمان بازنشانی).

موارد خارج از محدوده:

  • کوئری یا تحلیل پیچیده روی داده‌های محدودسازی نرخ
  • ماندگاری بلندمدت داده‌های محدودکننده نرخ
  • به‌روزرسانی پویای قوانین در زمان اجرا

نیازمندی‌های غیرعملکردی

در این مرحله باید از مصاحبه‌کننده درباره انتظارات مقیاس بپرسید. آیا این را برای یک API نوپا با هزاران درخواست در روز می‌سازیم یا برای یک پلتفرم بزرگ که میلیون‌ها درخواست در ثانیه را مدیریت می‌کند؟ مقیاس، انتخاب‌های طراحی را کاملاً تغییر می‌دهد.

فرض می‌کنیم برای باری قابل توجه ولی واقعی طراحی می‌کنیم: 1 میلیون درخواست در ثانیه در میان 100 میلیون کاربر فعال روزانه.

نیازمندی‌های اصلی:

  • سیستم باید حداقل سربار تأخیر را اضافه کند (کمتر از 5 میلی‌ثانیه برای هر بررسی درخواست).
  • سیستم باید بسیار در دسترس باشد. Eventual Consistency (سازگاری نهایی) مشکلی ندارد، چون تأخیرهای جزئی در اعمال محدودیت بین گره‌ها قابل قبول است.
  • سیستم باید 1 میلیون درخواست در ثانیه را در میان 10 میلیون کاربر فعال روزانه مدیریت کند.

موارد خارج از محدوده:

  • تضمین Strong Consistency (سازگاری قوی) بین همه گره‌ها
  • رابط‌های پیچیده مدیریت قوانین
  • تحلیل دقیق و قابلیت‌های گزارش‌گیری

آماده‌سازی

برنامه‌ریزی رویکرد

برای چنین مسئله‌ای باید هنگام انتخاب مسیر درست در چارچوب تحویل مصاحبه (Delivery Framework)، انعطاف نشان بدهید. در واقع این سؤال معروفی است که مصاحبه‌کننده‌های مختلف در شرکت‌های مختلف به شکل‌های بسیار متفاوتی می‌پرسند. بعضی دنبال طراحی سطح پایین‌ترند، حتی گاهی کد. بعضی دیگر بیشتر روی نحوه طراحی و مقیاس‌دهی سیستم تمرکز دارند.

در این تحلیل، رایج‌ترین مسیر را (که خودم هنگام پرسیدن این سؤال انتخاب می‌کنم) دنبال می‌کنیم: انتخاب الگوریتم را با طراحی سیستم توزیع‌شده سطح بالایی که بتواند بار مورد انتظار را تحمل کند، متعادل می‌کنیم.

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

تعریف موجودیت‌های اصلی

اگرچه محدودکننده‌های نرخ شبیه اجزای ساده زیرساختی به نظر می‌رسند، در واقع چند موجودیت مهم دارند که باید درست مدل شوند:

  • Rules (قوانین): سیاست‌های محدودسازی نرخ که برای سناریوهای مختلف محدودیت تعریف می‌کنند. هر قانون پارامترهایی مثل تعداد درخواست در هر بازه زمانی، اعمال‌شدن روی کدام کلاینت‌ها و پوشش کدام endpoint ها را مشخص می‌کند. مثلاً: «کاربران احراز هویت‌شده 1000 درخواست در ساعت می‌گیرند» یا «API جستجو برای هر IP اجازه 10 درخواست در دقیقه را می‌دهد».
  • Clients (کلاینت‌ها): موجودیت‌هایی که نرخشان محدود می‌شود. می‌توانند کاربران (با شناسه کاربر)، آدرس IP، کلید API یا ترکیبی از این‌ها باشند. هر کلاینت وضعیت محدودسازی خودش را دارد که میزان مصرف فعلی‌اش را بر اساس قوانین قابل اعمال ردیابی می‌کند.
  • Requests (درخواست‌ها): درخواست‌های API ورودی که باید بر اساس قوانین محدودسازی ارزیابی شوند. هر درخواست زمینه‌ای دارد، مثل هویت کلاینت، endpoint ای که به آن دسترسی دارد و مهر زمانی، که مشخص می‌کند کدام قوانین اعمال شوند و مصرف چطور ردیابی شود.

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

رابط سیستم

Rate Limiter یک جزء زیرساختی است که سرویس‌های دیگر برای بررسی مجاز بودن یک درخواست با آن تماس می‌گیرند. رابط ساده است:

1
isRequestAllowed(clientId, ruleId) -> boolean

این متد یک شناسه کلاینت (شناسه کاربر، آدرس IP یا کلید API) و یک شناسه قانون می‌گیرد و بر اساس مصرف فعلی برمی‌گرداند که درخواست باید مجاز باشد یا نه.

1
getCurrentUsage(clientId, ruleId) -> { remaining: number, resetTime: timestamp }

این متد اطلاعات لازم را برای هدرهای پاسخ مثل X-RateLimit-Remaining و X-RateLimit-Reset فراهم می‌کند.

طراحی سطح بالا

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

1) شناسایی کلاینت‌ها با شناسه کاربر، IP یا کلید API

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

محدودکننده نرخ را کجا بگذاریم؟

سه گزینه اصلی دارید که هرکدام مبادلات متفاوتی دارند.

رویکرد بد: محدودسازی داخل خود سرویس (In-process)

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

چالش‌ها: مشکل اصلی این است که هر سرور فقط ترافیک خودش را می‌بیند، نه تصویر سراسری را. فرض کنید می‌خواهید کاربران را به 100 درخواست در دقیقه محدود کنید. اگر 5 سرور اپلیکیشن پشت Load Balancer دارید و درخواست‌ها یکنواخت پخش می‌شوند، هر سرور ممکن است 20 درخواست در دقیقه از یک کاربر ببیند و فکر کند «خوب است، زیر 100». اما در سطح سراسری، کاربر در واقع 100 درخواست در دقیقه در کل سرورها می‌فرستد.

بدتر اینکه اگر Load Balancer نحوه مسیریابی ترافیک را تغییر بدهد، یا یک سرور بار بیشتری از بقیه بگیرد، محدودیت‌های شما کاملاً غیرقابل پیش‌بینی می‌شوند. کاربر ممکن است 100 درخواست را از یک سرور و 100 درخواست را از سرور دیگر بفرستد و در کل 200 درخواست بگیرد. این رویکرد فقط وقتی کار می‌کند که یک سرور اپلیکیشن داشته باشید، یا با محدودیت‌های تقریبی مشکلی نداشته باشید که ممکن است به اندازه تعداد سرورها خطا داشته باشند.

رویکرد خوب: سرویس اختصاصی محدودکننده نرخ

محدودکننده نرخ به میکروسرویس مخصوص خودش تبدیل می‌شود که بین کلاینت‌ها و سرورهای اپلیکیشن قرار می‌گیرد. وقتی درخواستی به سرور اپلیکیشن می‌رسد، سرور اول یک تماس API با سرویس محدودکننده نرخ می‌گیرد: «آیا باید این درخواست از کاربر 12345 را اجازه بدهم؟» محدودکننده نرخ شمارنده‌های متمرکز خودش را بررسی می‌کند و با «بله، اجازه بده» یا «نه، با 429 رد کن» پاسخ می‌دهد.

این معماری انعطاف بسیار بیشتری می‌دهد. سرورهای اپلیکیشن می‌توانند هنگام بررسی محدودیت نرخ، زمینه غنی بدهند، مثل ردیف اشتراک کاربر، وضعیت حساب، endpoint خاص API که صدا زده می‌شود، یا حتی منطق کسب‌وکاری پیچیده مثل «در Black Friday درخواست اضافه اجازه بده». همچنین می‌توانید سرویس‌های محدودکننده نرخ متفاوتی برای بخش‌های مختلف سیستم داشته باشید که هرکدام برای نیازهای خاص تنظیم شده‌اند.

مهم‌تر اینکه سرویس محدودکننده نرخ وضعیت سراسری را نگه می‌دارد، پس می‌تواند محدودیت‌های دقیق را در همه سرورهای اپلیکیشن اعمال کند. اگر 100 درخواست در دقیقه به‌صورت سراسری بخواهید، دقیقاً همان‌قدر می‌گیرید، فارغ از تعداد سرورها.

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

یک نقطه شکست دیگر هم اضافه کرده‌اید. اگر سرویس محدودکننده نرخ از کار بیفتد، باید تصمیم بگیرید: Fail-open (همه درخواست‌ها را اجازه بدهید و خطر اضافه‌بار را بپذیرید) یا Fail-closed (همه درخواست‌ها را رد کنید و عملاً API را آفلاین کنید)؟ هیچ‌کدام عالی نیست.

پیچیدگی عملیاتی هم هست. حالا سرویس دیگری برای استقرار، مانیتورینگ، مقیاس‌دهی و نگهداری دارید. خود سرویس محدودکننده نرخ هم باید بسیار در دسترس باشد، یعنی به افزونگی (Redundancy)، بررسی سلامت (Health Check) و احتمالاً نوعی همانندسازی داده نیاز دارید.

در نهایت باید مشکلات شبکه را با ظرافت مدیریت کنید. اگر محدودکننده نرخ کند پاسخ بدهد چه؟ منتظر می‌مانید (که تأخیر بیشتری اضافه می‌کند) یا Timeout می‌کنید و حدس می‌زنید؟ اگر بین سرورهای اپلیکیشن و محدودکننده نرخ، پارتیشن شبکه رخ بدهد چه؟

رویکرد عالی: محدودکننده نرخ در API Gateway

محدودکننده نرخ در لبه (Edge) سیستم اجرا می‌شود و در API Gateway (دروازه API) یا Load Balancer یکپارچه می‌شود. هر درخواست ورودی، پیش از رسیدن به هر کدام از سرورهای اپلیکیشن، اول به محدودکننده می‌خورد. محدودکننده درخواست را بررسی می‌کند (آدرس IP، هدرهای احراز هویت کاربر، کلیدهای API)، محدودیت‌های مناسب را اعمال می‌کند و یا درخواست را به پایین‌دست می‌فرستد یا فوراً پاسخ HTTP 429 برمی‌گرداند.

این محبوب‌ترین رویکرد در سیستم‌های تولیدی است، چون از نظر مفهومی ساده است و محافظت قوی می‌دهد. سرورهای اپلیکیشن هرگز درخواست‌های مسدودشده را نمی‌بینند و می‌توانند کامل روی ترافیک مشروع تمرکز کنند. برای کسانی که تشبیه دوست دارند: محدودکننده نرخ مثل یک نگهبان (Bouncer) دم در یک باشگاه عمل می‌کند. مزاحم‌ها از در برگردانده می‌شوند، نه بعد از اینکه داخل شدند و مشکل ساختند، که مشابه رویکرد «خوب» (سرویس اختصاصی) است.

چالش‌ها: محدودیت اصلی زمینه (Context) است. محدودکننده فقط به اطلاعات موجود در خود درخواست HTTP دسترسی دارد: هدرها، URL، آدرس IP و توکن‌های پایه احراز هویت. نمی‌تواند منطق کسب‌وکاری یا زمینه عمیق‌تر کاربر را که ممکن است در لایه اپلیکیشن باشد ببیند. مثلاً نمی‌توانید به‌راحتی قوانینی مثل «کاربران ممتاز 10 برابر محدودیت بالاتر می‌گیرند» پیاده کنید، مگر اینکه وضعیت ممتاز در یک توکن JWT یا چیزی شبیه آن کدگذاری شده باشد.

سؤال دیگر این است که وضعیت محدودکننده نرخ را کجا ذخیره کنیم. Gateway به دسترسی سریع به شمارنده‌ها و مهرهای زمانی نیاز دارد که معمولاً یعنی یک ذخیره‌ساز درون‌حافظه‌ای مثل Redis. اما حالا وابستگی خارجی دارید و باید مواردی را که Redis کند یا در دسترس نیست مدیریت کنید. در بررسی عمیق درباره چگونگی انجام مؤثر این کار صحبت می‌کنیم!

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

کلاینت‌ها را چطور شناسایی کنیم؟

چون رویکرد API Gateway را انتخاب کردیم، محدودکننده فقط به اطلاعات موجود در خود درخواست HTTP دسترسی دارد. این شامل URL یا مسیر درخواست، همه هدرهای HTTP (Authorization، User-Agent، X-API-Key و غیره)، پارامترهای کوئری و آدرس IP کلاینت است. اگرچه از نظر فنی می‌توانیم تماس خارجی به پایگاه داده یا سرویس‌های دیگر بگیریم، این کار تأخیری اضافه می‌کند که می‌خواهیم از آن دوری کنیم. پس به خود درخواست پایبند می‌مانیم.

اول باید تصمیم بگیریم چه چیزی یک «کلاینت» را یکتا می‌کند. کلیدی که استفاده می‌کنیم نحوه اعمال محدودیت‌ها را مشخص می‌کند. سه گزینه اصلی داریم:

  • شناسه کاربر: برای API های احراز هویت‌شده مناسب است. هر کاربر واردشده سهمیه محدودیت نرخ خودش را می‌گیرد. معمولاً در هدر Authorization به‌صورت توکن JWT است.
  • آدرس IP: برای API های عمومی یا وقتی حساب کاربری ندارید مناسب است. اما مراقب کاربران پشت NAT یا فایروال‌های شرکتی باشید. آدرس IP معمولاً در هدر X-Forwarded-For است.
  • کلید API: برای API های توسعه‌دهنده رایج است. هر دارنده کلید محدودیت‌های مخصوص خودش را دارد. معمولاً در هدر X-API-Key می‌آید.

این زمان عالی برای پرسیدن چند سؤال از مصاحبه‌کننده است. آیا همه کاربران احراز هویت شده‌اند؟ آیا این یک API توسعه‌دهنده است که کلید API می‌خواهد؟ و غیره.

در عمل احتمالاً ترکیبی می‌خواهید. ممکن است کاربران احراز هویت‌شده محدودیت بالاتری از IP های ناشناس بگیرند و کاربران ممتاز حتی بیشتر. این سیستم‌های واقعی را منعکس می‌کند که قوانین چندلایه اعمال می‌کنند، نه فقط یک محدودیت سراسری. مثلاً:

  • محدودیت برای هر کاربر: «Alice می‌تواند 1000 درخواست در ساعت بفرستد»
  • محدودیت برای هر IP: «این IP می‌تواند 100 درخواست در دقیقه بفرستد»
  • محدودیت سراسری: «API ما می‌تواند در مجموع 50000 درخواست در ثانیه را تحمل کند»
  • محدودیت مخصوص endpoint: «API جستجو به 10 درخواست در دقیقه محدود است، اما به‌روزرسانی پروفایل 100 در دقیقه»

محدودکننده نرخ شما باید همه قوانین قابل اعمال را بررسی کند و محدودکننده‌ترین آن‌ها را اجرا کند. اگر Alice از 1000 درخواست سهمیه‌اش فقط 50 تا استفاده کرده باشد، اما IP او به سقف 100 رسیده باشد، مسدود می‌شود.

2) محدود کردن درخواست‌ها بر اساس قوانین قابل تنظیم

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

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

شمارنده پنجره ثابت (Fixed Window Counter)

ساده‌ترین روش، زمان را به پنجره‌های ثابت تقسیم می‌کند (مثلاً سطل‌های 1 دقیقه‌ای) و درخواست‌ها را در هر پنجره می‌شمارد. برای هر کاربر یک شمارنده داریم که در ابتدای هر پنجره جدید به صفر برمی‌گردد. اگر شمارنده در طول یک پنجره از حد بگذرد، درخواست‌های جدید تا بازنشانی پنجره رد می‌شوند.

مثلاً با محدودیت 100 درخواست در دقیقه، ممکن است پنجره‌هایی مثل 12:00:00 تا 12:00:59 و 12:01:00 تا 12:01:59 داشته باشید. کاربر می‌تواند در هر پنجره 100 درخواست بفرستد و بعد باید تا شروع پنجره بعد صبر کند.

پیاده‌سازی آن واقعاً ساده است. فقط یک Hash Table است که شناسه کلاینت را به جفت (counter، window_start_time) نگاشت می‌کند. چالش اصلی اثرهای مرزی است: کاربر می‌تواند 100 درخواست در 12:00:59 بفرستد و بلافاصله 100 درخواست دیگر در 12:01:00، و عملاً در چند ثانیه 200 درخواست بگیرد. همچنین اگر کاربر در ابتدای پنجره به سقف برسد، احتمال «گرسنگی» (Starvation) وجود دارد.

1
2
3
4
5
6
7
8
9
{
  "alice:12:00:00": 100,
  "alice:12:01:00": 5,
  "bob:12:00:00": 20,
  "charlie:12:00:00": 0,
  "dave:12:00:00": 54,
  "eve:12:00:00": 0,
  "frank:12:00:00": 12
}

لاگ پنجره کشویی (Sliding Window Log)

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

این روش دقت کامل می‌دهد. همیشه دقیقاً N دقیقه آخر را می‌بینید، فارغ از اینکه درخواست فعلی کی برسد. اثر مرزی ندارد و هیچ انفجار ناعادلانه‌ای مجاز نیست.

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

شمارنده پنجره کشویی (Sliding Window Counter)

این ترکیب هوشمندانه‌ای است که پنجره کشویی را با پنجره‌های ثابت و کمی ریاضی تقریب می‌زند. شمارنده‌هایی برای پنجره فعلی و پنجره قبلی نگه می‌دارید. وقتی درخواست می‌رسد، با وزن‌دهی پنجره‌های قبلی و فعلی بر اساس اینکه چقدر از پنجره فعلی گذشته‌اید، تخمین می‌زنید کاربر در یک پنجره کشویی واقعی چند درخواست «باید» داشته باشد.

مثلاً اگر 30 درصد از دقیقه فعلی گذشته باشد، 70 درصد درخواست‌های دقیقه قبل را به‌علاوه 100 درصد درخواست‌های دقیقه فعلی می‌شمارید.

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

سطل توکن (Token Bucket)

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

مثلاً یک سطل ممکن است 100 توکن نگه دارد (که اجازه می‌دهد تا 100 درخواست پشت سر هم بیایند) و با نرخ 10 توکن در دقیقه دوباره پر شود (نرخ پایدار 10 درخواست در دقیقه). کلاینت می‌تواند فوراً 100 درخواست بفرستد و بعد باید منتظر بماند تا توکن‌ها دوباره پر شوند.

این روش هم بار پایدار (نرخ پرشدن) و هم انفجار موقت (ظرفیت سطل) را کنترل می‌کند. پیاده‌سازی آن هم ساده است: برای هر کلاینت فقط (tokens، last_refill_time) را ردیابی می‌کنید. چالش‌ها انتخاب اندازه درست سطل و نرخ پرشدن، و مدیریت سناریوی «شروع سرد» (Cold Start) است که کلاینت‌های بیکار با سطل پر شروع می‌کنند.

برای سیستم ما، الگوریتم Token Bucket را انتخاب می‌کنیم. بهترین تعادل را بین سادگی، کارایی حافظه و مدیریت الگوهای ترافیکی دنیای واقعی دارد. شرکت‌هایی مثل Stripe از این رویکرد استفاده می‌کنند، چون به‌طور طبیعی ماهیت انفجاری ترافیک API را در خود جا می‌دهد و در عین حال محدودیت‌های نرخ کلی را اعمال می‌کند.

ذخیره وضعیت سطل‌ها

حالا مشکل جدیدی داریم. می‌دانیم الگوریتم Token Bucket از نظر مفهومی چطور کار می‌کند، اما واقعاً سطل هر کاربر را کجا و چطور ذخیره کنیم؟ هر سطل باید دو داده را ردیابی کند: تعداد توکن فعلی و آخرین مهر زمانی پرشدن. این وضعیت باید بین همه نمونه‌های API Gateway مشترک باشد.

اگر سطل‌ها را در حافظه هر نمونه Gateway ذخیره کنیم، به همان مشکل هماهنگی برمی‌گردیم که درباره رویکرد In-process بحث کردیم. درخواست‌های کاربر توسط Load Balancer بین چند Gateway پخش می‌شود. هر Gateway نسخه خودش از سطل توکن کاربر را نگه می‌دارد و فقط بخشی از کل ترافیک او را می‌بیند.

مثلاً اگر Alice 50 درخواست بفرستد که به Gateway A می‌رود و 50 درخواست به Gateway B، هر Gateway فکر می‌کند Alice فقط 50 درخواست داده و همه را اجازه می‌دهد. اما در سطح سراسری Alice 100 درخواست فرستاده و باید محدود شود. بدون یک حالت متمرکز، الگوریتم ما بی‌فایده می‌شود.

می‌توانیم از چیزی مثل Redis استفاده کنیم. Redis یک ذخیره‌ساز سریع درون‌حافظه‌ای است که همه نمونه‌های Gateway به آن دسترسی دارند. Redis می‌تواند منبع حقیقت (Source of Truth) برای همه وضعیت‌های سطل توکن باشد. هر وقت Gateway نیاز داشته باشد محدودیت نرخ کاربر را بررسی یا به‌روز کند، با Redis صحبت می‌کند.

این است که الگوریتم Token Bucket با Redis دقیقاً چطور کار می‌کند:

  1. درخواستی برای کاربر Alice با شناسه alice به Gateway A می‌رسد.
  2. Gateway با HMGET alice:bucket tokens last_refill به Redis تماس می‌گیرد تا وضعیت فعلی سطل Alice را بگیرد. دستور HMGET مقدار چند فیلد را از یک Hash در Redis می‌خواند. در اینجا تعداد توکن فعلی و مهر زمانی last_refill سطل Alice را می‌گیریم.
  3. Gateway بر اساس زمان گذشته از آخرین پرشدن، محاسبه می‌کند چند توکن باید به سطل Alice اضافه شود. اگر سطل Alice آخرین بار 30 ثانیه پیش به‌روز شده و نرخ پرشدنش 1 توکن در ثانیه است، Gateway 30 توکن به تعداد فعلی او اضافه می‌کند، تا حداکثر ظرفیت سطل.
  4. سپس Gateway وضعیت سطل Alice را به‌صورت اتمی با تراکنش Redis به‌روز می‌کند تا از Race Condition جلوگیری شود:
1
2
3
4
5
MULTI
HSET alice:bucket tokens <new_token_count>
HSET alice:bucket last_refill <current_timestamp>
EXPIRE alice:bucket 3600
EXEC

بلوک MULTI/EXEC تضمین می‌کند همه دستورها به‌صورت یک عملیات اتمی اجرا شوند. دستورهای HSET فیلدهای Hash را با تعداد توکن جدید و مهر زمانی به‌روز می‌کنند و EXPIRE سطل را بعد از 1 ساعت بیکاری خودکار حذف می‌کند تا نشت حافظه رخ ندهد.

  1. در نهایت Gateway بر اساس تعداد توکن به‌روزشده Alice تصمیم نهایی را می‌گیرد. اگر دست‌کم 1 توکن داشته باشد، درخواست را اجازه می‌دهد و تعداد توکن‌ها را یکی کم می‌کند. اگر توکنی نمانده باشد، درخواست را رد می‌کند.

اما صبر کنید، یک Race Condition هست!

با وجود تراکنش MULTI/EXEC، پیاده‌سازی ما هنوز Race Condition ظریفی دارد. مشکل این است که عملیات خواندن (HMGET) خارج از تراکنش انجام می‌شود. اگر دو درخواست برای یک کاربر هم‌زمان برسند، هر دو Gateway تعداد توکن اولیه یکسانی را می‌خوانند، هر دو حساب می‌کنند که می‌توانند درخواست را اجازه بدهند و هر دو سطل را به‌روز می‌کنند. یعنی ممکن است 2 درخواست را اجازه بدهیم در حالی که فقط 1 توکن موجود بود.

راه‌حل این است که کل منطق خواندن، محاسبه و به‌روزرسانی را به یک عملیات اتمی واحد منتقل کنیم. با Redis می‌توان این کار را با اسکریپت Lua انجام داد. اسکریپت‌های Lua اتمی‌اند، پس کل تصمیم محدودسازی بدون Race Condition انجام می‌شود. به‌جای عملیات جدای خواندن و نوشتن، یک اسکریپت Lua به Redis می‌فرستیم که وضعیت فعلی را می‌خواند، تعداد توکن جدید را حساب می‌کند و سطل را در یک مرحله اتمی به‌روز می‌کند.

الگو: مدیریت رقابت (Dealing with Contention). Race Condition در شمارنده‌های توزیع‌شده یک چالش کلاسیک مدیریت Contention است. چند Thread یا فرایند که هم‌زمان می‌خواهند یک منبع را به‌روز کنند، حتی وقتی تک‌تک عملیات اتمی باشند، می‌توانند به از دست رفتن به‌روزرسانی برسند. راه‌حل این است که مرز اتمی را گسترش بدهیم تا کل دنباله خواندن، تغییر و نوشتن را در بر بگیرد.

چرا Redis برای این کار عالی است:

  • سرعت: پاسخ زیر میلی‌ثانیه برای عملیات ساده.
  • پاک‌سازی خودکار: EXPIRE سطل‌های کاربران غیرفعال را بعد از 1 ساعت بدون فعالیت حذف می‌کند.
  • High Availability (دسترس‌پذیری بالا): می‌توان آن را روی چند نمونه Redis همانندسازی (Replicate) کرد.
  • عملیات اتمی: با اسکریپت Lua (یا تراکنش) می‌توان Race Condition بین Gateway ها را حذف کرد.

نتیجه نهایی Rate Limiting دقیق و سازگار در همه نمونه‌های Gateway است. چه صدمین درخواست Alice به Gateway A برود، چه B یا C، همه وضعیت سطل توکن یکسانی را می‌بینند و محدودیت یکسانی اعمال می‌کنند.

3) رد درخواست‌ها با HTTP 429 و هدرهای مفید هنگام عبور از محدودیت

حالا باید تصمیم بگیریم وقتی کاربر به محدودیت نرخ می‌رسد چه اتفاقی بیفتد. این ممکن است ساده به نظر برسد (فقط یک خطا برگردان)، اما تصمیم‌های طراحی مهمی وجود دارد که هم تجربه کاربری و هم عملکرد سیستم را تحت تأثیر قرار می‌دهد.

درخواست‌ها را رها کنیم یا در صف بگذاریم؟

اولین تصمیم این است که درخواست‌های اضافه را فوراً رد کنید یا برای پردازش بعدی در صف بگذارید. بیشتر محدودکننده‌های نرخ از رویکرد «سریع شکست بخور» (Fail-fast) استفاده می‌کنند، یعنی وقتی از محدودیت‌ها فراتر رفت، فوراً کد وضعیت HTTP 429 برمی‌گردانند. ما هم همین را پیاده می‌کنیم.

راه‌حل دیگر این است که درخواست‌های اضافه را در صف بگذاریم و وقتی محدودیت نرخ کاربر بازنشانی شد، پردازششان کنیم. اگرچه این کاربرپسند به نظر می‌رسد، مشکلاتی بیش از آنچه حل می‌کند می‌سازد. درخواست‌های داخل صف حافظه و منابع پردازشی مصرف می‌کنند. کاربران ممکن است فکر کنند درخواستشان شکست خورده و دوباره امتحان کنند و بار بیشتری بسازند. بدتر اینکه تأخیر پردازش صف، زمان پاسخ API را غیرقابل پیش‌بینی می‌کند.

موارد ویژه‌ای هست که صف منطقی است، مثل سیستم‌های پردازش دسته‌ای که می‌توانند صبر کنند، اما برای API های تعاملی، «سریع شکست خوردن» تقریباً همیشه انتخاب درست است.

پاسخ را چطور مفید کنیم؟

429 کد وضعیت استاندارد HTTP برای «درخواست‌های خیلی زیاد» است و دقیقاً برای همین هدف در مشخصات HTTP وجود دارد.

هنگام رد درخواست، HTTP 429 «Too Many Requests» را همراه هدرهایی برمی‌گردانیم که به کلاینت‌ها کمک می‌کند بفهمند چه اتفاقی افتاده و چطور بازیابی شوند. هدرهای کلیدی:

  • X-RateLimit-Limit: سقف محدودیت نرخ برای آن درخواست (مثلاً «100»)
  • X-RateLimit-Remaining: تعداد درخواست باقی‌مانده در پنجره فعلی (مثلاً «0»)
  • X-RateLimit-Reset: زمان بازنشانی محدودیت به‌صورت Unix Timestamp (مثلاً «1640995200»)

بعضی سیستم‌ها هدر Retry-After را هم دارند که به کلاینت می‌گوید چند ثانیه صبر کند. یک پاسخ کامل 429 ممکن است این‌شکلی باشد:

1
2
3
4
5
6
7
8
9
10
11
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1640995200
Retry-After: 60
Content-Type: application/json

{
  "error": "Rate limit exceeded",
  "message": "You have exceeded the rate limit of 100 requests per minute. Try again in 60 seconds."
}

این هدرها به کلاینت‌های خوش‌رفتار اجازه می‌دهند استراتژی Backoff مناسب پیاده کنند. کلاینت می‌تواند دقیقاً ببیند کی باید دوباره امتحان کند، به‌جای اینکه API شما را با درخواست‌های ناموفق بکوبد. در مصاحبه معمولاً کافی است بگویید با 429 و هدرهای مناسب پاسخ می‌دهید.

بررسی‌های عمیق

تا اینجا یک محدودکننده نرخ ساده و تک‌گره (یعنی یک نمونه Redis) طراحی کرده‌ایم. اما حالا باید درباره چگونگی مقیاس‌دهی آن برای تحمل 1 میلیون درخواست در ثانیه در میان 10 میلیون کاربر، با حفظ دسترس‌پذیری بالا و Latency (تأخیر) کم صحبت کنیم.

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

1) مقیاس‌دهی برای 1 میلیون درخواست در ثانیه

طراحی فعلی ما چند API Gateway دارد که با یک نمونه Redis صحبت می‌کنند. این برای بارهای کوچک‌تر خوب کار می‌کند، اما در مقیاس هدف ما یعنی 1 میلیون درخواست در ثانیه، حساب‌وکتاب نمی‌خواند. یک نمونه Redis معمولی بسته به پیچیدگی عملیات حدود 100 هزار تا 200 هزار عملیات در ثانیه را تحمل می‌کند. هر بررسی محدودیت نرخ ما چند عملیات Redis می‌خواهد، دست‌کم یک HMGET برای واکشی وضعیت و یک HSET برای به‌روزرسانی آن. پس نمونه Redis واحد ما واقع‌بینانه می‌تواند 50 هزار تا 100 هزار بررسی محدودیت نرخ در ثانیه را قبل از تبدیل شدن به گلوگاه انجام بدهد.

در 1 میلیون درخواست در ثانیه، باید بار Redis را بین چند نمونه پخش کنیم. اما این فقط بالا آوردن سرورهای Redis بیشتر نیست، چون به راهی برای پارتیشن‌بندی داده‌های محدودسازی نرخ نیاز داریم تا هر درخواست بداند با کدام نمونه Redis صحبت کند.

الگو: مقیاس‌دهی نوشتن (Scaling Writes). محدودکننده‌های نرخ چالش‌های کلاسیک مقیاس‌دهی نوشتن را نشان می‌دهند، با میلیون‌ها به‌روزرسانی شمارنده در ثانیه. هر بررسی محدودیت نرخ یک عملیات اتمی خواندن، تغییر و نوشتن می‌خواهد تا سطل توکن یا شمارنده درخواست را بین Shard های توزیع‌شده Redis به‌روز کند.

استراتژی Sharding (شاردینگ / افراز داده‌ها) به قوانین محدودسازی ما بستگی دارد. یادتان باشد قبلاً چند نوع کلاینت شناسایی کردیم: شناسه کاربر برای کاربران احراز هویت‌شده، آدرس IP برای کاربران ناشناس و کلید API برای دسترسی توسعه‌دهنده. باید پایدار Shard کنیم تا همه درخواست‌های یک کلاینت همیشه به همان نمونه Redis برسد. اگر کاربر alice گاهی به Shard 1 و گاهی به Shard 2 بخورد، وضعیت محدودسازی او تکه‌تکه و بی‌فایده می‌شود.

برای حل این مسئله به الگوریتم توزیع مثل Consistent Hashing (هشینگ پایدار) نیاز داریم. برای کاربران احراز هویت‌شده، شناسه کاربر را هش می‌کنیم تا مشخص شود داده محدودسازی نرخ آن‌ها روی کدام Shard ذخیره شود. برای کاربران ناشناس، آدرس IP را هش می‌کنیم. برای درخواست‌های کلید API، خود کلید API را هش می‌کنیم. این تضمین می‌کند وضعیت محدودسازی هر کلاینت دقیقاً روی یک Shard باشد و بار هم یکنواخت بین همه Shard ها پخش شود.

هر API Gateway به منطق مسیریابی نیاز دارد تا مشخص کند کدام Shard Redis را باید کوئری کند. وقتی درخواستی می‌رسد، Gateway شناسه مناسب (شناسه کاربر، IP یا کلید API) را استخراج می‌کند، الگوریتم توزیع را اعمال می‌کند و بررسی محدودیت نرخ را به نمونه درست Redis می‌فرستد. الگوریتم Token Bucket کاملاً همان است، فقط به‌جای یک نمونه، با نمونه‌های مختلف Redis صحبت می‌کنیم.

اگر هر بررسی با اسکریپت Lua فقط یک عملیات Redis باشد، با 10 Shard که هرکدام 100 هزار عملیات در ثانیه را تحمل می‌کنند، باید بتوانیم هدف 1 میلیون درخواست در ثانیه را مدیریت کنیم.

در تولید احتمالاً به‌جای مدیریت دستی نمونه‌های Redis از Redis Cluster استفاده می‌کنید. Redis Cluster همین Sharding ای را که توضیح دادیم خودکار مدیریت می‌کند، با تقسیم کلیدها بین 16384 اسلات هش (Hash Slot) و توزیع آن اسلات‌ها بین چند گره Redis. وقتی کلید محدودسازی مثل alice:bucket را ذخیره می‌کنید، Redis Cluster بر اساس اسلات هش کلید خودکار تعیین می‌کند کدام گره آن را نگه دارد. به این ترتیب به‌جای ساختن منطق Consistent Hashing سفارشی در API Gateway ها، فقط به Redis Cluster وصل می‌شوید و مسیریابی خودکار انجام می‌شود.

2) دسترس‌پذیری بالا و تحمل خطا

حالا که به چند Shard کوچک Redis رسیده‌ایم، هر Shard به جزء حیاتی سیستم تبدیل شده است. اگر هر Shard Redis از کار بیفتد، همه کاربرانی که داده محدودسازی نرخشان روی آن Shard است، قابلیت محدودسازی را از دست می‌دهند. این مشکل دسترس‌پذیری می‌سازد و اگر کاربران وقتی پاسخ‌های محدودسازی درست نمی‌گیرند، تهاجمی Retry کنند، می‌تواند به شکست‌های آبشاری (Cascading Failure) برسد.

وقتی یک Shard Redis در دسترس نیست، با تصمیم اساسی درباره حالت شکست روبه‌رو می‌شویم. دو گزینه داریم.

رویکرد خوب: Fail-closed

وقتی محدودکننده نرخ نتواند به Redis برسد، همه درخواست‌ها را با پاسخ HTTP 503 «Service Unavailable» یا HTTP 429 رد کنید. این محافظه‌کارانه‌ترین گزینه است. هیچ درخواستی عبور نمی‌کند که نتوانیم تأیید کنیم داخل محدوده است.

چالش‌ها: این به‌طور مؤثر API شما را هنگام قطعی Redis آفلاین می‌کند. کاربران درخواست‌های ناموفق می‌بینند، حتی اگر سرویس‌های باطن شما سالم باشند. در عمل، این اغلب مشکلاتی بدتر از آنچه محدودسازی نرخ برای حلش بوده می‌سازد. کاربران ممکن است با دیدن خطای 503 تهاجمی Retry کنند و بار بیشتری روی سیستم بگذارند.

با این حال Fail-closed موارد استفاده مشروع دارد. سیستم‌های مالی که پرداخت پردازش می‌کنند ممکن است ترجیح بدهند تراکنش‌ها را رد کنند تا اینکه بدون محدودسازی نرخ پردازششان کنند. محیط‌های با امنیت بالا که دسترسی کنترل‌نشده بدتر از قطعی است هم ممکن است این رویکرد را انتخاب کنند.

رویکرد خوب: Fail-open

وقتی محدودکننده نرخ نتواند به Redis برسد، اجازه بدهید همه درخواست‌ها عبور کنند، انگار محدودسازی نرخ غیرفعال شده است. API Gateway از بررسی محدودیت نرخ می‌گذرد و درخواست‌ها را مستقیم به سرویس‌های باطن می‌فرستد. این API شما را حتی وقتی زیرساخت محدودکننده نرخ مشکل دارد، در دسترس نگه می‌دارد.

چالش‌ها: نقطه ضعف آشکار از دست رفتن موقت حفاظت محدودسازی نرخ است. هنگام قطعی Redis، کاربران مخرب بالقوه می‌توانند سرویس‌های باطن را با درخواست غرق کنند. مهم‌تر اینکه این می‌تواند باعث شکست آبشاری شود. اگر Redis به این دلیل از کار افتاده که از قبل زیر بار سنگین بودید، Fail-open همه آن ترافیک را به پایین‌دست می‌فرستد و ممکن است باعث فروپاشی کامل سیستم شود. برای پلتفرم‌های شبکه اجتماعی، به‌ویژه هنگام رویدادهای وایرال که جهش ترافیک از قبل سیستم را تحت فشار گذاشته، این خطرناک است. Fail-open می‌تواند قطعی محدودکننده نرخ را به شکست کامل پلتفرم تبدیل کند.

خواهید دید که هر دو گزینه «خوب»اند، چون به نیازهای خاص سیستم بستگی دارند!

برای پلتفرم شبکه اجتماعی خود، Fail-closed را انتخاب می‌کنیم. اگرچه این با خواسته ما برای High Availability (دسترس‌پذیری بالا) متناقض به نظر می‌رسد، واقعیت این است که خرابی محدودکننده نرخ اغلب هم‌زمان با جهش ترافیک رخ می‌دهد، یعنی وقتی بیشترین نیاز به حفاظت را داریم. در رویدادهای وایرال، اگر Redis از کار بیفتد و Fail-open کنیم، سیل ناگهانی توییت‌ها، به‌روزرسانی‌های Timeline و اعلان‌ها می‌تواند پایگاه داده‌های پشتیبان را تحت فشار بگیرد و قطعی محدودکننده را به شکست کامل پلتفرم تبدیل کند. دوره‌های کوتاه درخواست‌های ردشده بهتر از فروپاشی آبشاری سیستم است.

البته انتخاب حالت شکست فقط کنترل آسیب است. راه بهتر این است که در وهله اول با طراحی دسترس‌پذیری بالا، جلوی خرابی Redis را بگیرید. راه‌حل استاندارد برای دسترس‌پذیری Redis، همانندسازی (Replication) Master-Replica است. هر Shard Redis یک یا چند کپی خواندنی (Replica) می‌گیرد که مدام با Master همگام می‌شود. وقتی Master از کار بیفتد، یکی از Replica ها خودکار ارتقا پیدا می‌کند و Master جدید می‌شود. این با Redis Cluster به‌خوبی کار می‌کند، چون Failover داخلی دارد که می‌تواند خرابی Master را تشخیص بدهد و Replica ها را بدون مداخله دستی ارتقا بدهد. مبادله، افزایش هزینه زیرساخت و نیاز به مدیریت تأخیر همگام‌سازی Replica است، هرچند همانندسازی Redis معمولاً بسیار سریع است.

اگرچه تمایلی نداریم این را در بخش خرابی‌ها زیاد بگوییم (فقط چون معمولاً جالب‌ترین مطالب برای ذکر در جای دیگرند)، مانیتورینگ و هشداردهی برای حفظ High Availability مهم است. باید معیارهای سلامت Redis مثل مصرف CPU، مصرف حافظه و اتصال شبکه را در همه Shard ها ردیابی کنیم. همچنین به مانیتورینگ در سطح اپلیکیشن شامل نرخ موفقیت محدودکننده نرخ، تأخیر پاسخ‌ها و هشدارهایی نیاز داریم که هنگام ورود به حالت Fail-open فعال می‌شوند. هدف این است که مشکلات را سریع شناسایی کنیم و پاسخ بدهیم، پیش از اینکه کاربران اختلال سرویس را تجربه کنند.

3) کم کردن سربار تأخیر

هر بررسی محدودیت نرخ یک رفت‌وبرگشت شبکه به Redis می‌خواهد که به درخواست‌های کاربر تأخیر اضافه می‌کند. اگرچه عملیات Redis معمولاً زیر میلی‌ثانیه است، سربار شبکه می‌تواند چند میلی‌ثانیه به هر درخواست اضافه کند. در 1 میلیون درخواست در ثانیه، این تأخیر می‌تواند مشکل‌ساز شود.

مهم‌ترین بهینه‌سازی Connection Pooling (ادغام اتصال) است. به‌جای ساختن اتصال TCP جدید به Redis برای هر بررسی محدودیت، API Gateway ها مجموعه‌ای از اتصال‌های پایدار نگه می‌دارند. این کار سربار TCP Handshake را حذف می‌کند (که بسته به فاصله شبکه می‌تواند 20 تا 50 میلی‌ثانیه باشد) و امکان استفاده مجدد از اتصال‌ها را برای چند درخواست فراهم می‌کند. بیشتر کلاینت‌های Redis Connection Pooling را خودکار انجام می‌دهند، اما باید اندازه Pool را بر اساس حجم درخواست و زمان پاسخ Redis تنظیم کنید.

توزیع جغرافیایی بیشترین کاهش Latency را فراهم می‌کند. زیرساخت محدودکننده نرخ را نزدیک کاربران مستقر کنید: API Gateway ها و کلاستر Redis در چند منطقه (Region). کاربری در توکیو که با نمونه Redis در ویرجینیا صحبت می‌کند، تأخیر بسیار بالاتری از کاربری می‌بیند که با نمونه همان منطقه صحبت می‌کند. مبادله پیچیدگی سازگاری داده بین مناطق است، اما برای Rate Limiting اغلب می‌توانید در ازای تأخیر کمتر، Eventual Consistency بین مناطق را بپذیرید.

بهینه‌سازی‌های دیگری هم هست که ارزش ذکر کوتاه دارند، اما وارد آن‌ها نمی‌شویم: کش محلی وضعیت محدودسازی نرخ می‌تواند کارساز باشد، اما خطرناک است، چون داده کش‌شده کهنه می‌تواند به تصمیم‌های اشتباه محدودسازی برسد. Pipelining در Redis یا اسکریپت‌های Lua می‌توانند عملیات را دسته‌ای کنند تا رفت‌وبرگشت‌ها کم شوند. وقتی چند درخواست از یک کاربر هم‌زمان می‌رسند، دسته‌بندی درخواست‌ها می‌تواند کمک کند. با این حال این بهینه‌سازی‌ها پیچیدگی اضافه می‌کنند و معمولاً اگر Connection Pooling و توزیع جغرافیایی را درست مدیریت کرده باشید، غیرضروری‌اند. در مصاحبه احتمالاً از آن‌ها پرهیز می‌کنم، مگر اینکه صریحاً خواسته شود.

4) مدیریت کلیدهای داغ (سناریوهای محتوای وایرال)

کلیدهای داغ (Hot Keys) در بحث‌های طراحی سیستم زیاد مطرح می‌شوند و ما تکنیک‌های مدیریت آن‌ها را در الگوی «مقیاس‌دهی خواندن» توضیح می‌دهیم. برای Rate Limiting، کلید داغ می‌تواند هم از ترافیک سوءاستفاده‌گر و هم از کلاینت‌های مشروع با حجم بالا بیاید.

الگو: مقیاس‌دهی خواندن (Scaling Reads). اگرچه محدودکننده‌های نرخ اساساً نوشتن (به‌روزرسانی شمارنده) را مدیریت می‌کنند، سناریوهای کلید داغ وقتی فشار شدید مقیاس‌دهی خواندن می‌سازند که هزاران درخواست هم‌زمان بررسی محدودیت نرخ یک IP یا کاربر را بزنند و تک Shard های Redis را تحت فشار بگذارند.

فکر کنید چه چیزی لازم است تا کلید داغ در محدودکننده ما ساخته شود. یک کاربر یا IP تکی باید آن‌قدر درخواست بسازد که یک Shard Redis را سرریز کند. یعنی ده‌ها هزار درخواست در ثانیه از یک منبع. اگرچه این اغلب نشانه سوءاستفاده است (حمله DDoS یا ربات با پیکربندی غلط)، می‌تواند از کلاینت‌های مشروع با حجم بالا هم بیاید، مثل سیستم‌های تحلیلی، Data Pipeline ها یا اپلیکیشن‌های موبایل با الگوی Refresh تهاجمی.

برای سناریوهای مختلف به استراتژی‌های مختلف نیاز داریم.

برای کلاینت‌های مشروع با حجم بالا:

  • Rate Limiting سمت کلاینت: کلاینت‌های خوش‌رفتار را تشویق کنید محدودسازی نرخ خودشان را پیاده کنند تا الگوی ترافیک یکنواخت باشد. این از ساخته شدن تصادفی Shard های داغ توسط کاربران مشروع جلوگیری می‌کند و بار سرور را هم کم می‌کند. بسیاری از SDK های API محدودسازی نرخ داخلی سمت کلاینت دارند که هدرهای پاسخ سرور را رعایت می‌کند.
  • صف‌بندی یا دسته‌بندی درخواست‌ها: به کلاینت‌ها اجازه بدهید چند عملیات را در یک درخواست دسته‌ای کنند تا تعداد کل بررسی‌های لازم محدودیت نرخ کم شود.
  • سطوح ممتاز: برای کاربران قدرتمندی که نیاز دارند، محدودیت‌های بالاتر ارائه کنید، احتمالاً با زیرساخت اختصاصی.

برای ترافیک سوءاستفاده‌گر:

  • مسدودسازی خودکار: وقتی کلاینتی مدام به محدودیت‌ها می‌رسد (مثلاً 10 بار در دقیقه)، IP یا کلید API او را با افزودن به فهرست مسدود (Blocklist) موقتاً کامل مسدود کنید. این فهرست می‌تواند در یکی از Shard های Redis نگه داشته شود و فقط در صورت Cache Miss بررسی شود.
  • حفاظت DDoS: از سرویس‌هایی مثل Cloudflare یا AWS Shield استفاده کنید که می‌توانند ترافیک مخرب را قبل از رسیدن به محدودکننده نرخ شناسایی و مسدود کنند.

بینش کلیدی این است که Rate Limiting سمت کلاینت مکمل ارزشمندی برای حفاظت سمت سرور است. اگرچه برای امنیت نمی‌توان به کلاینت‌ها اعتماد کرد، تشویق «شهروندی خوب» در اکوسیستم API شما بار زیرساخت را کم می‌کند و جلوی ساخته شدن تصادفی سناریوهای داغ توسط کاربران مشروع را می‌گیرد.

غیرقابل تصور نیست که کاربران مشروع واقعاً IP مشترک داشته باشند (NAT شرکتی، Wi-Fi عمومی). باید محدودیت‌های نرخ را طوری طراحی کنید که این را پیشاپیش در نظر بگیرد، نه اینکه بعد از وقوع مشکل کلیدهای داغ را مدیریت کنید. برای Rate Limiting مبتنی بر IP محدودیت‌های بالاتر بگذارید و تا جای ممکن بیشتر به محدودیت‌های کاربر احراز هویت‌شده تکیه کنید.

5) مدیریت پیکربندی پویای قوانین

تا اینجا فرض کرده‌ایم قوانین محدودکننده نرخ ثابت‌اند: کاربر X در هر دقیقه Y درخواست می‌گیرد. اما سیستم‌های تولیدی برای تنظیم محدودیت‌ها بدون استقرار کد جدید به انعطاف نیاز دارند. ممکن است لازم باشد هنگام عرضه یک محصول، محدودیت‌ها را موقتاً بالا ببرید، به مشتریان ممتاز محدودیت بالاتر بدهید، یا اگر الگوی ترافیکی غیرعادی دیدید، سریع محدودیت‌ها را پایین بیاورید.

دو رویکرد اصلی برای مدیریت به‌روزرسانی پویای پیکربندی داریم.

رویکرد خوب: Polling پیکربندی

پیکربندی قوانین را در پایگاه داده یا سرویس پیکربندی اختصاصی ذخیره کنید. API Gateway ها دوره‌ای (مثلاً هر 30 ثانیه) برای تغییر پیکربندی Poll می‌کنند و منطق محدودکننده نرخ خود را بر آن اساس به‌روز می‌کنند. این رایج‌ترین رویکرد است، چون پیاده‌سازی‌اش ساده است و بیشتر کاربردها را پوشش می‌دهد.

سرویس پیکربندی می‌تواند فقط یک جدول پایگاه داده باشد با ستون‌هایی مثل client_type، endpoint، requests_per_minute و غیره. Gateway ها این جدول را طبق برنامه زمانی کوئری می‌کنند و نتیجه را محلی کش می‌کنند.

چالش‌ها: نقطه ضعف اصلی تأخیر به‌روزرسانی است. همیشه بین زمان تغییر قانون و اعمال آن در همه Gateway ها فاصله‌ای هست. اگر به‌خاطر حمله نیاز به کاهش اضطراری محدودیت‌ها دارید، ممکن است تا 30 ثانیه منتظر انتشار تغییر بمانید. برای بیشتر سناریوهای عملیاتی این تأخیر قابل قبول است، اما برای موقعیت‌های فوری می‌تواند مشکل‌ساز باشد.

رویکرد عالی: سیستم مبتنی بر Push

از سیستم مبتنی بر Push استفاده کنید که تغییرات پیکربندی را فوراً به همه API Gateway ها می‌فرستد. ZooKeeper دقیقاً برای همین طراحی شده است: مدیریت پیکربندی توزیع‌شده با اعلان‌های هم‌زمان. ZooKeeper داده پیکربندی را نگه می‌دارد و وقتی پیکربندی تغییر کند، فوراً به همه کلاینت‌های متصل (API Gateway های شما) اطلاع می‌دهد. گزینه‌های دیگر شامل Redis Pub/Sub (الگوی انتشار/اشتراک) یا سرویس‌های پیکربندی سفارشی هستند که اتصال دائمی به Gateway ها نگه می‌دارند.

وقتی اپراتوری قانون محدودسازی را تغییر می‌دهد، سرویس پیکربندی فوراً به همه Gateway های متصل اطلاع می‌دهد و آن‌ها ظرف چند ثانیه قوانینشان را به‌روز می‌کنند.

چالش‌ها: این رویکرد پیچیدگی بسیار بیشتری اضافه می‌کند. باید خرابی اتصال را مدیریت کنید، مطمئن شوید همه Gateway ها به‌روزرسانی را می‌گیرند، و با خرابی‌های جزئی روبه‌رو شوید که در آن بعضی Gateway ها موفق به‌روز می‌شوند و بعضی نه. همچنین در مواقعی که سیستم Push در دسترس نیست، به مکانیزم جایگزین (Fallback) نیاز دارید. پیچیدگی عملیاتی معمولاً فقط برای سیستم‌هایی توجیه‌پذیر است که به به‌روزرسانی بسیار سریع قوانین نیاز دارند، مثل سیستم‌هایی که با حوادث امنیتی یا سناریوهای معاملات با فرکانس بالا سروکار دارند.

انتظارات در هر سطح

سطح Mid-level

داوطلب Mid-level بیشتر روی عرض تمرکز می‌کند (80 درصد عرض و 20 درصد عمق) و باید بتواند طراحی سطح بالایی بسازد که نیازمندی‌های عملکردی را برآورده کند، هرچند با بسیاری از اجزا فقط آشنایی سطحی داشته باشد. مصاحبه‌کننده مدتی را صرف تأیید می‌کند که می‌دانید هر جزء چه کاری می‌کند. اگر Redis را نام ببرید، انتظار داشته باشید بپرسد چطور کار می‌کند و چرا آن را انتخاب کرده‌اید. باید مراحل اولیه مثل جمع‌آوری نیازمندی‌ها و انتخاب الگوریتم پایه را خودتان هدایت کنید، اما انتظار نمی‌رود فعالانه همه ایرادهای طراحی را شناسایی کنید.

برای این سؤال به‌طور خاص، باید یک الگوریتم محدودسازی نرخ را روشن توضیح بدهید (Token Bucket خوب جواب می‌دهد)، محدودکننده را در جای معقولی از معماری قرار بدهید (API Gateway)، Redis را به‌عنوان راه‌حل وضعیت مشترک شناسایی کنید، و وقتی درباره مقیاس‌دهی پرسیده شد، نیاز به Sharding Redis را با درک دقیق از نحوه کارش تشخیص بدهید.

سطح Senior

داوطلب Senior باید انتظار عمق فنی بیشتر داشته باشد (60 درصد عرض و 40 درصد عمق). باید با اطمینان درباره مبادلات بین الگوریتم‌های مختلف محدودسازی نرخ بحث کنید و انتخاب‌هایتان را توضیح بدهید. باید مفاهیم سیستم‌های توزیع‌شده مثل Consistent Hashing، Redis Cluster و Connection Pooling را بدون راهنمایی زیاد درک کنید، بدانید عملیات Redis باید اتمی باشد و تراکنش‌های MULTI/EXEC را پیشنهاد بدهید.

انتظار می‌رود مبادله بین استراتژی‌های Fail-open و Fail-closed را روشن توضیح بدهید، درباره مزایا و معایب جایگذاری‌های مختلف محدودکننده نرخ بحث کنید و فعالانه مسائل بالقوه مثل کلیدهای داغ، نگرانی‌های دسترس‌پذیری Redis و فرصت‌های بهینه‌سازی تأخیر را شناسایی کنید.

برای Rate Limiting به‌طور خاص، داوطلبان E5 باید بحث الگوریتم پایه را سریع جلو ببرند تا وقتشان را صرف چالش‌های سیستم توزیع‌شده کنند، با اطمینان درباره استراتژی‌های Sharding Redis و سناریوهای خرابی بحث کنند و درباره رویکردهای مدیریت پیکربندی نظر داشته باشند.

سطح Staff به بالا

داوطلبان Staff به بالا باید درک عمیقی از محدودسازی نرخ توزیع‌شده در محیط‌های تولیدی نشان بدهند (40 درصد عرض و 60 درصد عمق) و از تجربه واقعی با سیستم‌هایی در مقیاس مشابه بهره ببرند. پیش‌قدمی استثنایی انتظار می‌رود. باید موارد لبه را شناسایی کنید، الزامات Observability (قابلیت مشاهده) را بحث کنید و روش‌های عملیاتی (Operational Practices) را بدون راهنمایی پیشنهاد بدهید. باید درباره انتخاب فناوری‌ها بر اساس تجربه نظر قوی داشته باشید، به‌طور طبیعی درباره استقرار چندمنطقه‌ای و سازگاری داده در مرزهای جغرافیایی بحث کنید و سؤال‌های مربوط به عرضه تدریجی (Gradual Rollout) و استقرار Canary را به‌عنوان موضوع‌های معمول بررسی کنید.

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