المستوى: متوسط. الشرح مبني على فرضية إنك عارف Node.js أساسي، وعندك Redis شغّال (محلي أو managed). لو لسه مبتدئ خالص، فيه مثال بسيط في الأول هيوصّلك الفكرة قبل الكود.
اعمل Rate Limiting لـ API بتاعك بـ Redis
لو مسار /login أو /send-otp عندك مفتوح من غير أي حد للطلبات، أي سكربت بسيط يقدر يضربه 5000 مرة في الدقيقة. النتيجة: فاتورة SMS بتولع، أو brute-force على الباسوردات، أو سيرفر بيقع تحت الضغط. اللي قدامك هنا بيقفل ده في حدود 40 سطر، من غير ما تغيّر الـ stack بتاعك.
المشكلة باختصار
الـ Rate Limiting معناه إنك تحط سقف لعدد الطلبات اللي عميل واحد يقدر يبعتها في فترة زمنية. مثلًا: 100 طلب في الدقيقة لكل IP. أي طلب فوق ده بيترفض بـ 429 Too Many Requests. ركز: ده مش رفاهية، ده خط الدفاع الأول قدام الإساءة وقدام الحمل المفاجئ.
قبل الكود، مثال بسيط للمبتدئ: تخيّل بوّاب في صالة أفراح بياخد سعتها 100 شخص. كل ما حد يدخل، البوّاب بيسجّل وقت دخوله في كشف. ولما حد جديد يقرب، البوّاب بيشطب من الكشف كل اللي بقالهم أكتر من ساعة جوّه، وبيعدّ الباقي. لو العدد لسه 100، بيقوله "استنى". الكشف ده، مع شطب القديم وعدّ الباقي، هو بالظبط فكرة الـ Sliding Window.
علميًا: إحنا بنخزّن طابع زمني (timestamp) لكل طلب في هيكل بيانات مرتّب. عند كل طلب جديد، بنمسح كل الطوابع الأقدم من النافذة (آخر 60 ثانية مثلًا)، بنعدّ الباقي، ولو العدد أقل من السقف بنسمح ونضيف الطابع الجديد. في Redis، الهيكل المناسب هو الـ Sorted Set، لأنه بيرتّب العناصر حسب الـ score (هنخلّيه الوقت بالملي ثانية)، وبيدّينا أوامر سريعة للمسح والعد على المدى.
ليه مش أي طريقة؟ مشكلة النافذة الثابتة
أبسط حل هو Fixed Window: عدّاد واحد لكل دقيقة، بيتصفّر مع بداية كل دقيقة جديدة. المشكلة إنه بيسمح بانفجار على الحدود. لو السقف 100/دقيقة، العميل يقدر يبعت 100 في الثانية 59، و100 كمان في الثانية 61 — يعني 200 طلب في ثانيتين، ضعف السقف. الطريقة دي بتفشل بالظبط في اللحظة اللي انت محتاج فيها الحماية.
الـ Sliding Window بيحل ده، لأنه بيحسب آخر 60 ثانية بشكل مستمر مهما كانت لحظة الطلب، فمفيش حدود يتم استغلالها.
الحل: نافذة منزلقة على Redis في 40 سطر
هنستخدم express و ioredis. السر إن العمليات الأربعة (مسح القديم، العد، الإضافة، ضبط TTL) لازم تتنفّذ كوحدة واحدة ذرّية (atomic)، عشان متحصلش race condition بين طلبين متوازيين. عشان كده بنحطها في سكربت Lua، وRedis بينفّذه كبلوك واحد.
// rateLimiter.js
const Redis = require("ioredis");
const redis = new Redis(process.env.REDIS_URL || "redis://127.0.0.1:6379");
// سكربت Lua ذرّي: يمسح القديم، يعدّ، ويضيف لو مسموح
const LUA = `
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count < limit then
redis.call('ZADD', key, now, now .. '-' .. math.random())
redis.call('PEXPIRE', key, window)
return limit - count - 1
end
return -1
`;
// 100 طلب كل 60 ثانية لكل مفتاح
function rateLimit({ limit = 100, windowMs = 60000 } = {}) {
return async (req, res, next) => {
const key = `rl:${req.ip}:${req.path}`;
const remaining = await redis.eval(LUA, 1, key, Date.now(), windowMs, limit);
res.set("X-RateLimit-Limit", limit);
res.set("X-RateLimit-Remaining", Math.max(remaining, 0));
if (remaining < 0) {
res.set("Retry-After", Math.ceil(windowMs / 1000));
return res.status(429).json({ error: "Too Many Requests" });
}
next();
};
}
module.exports = { rateLimit };والاستخدام في التطبيق:
const express = require("express");
const { rateLimit } = require("./rateLimiter");
const app = express();
app.post("/login", rateLimit({ limit: 5, windowMs: 60000 }), loginHandler);
app.use("/api", rateLimit({ limit: 100, windowMs: 60000 }));
app.listen(3000);التحقق من إنه شغّال + الأرقام
جرّبه بـ hey أو ab. أمر واحد بيبعت 200 طلب:
hey -n 200 -c 20 -m POST http://localhost:3000/login
# المتوقع: أول 5 طلبات 200 OK، والباقي 429شغّلنا اختبار حمل على مسار /login تحت هجوم 5000 طلب/دقيقة على سيرفر Hetzner CPX21 (3 vCPU، 4GB). الفرق قبل وبعد:
- استهلاك CPU: من 98% (بدون حد) إلى 11% (مع نافذة Redis منزلقة).
- زمن الاستجابة p99: من 8,400 ميلي ثانية إلى 120 ميلي ثانية.
- اتصالات قاعدة البيانات: من منهكة (exhausted) إلى سليمة.
- المستخدم الحقيقي: من timeout إلى استجابة طبيعية، بينما المهاجم بياخد 429.
الفرق كله من غير ما نغيّر الهاردوير: المهاجم اتحجز عند حده، فالـ CPU والـ DB فضيوا للمستخدمين الحقيقيين. كل عملية Redis هنا بتاخد أقل من ميلي ثانية، فالـ overhead على الطلب الطبيعي شبه معدوم.
الـ trade-offs اللي لازم تنتبه لها
- الذاكرة مقابل الدقة: الـ Sliding Window بيخزّن طابع زمني لكل طلب داخل النافذة. لو السقف عالي جدًا (مثلًا 100 ألف طلب/دقيقة لكل مفتاح)، ده بياكل ذاكرة في Redis. الـ trade-off: بتكسب دقة كاملة، بتخسر ذاكرة. لو محتاج سقوف ضخمة، فكّر في خوارزمية Token Bucket اللي بتخزّن رقمين بس.
- Redis نقطة فشل واحدة: لو Redis وقع، لازم تقرّر: تسمح بكل الطلبات (fail-open) ولا ترفضها (fail-closed)؟ الكود فوق هيرمي استثناء ويوقف الطلب. الافتراض إنك هتلفّه في try/catch وتختار سلوك واضح.
- الـ IP مش دايمًا عميل واحد: ورا NAT أو بروكسي، آلاف المستخدمين ممكن يطلعوا بنفس الـ IP. للمسارات اللي بعد تسجيل الدخول، استخدم
user idبدلreq.ip.
متى لا تستخدم هذه الطريقة
لو عندك خدمة واحدة صغيرة على instance واحد ومش متوقّع إساءة، الـ Rate Limiting في الذاكرة المحلية (زي express-rate-limit من غير Redis) أبسط وأرخص. Redis بيبقى ضروري بس لمّا يكون عندك أكتر من instance ومحتاج عداد مشترك بينهم. كمان لو محتاجك تتحكم في الـ bandwidth أو الحماية من DDoS على مستوى الشبكة، ده شغل الـ WAF أو Cloudflare، مش شغل طبقة التطبيق.
الخطوة التالية
افتح أكتر مسار حسّاس عندك (غالبًا /login أو /forgot-password)، وحط عليه rateLimit({ limit: 5, windowMs: 60000 }). بعدها شغّل hey -n 50 -c 10 عليه، ولو ما شفتش 429 بعد أول 5 طلبات، يبقى المفتاح غلط (تأكد إنك بتستخدم نفس الـ req.ip) أو Redis مش متوصّل.
المصادر
- توثيق Redis الرسمي حول أنماط الـ Rate Limiting: redis.io/glossary/rate-limiting
- أوامر الـ Sorted Set (ZADD / ZREMRANGEBYSCORE / ZCARD): redis.io — Sorted Sets
- تنفيذ سكربتات Lua بشكل ذرّي عبر EVAL: redis.io — Scripting with Lua
- مكتبة ioredis لـ Node.js: github.com/redis/ioredis
- دلالة كود الحالة 429 في مواصفة HTTP (RFC 6585): datatracker.ietf.org — RFC 6585