لماذا تحديد معدل الطلبات ليس خياراً
كل نقطة نهاية HTTP تعرضها — سواء كانت واجهة API عامة أو خدمة مصغّرة داخلية أو مستقبل لـ Webhook — قابلة للاستنزاف عند وجود تدفق غير متوقع من الطلبات. قد يكون هذا التدفق بسبب حلقة إعادة محاولة مُهيَّأة بشكل خاطئ، أو برنامج تجميع بيانات، أو هجوم حجب خدمة، أو حملة تسويقية أرسلت حركة مرور أكثر مما توقعت. بدون طبقة تحديد معدل الطلبات، يمكن لعميل واحد متهوّر أن يُدهور تجربة الجميع أو يُوقف خدمتك بالكامل.
تحديد معدل الطلبات (Rate Limiting) يفرض حداً أقصى مسموحاً به من الطلبات لكل عميل أو لكل مسار أو لكل شريحة. أما تقييد الحمل (Throttling) فهو مصطلح أشمل يتضمن أيضاً وضع الطلبات في قائمة انتظار أو تأخيرها أو تخفيض جودة الخدمة بشكل لطيف بدلاً من رفضها كلياً.
يستعرض هذا الدليل الخوارزميات الثلاث الأكثر عملية: النافذة الثابتة، والنافذة المنزلقة، ودلو الرموز، مع أمثلة تنفيذية بـ Node.js وRedis، ومناقشة لأفضل حالة استخدام لكل منها.
---
فهم الخوارزميات الثلاث الأساسية
1. عدّاد النافذة الثابتة
أبسط المقاربات: تقسيم الوقت إلى نوافذ متساوية (مثلاً دقيقة واحدة) وعدّ الطلبات لكل مفتاح في كل نافذة.
النافذة [12:00 – 12:01] ← client_A: 47 طلباً
النافذة [12:01 – 12:02] ← client_A: 0 طلبات (إعادة ضبط)
كيف تعمل:
- تخزين عدّاد في Redis مع مهلة صلاحية تساوي مدة النافذة.
- زيادة العدّاد عند كل طلب.
- رفض الطلبات التي تتجاوز الحد.
// مثال توضيحي — غير جاهز للإنتاج
async function fixedWindowAllow(redis, key, limit, windowSeconds) {
const redisKey = `ratelimit:fixed:${key}`;
const count = await redis.incr(redisKey);
if (count === 1) {
await redis.expire(redisKey, windowSeconds);
}
return count <= limit;
}
المقايضة: يمكن للعميل أن يُرسل ضعف الحد المسموح به في وقت قصير عبر إرسال limit طلبات في نهاية نافذة وعدد مساوٍ في بداية النافذة التالية مباشرة. هذا هو الضعف الرئيسي للخوارزمية.
---
2. سجل النافذة المنزلقة
يُسجَّل الطابع الزمني الدقيق لكل طلب لكل عميل. عند وصول طلب جديد، يتم حذف المدخلات الأقدم من مدة النافذة ثم عدّ ما تبقى.
// مثال توضيحي — غير جاهز للإنتاج
async function slidingWindowLogAllow(redis, key, limit, windowMs) {
const now = Date.now();
const windowStart = now - windowMs;
const redisKey = `ratelimit:swlog:${key}`;
const [, , count] = await redis
.multi()
.zremrangebyscore(redisKey, '-inf', windowStart)
.zadd(redisKey, now, `${now}-${Math.random()}`)
.zcard(redisKey)
.expire(redisKey, Math.ceil(windowMs / 1000) + 1)
.exec();
return count <= limit;
}
المقايضة: دقيق تماماً — لا توجد فجوة عند حدود النوافذ. لكنه يخزّن مدخلاً واحداً في Redis لكل طلب، مما يرفع استهلاك الذاكرة مع ارتفاع حركة المرور.
---
3. دلو الرموز (Token Bucket)
يحتوي "الدلو" على capacity رموز كحد أقصى. في كل ثانية، تُضاف refillRate رموز (بحد أقصى يساوي السعة). كل طلب يستهلك رمزاً واحداً. إذا كان الدلو فارغاً، يُرفض الطلب.
يعالج الدلو الطفرات التلقائية حتى السعة القصوى مع فرض معدل إنتاجية ثابت — وهو النموذج الأقرب لمنح حصص API في الواقع العملي.
// مثال توضيحي — غير جاهز للإنتاج
async function tokenBucketAllow(redis, key, capacity, refillRatePerSec) {
const redisKey = `ratelimit:bucket:${key}`;
const now = Date.now() / 1000;
const script = `
local tokens = tonumber(redis.call('hget', KEYS[1], 'tokens'))
local lastRefill = tonumber(redis.call('hget', KEYS[1], 'ts'))
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
if not tokens then
tokens = capacity
lastRefill = now
end
local elapsed = math.max(0, now - lastRefill)
tokens = math.min(capacity, tokens + elapsed * rate)
lastRefill = now
local allowed = 0
if tokens >= 1 then
tokens = tokens - 1
allowed = 1
end
redis.call('hset', KEYS[1], 'tokens', tokens, 'ts', lastRefill)
redis.call('expire', KEYS[1], math.ceil(capacity / rate) + 10)
return allowed
`;
const result = await redis.eval(
script, 1, redisKey, capacity, refillRatePerSec, now,
);
return result === 1;
}
ينفَّذ سكريبت Lua بشكل ذري على خادم Redis، مما يلغي حالات التسابق دون الحاجة إلى معاملات إضافية.
---
4. عداد النافذة المنزلقة (الهجين الأمثل)
منهج عملي وسطي: الاحتفاظ بعدّادَين فقط (النافذة السابقة والنافذة الحالية) مع حساب تقدير الطلبات بناءً على موضع الطلب داخل النافذة الحالية:
rate_estimate = (عدد_سابق × نسبة_التداخل) + عدد_حالي
هذا يلغي فجوة الحد في النافذة الثابتة مع الاحتفاظ بتعقيد تخزين O(1) — بدون مجموعات مرتبة كبيرة.
// مثال توضيحي — غير جاهز للإنتاج
async function slidingWindowCounterAllow(redis, key, limit, windowSeconds) {
const now = Math.floor(Date.now() / 1000);
const currentWindow = Math.floor(now / windowSeconds);
const prevWindow = currentWindow - 1;
const currKey = `ratelimit:swc:${key}:${currentWindow}`;
const prevKey = `ratelimit:swc:${key}:${prevWindow}`;
const [rawPrev, rawCurr] = await redis.mget(prevKey, currKey);
const prevCount = parseInt(rawPrev ?? '0', 10);
const currCount = parseInt(rawCurr ?? '0', 10);
const position = (now % windowSeconds) / windowSeconds;
const overlapRatio = 1 - position;
const estimate = prevCount * overlapRatio + currCount;
if (estimate >= limit) return false;
await redis.multi().incr(currKey).expire(currKey, windowSeconds * 2).exec();
return true;
}
---
اختيار الخوارزمية المناسبة
| الخوارزمية | الدقة | الذاكرة | تحمّل الطفرات | التعقيد | الأنسب لـ | |---|---|---|---|---|---| | النافذة الثابتة | منخفضة | O(1) | ثغرة حدود النافذة | منخفض | الخدمات الداخلية | | سجل النافذة المنزلقة | عالية | O(عدد الطلبات) | دقيق | متوسط | واجهات API منخفضة-متوسطة الحركة | | دلو الرموز | عالية | O(1) | سعة طفرة صريحة | متوسط | واجهات API العامة والـ SDK | | عداد النافذة المنزلقة | عالية | O(1) | أثر حدودي ضئيل | متوسط | معظم واجهات API الإنتاجية |
للحالة العامة في تطبيقات SaaS وواجهات الأعمال: عداد النافذة المنزلقة هو الخيار الافتراضي الأنسب — دقيق بما يكفي وفعّال في استخدام الذاكرة وسهل التنفيذ.
دلو الرموز هو الأنسب حين تريد منح العملاء سعة طفرة صريحة — مثلاً السماح بـ 50 طلباً في الثانية الواحدة بشرط ألا يتجاوزوا 1,000 طلب في الساعة. هذا هو النموذج الذي تستخدمه GitHub وStripe وCloudflare.
---
تنظيم مفاتيح تحديد المعدل
المفتاح يُحدد مَن يخضع للتقييد. من أشيع الاستراتيجيات:
- عنوان IP — الأبسط؛ قد يكون مشتركاً بين عدة مستخدمين عبر NAT.
- مفتاح API — المفضّل للواجهات المصادَق عليها.
- معرّف المستخدم — مفيد لميزات المستخدم الموثَّق.
- IP + مسار — لنقاط نهاية حساسة كتسجيل الدخول والتحقق بـ OTP.
- معرّف المستأجر + المسار — للتطبيقات متعددة المستأجرين.
// أمثلة على مفاتيح التحديد (توضيحية)
`ratelimit:${apiKey}:global` // حصة عالمية لكل مفتاح
`ratelimit:${ip}:/auth/login` // حماية من القوة العمياء لكل IP
`ratelimit:${tenantId}:api:${route}` // لكل مستأجر ولكل مسار
---
اصطلاحات استجابة HTTP
RFC 6585 يُحدد 429 Too Many Requests كرمز الحالة الصحيح. يجب دائماً إضافة ترويسات تتيح للعملاء تنفيذ التراجع اللطيف:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1731254400
{
"error": "rate_limit_exceeded",
"message": "تجاوزت 100 طلب في الدقيقة. أعد المحاولة بعد 30 ثانية.",
"retry_after": 30
}
Retry-After: الثواني حتى يمكن إعادة المحاولة — الأهم بين الترويسات.X-RateLimit-Limit: الحد الأقصى للنافذة الحالية.X-RateLimit-Remaining: الطلبات المتبقية.X-RateLimit-Reset: الطابع الزمني لإعادة ضبط العداد.
أضف هذه الترويسات على جميع الاستجابات الناجحة أيضاً، لا فقط على 429.
---
تكامل Middleware مع Express
// مثال توضيحي — غير جاهز للإنتاج
function rateLimitMiddleware({ limit = 100, windowSeconds = 60, keyFn } = {}) {
return async (req, res, next) => {
const key = keyFn ? keyFn(req) : req.ip;
const allowed = await slidingWindowCounterAllow(redis, key, limit, windowSeconds);
res.set('X-RateLimit-Limit', String(limit));
if (!allowed) {
res.set('Retry-After', String(windowSeconds));
return res.status(429).json({
error: 'rate_limit_exceeded',
message: `تجاوزت حد ${limit} طلباً كل ${windowSeconds} ثانية.`,
retry_after: windowSeconds,
});
}
next();
};
}
// الاستخدام
app.use('/api/', rateLimitMiddleware({ limit: 100, windowSeconds: 60 }));
app.use('/auth/login', rateLimitMiddleware({
limit: 10,
windowSeconds: 300,
keyFn: (req) => `${req.ip}:/auth/login`,
}));
---
اعتبارات نشر Redis
يعتمد محدد معدل الطلبات على مخزن مشترك وسريع الاستجابة. Redis هو الخيار القياسي لثلاثة أسباب:
- العمليات الذرية —
INCRوEXPIREوالمجموعات المرتبة وEVAL(Lua) تُنفَّذ ذرياً. - زمن الاستجابة دون الميلي ثانية — استعلام Redis أسرع بمرتبة من استعلام قاعدة بيانات.
- التنظيف التلقائي بـ TTL — لا حاجة لأي عملية تنظيف في الخلفية.
لـ Redis Cluster: استخدم وسوم التجزئة (hash tags) لضمان وجود المفاتيح ذات الصلة في نفس الـ slot:
const currKey = `ratelimit:{${key}}:${currentWindow}`;
const prevKey = `ratelimit:{${key}}:${prevWindow}`;
---
الحصص متعددة الطبقات للتطبيقات متعددة المستأجرين
// مثال توضيحي — غير جاهز للإنتاج
async function getTierLimits(apiKey) {
return db.query(
'SELECT rate_limit, rate_window FROM api_keys WHERE key = $1',
[apiKey],
);
}
async function tieredRateLimitMiddleware(req, res, next) {
const apiKey = req.headers['x-api-key'];
if (!apiKey) return res.status(401).json({ error: 'api_key_required' });
const tier = await getTierLimits(apiKey);
if (!tier) return res.status(401).json({ error: 'invalid_api_key' });
const allowed = await slidingWindowCounterAllow(
redis, apiKey, tier.rate_limit, tier.rate_window,
);
if (!allowed) {
return res.status(429).json({
error: 'rate_limit_exceeded',
retry_after: tier.rate_window,
});
}
next();
}
خزّن نتيجة بحث الشريحة في الذاكرة المحلية (cache) لمدة 60 ثانية مثلاً، لتجنب استعلام قاعدة البيانات عند كل طلب.
---
اختبار محدد معدل الطلبات
اختبار الوحدة: استدعِ دالة المحدد مباشرة limit + 1 مرة وتحقق من أن الاستدعاء الأخير يُعيد false.
اختبار التزامن: أرسل الطلبات بشكل متوازٍ باستخدام Promise.all للتحقق من أن الذرية تعمل فعلياً:
// اختبار تزامن توضيحي
test('يرفض الطلبات المتزامنة التي تتجاوز الحد', async () => {
const key = `test:${Date.now()}`;
const limit = 10;
const results = await Promise.all(
Array.from({ length: limit + 5 }, () =>
slidingWindowCounterAllow(redis, key, limit, 60),
),
);
const allowed = results.filter(Boolean).length;
expect(allowed).toBeLessThanOrEqual(limit);
});
اختبار الحمل: استخدم أدوات مثل k6 أو Artillery لتوليد حمل مستمر والتحقق من سلوك الـ 429 وترويسة Retry-After.
---
أخطاء شائعة يجب تجنبها
1. تطبيق التحديد بعد المصادقة لا قبلها. العمليات المكلفة (تجزئة كلمات المرور، استعلامات قاعدة البيانات) في middleware المصادقة تعمل قبل أن يتاح للمحدد رفض الطلب. ضع تحديد معدل الطلبات المبني على IP في بداية سلسلة الـ middleware.
2. عدم تسجيل استجابات 429. أحداث التحديد إشارات قيّمة. تسجيلها يساعد على الكشف المبكر عن العملاء المُهيَّأين بشكل خاطئ أو الهجمات أو الحدود التي تحتاج رفعاً.
3. استخدام عداد عالمي واحد لجميع المسارات. عميل يستنفد حصته في عملية تصدير ثقيلة لا يجب أن يحجب طلبات تأكيد الدفع ذات الأولوية العالية. طبّق حدوداً منفصلة على مجموعات مسارات منفصلة.
4. إهمال معالجة عدم توافر Redis. إذا توقف Redis، ستُرمى استثناءات من دالة المحدد. قرر مسبقاً: هل تفشل بشكل مفتوح (تسمح بجميع الطلبات) أم مغلق (تحجب الجميع)؟ الفشل المفتوح يحافظ على التوافر؛ الفشل المغلق يحمي الخلفية.
try {
const allowed = await slidingWindowCounterAllow(redis, key, limit, window);
if (!allowed) return res.status(429).json({ error: 'rate_limit_exceeded' });
} catch (err) {
// Redis غير متاح — الفشل المفتوح للحفاظ على التوافر
console.error('محدد معدل الطلبات غير متاح:', err);
}
// الاستمرار إلى معالج الطلب
---
ملخص
| المتطلب | التوصية | |---|---| | خدمة داخلية بسيطة | عداد النافذة الثابتة | | واجهة API عامة، حركة متوسطة | عداد النافذة المنزلقة | | SDK مع تحمّل طفرات | دلو الرموز | | نقاط نهاية حساسة (تسجيل الدخول، OTP) | عداد النافذة المنزلقة، حدود صارمة، IP + مسار | | SaaS متعدد المستأجرين | عداد النافذة المنزلقة مع حدود لكل مفتاح من قاعدة البيانات |
تحديد معدل الطلبات هو إحدى أولى طبقات الصمود التي يجب إضافتها إلى أي واجهة API في الإنتاج. ابدأ بعداد النافذة المنزلقة، اقرنه بتسجيل منظَّم لأحداث 429، وكرر قيم الحدود بناءً على أنماط الحركة الفعلية.
---
ابنِ واجهات API صامدة مع دراجون سوفت (Dragonfly Soft)
تصميم وتشغيل Backend إنتاجي — يشمل تحديد معدل الطلبات والمصادقة والتخزين المؤقت والمراقبة — يتطلب عمقاً هندسياً وخبرة تشغيلية حقيقية. في دراجون سوفت قدّمنا منصات API-first للشركات في سوريا والمنطقة، من بوابات مالية إلى منتجات SaaS متعددة المستأجرين.
إذا كنت تبني واجهة API جديدة أو تُحصّن واجهة قائمة، تواصل معنا لنناقش كيف يمكننا مساعدتك في تصميم نظام يظل موثوقاً تحت الحمل الحقيقي.