دراسات حالة واقعية لتصميم الأنظمة

تصميم محدِّد معدل الطلبات

18 دقيقة الدرس 3 من 10

تصميم محدِّد معدل الطلبات

محدِّد معدل الطلبات (Rate Limiter) هو مكوِّن يتحكم في عدد الطلبات التي يمكن لعميل ما إرسالها إلى خدمة ما خلال فترة زمنية محددة. بدونه، يستطيع عميل واحد مُسيء — أو مهاجم يشغِّل نصًّا برمجيًّا لحشو بيانات الاعتماد — إغراق خوادمك وتدهور الأداء لجميع المستخدمين. على نطاق واسع، تحديد معدل الطلبات ليس اختياريًّا بل هو ضرورة بنيوية.

أمثلة واقعية: تفرض GitHub حدًّا بـ 5,000 طلب API في الساعة لكل مستخدم موثَّق. تحدِّد Stripe المدفوعات بـ 100 طلب في الثانية لكل مفتاح API. تحدِّد Twilio إرسال الرسائل النصية لكل حساب لمنع البريد المزعج. يتطلب كل ذلك محدِّدًا موزَّعًا وعالي الأداء لا يضيف سوى أجزاء قليلة من الثانية.

الخطوة 1 — تحديد المتطلبات

قبل اختيار الخوارزمية، حدِّد القيود:

  • مستوى التطبيق: هل الحد لكل مستخدم؟ لكل IP؟ لكل مفتاح API؟ لكل نقطة نهاية؟
  • نوع النافذة الزمنية: نافذة ثابتة (مثلاً "60 طلبًا في الدقيقة، تُعاد عند :00")؟ أم نافذة منزلقة؟ أم دلو رموز (تدفق منتظم)؟
  • نقطة التطبيق: بوابة API، أم شبكة خدمات، أم وسيط تطبيق، أم مكتبة مشتركة؟
  • النطاق: كم عدد المحدِّدات الفريدة؟ خدمة بـ 10 ملايين مستخدم تحتاج 10 ملايين عداد — يمكن تخزينها في ~80 ميغابايت من الذاكرة (8 بايت لكل عداد).
  • الدقة مقابل الأداء: هل يُقبَل تجاوز بسيط للحد، أم يجب أن تكون الحدود دقيقة تمامًا؟
  • الاستجابة عند الخرق: رفض صارم بـ 429 Too Many Requests؟ أم تقليل سرعة ناعم؟ أم إضافة إلى طابور انتظار؟

الخطوة 2 — خوارزميات تحديد المعدل

هناك أربع خوارزميات شائعة الاستخدام، كل منها يحل مشكلة مختلفة.

عداد النافذة الثابتة

قسِّم الوقت إلى حاويات ثابتة (مثلاً كل دقيقة). زِد عدادًا لكل طلب؛ ارفض عندما يتجاوز العداد الحد. أعِد التهيئة عند الحدود.

  • الميزة: ذاكرة O(1) لكل عميل؛ قابل للتنفيذ بسهولة باستخدام INCR + EXPIRE في Redis.
  • العيب: انفجار الحدود. إذا كان الحد 100 طلب/دقيقة وأرسل العميل 100 طلب في الثانية 00:59 و100 أخرى في 01:01، حصل على 200 طلب في ثانيتين — ضعف المعدل المقصود.

سجل النافذة المنزلقة

خزِّن مجموعة مرتبة (سجل طوابع زمنية) لكل طلب. عند كل طلب جديد، احذف المدخلات الأقدم من النافذة، عُدَّ الباقية، وقرِّر القبول أو الرفض.

  • الميزة: دقة تامة؛ لا انفجار عند الحدود.
  • العيب: ذاكرة متناسبة مع عدد الطلبات لكل عميل. عند 1,000 طلب/دقيقة لكل عميل، يكلِّف تخزين كل طابع زمني ~50 بايت → 50 كيلوبايت للعميل الثقيل.

عداد النافذة المنزلقة (الهجين)

احتفظ بحاويتين ثابتتين: الدقيقة الحالية والدقيقة السابقة. رجِّح بينهما حسب مدى تقدمك في النافذة الحالية:

estimated_count = prev_bucket_count * (1 - elapsed_in_current_window / window_size) + current_bucket_count

هذا التقدير دقيق بنسبة ~0.1% لحركة المرور السلسة ويكلِّف عدادَين فقط لكل عميل. تستخدمه Cloudflare في قواعد 429 الخاصة بها على نطاق تريليونات الطلبات.

