تحديد معدل الطلبات على API بخوارزمية Token Bucket
هذا المقال لمستوى: متوسط
في نهاية المقال هيبقى عندك سكربت واحد يمنع أي عميل من إغراق الـ API بتاعك، ويسمح في نفس الوقت بموجة طلبات قصيرة طبيعية. الطريقة الشائعة (عدّاد بسيط لكل ثانية) بتفشل عند حدود النافذة الزمنية، وهنشوف ليه، وهنحل المشكلة بخوارزمية دلو الرموز.
المشكلة باختصار
تخيّل بواب نادي بيوزّع تذاكر دخول. كل دقيقة بيحط 10 تذاكر في صندوق، والصندوق بيسع 10 بس. أي زائر عايز يدخل لازم ياخد تذكرة. لو الصندوق فاضي، الزائر يستنى. لو النادي هدأ شوية، التذاكر بتتراكم لحد سقف الـ 10، فتقدر مجموعة تدخل مرة واحدة من غير زحمة.
ده بالظبط اللي بيعمله تحديد المعدل (Rate Limiting) للـ API. الافتراض هنا إن عندك خدمة عليها ضغط حقيقي، وعميل واحد (أو IP واحد) ممكن يبعت طلبات أكتر من نصيبه العادل، فيخنق باقي المستخدمين أو يزوّد فاتورة السيرفر.
ليه العدّاد البسيط بيفشل
أول حل بيخطر على البال: عدّاد ثابت لكل نافذة. "مسموح 60 طلب في الدقيقة"، فبتزوّد مفتاح في Redis وبتصفّره كل دقيقة. المشكلة إن ده بيسمح بضعف الحد على حدود النافذة. لو العميل بعت 60 طلب في الثانية 59، و60 تاني في الثانية 61، يبقى بعت 120 طلب في ثانيتين وانت فاكر إنك حاميت. ده اسمه مشكلة حدود النافذة (window boundary).
إزاي بتشتغل خوارزمية دلو الرموز
الفكرة العلمية بسيطة. كل عميل عنده "دلو" فيه رموز (tokens). الدلو بيتعبّى بمعدل ثابت r رمز في الثانية، وله سعة قصوى C. كل طلب بياخد رمز واحد. لو في رمز، الطلب يعدّي والرصيد ينقص. لو الدلو فاضي، الطلب يترفض فورًا بخطأ HTTP 429.
الحاجة الذكية إن الرموز بتتراكم لما العميل يهدأ، لحد سقف السعة. يعني المتوسط على المدى الطويل بيفضل r طلب/ثانية، لكن الخوارزمية بتسمح بموجة (burst) حجمها C. ده اللي بيخلّيها ألطف من العدّاد الجامد للمستخدم الحقيقي.
التطبيق العملي: Redis + Lua ذري
عشان تشتغل مع أكتر من سيرفر تطبيق في نفس الوقت، محتاج مكان مركزي للحالة، و Redis مثالي لأنه بيشغّل سكربت Lua بشكل ذري (atomic)، فمفيش طلبين بيقروا نفس الرصيد ويصرفوه مرتين. ده السكربت كامل قابل للنسخ:
-- KEYS[1] = مفتاح دلو العميل، مثال: rl:user:42
-- ARGV[1] = السعة C ARGV[2] = معدل التعبئة r (رمز/ثانية)
-- ARGV[3] = الوقت الحالي now (ثواني) ARGV[4] = الرموز المطلوبة (عادة 1)
local capacity = tonumber(ARGV[1])
local refill = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local needed = tonumber(ARGV[4])
local d = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(d[1])
local ts = tonumber(d[2])
if tokens == nil then -- أول مرة: الدلو مليان
tokens = capacity
ts = now
end
-- ضيف الرموز المتراكمة منذ آخر طلب، بحد أقصى السعة
local delta = math.max(0, now - ts)
tokens = math.min(capacity, tokens + delta * refill)
local allowed = tokens >= needed
if allowed then tokens = tokens - needed end
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / refill) * 2) -- تنظيف تلقائي للخاملين
return { allowed and 1 or 0, math.floor(tokens) }