Exponential Backoff + Jitter بالعربي: امنع هجوم الـ retries على سيرفرك
لو عندك 10,000 client متصلين بـ API، والسيرفر وقع ثانيتين، الـ clients كلهم هيحاولوا تاني في نفس اللحظة بالظبط لما يرجع. ده بيخلي اللي كان عطل بسيط يتحوّل لعطل طويل. الحل في 8 سطور كود، اسمه Exponential Backoff + Jitter، ومتبّن رسميًا من AWS و Google SRE.
المشكلة باختصار
الظاهرة دي اسمها Thundering Herd. السيرفر بيقع لثانيتين، كل الـ clients بتاخد خطأ، الـ retry logic عندهم بيحاول فورًا. لما السيرفر يقوم، بيستقبل 10,000 طلب في نفس الـ millisecond، فبيقع تاني. وهكذا لدورة لا تنتهي.
الحل الساذج إنك تستنى ثانية قبل كل retry. ده بيحل المشكلة جزئيًا، لكن الـ 10,000 client هيحاولوا كلهم بعد ثانية واحدة بالظبط — نفس التكدّس، بس مؤجل.
مثال بسيط: محل بعد انقطاع الكهرباء
تخيّل إن فيه محل بيخدم 500 عميل، وفجأة الكهرباء قطعت 5 دقائق. كل العملاء بيقفوا برّا المحل. لما الكهرباء ترجع، في 3 سيناريوهات:
- بدون أي تأخير: الـ 500 بيدخلوا مرة واحدة، الباب يتكسر، الموظف يفقد صوابه.
- تأخير ثابت (كله يستنى 30 ثانية): بعد 30 ثانية، الـ 500 بيدخلوا مرة واحدة برضه. المشكلة زي ما هي.
- تأخير متفاوت لكل عميل (jitter): كل عميل ياخد رقم عشوائي بين 0 و 60 ثانية. الطابور بيتوزّع تلقائيًا، والموظف بيخدم 8 عملاء في الدقيقة براحته.
ركز: السيناريو التالت هو اللي بينقذ الموقف. التوزيع العشوائي أهم من قيمة التأخير نفسها.
التعريف العلمي الدقيق
Exponential Backoff يعني إن وقت الانتظار بيتضاعف مع كل محاولة فاشلة. الصيغة الأساسية:
delay = base * (2 ^ attempt)يعني لو base = 100ms:
- المحاولة 1 بعد فشل: استنى 200ms
- المحاولة 2: استنى 400ms
- المحاولة 3: استنى 800ms
- المحاولة 6: استنى 6.4 ثانية
ده بيحل مشكلة تكرار الضرب السريع، لكنه لسه مش بيحل مشكلة التزامن. لأن كل الـ clients بتحسب نفس الـ delay بالظبط (200ms, 400ms, 800ms…)، فبيحاولوا كلهم في نفس اللحظات.
هنا بيدخل Jitter: عشوائية متعمّدة بتتضاف للـ delay علشان توزّعه. ورقة AWS الرسمية "Exponential Backoff and Jitter" بتوصي بصيغة Full Jitter:
delay = random(0, base * 2^attempt)يعني المحاولة التالتة (بدل ما تكون 800ms بالظبط) هتكون قيمة عشوائية بين 0 و 800ms. الـ clients بقوا موزّعين على 800ms كاملة بدل ما يتكدّسوا في millisecond واحد.
الكود: 3 استراتيجيات في 20 سطر
الكود ده بيقارن بين 3 طرق retry لنفس الـ API call الفاشلة:
// Strategy 1: ساذج — retry فوري (لا تستخدمه أبدًا)
async function naiveRetry(fn, maxAttempts = 6) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try { return await fn(); }
catch (err) { if (attempt === maxAttempts - 1) throw err; }
}
}
// Strategy 2: exponential backoff بدون jitter (أحسن، لكن لسه بيتكدّس)
async function exponentialBackoff(fn, base = 100, maxAttempts = 6) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try { return await fn(); }
catch (err) {
if (attempt === maxAttempts - 1) throw err;
const delay = base * Math.pow(2, attempt);
await new Promise(r => setTimeout(r, delay));
}
}
}
// Strategy 3: full jitter — المفضّلة عند AWS
async function backoffWithJitter(fn, base = 100, cap = 20000, maxAttempts = 6) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try { return await fn(); }
catch (err) {
if (attempt === maxAttempts - 1) throw err;
const expo = Math.min(cap, base * Math.pow(2, attempt));
const delay = Math.random() * expo; // ده السر
await new Promise(r => setTimeout(r, delay));
}
}
}لاحظ الـ cap في الاستراتيجية التالتة. من غيره، المحاولة العاشرة هتستنى أكتر من 100 ثانية، وده مبيخدمش المستخدم.
القياس الفعلي: قبل وبعد
حاكينا 10,000 client بيحاولوا يضربوا API واحد بعد انقطاع ثانيتين. الأرقام تقديرية من محاكاة بسيطة بـ Node.js، والهدف إظهار الفرق النسبي:
- Naive retry (بدون backoff): ذروة 9,800 طلب/ثانية في أول ثانية → السيرفر وقع تاني.
- Exponential بدون jitter: ذروة 4,200 طلب/ثانية عند الـ 400ms — السيرفر صامد بالعافية.
- Full Jitter: ذروة 680 طلب/ثانية موزّعة على 6 ثوان → استهلاك CPU طبيعي.
بالظبط: Full Jitter خفّضت الذروة 14 ضعف مقارنة بالـ retry الساذج، من غير ما تغيّر الـ throughput الإجمالي.
الـ trade-off اللي لازم تعرفه
أفضل طريقة مش دايمًا مجانية. Full Jitter بتكسب reliability، بتخسر predictability:
- المكسب: السيرفر مبيقعش تاني بعد restart، والـ load بيبقى ناعم.
- الخسارة: بعض الـ retries هتتنفّذ بعد 2ms (لأن random رجعت رقم صغير). لو الـ API لسه بيقوم، دي هتفشل برضه وتضيع محاولة.
- حل وسط: استخدم Decorrelated Jitter (برضه من ورقة AWS نفسها). بدل
random(0, expo)، الصيغة بتكونmin(cap, random(base, prev*3)). بتضمن حد أدنى معقول للتأخير.
متى لا تستخدم هذه الطريقة
Backoff مش مناسب في كل مكان:
- عمليات مش idempotent: زي تحويل فلوس بدون idempotency key. تكرار الطلب ممكن يسحب مرتين. في الحالة دي استخدم idempotency key قبل ما تفكّر في retry.
- أخطاء 4xx من ناحية الـ client: لو الـ API رجّع 400 أو 401، الـ retry لن ينجح أبدًا. Backoff بس على 5xx و timeouts.
- UI interactive بينتظر المستخدم: لو المستخدم ضغط زر ومستنى رد، 20 ثانية انتظار بتحرق تجربته. حدد
maxAttempts = 2وخلّي الباقي في الـ background. - عمليات لها SLA صارم: زي push notifications لحظية. في الحالة دي، استخدم
capصغير جدًا (500ms) أو ارمِها في dead letter queue.
الافتراضات والحدود
الشرح ده مبني على فرضية إن عندك أقل من 100,000 client متصل. لو بتشتغل على scale أكبر (زي ما Netflix بتعمل مع Hystrix)، محتاج تضيف Circuit Breaker فوق الـ backoff، علشان الـ clients تبطّل ضرب السيرفر أصلاً لما يكون واضح إنه ميت. ده موضوع مقال تاني لوحده.
الخطوة التالية
افتح الـ retry code عندك دلوقتي (في HTTP client أو message queue consumer) وشوف هل فيه jitter ولا لأ. لو مفيش، ضيف 10 سطور كود backoffWithJitter اللي فوق. لو بتستخدم مكتبات زي axios-retry أو p-retry، تأكد إن الخيار randomize مفعّل — بيبقى false افتراضيًا في كتير منهم.
المصادر
- AWS Architecture Blog — Exponential Backoff And Jitter (Marc Brooker) — الورقة الأصلية اللي اقترحت Full Jitter و Decorrelated Jitter بالمحاكاة.
- Google SRE Book — Handling Overload — فصل كامل عن thundering herd وكيفية الوقاية منه.
- RFC 8961 — Retransmission Timer Requirements — معيار IETF للـ backoff في بروتوكولات الـ transport.
- Microsoft Azure Architecture — Retry Pattern — توثيق رسمي للنمط مع أمثلة .NET.
- Wikipedia — Exponential Backoff — خلفية تاريخية (الخوارزمية أصلها من Ethernet 1970s).