دلو الرموز

لكل عميل دلو يسع حتى capacity رمزًا. تُضاف الرموز بمعدل ثابت (مثلاً 10/ثانية). يستهلك كل طلب رمزًا واحدًا؛ إذا فرغ الدلو رُفض الطلب. يُعرَّف الدلو برقمين: السعة (سقف الانفجار) ومعدل الإعادة (المعدل المستدام).

  • الميزة: يسمح بفترات انفجار قصيرة حتى capacity؛ معدل مستدام سلس. تستخدم Amazon API Gateway هذا النموذج.
  • العيب: يتطلب تخزين طابع آخر إعادة وعدد الرموز الحالي. يحتاج التحكم في التزامن عند تحديث أكثر من عقدة لنفس الدلو.
القاعدة الذهبية: استخدم عداد النافذة المنزلقة عندما تحتاج إلى دقة مع حد أدنى من الذاكرة. استخدم دلو الرموز عندما تريد السماح بانفجارات مشروعة (مثلاً تطبيق موبايل يرسل رسائل متراكمة عند إعادة الاتصال).
Four rate-limiting algorithms compared Fixed Window :00–:59 cnt=98 01:00– cnt=0 ⚠ boundary burst Sliding Log Sorted Set (timestamps) t-59s … t-2s … t-0s count = 97 → allow ✓ exact accuracy Sliding Counter prev cnt=60 curr cnt=30 est = 60×0.4 + 30 = 54 ✓ low memory Token Bucket cap=100 refill 10/sec ✓ burst-friendly Algorithm Comparison Algorithm Memory Accuracy Burst handling Fixed Window O(1) Boundary burst Poor Sliding Log O(n) per client Exact N/A Sliding Counter O(1) ~99.9% Fair Token Bucket O(1) Exact (sustained) Excellent
مقارنة بين الخوارزميات الأربع الرئيسية لتحديد معدل الطلبات وتبادلاتها.

الخطوة 3 — التخزين الموزَّع مع Redis

محدِّد المعدل أحادي الخادم أمر بسيط. الصعوبة تكمن في جعله يعمل عبر مجموعة من عقد بوابة API بحيث تكون العدادات متسقة بغض النظر عن أيِّ عقدة تعالج الطلب.

Redis هو الحل المعياري. فهو أحادي الخيط (لذا العمليات ذرية بطبيعتها) ويدعم العمليات الأساسية اللازمة:

  • INCR key + EXPIRE key seconds — عداد نافذة ثابتة بأمرَين.
  • ZADD + ZREMRANGEBYSCORE + ZCARD — سجل نافذة منزلقة.
  • نصوص Lua — تجمع أوامر متعددة في عملية ذرية واحدة، ما يلغي حالات السباق بين الفحص والزيادة.
استخدم نص Lua لعداد النافذة المنزلقة. يعمل نص Lua بصورة ذرية على خادم Redis؛ لا يستطيع أيُّ عميل آخر تداخل أوامره بين القراءة والكتابة. هذا أبسط بكثير من الأقفال الموزَّعة ولا يضيف تقريبًا أي زمن استجابة.
-- Sliding window counter (Lua, runs atomically on Redis) local key_curr = KEYS[1] -- "rl:{user}:{minute}" local key_prev = KEYS[2] -- "rl:{user}:{prev_minute}" local limit = tonumber(ARGV[1]) local now_frac = tonumber(ARGV[2]) -- e.g. 0.75 = 45s into current minute local prev = tonumber(redis.call('GET', key_prev) or 0) local curr = tonumber(redis.call('GET', key_curr) or 0) local estimate = prev * (1 - now_frac) + curr if estimate >= limit then return 0 -- rejected end redis.call('INCR', key_curr) redis.call('EXPIRE', key_curr, 120) return 1 -- allowed

الخطوة 4 — البنية الموزَّعة

على نطاق واسع يجب ألا يصبح محدِّد المعدل نفسه نقطة اختناق. تضع البنية المرجعية وسيطًا خفيف الوزن لتحديد المعدل (أو قاعدة بوابة API) أمام كل خدمة خلفية. يتواصل كل مثيل مع مجموعة Redis صغيرة — ليس Redis عالمي واحد، لأن مثيل Redis فردي يصل إلى ذروته عند ~100-200 ألف عملية/ثانية.

