ساخت یک محدودکننده نرخ با پنجره لغزان (Sliding Window) در Redis

سهمیه (quota) API یوتیوب ما در سه ماه گذشته، دو بار قبل از ساعت ۹ صبح به صفر رسید. این اتفاق باعث شد فیدهای ترند ما در نیمی از مناطق مورد استفاده، قدیمی و بی‌تازه‌ شوند.

مشکل از رشد ترافیک نبود؛ مشکل از محدودکننده نرخ (rate limiter) ما بود.

ما از یک شمارنده پنجره ثابت (fixed-window counter) استفاده می‌کردیم. این روش اجازه می‌داد اگر دو موج عظیم از فراخوانی‌های API دقیقاً در مرز پنجره رخ دهند، از سیستم عبور کنند. برای یک خط لوله (pipeline) جهانی با ۸ منطقه، این هم‌پوشانی مرزی مکرراً اتفاق می‌افتاد و باعث می‌شد سهمیه روزانه دقیق ما، مانند یک نشتی بودجه عمل کند.

من پنجره ثابت را با یک لاگ پنجره لغزان (sliding window log) با استفاده از مجموعه‌های مرتب‌شده (sorted sets) در Redis جایگزین کردم.

نحوه عملکرد آن به این صورت است:

  • استفاده از ZADD برای ثبت یک درخواست با برچسب زمانی (timestamp).
  • استفاده از ZREMRANGEBYSCORE برای حذف ورودی‌های قدیمی خارج از محدوده پنجره.
  • استفاده از ZCARD برای شمارش دقیق تعداد درخواست‌های باقی‌مانده در پنجره.
  • استفاده از PEXPIRE برای پاکسازی خودکار کلیدهای بلااستفاده.

این روش دقیق است و هیچ مرزی برای عبور از آن وجود ندارد.

من این قابلیت را با استفاده از یک اسکریپت Lua در Redis پیاده‌سازی کردم. این کار تضمین می‌کند که کل فرآیند بررسی و ثبت، اتمیک (atomic) باشد. اگر این مراحل را در کد اپلیکیشن اجرا کنید، شرایط رقابتی (race conditions) اجازه عبور درخواست‌های اضافی را خواهند داد.

تصمیمات فنی کلیدی برای محیط عملیاتی (production):

  • استفاده از Redis TIME: از برچسب‌های زمانی سرورهای اپلیکیشن خود استفاده نکنید. اختلاف زمانی (clock skew) بین سرورها دقت پنجره را از بین می‌برد.
  • هزینه‌های وزنی: همه فراخوانی‌های API یکسان نیستند. یک فراخوانی جستجو ممکن است ۱۰۰ واحد هزینه داشته باشد، در حالی که لیست ویدیوها تنها ۱ واحد هزینه دارد. اسکریپت من با درج چندین عضو (member) به ازای هر فراخوانی، این موضوع را مدیریت می‌کند.
  • زمان دقیق بازگشت (Retry-After): با بررسی قدیمی‌ترین ورودی در مجموعه مرتب‌شده، سیستم دقیقاً محاسبه می‌کند که ظرفیت چه زمانی آزاد می‌شود.
  • منطق ایمنی (Fail-safe): من از EVALSHA به همراه یک جایگزین (fallback) با EVAL استفاده می‌کنم. اگر کش اسکریپت Redis در حین ری‌استارت پاک شود، اپلیکیشن به شکلی صحیح آن را مدیریت می‌کند.

هزینه این کار، مصرف حافظه است. هر درخواست حدود ۱۰۰ بایت فضا می‌گیرد. برای یک سهمیه روزانه ۱۰,۰۰۰ واحدی، این تنها حدود ۱ مگابایت حافظه است. برای اکثر موارد استفاده، این دقت ارزش این هزینه را دارد.

از زمان اعمال این تغییر، سهمیه ما حتی یک بار هم تمام نشده است. کارهای ما (jobs) به جای مواجهه با خطاهای 403، به شکلی تمیز و منظم متوقف می‌شوند.

اگر محدودکننده نرخ شما در یک عدد رند ساعت ریست می‌شود، شما محدودیت ندارید؛ بلکه یک حفره (loophole) دارید.

منبع: https://dev.to/ahmet_gedik778845/building-a-sliding-window-rate-limiter-in-redis-for-a-multi-region-video-api-50ni