> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# كيف تمنع خدمة بطيئة من إسقاط نظامك كله؟
- URL: https://arabroot.io/articles/كيف-تمنع-خدمة-بطيئة-من-اسقاط-نظامك-كله/
- Published: 2026-10-11T10:30:00.000Z
- Updated: 2026-10-11T06:00:26.000Z
- Description: لماذا تسقط خدمة بطيئة واحدة تطبيقك السليم، وكيف تحميه بالمهلة وإعادة المحاولة المحدودة وقاطع الدائرة ورفض الزائد.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, هندسة البرمجيات وتصميم الأنظمة, الاستضافة الذاتية

في دليل [تتبع الطلب البطيء](https://arabroot.io/articles/%D8%AA%D8%AA%D8%A8%D8%B9-%D8%A7%D9%84%D8%B7%D9%84%D8%A8-%D8%A7%D9%84%D8%A8%D8%B7%D9%8A%D8%A1-%D9%85%D9%86-%D8%A7%D9%84%D9%85%D8%AA%D8%B5%D9%81%D8%AD-%D8%A7%D9%84%D9%89-%D9%82%D8%A7%D8%B9%D8%AF%D8%A9-%D8%A7%D9%84%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA/#lab-external) رأينا طلباً واحداً ينتظر خدمة خارجية بلا مهلة حتى يقطعه Nginx برمز 504، وفي [الأنظمة الموزعة للمبتدئين](https://arabroot.io/articles/%D8%A7%D9%84%D8%A7%D9%86%D8%B8%D9%85%D8%A9-%D8%A7%D9%84%D9%85%D9%88%D8%B2%D8%B9%D8%A9-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86-cap-%D9%88%D8%A7%D9%84%D8%A7%D8%AA%D8%B3%D8%A7%D9%82-%D9%88%D8%A7%D9%84%D8%A7%D8%AC%D9%85%D8%A7%D8%B9/#failures) مررنا سريعاً على المهلة وإعادة المحاولة وقاطع الدائرة في فقرة لكل منها. ولكن المشكلة الحقيقية لا تظهر في طلب واحد، وإنما عندما يأتي عشرون طلباً في الثانية وكلها تنتظر الخدمة البطيئة نفسها، فتمتلئ موارد تطبيقك السليم واحداً بعد الآخر، ثم يتوقف عن الرد حتى على الصفحات التي لا علاقة لها بالخدمة البطيئة، ثم يرى موزع الحمل أنه لا يرد فيخرجه، فينتقل الحمل كله إلى النسخ الأخرى فتسقط هي أيضاً. وهذا هو الفشل المتتالي Cascading Failure، وهو من أكثر أسباب توقف الأنظمة الكبيرة شيوعاً.

وفي هذا المقال نأخذ كل نمط من أنماط الحماية ونقيس أثره بأرقام حقيقية من تجربة واحدة: خدمة أسعار نجعلها بطيئة عمداً، وتطبيق فيه عشرة خيوط Threads فقط، ومولد حمل يرسل الطلبات بمعدل ثابت كما يفعل المستخدمون.

[![](https://arabroot.io/content/images/2026/10/distributed-systems-cover-cover.webp)دليلالأنظمة الموزعة للمبتدئين: ماذا يحدث عندما تنقطع الشبكةلماذا تتصرف الأنظمة الموزعة بشكل غريب عند انقطاع الشبكة، وكيف تتفق السيرفرات على قرار واحد رغم ذلك.](https://arabroot.io/articles/%D8%A7%D9%84%D8%A7%D9%86%D8%B8%D9%85%D8%A9-%D8%A7%D9%84%D9%85%D9%88%D8%B2%D8%B9%D8%A9-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86-cap-%D9%88%D8%A7%D9%84%D8%A7%D8%AA%D8%B3%D8%A7%D9%82-%D9%88%D8%A7%D9%84%D8%A7%D8%AC%D9%85%D8%A7%D8%B9/)[![](https://arabroot.io/content/images/2026/10/slow-requests-cover-cover.webp)دليلتتبع الطلب البطيء من المتصفح إلى قاعدة البياناتكيف تأخذ طلباً واحداً بطيئاً أو فاشلاً وتعرف في أي طبقة ضاع وقته، بقياسات حقيقية في كل طبقة بدل التخمين.](https://arabroot.io/articles/%D8%AA%D8%AA%D8%A8%D8%B9-%D8%A7%D9%84%D8%B7%D9%84%D8%A8-%D8%A7%D9%84%D8%A8%D8%B7%D9%8A%D8%A1-%D9%85%D9%86-%D8%A7%D9%84%D9%85%D8%AA%D8%B5%D9%81%D8%AD-%D8%A7%D9%84%D9%89-%D9%82%D8%A7%D8%B9%D8%AF%D8%A9-%D8%A7%D9%84%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA/)[![](https://arabroot.io/content/images/2026/10/connection-pooling-cover-cover.webp)دليلConnection Pooling: لماذا الاتصال بقاعدة البيانات مكلفلماذا تظهر رسالة too many clients عندما تزيد نسخ تطبيقك، وكيف يحل مجمع الاتصالات وPgBouncer هذه المشكلة.](https://arabroot.io/articles/%D8%AA%D8%AC%D9%85%D9%8A%D8%B9-%D8%A7%D9%84%D8%A7%D8%AA%D8%B5%D8%A7%D9%84%D8%A7%D8%AA-connection-pooling-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)

وسوف نناقش في هذا المقال ما يلي:

- لماذا تسقط خدمة بطيئة نظاماً سليماً، ولماذا البطء أسوأ من التوقف الكامل، مع قانون ليتل Little's Law الذي يحسب ذلك في سطر.
- المهلة Timeout: كيف تختار قيمتها، والفرق بين مهلة الاتصال ومهلة القراءة، والمهلة الواحدة التي تنتقل مع الطلب Deadline Propagation.
- إعادة المحاولة Retry مع التأخير المتزايد Exponential Backoff والعشوائية Jitter، وكيف تتحول إلى عاصفة إعادة المحاولة Retry Storm، وميزانية إعادة المحاولة Retry Budget.
- قاطع الدائرة Circuit Breaker وحالاته الثلاث، والحواجز Bulkheads، ورفض الزائد Load Shedding، والضغط العكسي Back-pressure في الطوابير.
- تجربة عملية بثماني خطوات: نسقط التطبيق، ثم نصلحه نمطاً بعد نمط، ونقرأ الأرقام في كل مرة.

## كيف تسقط خدمة بطيئة نظاماً سليماً؟

أغلب تطبيقات الويب تخدم الطلبات من مجموعة محدودة من الخيوط أو العمليات أو الاتصالات: خيوط Tomcat في Java، و Workers في Gunicorn وPHP-FPM، والاتصالات في [مجمع الاتصالات Connection Pool](https://arabroot.io/articles/%D8%AA%D8%AC%D9%85%D9%8A%D8%B9-%D8%A7%D9%84%D8%A7%D8%AA%D8%B5%D8%A7%D9%84%D8%A7%D8%AA-connection-pooling-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)، وأمام هذه المجموعة طابور ينتظر فيه كل طلب لم يجد خيطاً فارغاً، ويصف [كتاب Google SRE في فصل الفشل المتتالي](https://sre.google/sre-book/addressing-cascading-failures/?ref=arabroot.io) هذا الشكل بأنه ما تعمل به أغلب السيرفرات التي تخصص خيطاً لكل طلب. وعدد الخيوط المشغولة في أي لحظة يحسبه قانون ليتل Little's Law في سطر واحد:

`الخيوط المشغولة = عدد الطلبات في الثانية × مدة الطلب بالثواني`

فإذا جاءك عشرون طلب دفع في الثانية، وكل طلب يسأل خدمة الأسعار التي ترد في 50 ملي ثانية، فأنت تحتاج خيطاً واحداً تقريباً (20 × 0.05)، وعندك عشرة، فالتطبيق مرتاح. والآن تتأخر خدمة الأسعار فتصبح ثلاث ثوان، والطلبات ما زالت عشرين في الثانية، فتحتاج 60 خيطاً (20 × 3) وعندك عشرة، والخمسون الباقية تنتظر في الطابور، والطابور يكبر كل ثانية بلا نهاية، وكل طلب جديد يقف خلفه، بما فيها طلب فحص الصحة `/health` الذي لا يسأل خدمة الأسعار أصلاً. والصورة التالية تبين ما حدث في الخطوة الأولى من المختبر:

![مستخدم يرسل 20 طلب checkout و5 طلبات health في الثانية إلى صندوق API: 10 THREADS, ALL WAITING فيه عشرة مستطيلات حمراء تحت كل منها 3 s، وسهم أحمر متقطع مكتوب عليه no timeout إلى سيرفر price service مع ساعة رملية و3000 ms per answer. وتحتها طابور QUEUE IN FRONT OF THE POOL من ثلاثين مستطيلاً برتقالياً وأزرق، والأزرق هو /health الذي لا يسأل خدمة الأسعار لكنه ينتظر في الطابور نفسه، وصندوق Little's law فيه 20 /s x 3 s = 60 threads busy وthe pool has 10، وسطر: في المختبر 270 من 300 طلب دفع و65 من 75 فحص صحة انتهت مهلة المستخدم عليها بعد 10 ثوان](https://arabroot.io/content/images/2026/10/resilience-patterns-01-cascade.webp)

الخيوط العشرة كلها تنتظر خدمة الأسعار، وفحص الصحة يقف في الطابور خلف طلبات لا علاقة له بها

وقد يتساءل البعض: لماذا البطء أسوأ من التوقف الكامل؟ والإجابة أن الخدمة المتوقفة تماماً ترفض الاتصال في ملي ثوان، فيفشل الطلب بسرعة ويعود الخيط إلى المجموعة، أما الخدمة البطيئة فتقبل الاتصال ثم تمسك الخيط ثلاث ثوان، فتأكل مواردك وهي تبدو سليمة. ولاحظ في الصورة أن فحص الصحة نفسه فشل، وهذا يعني أن موزع الحمل أو Kubernetes سوف يقرر أن النسخة ميتة فيخرجها أو يعيد تشغيلها، فيذهب حملها إلى النسخ الباقية التي هي أصلاً في نفس الحالة، وهكذا يتحول بطء خدمة صغيرة إلى توقف النظام كله، ولهذا نصحنا في [الأنظمة الموزعة](https://arabroot.io/articles/%D8%A7%D9%84%D8%A7%D9%86%D8%B8%D9%85%D8%A9-%D8%A7%D9%84%D9%85%D9%88%D8%B2%D8%B9%D8%A9-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86-cap-%D9%88%D8%A7%D9%84%D8%A7%D8%AA%D8%B3%D8%A7%D9%82-%D9%88%D8%A7%D9%84%D8%A7%D8%AC%D9%85%D8%A7%D8%B9/#health-checks) بألا يفحص فحص الحياة Liveness الخدمات التي يعتمد عليها التطبيق.

وهناك أثر جانبي لا يراه أحد: المستخدم الذي ينتظر عشر ثوان ثم يغلق الصفحة لا يلغي شيئاً في السيرفر، فطلبه ما زال في الطابور، وعندما يصل دوره سوف يسأل التطبيق خدمة الأسعار ويحسب الرد ويرسله إلى اتصال مغلق. وفي المختبر استمر التطبيق بعد توقف الحمل أكثر من أربعين ثانية يرسل طلبات لم يعد أصحابها ينتظرونها، وظل فحص الصحة يفشل طوال هذه المدة.

## المهلة Timeout: أول خط دفاع

كل اتصال عبر الشبكة يجب أن تكون له مهلة، والسبب أن مكتبات كثيرة تنتظر للأبد إذا لم تحدد لها مهلة، ومنها مكتبة Python القياسية: [http.client](https://docs.python.org/3/library/http.client.html?ref=arabroot.io) تستخدم المهلة العامة إذا لم تعطها مهلة، و[توثيق socket](https://docs.python.org/3/library/socket.html?ref=arabroot.io) يذكر أن هذه المهلة العامة تبدأ بقيمة None، أي بلا مهلة. وهذا ما فعله التطبيق في الخطوة الأولى من المختبر.

والمهلة نوعان، وتحتاج كليهما:

- مهلة الاتصال Connect Timeout: كم تنتظر حتى يقبل السيرفر الاتصال، وتكون قصيرة (مئات الملي ثواني داخل نفس الشبكة)، لأن السيرفر السليم يقبل الاتصال فوراً.
- مهلة القراءة Read Timeout أو مهلة الطلب كله: كم تنتظر الرد بعد أن أرسلت الطلب، وهي التي تحمي الخيوط من الخدمة البطيئة.

أما القيمة الصحيحة فتأتي من القياس وليس من التخمين، ويشرح [مقال AWS Builders' Library عن المهلات وإعادة المحاولة](https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/?ref=arabroot.io) الطريقة التي يستخدمونها: تختار نسبة مقبولة من المهلات الخاطئة، أي الطلبات التي كانت سوف تنجح لو انتظرت أكثر، مثل 0.1%، ثم تأخذ زمن الرد عند المئين المقابل للخدمة التي تتصل بها، وهو p99.9 في هذا المثال. فإذا كانت خدمة الأسعار ترد في 99.9% من الحالات خلال 250 ملي ثانية، فمهلة 300 ملي ثانية معقولة، ومهلة 30 ثانية (وهي القيمة الافتراضية في مكتبات كثيرة) لا تحميك من شيء.

وفي الخطوة الثانية من المختبر أضفنا مهلة 300 ملي ثانية فقط، والخدمة ما زالت بطيئة، فتغيرت الصورة كلها: طلبات الدفع فشلت كلها برمز 503 بعد 303 ملي ثانية بدل أن تعلق عشر ثوان، وفحص الصحة نجح 75 مرة من 75\. والسبب قانون ليتل نفسه: 20 طلباً × 0.3 ثانية = 6 خيوط، وعندنا عشرة.

### مهلة واحدة تنتقل مع الطلب

في نظام فيه أكثر من طبقة، المهلات الثابتة في كل طبقة لا تكفي، فإذا كان المستخدم ينتظر ثانيتين، والواجهة تعطي خدمة الطلبات ثانيتين، وخدمة الطلبات تعطي خدمة الأسعار ثانيتين، وخدمة الأسعار تعطي قاعدة البيانات ثانيتين، فقد تبقى قاعدة البيانات تعمل على استعلام بعد أن غادر المستخدم بثوان. والحل الذي يشرحه [توثيق gRPC عن المهلات](https://grpc.io/docs/guides/deadlines/?ref=arabroot.io) هو أن تحدد مهلة واحدة Deadline عند أول نقطة، وتنتقل مع الطلب إلى كل خدمة يستدعيها، فتأخذ كل خدمة ما بقي منها فقط، وتتوقف فوراً إذا انتهى:

![مستخدم ينتظر ثانيتين، ثم أربعة أسهم زرقاء متتالية إلى checkout API ثم orders ثم price ثم database، وفوق كل سهم المهلة الباقية: 2000 ms ثم 1850 ms ثم 1500 ms ثم 1200 ms، وفوقها ما استهلكته كل خطوة: 150 ms used و350 ms used و300 ms used. وتحتها صندوقان: أحمر fixed timeouts: 2 s, 2 s, 2 s, 2 s وأن قاعدة البيانات تبقى تعمل لمستخدم غادر منذ مدة، وأخضر one deadline, passed down with the call وأن كل خطوة تأخذ ما بقي وتتوقف فوراً عندما لا يبقى شيء](https://arabroot.io/content/images/2026/10/resilience-patterns-02-deadline.webp)

المهلة الواحدة تمنع العمل لمستخدم غادر، والأرقام في الصورة للتوضيح

وفي gRPC تنتقل المهلة تلقائياً، أما في HTTP فلا توجد ترويسة معيارية لها، فإذا احتجتها فاتفق في فريقك على ترويسة داخلية تحمل الوقت الباقي، واحسب منها مهلة كل استدعاء، ولا تقبلها من الإنترنت كما هي.

## إعادة المحاولة: علاج يتحول إلى مرض

إعادة المحاولة مفيدة عندما يكون الفشل عابراً، مثل اتصال انقطع أو سيرفر أعيد تشغيله، وهي آمنة فقط إذا كانت العملية آمنة عند التكرار Idempotent، أي أن تنفيذها مرتين يعطي نفس الأثر، وقد شرحنا ذلك بالتفصيل في [مقال Idempotency](https://arabroot.io/articles/idempotency-%D9%83%D9%8A%D9%81-%D8%AA%D8%AC%D8%B9%D9%84-%D8%A7%D9%84%D8%B9%D9%85%D9%84%D9%8A%D8%A9-%D8%A7%D9%85%D9%86%D8%A9-%D8%B9%D9%86%D8%AF-%D8%AA%D9%83%D8%B1%D8%A7%D8%B1%D9%87%D8%A7/). والحل البسيط أن تعيد المحاولة فوراً، وهو حل خطير، والسبب أن الخدمة التي فشلت غالباً فشلت لأنها مضغوطة، فإذا أعاد ألف كلاينت المحاولة في نفس اللحظة تضاعف الضغط عليها.

والحل المعروف هو التأخير المتزايد Exponential Backoff مع العشوائية Jitter، ويقارن Marc Brooker في مقال [Exponential Backoff And Jitter](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/?ref=arabroot.io) المنشور في مدونة AWS في مارس 2015 بين عدة طرق للعشوائية منها Full Jitter وEqual Jitter وDecorrelated Jitter، والطريقة التي في المختبر هي Full Jitter: تنتظر وقتاً عشوائياً بين صفر و`min(cap, base × 2^attempt)`، فتتوزع المحاولات بدل أن تضرب الخدمة معاً. وقد رأيت مثالاً كاملاً لها مع مفتاح منع التكرار في [الأنظمة الموزعة](https://arabroot.io/articles/%D8%A7%D9%84%D8%A7%D9%86%D8%B8%D9%85%D8%A9-%D8%A7%D9%84%D9%85%D9%88%D8%B2%D8%B9%D8%A9-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86-cap-%D9%88%D8%A7%D9%84%D8%A7%D8%AA%D8%B3%D8%A7%D9%82-%D9%88%D8%A7%D9%84%D8%A7%D8%AC%D9%85%D8%A7%D8%B9/#retries)، فلن نكرره.

ولكن التأخير وحده لا يمنع المشكلة الأكبر، وهي أن كل طبقة تعيد المحاولة. ويضرب [مقال AWS Builders' Library](https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/?ref=arabroot.io) مثالاً بطلب يمر بخمس طبقات من الخدمات وينتهي بقاعدة بيانات، وكل طبقة تحاول ثلاث مرات، فإذا بدأت قاعدة البيانات تفشل تحت الحمل زاد الحمل عليها 243 ضعفاً، لأن المحاولات تتضاعف في كل طبقة، وينصح لذلك بأن تعيد المحاولة في نقطة واحدة فقط من السلسلة. والصورة التالية تبين المثال، وتحته ما قسناه في المختبر:

![في الأعلى 3 TRIES AT EACH OF 5 LAYERS: خمسة صناديق متتالية API x3 ثم service B x9 ثم service C x27 ثم service D x81 ثم database x243 باللون الأحمر، ومثال AWS Builders' Library أن الحمل على قاعدة البيانات يزيد 243 مرة. وفي الأسفل LAB: 300 CHECKOUTS, PRICE SERVICE SLOW ثلاثة أعمدة: no retries 300، و3 retries 886 بالأحمر، وretry budget 10% 330 بالأخضر، وهي عدد الطلبات التي وصلت إلى خدمة الأسعار](https://arabroot.io/content/images/2026/10/resilience-patterns-03-retry-storm.webp)

ثلاث محاولات إضافية في طبقة واحدة ضاعفت الحمل على الخدمة المتعبة ثلاث مرات تقريباً، والميزانية أعادته قريباً من الأصل

وفي الخطوة الثالثة من المختبر أضفنا ثلاث محاولات إضافية إلى المهلة نفسها، فوصل إلى خدمة الأسعار 886 طلباً بدل 300، وعاد التطبيق إلى السقوط، لأن كل طلب أصبح يمسك الخيط 1.2 ثانية (4 × 0.3)، والحساب 20 × 1.2 = 24 خيطاً، وعندنا عشرة، ففشل فحص الصحة مرة أخرى في 37 حالة من 75\. أي أن إعادة المحاولة ألغت ما أصلحته المهلة.

والحل الذي يصفه [كتاب Google SRE في فصل التعامل مع الحمل الزائد](https://sre.google/sre-book/handling-overload/?ref=arabroot.io) هو ميزانية إعادة المحاولة Retry Budget على مستويين: حد أقصى ثلاث محاولات للطلب الواحد، وحد لكل كلاينت لا تزيد فيه إعادة المحاولة عن 10% من طلباته، ويذكر الكتاب أن الحمل في حالة الفشل يقترب من ثلاثة أضعاف بالحد الأول وحده، وينزل إلى 1.1 ضعف تقريباً بإضافة الثاني. وفي الخطوة الرابعة من المختبر أضفنا ميزانية 10% فوصل إلى خدمة الأسعار 330 طلباً، ونجح فحص الصحة 75 مرة من 75.

📌

في 7 ديسمبر 2021 تعطلت خدمات كثيرة في منطقة US-EAST-1 لساعات، وبحسب [تقرير AWS الرسمي](https://aws.amazon.com/message/12721/?ref=arabroot.io) بدأ الأمر في الساعة 7:30 صباحاً بتوقيت المحيط الهادئ بعملية آلية لزيادة سعة خدمة في الشبكة الرئيسية، فسببت سلوكاً غير متوقع لعدد كبير من الكلاينتات في الشبكة الداخلية، وقفزة في الاتصالات أغرقت أجهزة الشبكة بين الشبكتين، فزاد التأخير والأخطاء، فزادت محاولات الاتصال وإعادة المحاولة، فاستمر الازدحام. ويذكر التقرير أن هذه الكلاينتات فيها سلوك تراجع Back-off مختبر، ولكن خطأ كامناً منعها من التراجع بما يكفي في هذا الحدث. والدرس أن آلية التراجع نفسها تحتاج اختباراً تحت حمل حقيقي، لأنها لا تعمل إلا في اليوم الأسوأ.

ولاحظ شيئاً آخر في المختبر: التأخير بين المحاولات في طلب متزامن يمسك الخيط طوال فترة الانتظار، لذلك في المسار الذي ينتظر فيه مستخدم اجعل المحاولات قليلة (واحدة غالباً) وقصيرة، واترك المحاولات الطويلة للأعمال في الخلفية التي تمر عبر [طابور يعيد المحاولة](https://arabroot.io/articles/%D9%85%D9%81%D8%A7%D9%87%D9%8A%D9%85-%D8%A7%D9%84%D8%B7%D9%88%D8%A7%D8%A8%D9%8A%D8%B1-queue-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#retry).

## قاطع الدائرة Circuit Breaker

المهلة والميزانية تحددان كم تخسر في كل طلب، ولكن إذا كانت الخدمة متوقفة منذ دقائق فما فائدة أن يجربها كل طلب وينتظر 300 ملي ثانية ليفشل؟ وقاطع الدائرة، كما يشرحه Martin Fowler في [مقاله عن Circuit Breaker](https://martinfowler.com/bliki/CircuitBreaker.html?ref=arabroot.io)، يعمل مثل القاطع الكهربائي في بيتك، وله ثلاث حالات:

- مغلق Closed: الطلبات تمر، والقاطع يعد النجاح والفشل في نافذة من آخر الطلبات.
- مفتوح Open: إذا تجاوزت نسبة الفشل الحد فتح القاطع، فيفشل كل طلب فوراً دون أن يخرج من التطبيق، فتأخذ الخدمة المتعبة وقتاً لتتعافى، وتعود خيوطك فوراً.
- نصف مفتوح Half-Open: بعد مدة محددة يسمح القاطع بعدد قليل من الطلبات التجريبية، فإذا نجحت أغلق، وإذا فشل أحدها فتح من جديد.

والقاطع في المختبر يفتح إذا فشل نصف آخر عشرة طلبات، ويبقى مفتوحاً خمس ثوان، ثم يسمح بثلاثة طلبات تجريبية، والصورة التالية تبين حالاته مع الأوقات الحقيقية من سجل التطبيق:

![ثلاث دوائر: CLOSED بالأخضر وفيها calls pass, failures counted، وOPEN بالأحمر وفيها fail at once, 0 calls out، وHALF-OPEN بالبرتقالي وفيها 3 trial calls only. سهم أحمر من CLOSED إلى OPEN مكتوب عليه 5 of last 10 failed، وسهم من OPEN إلى HALF-OPEN مكتوب عليه after 5 s، وسهم أحمر من HALF-OPEN إلى OPEN مكتوب عليه a trial failed، وقوس أخضر من HALF-OPEN إلى CLOSED مكتوب عليه 3 trials succeeded. وفوقها أوقات المختبر: open at 3.37 s، ثم half-open 8.72 s وopen again 9.02 s، ثم half-open 14.17 s وclosed 14.32 s](https://arabroot.io/content/images/2026/10/resilience-patterns-04-breaker.webp)

القاطع فتح، ثم جرب والخدمة ما زالت بطيئة ففتح مرة أخرى، ثم جرب بعد إصلاحها فأغلق

والأرقام الافتراضية في المكتبات أكبر بكثير من المختبر، فمثلاً [توثيق Resilience4j](https://resilience4j.readme.io/docs/circuitbreaker?ref=arabroot.io) في Java يذكر أن حد الفشل الافتراضي 50%، وأن القاطع لا يحسب النسبة قبل 100 طلب، وأنه يبقى مفتوحاً 60 ثانية قبل أن يجرب، ويمكنه أيضاً أن يفتح بسبب الطلبات البطيئة وليس الفاشلة فقط. لذلك اضبط هذه القيم على حجم حركتك الحقيقي، فقاطع يحتاج 100 طلب ليقرر لن يفتح أبداً على خدمة تستقبل طلباً كل ثانية.

والسؤال المهم هو ماذا تفعل عندما يكون القاطع مفتوحاً، ولا تقم بإظهار خطأ عام للمستخدم إذا كان هناك بديل معقول: آخر سعر محفوظ في ال Cache مع ملاحظة، أو إخفاء قسم «منتجات مقترحة» بدل إسقاط الصفحة، أو قبول الطلب ووضعه في طابور. وهذا ما يسمى التدهور المتدرج Graceful Degradation، وقراره قرار عمل وليس قراراً تقنياً، فاسأل صاحب المنتج: هل نبيع بسعر عمره خمس دقائق أم نرفض البيع؟

## الحواجز Bulkheads: لا تجعل خدمة واحدة تأخذ كل شيء

اسم الحواجز من الجدران التي تقسم السفينة إلى غرف، فإذا دخل الماء غرفة لم تغرق السفينة كلها. وفي تطبيقك تعني أن تعطي كل خدمة خارجية حداً أقصى من الخيوط أو الاتصالات المتزامنة، فإذا علقت خدمة الأسعار أخذت حصتها فقط، وبقيت الخيوط الأخرى لفحص الصحة وبقية الصفحات. وفي الخطوة السادسة من المختبر لم نضع أي مهلة، ووضعنا حاجزاً يسمح بأربعة طلبات فقط إلى خدمة الأسعار في نفس الوقت، والطلب الخامس يفشل فوراً، فنجح فحص الصحة 75 مرة من 75 رغم أن الخدمة بطيئة ولا توجد مهلة، كما في الجزء الأيسر من الصورة التالية:

![لوحتان: في اليسار BULKHEAD: 4 SEATS FOR THE PRICE SERVICE، عشرة خيوط منها أربعة برتقالية مكتوب تحتها max 4 wait for prices وستة خضراء مكتوب تحتها 6 stay free for the rest، ثم أرقام المختبر: no timeout, price service 3000 ms و/health: 75 of 75 ok, p99 40 ms و/checkout: 280 refused at once. وفي اليمين LOAD SHEDDING: A SHORT QUEUE مع 80 checkout/s والسعة نحو 50/s: صندوق أحمر unbounded queue فيه checkout p50 4974 ms, p99 9534 ms وhealth p50 4728 ms وeveryone is served, everyone is late، وصندوق أخضر at most 20 waiting, the rest get 503 + Retry-After فيه 759 served, p50 535 ms, p99 608 ms و441 refused at once، و34 health checks were refused too فارفض بحسب الأولوية وليس عشوائياً](https://arabroot.io/content/images/2026/10/resilience-patterns-05-bulkhead-shedding.webp)

الحاجز يحمي بقية التطبيق من خدمة واحدة، والطابور القصير يحمي المستخدمين المقبولين من انتظار بلا نهاية

والحاجز والمهلة يكملان بعضهما ولا يغني أحدهما عن الآخر: المهلة تحدد كم يمسك الطلب الواحد الخيط، والحاجز يحدد كم خيطاً تأخذ الخدمة الواحدة مهما حدث. وأبسط صورة للحاجز تملكها اليوم دون كود هي مجمع اتصالات منفصل لكل قاعدة بيانات أو خدمة، بدل مجمع واحد مشترك.

## رفض الزائد Load Shedding: الرفض السريع أرحم من الانتظار

حتى لو كانت كل الخدمات سليمة، فلكل تطبيق سعة، وفي المختبر جعلنا خدمة الأسعار ترد في 200 ملي ثانية، فسعة الخيوط العشرة نحو 50 طلباً في الثانية (10 ÷ 0.2)، ثم أرسلنا 80 طلباً في الثانية. وبطابور بلا حد خدم التطبيق الطلبات كلها، ولكن الوسيط وصل إلى خمس ثوان، وأبطأ الطلبات إلى تسع ثوان ونصف، أي أن الجميع خدموا والجميع تأخروا، وفي الواقع كان أغلبهم سيغادر قبل أن يصله الرد. ويقول [كتاب Google SRE](https://sre.google/sre-book/addressing-cascading-failures/?ref=arabroot.io) إن الأفضل للأنظمة ذات الحركة الثابتة أن يكون الطابور قصيراً مقارنة بعدد الخيوط.

وفي الخطوة السابعة جعلنا الطابور 20 طلباً كحد أقصى، والطلب الزائد يرد عليه فوراً برمز 503 مع الترويسة `Retry-After`، وهي الترويسة التي تخبر الكلاينت كم ينتظر قبل أن يعيد الطلب كما في [المواصفة RFC 9110](https://www.rfc-editor.org/rfc/rfc9110?ref=arabroot.io)، فخدم التطبيق 759 طلباً بوسيط 535 ملي ثانية، ورفض 441 فوراً. ولاحظ أن 34 من فحوص الصحة رفضت أيضاً، لأن الرفض في المختبر لا يفرق بين الطلبات، وهذا خطأ في التصميم عن قصد لتراه: ارفض بحسب الأولوية، ففحص الصحة وطلبات الدفع تأخذ الأولوية على التقارير وصفحات البحث.

والفرق بين 503 و429 مهم: الرمز 503 يعني أن السيرفر نفسه مضغوط الآن، أما 429 فيعني أن هذا الكلاينت بالذات تجاوز حده، وهو موضوع [تحديد معدل الطلبات Rate Limiting](https://arabroot.io/articles/%D8%AA%D8%AD%D8%AF%D9%8A%D8%AF-%D9%85%D8%B9%D8%AF%D9%84-%D8%A7%D9%84%D8%B7%D9%84%D8%A8%D8%A7%D8%AA-rate-limiting-%D9%88%D8%AE%D9%88%D8%A7%D8%B1%D8%B2%D9%85%D9%8A%D8%A7%D8%AA%D9%87/).

## الضغط العكسي Back-pressure في الطوابير

رفض الزائد يرمي العمل، أما الضغط العكسي Back-pressure فيبطئ من يرسله. والفكرة أن المستهلك البطيء يجب أن يكون له طريق ليقول للمنتج «تمهل»، وإلا تراكمت الرسائل في الذاكرة حتى ينهار من يحملها. وأقدم مثال تعرفه هو TCP نفسه، فالمستقبل يعلن حجم النافذة التي يستطيع استقبالها، والمرسل لا يرسل أكثر منها. وفي الطوابير:

- الطابور المحدود Bounded Queue: كل طابور في الذاكرة داخل تطبيقك يجب أن يكون له حد، وعند امتلائه إما أن ينتظر المنتج، أو يرفض الرسالة ويخبر من أرسلها، أما الطابور بلا حد فهو تأجيل لانهيار الذاكرة.
- الوسيط Broker: يذكر [توثيق RabbitMQ عن التحكم في التدفق](https://www.rabbitmq.com/docs/flow-control?ref=arabroot.io) أنه يطبق ضغطاً عكسياً على اتصالات النشر، فيبطئ الاتصالات التي تنشر أسرع مما تستطيع الطوابير استيعابه، حتى لا تنمو الذاكرة بلا حد، ولا يحتاج ذلك أي إعداد.
- جهة المستهلك: ال Prefetch في RabbitMQ هو حد الرسائل التي تكون مع ال Worker قبل أن يؤكدها، وقد شرحناه في [الطابور للمبتدئين](https://arabroot.io/articles/%D9%85%D9%81%D8%A7%D9%87%D9%8A%D9%85-%D8%A7%D9%84%D8%B7%D9%88%D8%A7%D8%A8%D9%8A%D8%B1-queue-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#competing-consumers)، وهو ضغط عكسي من المستهلك على الوسيط.
- بين الخدمات عبر HTTP: الرمز 503 أو 429 مع `Retry-After` هو طريقة السيرفر ليقول «تمهل»، بشرط أن يحترمه الكلاينت ولا يعيد المحاولة فوراً.

والقاعدة العامة: كل مكان في النظام يمكن أن يتجمع فيه عمل يجب أن يكون له حد، وقرار واضح لما يحدث عند الوصول إليه، فإما أن تبطئ من يرسل، أو ترفض بسرعة وتخبره، أما أن تقبل كل شيء وتعده بالرد فهذا هو الخيار الوحيد الخاطئ.

## التجربة العملية: نسقط التطبيق ثم نصلحه

التجربة فيها ثلاثة برامج Python صغيرة دون أي مكتبة خارجية، تعمل في Containers على شبكة واحدة: خدمة الأسعار `dep.py` التي نتحكم في بطئها وفشلها أثناء العمل، والتطبيق `api.py` الذي يخدم الطلبات من عشرة خيوط ونشغل فيه أنماط الحماية بمتغيرات البيئة، ومولد الحمل `load.py` الذي يرسل الطلبات بمعدل ثابت سواء انتهت الطلبات السابقة أم لا، وهذا مهم لأن المستخدمين الحقيقيين لا ينتظرون بعضهم، ويتخلى عن الطلب بعد عشر ثوان. والأرقام كلها من جهاز واحد، وهي للمقارنة بين الإعدادات فقط وليست قياساً لأداء أي شيء.

### ملفات التجربة

الملف الأول `dep.py`، خدمة الأسعار:

```python
# dep.py: the dependency (a "price service") that we can make slow or broken on purpose.
#   GET /price               answers after the current delay (or fails with 500)
#   GET /mode?delay=50&fail=0 changes the behaviour at runtime
#   GET /stats[?reset=1]     how many /price requests arrived
import json, threading, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs

mode = {"delay_ms": 50, "fail": False}
received = 0
lock = threading.Lock()

class H(BaseHTTPRequestHandler):
    def reply(self, code, body):
        data = json.dumps(body).encode()
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    def do_GET(self):
        global received
        u = urlparse(self.path); q = parse_qs(u.query)
        if u.path == "/price":
            with lock:
                received += 1
            time.sleep(mode["delay_ms"] / 1000)
            if mode["fail"]:
                return self.reply(500, {"error": "price db down"})
            return self.reply(200, {"sku": "A1", "price": 150})
        if u.path == "/mode":
            mode["delay_ms"] = int(q.get("delay", [mode["delay_ms"]])[0])
            mode["fail"] = q.get("fail", ["0"])[0] == "1"
            return self.reply(200, mode)
        if u.path == "/stats":
            with lock:
                n = received
                if "reset" in q:
                    received = 0
            return self.reply(200, {"received": n})
        self.reply(404, {"error": "not found"})

    def log_message(self, *a):
        pass

ThreadingHTTPServer.daemon_threads = True
ThreadingHTTPServer.request_queue_size = 1024
ThreadingHTTPServer(("0.0.0.0", 8080), H).serve_forever()
```

الملف الثاني `api.py`، وفيه كل الأنماط التي شرحناها، وكل نمط مطفأ حتى تشغله بمتغير بيئة:

```python
# api.py: the service we protect. A fixed pool of WORKERS threads serves every request,
# like a thread-per-request app server with a maximum pool size.
#   GET /checkout  calls the dependency (dep:8080/price) with the protections switched on below
#   GET /health    answers at once, it never touches the dependency
# Settings (environment variables):
#   WORKERS=10       threads in the pool
#   TIMEOUT=0        seconds to wait for the dependency, 0 = wait forever (the library default)
#   RETRIES=0        extra attempts after a failure
#   BACKOFF=0        1 = exponential backoff with full jitter between attempts
#   RETRY_BUDGET=0   0 = no budget, 0.1 = retries may be at most 10% of the calls
#   BREAKER=0        1 = circuit breaker in front of the dependency
#   BULKHEAD=0       N = at most N calls to the dependency at the same time, the rest fail at once
#   MAX_QUEUE=0      N = at most N requests waiting for a thread, the rest get 503 + Retry-After
import http.client, json, os, random, socket, socketserver, threading, time
from collections import deque
from concurrent.futures import ThreadPoolExecutor
from http.server import BaseHTTPRequestHandler

WORKERS = int(os.getenv("WORKERS", "10"))
TIMEOUT = float(os.getenv("TIMEOUT", "0")) or None
RETRIES = int(os.getenv("RETRIES", "0"))
BACKOFF = os.getenv("BACKOFF", "0") == "1"
RETRY_BUDGET = float(os.getenv("RETRY_BUDGET", "0"))
BREAKER = os.getenv("BREAKER", "0") == "1"
BULKHEAD = int(os.getenv("BULKHEAD", "0"))
MAX_QUEUE = int(os.getenv("MAX_QUEUE", "0"))
T0 = time.monotonic()

def log(msg):
    print(f"[{time.monotonic() - T0:6.2f}s] {msg}", flush=True)

class Breaker:
    """closed -> open after 50% failures in the last 10 calls (at least 10 calls),
    open -> half-open after 5 s, half-open lets 3 trial calls through:
    3 successes -> closed, 1 failure -> open again."""
    def __init__(self, window=10, threshold=0.5, open_seconds=5, trials=3):
        self.window, self.threshold, self.open_seconds, self.trials = window, threshold, open_seconds, trials
        self.state, self.results, self.opened_at, self.in_trial, self.trial_ok = "closed", deque(maxlen=window), 0, 0, 0
        self.lock = threading.Lock()

    def allow(self):
        with self.lock:
            if self.state == "open" and time.monotonic() - self.opened_at >= self.open_seconds:
                self._go("half-open"); self.in_trial = self.trial_ok = 0
            if self.state == "open":
                return False
            if self.state == "half-open":
                if self.in_trial >= self.trials:
                    return False
                self.in_trial += 1
            return True

    def record(self, ok):
        with self.lock:
            if self.state == "half-open":
                if not ok:
                    self._open(); return
                self.trial_ok += 1
                if self.trial_ok >= self.trials:
                    self._go("closed"); self.results.clear()
                return
            self.results.append(ok)
            fails = self.results.count(False)
            if len(self.results) >= self.window and fails / len(self.results) >= self.threshold:
                self._open()

    def _open(self):
        self.opened_at = time.monotonic(); self._go("open")

    def _go(self, s):
        if s != self.state:
            log(f"breaker {self.state} -> {s}")
        self.state = s

breaker = Breaker() if BREAKER else None
bulkhead = threading.BoundedSemaphore(BULKHEAD) if BULKHEAD else None
calls = retries = 0
count_lock = threading.Lock()

class Fail(Exception):
    pass

def call_dep_once():
    conn = http.client.HTTPConnection("dep", 8080, timeout=TIMEOUT)
    try:
        conn.request("GET", "/price")
        r = conn.getresponse(); body = r.read()
        if r.status != 200:
            raise Fail(f"dep {r.status}")
        return json.loads(body)
    except (socket.timeout, TimeoutError):
        raise Fail("timeout")
    except OSError as e:
        raise Fail(type(e).__name__)
    finally:
        conn.close()

def retry_allowed():
    global retries
    with count_lock:
        if RETRY_BUDGET and retries >= RETRY_BUDGET * calls:
            return False
        retries += 1
        return True

def call_dep():
    global calls
    with count_lock:
        calls += 1
    for attempt in range(RETRIES + 1):
        if breaker and not breaker.allow():
            raise Fail("breaker open")
        if bulkhead and not bulkhead.acquire(blocking=False):
            raise Fail("bulkhead full")
        try:
            result = call_dep_once()
            if breaker: breaker.record(True)
            return result
        except Fail:
            if breaker: breaker.record(False)
            if attempt == RETRIES or not retry_allowed():
                raise
        finally:
            if bulkhead: bulkhead.release()
        if BACKOFF:
            time.sleep(random.uniform(0, min(2.0, 0.1 * 2 ** attempt)))   # full jitter

class H(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.0"
    def reply(self, code, body, extra=()):
        data = json.dumps(body).encode()
        self.send_response(code)
        for k, v in extra:
            self.send_header(k, v)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    def do_GET(self):
        if self.path == "/health":
            return self.reply(200, {"status": "ok"})
        if self.path == "/checkout":
            try:
                return self.reply(200, {"order": "ok", "price": call_dep()["price"]})
            except Fail as e:
                return self.reply(503, {"error": str(e)})
        self.reply(404, {"error": "not found"})

    def log_message(self, *a):
        pass

waiting = 0
wait_lock = threading.Lock()

class PoolServer(socketserver.TCPServer):
    """Accepts connections on one thread and hands them to a fixed pool, like Tomcat or Gunicorn threads."""
    allow_reuse_address = True
    request_queue_size = 1024
    pool = ThreadPoolExecutor(max_workers=WORKERS)

    def process_request(self, request, client_address):
        global waiting
        with wait_lock:
            if MAX_QUEUE and waiting >= MAX_QUEUE:
                shed = True
            else:
                shed = False; waiting += 1
        if shed:   # load shedding: answer now, from the accept thread, instead of queueing
            request.sendall(b"HTTP/1.0 503 Service Unavailable\r\nRetry-After: 1\r\nContent-Length: 0\r\n\r\n")
            self.shutdown_request(request)
            return
        self.pool.submit(self.work, request, client_address)

    def work(self, request, client_address):
        global waiting
        with wait_lock:
            waiting -= 1
        try:
            self.finish_request(request, client_address)
        finally:
            self.shutdown_request(request)

log(f"api: WORKERS={WORKERS} TIMEOUT={TIMEOUT} RETRIES={RETRIES} BACKOFF={BACKOFF} "
    f"RETRY_BUDGET={RETRY_BUDGET} BREAKER={BREAKER} BULKHEAD={BULKHEAD} MAX_QUEUE={MAX_QUEUE}")
PoolServer(("0.0.0.0", 8000), H).serve_forever()
```

في الكود أعلاه لاحظ التالي:

- الكلاس `PoolServer` يقلد سيرفرات التطبيقات المعتادة: خيط واحد يقبل الاتصالات ويضعها في طابور أمام عشرة خيوط، و`MAX_QUEUE` يحدد طول هذا الطابور، والرفض يحدث من خيط القبول نفسه دون أن ينتظر خيطاً فارغاً.
- مع `TIMEOUT=0` تنشئ `http.client.HTTPConnection` الاتصال بلا مهلة، وهذا هو السلوك الافتراضي للمكتبة.
- القاطع والحاجز يفحصان قبل كل محاولة، فالمحاولة المكررة لا تتجاوزهما.
- الميزانية في `retry_allowed` تقارن عدد إعادات المحاولة بعدد الطلبات منذ تشغيل التطبيق، وفي الإنتاج تكون نافذة زمنية متحركة.

الملف الثالث `load.py`، مولد الحمل:

```python
# load.py: an open-loop load generator. New requests keep arriving at a fixed rate whether the
# old ones finished or not, which is how real users behave.
#   python load.py --checkout 20 --health 5 --seconds 15 [--at 8 --call http://dep:8080/mode?delay=50]
import argparse, http.client, statistics, threading, time, urllib.request
from collections import Counter, defaultdict

p = argparse.ArgumentParser()
p.add_argument("--checkout", type=float, default=20)   # requests per second to /checkout
p.add_argument("--health", type=float, default=5)      # requests per second to /health
p.add_argument("--seconds", type=float, default=15)
p.add_argument("--timeout", type=float, default=10)    # how long a user waits before giving up
p.add_argument("--at", type=float)                     # at this second ...
p.add_argument("--call")                               # ... call this URL (to fix or break the dependency)
a = p.parse_args()

results = defaultdict(list)    # path -> [(seconds, outcome)]
lock = threading.Lock()
threads = []

def one(path):
    t = time.monotonic()
    conn = http.client.HTTPConnection("api", 8000, timeout=a.timeout)
    try:
        conn.request("GET", path)
        r = conn.getresponse(); r.read()
        outcome = str(r.status)
    except TimeoutError:
        outcome = "client gave up"
    except OSError as e:
        outcome = type(e).__name__
    finally:
        conn.close()
    with lock:
        results[path].append((time.monotonic() - t, outcome))

def stream(path, rate):
    if rate <= 0:
        return
    start, i = time.monotonic(), 0
    while (due := start + i / rate) < start + a.seconds:
        time.sleep(max(0, due - time.monotonic()))
        th = threading.Thread(target=one, args=(path,)); th.start(); threads.append(th); i += 1

def trigger():
    time.sleep(a.at)
    urllib.request.urlopen(a.call).read()
    print(f"[{a.at:.0f}s] called {a.call}", flush=True)

ss = [threading.Thread(target=stream, args=("/checkout", a.checkout)), threading.Thread(target=stream, args=("/health", a.health))]
if a.at is not None:
    ss.append(threading.Thread(target=trigger))
for s in ss: s.start()
for s in ss: s.join()
for th in list(threads): th.join()

def pct(xs, q):
    xs = sorted(xs); return xs[min(len(xs) - 1, int(q * len(xs)))]

print(f"{'path':10} {'sent':>5} {'200':>5} {'503':>5} {'gave up':>8} {'p50 ms':>8} {'p99 ms':>8} {'max ms':>8}")
for path in ("/checkout", "/health"):
    rs = results[path]
    if not rs: continue
    c = Counter(o for _, o in rs); ms = [s * 1000 for s, _ in rs]
    other = sum(v for k, v in c.items() if k not in ("200", "503", "client gave up"))
    print(f"{path:10} {len(rs):5} {c['200']:5} {c['503']:5} {c['client gave up']:8} {pct(ms, .5):8.0f} {pct(ms, .99):8.0f} {max(ms):8.0f}"
          + (f"  other: {dict((k, v) for k, v in c.items() if k not in ('200', '503', 'client gave up'))}" if other else ""))
```

وأخيراً `step.sh` الذي يعيد تشغيل التطبيق بالإعدادات التي نريدها، ويضبط خدمة الأسعار، ويشغل الحمل، ثم يطبع ما وصل إلى خدمة الأسعار وسجل التطبيق:

```bash
#!/bin/sh
# step.sh "<api env vars>" "<dep mode>" <load.py args...>
#   restarts the api with the given settings, sets the dependency's mode, runs the load and prints the api log
# example: ./step.sh "TIMEOUT=0.3" "delay=3000" --checkout 20 --health 5 --seconds 15
set -e
ENV="$1"; MODE="$2"; shift 2
docker rm -f lab-resil-api >/dev/null 2>&1 || true
ARGS=""; for kv in $ENV; do ARGS="$ARGS -e $kv"; done
docker run -d --name lab-resil-api --network lab-resil-net --network-alias api -p 127.0.0.1:25080:8000 \
  $ARGS -v "$PWD/api.py:/api.py:ro" python:3.13.16-slim python -u /api.py >/dev/null
sleep 1
docker exec lab-resil-dep python -c "import urllib.request as u; u.urlopen('http://localhost:8080/mode?$MODE').read(); u.urlopen('http://localhost:8080/stats?reset=1').read()"
docker run --rm --name lab-resil-load --network lab-resil-net -v "$PWD/load.py:/load.py:ro" \
  python:3.13.16-slim python -u /load.py "$@"
echo "--- dependency received:"; docker exec lab-resil-dep python -c "import urllib.request as u; print(u.urlopen('http://localhost:8080/stats').read().decode())"
echo "--- api log:"; docker logs lab-resil-api 2>&1
```

والآن ننشئ الشبكة ونشغل خدمة الأسعار مرة واحدة لكل الخطوات:

```bash
docker network create lab-resil-net
docker run -d --name lab-resil-dep --network lab-resil-net --network-alias dep \
  -v "$PWD/dep.py:/dep.py:ro" python:3.13.16-slim python -u /dep.py
```

💡

إذا كنت تستخدم Git Bash على Windows فشغل `export MSYS_NO_PATHCONV=1` قبل الأوامر، لأنه يحول المسار `/load.py` في الأمر إلى مسار داخل مجلد Git فيفشل التشغيل، وهذا ما حدث معنا في أول تشغيل.

### الخطوة 0: كل شيء سليم

خدمة الأسعار ترد في 50 ملي ثانية، ولا توجد أي حماية، و20 طلب دفع و5 فحوص صحة في الثانية لمدة 15 ثانية:

```bash
./step.sh "" "delay=50&fail=0" --checkout 20 --health 5 --seconds 15
```

```text
path        sent   200   503  gave up   p50 ms   p99 ms   max ms
/checkout    300   300     0        0       54       67      105
/health       75    75     0        0        2       34       34
--- dependency received:
{"received": 300}
--- api log:
[  0.00s] api: WORKERS=10 TIMEOUT=None RETRIES=0 BACKOFF=False RETRY_BUDGET=0.0 BREAKER=False BULKHEAD=0 MAX_QUEUE=0
```

كل الطلبات نجحت، والوسيط 54 ملي ثانية، وهو زمن خدمة الأسعار تقريباً، وهذا هو المرجع الذي نقارن به.

### الخطوة 1: خدمة الأسعار بطيئة، ولا توجد مهلة

```bash
./step.sh "" "delay=3000&fail=0" --checkout 20 --health 5 --seconds 15
```

والمخرج سوف يكون كما يلي:

```text
path        sent   200   503  gave up   p50 ms   p99 ms   max ms
/checkout    300    30     0      270    10010    10031    10073
/health       75    10     0       65    10010    10080    10080
--- dependency received:
{"received": 100}
--- api log:
[  0.00s] api: WORKERS=10 TIMEOUT=None RETRIES=0 BACKOFF=False RETRY_BUDGET=0.0 BREAKER=False BULKHEAD=0 MAX_QUEUE=0
```

في المخرج أعلاه لاحظ التالي:

- من 300 طلب دفع نجح 30 فقط، وتخلى المستخدم عن 270 بعد عشر ثوان.
- فحص الصحة الذي لا يسأل خدمة الأسعار أصلاً فشل في 65 من 75، وهذا ما سوف يراه موزع الحمل فيخرج النسخة.
- العدد 30 ليس صدفة: عشرة خيوط تنهي طلباً كل ثلاث ثوان، أي نحو ثلاثة طلبات في الثانية، وخلال عشر ثوان تقريباً ينتهي 30 طلباً قبل أن يتخلى أصحابها.

والآن ننتظر بعد انتهاء الحمل ونسأل كل عشرين ثانية تقريباً: كم طلباً وصل إلى خدمة الأسعار، وهل يرد فحص الصحة خلال خمس ثوان؟

```bash
for i in 1 2 3 4; do
  docker exec lab-resil-dep python -c "import urllib.request as u; print(u.urlopen('http://localhost:8080/stats').read().decode())"
  docker run --rm --network lab-resil-net curlimages/curl:8.11.1 -s -o /dev/null -w "health %{http_code} %{time_total}s\n" -m 5 http://api:8000/health || echo "health timeout"
  sleep 15
done
```

```text
{"received": 100}
health 000 5.001830s
health timeout
{"received": 204}
health 000 5.002775s
health timeout
{"received": 280}
health 200 0.001739s
{"received": 300}
health 200 0.001704s
```

الحمل توقف، والمستخدمون غادروا كلهم، ولكن التطبيق استمر يرسل طلباتهم إلى خدمة الأسعار حتى أرسل الثلاثمئة كلها، وبقي فحص الصحة يفشل أكثر من أربعين ثانية بعد توقف الحمل، أي أن النسخة كانت ستبقى خارج موزع الحمل وقتاً أطول من العطل نفسه.

### الخطوة 2: مهلة 300 ملي ثانية

```bash
./step.sh "TIMEOUT=0.3" "delay=3000&fail=0" --checkout 20 --health 5 --seconds 15
```

```text
path        sent   200   503  gave up   p50 ms   p99 ms   max ms
/checkout    300     0   300        0      303      311      339
/health       75    75     0        0        2       21       21
--- dependency received:
{"received": 300}
--- api log:
[  0.00s] api: WORKERS=10 TIMEOUT=0.3 RETRIES=0 BACKOFF=False RETRY_BUDGET=0.0 BREAKER=False BULKHEAD=0 MAX_QUEUE=0
```

طلبات الدفع ما زالت تفشل، لأن الخدمة بطيئة ولا يمكن إصلاح ذلك من هنا، ولكنها تفشل في 303 ملي ثوان برمز واضح، والتطبيق نفسه سليم: فحص الصحة نجح في كل المرات بوسيط 2 ملي ثانية.

### الخطوة 3: ثلاث محاولات إضافية

```bash
./step.sh "TIMEOUT=0.3 RETRIES=3" "delay=3000&fail=0" --checkout 20 --health 5 --seconds 15
```

```text
path        sent   200   503  gave up   p50 ms   p99 ms   max ms
/checkout    300     0   130      170    10010    10039    10140
/health       75    38     0       37     9960    10043    10043
--- dependency received:
{"received": 886}
--- api log:
[  0.00s] api: WORKERS=10 TIMEOUT=0.3 RETRIES=3 BACKOFF=False RETRY_BUDGET=0.0 BREAKER=False BULKHEAD=0 MAX_QUEUE=0
```

إعادة المحاولة أعادت السقوط: 886 طلباً إلى خدمة الأسعار المتعبة، وتخلى المستخدمون عن 170 طلب دفع و37 فحص صحة، ولم ينجح أي طلب دفع، فلم تكسب المحاولات شيئاً ودفعنا ثمنها كله.

### الخطوة 4: ميزانية إعادة المحاولة 10%

```bash
./step.sh "TIMEOUT=0.3 RETRIES=3 RETRY_BUDGET=0.1" "delay=3000&fail=0" --checkout 20 --health 5 --seconds 15
```

```text
path        sent   200   503  gave up   p50 ms   p99 ms   max ms
/checkout    300     0   300        0      303      606      632
/health       75    75     0        0        2       16       16
--- dependency received:
{"received": 330}
--- api log:
[  0.00s] api: WORKERS=10 TIMEOUT=0.3 RETRIES=3 BACKOFF=False RETRY_BUDGET=0.1 BREAKER=False BULKHEAD=0 MAX_QUEUE=0
```

نفس الإعداد مع ميزانية: 30 إعادة محاولة فقط (10% من 300)، والتطبيق سليم، وp99 أصبح 606 لأن بعض الطلبات أخذت محاولة ثانية. وعندما يكون الفشل عابراً فهذه المحاولات القليلة هي التي تنقذ الطلب، وعندما يكون الفشل دائماً فهي لا تكلف إلا 10%.

### الخطوة 5: قاطع الدائرة، ثم إصلاح الخدمة

هذه المرة نشغل الحمل 20 ثانية، وبعد عشر ثوان يصلح مولد الحمل خدمة الأسعار فيعيد زمنها إلى 50 ملي ثانية:

```bash
./step.sh "TIMEOUT=0.3 BREAKER=1" "delay=3000&fail=0" --checkout 20 --health 5 --seconds 20 \
  --at 10 --call "http://dep:8080/mode?delay=50"
```

```text
[10s] called http://dep:8080/mode?delay=50
path        sent   200   503  gave up   p50 ms   p99 ms   max ms
/checkout    400   168   232        0        2      305      369
/health      100   100     0        0        2       29       29
--- dependency received:
{"received": 187}
--- api log:
[  0.00s] api: WORKERS=10 TIMEOUT=0.3 RETRIES=0 BACKOFF=False RETRY_BUDGET=0.0 BREAKER=True BULKHEAD=0 MAX_QUEUE=0
[  3.37s] breaker closed -> open
[  8.72s] breaker open -> half-open
[  9.02s] breaker half-open -> open
[ 14.17s] breaker open -> half-open
[ 14.32s] breaker half-open -> closed
```

في المخرج أعلاه لاحظ التالي:

- الأوقات في سجل التطبيق تبدأ من تشغيله، وهو يسبق الحمل بنحو ثانية، فالإصلاح حدث عند الثانية 11 تقريباً في هذا السجل.
- القاطع فتح بعد عشرة طلبات فاشلة، ثم جرب عند 8.72 والخدمة ما زالت بطيئة ففشلت التجربة وفتح مرة أخرى، ثم جرب عند 14.17 بعد الإصلاح فنجحت الطلبات الثلاثة وأغلق.
- وصل إلى خدمة الأسعار 187 طلباً من 400، فطوال فترة الفتح لم يصلها شيء، وهذا وقت تتعافى فيه، والوسيط 2 ملي ثانية لأن أغلب الطلبات الفاشلة فشلت فوراً دون انتظار المهلة.
- بعد إغلاق القاطع نجحت الطلبات كلها، وهي 168 طلب دفع ناجح.

### الخطوة 6: حاجز بأربعة مقاعد دون مهلة

```bash
./step.sh "BULKHEAD=4" "delay=3000&fail=0" --checkout 20 --health 5 --seconds 15
```

```text
path        sent   200   503  gave up   p50 ms   p99 ms   max ms
/checkout    300    20   280        0        2     3018     3057
/health       75    75     0        0        2       40       40
--- dependency received:
{"received": 20}
--- api log:
[  0.00s] api: WORKERS=10 TIMEOUT=None RETRIES=0 BACKOFF=False RETRY_BUDGET=0.0 BREAKER=False BULKHEAD=4 MAX_QUEUE=0
```

نفس الحالة التي أسقطت التطبيق في الخطوة الأولى، ولكن خدمة الأسعار لم تأخذ أكثر من أربعة خيوط، فبقي فحص الصحة سليماً، ونجح 20 طلب دفع (أربعة كل ثلاث ثوان)، ورفض الباقي فوراً.

### الخطوة 7: حمل أكبر من السعة، مع رفض الزائد ودونه

خدمة الأسعار ترد في 200 ملي ثانية، فسعة التطبيق نحو 50 طلباً في الثانية، ونرسل 80\. المرة الأولى بطابور بلا حد، والثانية بطابور أقصاه 20:

```bash
./step.sh "TIMEOUT=1" "delay=200&fail=0" --checkout 80 --health 5 --seconds 15
./step.sh "TIMEOUT=1 MAX_QUEUE=20" "delay=200&fail=0" --checkout 80 --health 5 --seconds 15
```

```text
path        sent   200   503  gave up   p50 ms   p99 ms   max ms
/checkout   1200  1200     0        0     4974     9534     9615
/health       75    75     0        0     4728     9344     9344
--- dependency received:
{"received": 1200}
--- api log:
[  0.00s] api: WORKERS=10 TIMEOUT=1.0 RETRIES=0 BACKOFF=False RETRY_BUDGET=0.0 BREAKER=False BULKHEAD=0 MAX_QUEUE=0
path        sent   200   503  gave up   p50 ms   p99 ms   max ms
/checkout   1200   759   441        0      535      608      613
/health       75    41    34        0      313      406      406
--- dependency received:
{"received": 759}
--- api log:
[  0.00s] api: WORKERS=10 TIMEOUT=1.0 RETRIES=0 BACKOFF=False RETRY_BUDGET=0.0 BREAKER=False BULKHEAD=0 MAX_QUEUE=20
```

في المرة الأولى نجح كل شيء على الورق، ولكن وسيط الانتظار خمس ثوان، وفحص الصحة نفسه ينتظر أربع ثوان ونصف، وفي الإنتاج سوف يغادر أغلب هؤلاء قبل الرد. وفي المرة الثانية خدم التطبيق 759 طلباً في نحو نصف ثانية، ورفض 441 فوراً برمز 503 مع `Retry-After: 1`، ورفض معها 34 فحص صحة، وهذا ما ذكرناه عن الرفض بحسب الأولوية.

### حذف التجربة

```bash
docker rm -f lab-resil-api lab-resil-dep
docker network rm lab-resil-net
```

## المكتبات والأدوات

لا تكتب هذه الأنماط بنفسك في الإنتاج كما فعلنا في المختبر، فالمكتبات المعروفة مختبرة وتعطيك المقاييس Metrics لكل قاطع وحاجز:

- Java: [Resilience4j](https://github.com/resilience4j/resilience4j?ref=arabroot.io)، وفيها قاطع الدائرة والحواجز وإعادة المحاولة وتحديد المعدل.
- .NET: [Polly](https://github.com/App-vNext/Polly?ref=arabroot.io)، وفيها نفس الأنماط.
- Python: [tenacity](https://github.com/jd/tenacity?ref=arabroot.io) لإعادة المحاولة مع التأخير المتزايد والعشوائية، والمهلة تحددها في مكتبة HTTP نفسها في كل استدعاء.
- على مستوى البنية: [Envoy](https://www.envoyproxy.io/docs/envoy/latest/intro/arch%5Foverview/upstream/circuit%5Fbreaking?ref=arabroot.io) وما يبنى عليه من Service Mesh يطبق حدود الاتصالات والطلبات المعلقة وإعادة المحاولة لكل خدمة دون تغيير الكود، و[HAProxy](https://arabroot.io/articles/%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-haproxy-load-balancer/) يحدد المهلات والحد الأقصى للاتصالات لكل سيرفر.

وكل هذه الأنماط لا تختبر إلا تحت حمل، لذلك أضف خدمة بطيئة عمداً إلى [اختبار الأحمال](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%A7%D9%84%D8%A7%D8%AD%D9%85%D8%A7%D9%84-%D9%88%D8%A7%D9%84%D8%B6%D8%BA%D8%B7-load-%D9%88-stress-testing-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/) كما فعلنا هنا، وتابع كل طلب عبر الخدمات بـ [التتبع الموزع](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%AA%D8%A8%D8%B9-%D8%A7%D9%84%D9%85%D9%88%D8%B2%D8%B9-distributed-tracing-%D9%85%D8%B9-opentelemetry/) حتى ترى أين ضاع الوقت.

## أخطاء شائعة

- استدعاء خدمة عبر الشبكة دون مهلة، والاعتماد على القيمة الافتراضية للمكتبة التي قد تكون «للأبد».
- مهلة أطول من صبر المستخدم، مثل 30 ثانية لطلب ينتظره شخص أمام الشاشة.
- إعادة المحاولة في كل طبقة من السلسلة، فيتضاعف الحمل على الخدمة المتعبة.
- إعادة المحاولة فوراً ودون عشوائية، أو على عمليات غير آمنة عند التكرار مثل الخصم.
- قاطع دائرة بإعدادات افتراضية لا تناسب حجم الحركة، فلا يفتح أبداً أو يفتح بلا سبب.
- فحص صحة يسأل قاعدة البيانات والخدمات الخارجية، فتخرج كل النسخ معاً عند بطء خدمة واحدة.
- مجمع خيوط أو اتصالات واحد مشترك لكل الخدمات الخارجية.
- طوابير بلا حد في الذاكرة، ورفض لا يفرق بين طلب دفع وصفحة تقرير.
- كلاينت يتجاهل `Retry-After` ويعيد المحاولة فوراً.

## قائمة تحقق

- لكل استدعاء عبر الشبكة مهلة اتصال ومهلة طلب، مبنية على p99 أو p99.9 المقاس.
- مهلة واحدة للطلب كله تنتقل إلى الخدمات التالية، ولا تبدأ خدمة عملاً لم يبق له وقت.
- إعادة المحاولة في طبقة واحدة، بتأخير متزايد مع عشوائية، وبميزانية، وعلى العمليات الآمنة عند التكرار فقط.
- قاطع دائرة أمام كل خدمة خارجية مهمة، مع بديل معقول عندما يفتح.
- حد أقصى للاتصالات المتزامنة لكل خدمة خارجية.
- طابور محدود أمام الخيوط، ورفض سريع بـ 503 و`Retry-After`، بحسب الأولوية.
- فحص الحياة لا يعتمد على الخدمات الأخرى.
- اختبار حمل فيه خدمة بطيئة عمداً قبل أن يحدث ذلك في الإنتاج.

## الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

- الخدمة البطيئة أخطر من المتوقفة، لأنها تمسك الخيوط، وقانون ليتل يخبرك متى تنفد: الطلبات في الثانية × مدة الطلب.
- في المختبر أسقطت خدمة بطيئة واحدة التطبيق كله، حتى فحص الصحة، واستمر التطبيق يعمل لمستخدمين غادروا أكثر من أربعين ثانية.
- المهلة وحدها أعادت التطبيق سليماً، وإعادة المحاولة دون ميزانية أسقطته مرة أخرى بـ 886 طلباً بدل 300، والميزانية أعادته إلى 330.
- قاطع الدائرة يعطي الخدمة المتعبة وقتاً للتعافي ويعيد خيوطك فوراً، والحاجز يمنع خدمة واحدة من أخذ كل الخيوط حتى دون مهلة.
- رفض الزائد بطابور قصير و503 مع `Retry-After` أرحم من خدمة الجميع متأخرين، والضغط العكسي يبطئ المرسل بدل أن يرمي عمله.
- كل مكان يتجمع فيه عمل يحتاج حداً وقراراً لما يحدث عند الوصول إليه.

[![](https://arabroot.io/content/images/2026/10/idempotency-cover-cover.webp)دليلIdempotency: كيف تجعل العملية آمنة عند تكرارهالماذا تصل الرسالة نفسها أكثر من مرة، وكيف تكتب مستهلكاً يطبقها مرة واحدة فقط مهما تكررت.](https://arabroot.io/articles/idempotency-%D9%83%D9%8A%D9%81-%D8%AA%D8%AC%D8%B9%D9%84-%D8%A7%D9%84%D8%B9%D9%85%D9%84%D9%8A%D8%A9-%D8%A7%D9%85%D9%86%D8%A9-%D8%B9%D9%86%D8%AF-%D8%AA%D9%83%D8%B1%D8%A7%D8%B1%D9%87%D8%A7/)[![](https://arabroot.io/content/images/2026/10/rate-limiting-cover-cover.webp)دليلتحديد معدل الطلبات Rate Limiting وخوارزمياتهكيف تعمل خوارزميات تحديد معدل الطلبات، وأيها يناسب واجهتك، وكيف تطبقها على عدة سيرفرات وترد بالرمز 429 الصحيح.](https://arabroot.io/articles/%D8%AA%D8%AD%D8%AF%D9%8A%D8%AF-%D9%85%D8%B9%D8%AF%D9%84-%D8%A7%D9%84%D8%B7%D9%84%D8%A8%D8%A7%D8%AA-rate-limiting-%D9%88%D8%AE%D9%88%D8%A7%D8%B1%D8%B2%D9%85%D9%8A%D8%A7%D8%AA%D9%87/)[![](https://arabroot.io/content/images/2026/10/distributed-tracing-cover-cover.webp)دليلالتتبع الموزع Distributed Tracing مع OpenTelemetryكيف ترى طلباً واحداً يمر بعدة خدمات في شجرة زمنية واحدة، فتعرف أين أبطأ وأين فشل دون أن تخمن من السجلات.](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%AA%D8%A8%D8%B9-%D8%A7%D9%84%D9%85%D9%88%D8%B2%D8%B9-distributed-tracing-%D9%85%D8%B9-opentelemetry/)[![](https://arabroot.io/content/images/2026/10/service-discovery-cover-cover.webp)دليلاكتشاف الخدمات وإعداداتها مع Consul وetcdكيف تجد خدماتك بعضها رغم تغير عناوينها، وكيف توزع الإعدادات عليها، وماذا يحدث عندما يفقد العنقود الأغلبية.](https://arabroot.io/articles/%D8%A7%D9%83%D8%AA%D8%B4%D8%A7%D9%81-%D8%A7%D9%84%D8%AE%D8%AF%D9%85%D8%A7%D8%AA-%D9%88%D8%A7%D8%B9%D8%AF%D8%A7%D8%AF%D8%A7%D8%AA%D9%87%D8%A7-%D9%85%D8%B9-consul-%D9%88-etcd/)[![](https://arabroot.io/content/images/2026/10/load-testing-cover-cover.webp)دليلاختبار الأحمال والضغط Load Testing للمبتدئينلماذا يعمل تطبيقك مع خمسة مستخدمين وينهار مع خمسمئة، وكيف تكتشف ذلك وتصلحه قبل يوم الإطلاق.](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%A7%D9%84%D8%A7%D8%AD%D9%85%D8%A7%D9%84-%D9%88%D8%A7%D9%84%D8%B6%D8%BA%D8%B7-load-%D9%88-stress-testing-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)

## سجل التحديثات

- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة العملية على Docker Engine 29.8.0 مع Python 3.13.16 (python:3.13.16-slim) وcurl 8.11.1.