قرارات التصميم الرئيسية:

  • التجزئة حسب مفتاح العميل: hash(user_id) % num_redis_nodes يضمن أن عداد كل مستخدم يذهب دائمًا إلى نفس العقدة — دون الحاجة إلى تنسيق بين العقد.
  • ذاكرة تخزين مؤقت محلية للمفاتيح الساخنة: ذاكرة مؤقت داخل العملية بـ 10 مللي ثانية لكل عقدة بوابة تمنع ضرب Redis بكل طلب. المقايضة هي نافذة قصيرة قد يتجاوز فيها عميل الحد قليلاً عبر العقد. مقبول في معظم الحالات.
  • سياسة الاحتياط: إذا كان Redis غير متاح، قرِّر مسبقًا: فتح الفشل (السماح بالطلب) أم إغلاقه (الرفض). فتح الفشل يحافظ على التوفر؛ إغلاقه يمنع الإساءة أثناء الانقطاع. معظم أنظمة الإنتاج تفتحه مع تنبيه.
  • إعادة الترويسات: دائمًا أرسل X-RateLimit-Limit وX-RateLimit-Remaining وX-RateLimit-Reset حتى يتراجع العملاء بأمان بدلًا من الاصطدام بجدار 429.
Distributed rate limiter architecture with Redis sharding Client A Client B API Gateway + Rate Limiter Local Cache (10ms TTL) Lua Script (atomic check) Headers X-RateLimit-* INCR / Lua Redis Cluster (sharded by key) Node 0 keys A–H Node 1 keys I–P Node 2 keys Q–Z Node 3 (replica) allow / deny Backend Services Allowed only 429 if denied Fail open if Redis unreachable
بنية محدِّد المعدل الموزَّع: بوابة API تطبِّق ذاكرة مؤقت محلية + فحص Lua ضد مجموعة Redis مجزَّأة.

الخطوة 5 — حالات الحافة ومخاوف الإنتاج

  • انحراف الساعة: عندما تحسب عقد متعددة "في أيِّ دقيقة نحن؟" بساعاتها المحلية، قد تتسبب اختلافات صغيرة (~1 ثانية) في وضع العدادات في حاويات مختلفة. زامِن الساعات باستخدام NTP واستخدم نافذة زمنية لا تقل عن 10 ثوانٍ حتى يصبح انحراف الثانية الواحدة غير ذي صلة.
  • حالة السباق عند الطلب الأول: عقدتان تقومان في آنٍ واحد بـ GET → 0 → INCR قد تسمحان بطلب يجب أن يكون الأخير. نص Lua أو Redis الذري SET NX يلغي هذا.
  • التحديد متعدد المستويات: طبِّق الحدود على نطاقات متعددة في آنٍ واحد — لكل IP (لمنع الروبوتات)، لكل مستخدم (الاستخدام العادل)، لكل مفتاح API (مستويات اتفاقية الخدمة). خزِّن كل منها كمفتاح Redis منفصل؛ تحقق من الثلاثة في جولة واحدة.
  • تحذير Lua الموزَّع: في Redis Cluster، يجب أن تُجزَّأ جميع المفاتيح في نص Lua إلى نفس الفتحة. استخدم علامات التجزئة: rl:{user_id}:curr وrl:{user_id}:prev كلاهما يحتويان {user_id}، لذا يوجِّههما Redis إلى نفس العقدة.
تجنَّب تخزين حالة تحديد المعدل في قاعدة بيانات تطبيقك. عند 50,000 طلب/ثانية، كل فحص لتحديد المعدل هو عملية كتابة. حتى PostgreSQL السريعة ستنهار. يتعامل Redis مع هذا بزمن استجابة أقل من ميللي ثانية دون أي تنازع — لهذا السبب تحديدًا تم بناؤه.

الخلاصة

يجمع محدِّد معدل الطلبات الموزَّع الجاهز للإنتاج بين الخوارزمية الصحيحة (عداد النافذة المنزلقة للدقة مع انخفاض الذاكرة، ودلو الرموز لتحمُّل الانفجارات) مع Redis للتخزين الذري منخفض الزمن، ونصوص Lua للتحديثات الخالية من السباق، وتجزئة المفاتيح لتوسع أفقي، وسياسة احتياط واضحة عند عدم توفر التخزين. والنتيجة مكوِّن يضيف أقل من 2 مللي ثانية لكل طلب مع حماية خلفيتك من عشرات الآلاف من العملاء المسيئين في الثانية.