مشكلة الـ reconnect storm
لما 800 موبايل يرجعوا متّصلين بنفس الوقت، شو بصير للسيرفر. الأنماط اللي بتصمد.
جاري تحميل المقال…
لما 800 موبايل يرجعوا متّصلين بنفس الوقت، شو بصير للسيرفر. الأنماط اللي بتصمد.
جاري تحميل المقال…
عاصفة إعادة الاتّصال هي ما يحدث حين تعود أجهزة كثيرة إلى الاتّصال في نفس اللحظة. انقطاع تيّار في مدينة، إعادة تشغيل راوتر في سلسلة مطاعم، انتقال بين أبراج يُعيد ضبط آلاف جلسات الجوّال: كلّها تنتج نفس الشكل. طوفان من العملاء، كلّ منهم يحمل queue، كلّ منهم يفتح websocket، كلّ منهم يُصدر نفس الطلب الأوّل. الخادم، الذي كان خاملاً قبل ثانية، الآن تحت حمل أكبر ممّا يراه في أسبوع عاديّ.
هذا المقال عمّا فعلناه في المرّة الأولى التي كسرتنا فيها، والأنماط التي عملت.
يستحقّ التدقيق فيما يحدث. العملاء ليسوا خبثاء؛ يفعلون ما قالت لهم الوثائق أن يفعلوه. عند إعادة الاتّصال:
في التشغيل العاديّ هذه الخطوات منتشرة عبر المستخدمين والوقت، فيرى الخادم تدفّقاً ثابتاً. في العاصفة، يطلق بضعة آلاف جهاز الخطوات الخمس كلّها في نفس نافذة الخمس ثوانٍ. الخادم يقضي تلك الخمس ثوانٍ في خدمة المصافحات؛ قاعدة البيانات تقضيها في خدمة ٥٠٠٠٠ استعلام صغير مع تنازع عالٍ على الأقفال؛ طبقة الـ websocket تقضيها في إنشاء ٥٠٠٠ اتّصال جديد. الخطوة الأبطأ هي بوّابة كلّ شيء.
في المرّة الأولى التي حدث فيها لنا، بقي الخادم قائماً لكنّ الاستعلامات تكدّست في الـ queue؛ بدأ العملاء يحصلون على timeout، بدأوا بإعادة المحاولة، وإعادة المحاولة جعلت العاصفة أسوأ. بحلول الدقيقة الثالثة كنّا نشاهد الـ queue ينمو أسرع ممّا نستطيع تصريفه. بحلول الدقيقة الخامسة صنعنا kill switch بأيدينا.
لم نحصل عليها كلّها دفعة واحدة. حصلنا على الأوّل في ذعر ذلك العصر والبقيّة على مدى الشهر التالي.
أكبر فوز منفرد. كلّ عميل، عند فقدان الاتّصال، يختار تأخيراً عشوائيّاً
في [0, jitter_max] قبل أوّل محاولة إعادة اتّصال؛ في الإخفاقات
اللاحقة ينمو التأخير أُسّياً مع jitter صغير آخر. هذا يكسر التزامن.
ألف عميل لم يعودوا يعيدون الاتّصال في الثانية ٠؛ ينتشرون عبر [0, 30]،
ويرى الخادم منحدراً بدلاً من جدار.
Duration nextReconnectDelay({required int attempt}) {
final base = math.min(30000, 500 * math.pow(1.7, attempt).toInt());
final jitterMs = math.Random().nextInt(base ~/ 4);
return Duration(milliseconds: base + jitterMs);
}ملاحظة: الـ jitter يجب أن يكون مضاعفاً تقريباً، لا جمعيّاً. إضافة ±٢٠٠ مللي ثانية إلى تأخير ٣٠ ثانية بالكاد ينعّم المنحنى؛ المضاعفة أو إضافة كسر ملحوظ يفعل.
ورثنا نقاط نهاية مزامنة لكلّ كيان (/products/list، /orders/list،
/contacts/list، …). عند إعادة الاتّصال يُصدر العميل الواحد ٤٨ من
هذه. نقطة نهاية سحب موحّدة تأخذ cursor الـ since عبر كلّ أنواع
الكيانات في طلب واحد تطوي ٤٨ طلباً إلى ١. اتّصالات أقلّ، استعلامات
أقلّ، إجراءات أقلّ.
POST /api/v1/sync/pull
{
"cursors": {
"contacts": "2026-05-08T14:22:01.123Z",
"invoices": "2026-05-08T14:22:01.500Z",
"products": "2026-05-08T14:22:00.000Z"
},
"limits": { "perEntity": 200 }
}الخادم يعيد حزمة واحدة مفهرسة بنوع الكيان. العميل يمشي في الحزمة ويطبّق بترتيب التبعيّة (جهات الاتّصال قبل الفواتير، المنتجات قبل بنود الفاتورة، إلخ).
إن كان مستأجر واحد يسيء التصرّف (أو لديه ألف جهاز)، يجب ألّا يُجوّع الباقين. بدلاً من queue Horizon عالميّة واحدة، نقسّم حسب المستأجر ونقوم بـ round-robin بين المستأجرين النشطين. مستأجر صاخب يستغرق وقتاً أطول لتصريف queue الخاصّ به؛ ضوضاؤه لا تنزف عبر.
المقايضة هي أنّك لا تستطيع استخدام autoscaling Horizon لكلّ «حمل عمل» بعد الآن، لأنّ «حمل العمل» الآن لكلّ مستأجر. يستحقّ.
نقطة نهاية السحب هي المسار البارد. إن بدأت تفشل بأكثر من ~٥٪ على
دقيقة منزلقة، نُسقط breaker على جانب الخادم ونعيد «حاول ثانية بعد
٣٠ ثانية» مهذّباً مع header Retry-After. العملاء يحترمونه. الخادم
يستعيد أنفاسه. بدون الـ breaker، كلّ سحب فاشل يُحفّز إعادة محاولة،
إعادة المحاولة تضيف حملاً، الحمل يحفّز فشلاً أكثر.
طبقة الـ websocket (Reverb في حالتنا، لكن يمكن أن تكون أيّ طبقة) لديها ميزانيّة اتّصال محدودة لكلّ عمليّة. بدون حدود، تملأ العاصفة الـ pool، تُرفض الاتّصالات الجديدة على مستوى نظام التشغيل، ورسالة الخطأ في طرف العميل تقول «Connection refused» — عديمة الفائدة. تعيين حدود اتّصال صريحة لكلّ مستأجر ومستوى أقصى لكلّ عمليّة يعني أنّ الخادم يبدأ بـ shedding بنظافة، مع header يخبر العميل لماذا.
لوحة معلومات صحّة العاصفة صغيرة:
| المقياس | صحّيّ | تحذير |
|---|---|---|
| إعادات الاتّصال في الثانية | < ٥٠/ث | > ٢٠٠/ث |
| زمن استجابة السحب p99 | < ٨٠٠ مللي ثانية | > ٣ ثوانٍ |
| معدّل تصريف الـ queue | > ١× التدفّق الداخل | < ٠٫٧× التدفّق |
| أحداث فتح breaker / ساعة | ٠ | ≥ ١ |
| استخدام الـ pool | < ٦٠٪ | > ٨٥٪ |
المثير للاهتمام هو «معدّل تصريف الـ queue ÷ معدّل التدفّق الداخل». إن كان أقلّ من ١، أنت تخسر أرضاً. إن بقي أقلّ من ١ لأكثر من دقيقة أو اثنتين، العاصفة تنتصر. الـ kill switch يُسقط في مكان ما حول ٠٫٦.
في المرّة الأولى التي حصلت فيها عاصفة، انجرّ الفريق إلى حادث غير مخطّط صباح ثلاثاء انتهى بكلفة قرابة ست ساعات مهندس واعتذار مذعور لعميل واحد. الإصلاحات الخمسة أعلاه استغرقت تقريباً أسبوعَين إجمالاً للتصميم والشحن والتحقّق. العاصفة التالية، تعامل النظام مع نفسه واكتشفنا الأمر من اللوحة، لا من العميل. هذه هي المقايضة التي تشتريها.
إن كانت محرّكات المزامنة تثير اهتمامك، صفحة السلسلة تستكمل من حيث ينتهي هذا.