> ## 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/الانظمة-الموزعة-للمبتدئين-cap-والاتساق-والاجماع/
- Published: 2026-10-05T15:00:00.000Z
- Updated: 2026-10-09T09:42:25.000Z
- Description: لماذا تتصرف الأنظمة الموزعة بشكل غريب عند انقطاع الشبكة، وكيف تتفق السيرفرات على قرار واحد رغم ذلك.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, هندسة البرمجيات وتصميم الأنظمة, DevOps وCI/CD, قواعد البيانات, الاستضافة الذاتية

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

وقد شرحنا في دليل [مدخل إلى تصميم الأنظمة](https://arabroot.io/articles/%D9%85%D8%AF%D8%AE%D9%84-%D8%A7%D9%84%D9%89-%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D8%A7%D9%84%D8%A7%D9%86%D8%B8%D9%85%D8%A9-%D9%85%D9%86-%D8%B3%D9%8A%D8%B1%D9%81%D8%B1-%D9%88%D8%A7%D8%AD%D8%AF-%D8%A7%D9%84%D9%89-%D9%85%D9%84%D8%A7%D9%8A%D9%8A%D9%86/) كيف يكبر النظام خطوة بخطوة من سيرفر واحد إلى ملايين المستخدمين بتوزيع الحمل Load Balancing والتخزين المؤقت Caching ونسخ القراءة، أما هذا المقال فيتعمق في المفاهيم التي تقف خلف تلك الخطوات، أي لماذا تتصرف الأنظمة الموزعة بهذا الشكل الغريب أحياناً، وما الذي تختاره كل أداة عندما تنقطع الشبكة. وإذا كنت قرأت دليل [المعالج والخيوط والتزامن](https://arabroot.io/articles/%D8%A7%D9%84%D9%85%D8%B9%D8%A7%D9%84%D8%AC-%D9%88%D8%A7%D9%84%D8%AE%D9%8A%D9%88%D8%B7-%D9%88%D8%A7%D9%84%D8%AA%D8%B2%D8%A7%D9%85%D9%86-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/) فهذا المقال هو الخطوة التالية: هناك كان التزامن Concurrency بين خيوط على جهاز واحد تشترك في ذاكرة واحدة وساعة واحدة، وهنا يصبح بين أجهزة لا يربطها إلا شبكة لا تضمن لك شيئاً، وسوف نناقش في هذا المقال ما يلي:

- ما الذي يجعل النظام موزعاً، ولماذا هو صعب: الشبكة غير الموثوقة، والفشل الجزئي، وغياب الساعة الموحدة، والافتراضات الخاطئة الثمانية.
- النسخ المتماثل Replication بأنواعه الثلاثة، والنسخ المتزامن وغير المتزامن، والمشاكل التي يسببها تأخر النسخ.
- تقسيم البيانات Partitioning بالمدى وبالهاش، والنقاط الساخنة، وإعادة التوزيع، والهاش المتسق Consistent Hashing.
- نظرية CAP كما هي فعلاً وما يفهم منها خطأ، ثم PACELC، ونماذج الاتساق Consistency Models بلغة بسيطة، وما تختاره الأنظمة المعروفة.
- الوقت والترتيب: لماذا لا تصلح الساعة لترتيب الأحداث، والساعات المنطقية، ورموز الحماية Fencing Tokens.
- الإجماع Consensus وخوارزمية Raft خطوة بخطوة، ولماذا ثلاثة أو خمسة سيرفرات وليس اثنين أو أربعة، وانقسام الدماغ Split Brain.
- تجربة عملية على عنقود etcd من ثلاثة سيرفرات: نوقف القائد ونرى الانتخابات، ثم نكسر الأغلبية ونرى الكتابة تفشل.
- أنماط التعامل مع الفشل: المهلة وإعادة المحاولة والتأخير المتزايد ومفتاح منع التكرار وقاطع الدائرة، وضمانات التسليم.
- المعاملات الموزعة: لماذا يتجنب الناس 2PC، ونمطا Saga وOutbox، ومعنى الاتساق النهائي في لغة العمل.
- خريطة الأدوات مفتوحة المصدر مع ترخيص كل واحدة، لأن بعضها غير ترخيصه في السنوات الأخيرة.

## ما الذي يجعل النظام موزعاً، ولماذا هو صعب؟

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

وقد يتساءل البعض: لماذا لا نبقى على سيرفر واحد ونرتاح؟ والإجابة أن هذا هو الخيار الصحيح ما دام يكفيك، وقد ذكرنا في دليل [تصميم قواعد البيانات](https://arabroot.io/articles/%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D9%82%D9%88%D8%A7%D8%B9%D8%AF-%D8%A7%D9%84%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA-%D8%A7%D9%84%D8%AA%D8%B7%D8%A8%D9%8A%D8%B9-%D9%88%D8%A7%D9%84%D8%AA%D9%88%D8%B3%D8%B9/#sharding) أن سيرفراً واحداً قوياً مع فهارس صحيحة ونسخ للقراءة يكفي أغلب التطبيقات. ولكن هناك ثلاثة أسباب تدفعك إلى التوزيع مهما كان السيرفر قوياً: الأول التوفر Availability، فالسيرفر الواحد سوف يتوقف يوماً ما لعطل أو تحديث، والثاني الحجم Scalability، فهناك حد لما يحمله جهاز واحد من بيانات وطلبات، والثالث زمن الوصول Latency، فالمستخدم في جدة يصل إلى سيرفر في جدة أسرع من سيرفر في فرانكفورت.

### الافتراضات الثمانية الخاطئة عن الشبكة

في سنة 1994 كتب L. Peter Deutsch في شركة Sun Microsystems قائمة من سبعة افتراضات يقع فيها كل من يبني أول نظام موزع، وقد بنى فيها على أربعة افتراضات سبق أن جمعها Bill Joy وزميله، ثم أضاف James Gosling مخترع Java الافتراض الثامن قرابة سنة 1997، وصارت تعرف باسم [الافتراضات الخاطئة في الحوسبة الموزعة Fallacies of Distributed Computing](https://en.wikipedia.org/wiki/Fallacies%5Fof%5Fdistributed%5Fcomputing?ref=arabroot.io):

1. **الشبكة موثوقة The network is reliable:** والحقيقة أن الرسالة قد تضيع، أو تصل مرتين، أو تصل بعد رسالة أرسلت بعدها.
2. **زمن الوصول صفر Latency is zero:** الاستدعاء عبر الشبكة أبطأ بآلاف المرات من استدعاء دالة في الذاكرة، والحلقة التي ترسل مئة طلب متتالية تدفع ثمن مئة ذهاب وعودة عبر الشبكة Round Trip.
3. **عرض النطاق لا نهائي Bandwidth is infinite:** نسخ قاعدة بيانات كاملة إلى نسخة جديدة قد يملأ الرابط بين مركزي البيانات لساعات.
4. **الشبكة آمنة The network is secure:** لذلك تشفر الاتصال بين السيرفرات حتى داخل شبكتك.
5. **شكل الشبكة لا يتغير Topology doesn't change:** السيرفرات تضاف وتحذف، والعناوين تتغير، وال Container يعاد إنشاؤه بعنوان جديد.
6. **هناك مدير واحد There is one administrator:** بين تطبيقك والسيرفر الآخر جدار حماية لفريق آخر، وموزع حمل لمزود الخدمة، وشبكة لشركة اتصالات.
7. **تكلفة النقل صفر Transport cost is zero:** تحويل البيانات إلى JSON ونقلها له تكلفة على المعالج، والنقل بين المناطق في السحابة له فاتورة.
8. **الشبكة متجانسة The network is homogeneous:** أجهزة وأنظمة وإصدارات مختلفة تتحدث معاً، ولكل منها سلوكه عند الضغط.

### الفشل الجزئي، والساعة التي لا يتفق عليها أحد

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

والمشكلة الثانية أنه لا توجد ساعة واحدة يتفق عليها الجميع، فكل سيرفر له ساعته، والساعات تنحرف وتصحح وقد ترجع إلى الخلف كما شرحنا في دليل [التاريخ والوقت](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%A7%D8%B1%D9%8A%D8%AE-%D9%88%D8%A7%D9%84%D9%88%D9%82%D8%AA-utc-%D9%88%D8%A7%D9%84%D9%85%D9%86%D8%A7%D8%B7%D9%82-%D8%A7%D9%84%D8%B2%D9%85%D9%86%D9%8A%D8%A9-%D9%88%D8%A7%D9%84%D8%AA%D9%82%D9%88%D9%8A%D9%85-%D8%A7%D9%84%D9%87%D8%AC%D8%B1%D9%8A/#logs-and-drift)، لذلك لا تستطيع أن تعتمد على الوقت لتعرف أي حدث وقع أولاً على سيرفرين مختلفين، وسوف نعود لهذه النقطة في قسم الوقت والترتيب.

والصورة التالية تبين ما يمكن أن يحدث لرسالة واحدة بين سيرفرين، ولماذا يرى المرسل كل هذه الحالات بالشكل نفسه:

![مخطط لسيرفر تطبيق يرسل طلب خصم مبلغ إلى سيرفر الدفع عبر الشبكة في ست حالات: الرسالة تصل ويعود الرد، الرسالة تضيع في الطريق، الرسالة تتأخر عشر ثوان، الرسالة تصل مرتين، رسالتان تصلان بترتيب معكوس، والطلب ينفذ ثم يضيع الرد، وفي كل الحالات الفاشلة يرى سيرفر التطبيق الشيء نفسه وهو انتهاء المهلة دون أن يعرف هل نفذ الطلب، وفي الأسفل ثلاثة سيرفرات بساعات مختلفة 10:00:00.120 و10:00:01.900 و09:59:58.400](https://arabroot.io/content/images/2026/10/distributed-systems-01-network.webp)

كل ما يراه المرسل هو أن الرد لم يصل، ولا يعرف هل ضاع الطلب أم ضاع الرد أم ما زال في الطريق

## النسخ المتماثل Replication: نسخ البيانات على أكثر من سيرفر

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

### القائد والتابع Leader-Follower

وهي الطريقة الأشهر، حيث يكون سيرفر واحد هو القائد Leader، ويسمى أيضاً Primary، وهو الوحيد الذي يستقبل الكتابة، ثم يرسل كل تغيير إلى التابعين Followers، ويسمون أيضاً Replicas أو Standby، والقراءة تكون من أي واحد منهم. وهذا ما يفعله ال Streaming Replication في PostgreSQL، والنسخ في MySQL وRedis وValkey وMongoDB. ونقطة القوة أنها بسيطة ولا تحدث فيها تعارضات Conflicts لأن الكتابة في مكان واحد، أما نقطة الضعف فهي أن القائد نقطة فشل، فإذا توقف يجب أن يرقى أحد التابعين إلى قائد Failover، ومن يقرر ذلك ومتى هو سؤال سوف نجيب عنه في قسم الإجماع.

### عدة قادة Multi-Leader

هنا يقبل أكثر من سيرفر الكتابة، وكل واحد منهم يرسل تغييراته للآخرين، والاستخدام الطبيعي لها هو مركزا بيانات في مدينتين، لكل منهما قائد يقبل الكتابة من المستخدمين القريبين منه، أو تطبيق يعمل دون اتصال Offline فيكون كل جهاز قائداً لنفسه حتى يتصل. والثمن هو التعارض: إذا غير مستخدم في الرياض عنوان الطلب وغيره موظف في جدة في الثانية نفسها، فأي القيمتين تبقى؟ والحلول المعروفة إما «آخر كتابة تفوز Last Write Wins» وهي تعتمد على الساعة فتضيع بها كتابات صحيحة بصمت، وإما أن تحتفظ بالقيمتين وتسأل التطبيق أو المستخدم أن يدمجهما، وإما هياكل بيانات خاصة تدمج نفسها تلقائياً اسمها CRDT. لذلك لا تقم باختيار عدة قادة إلا إذا كان عندك سبب حقيقي، والسبب أن حل التعارضات يصبح جزءاً من منطق تطبيقك إلى الأبد.

### دون قائد Leaderless والنصاب Quorum

في هذه الطريقة لا يوجد قائد أصلاً، فالكلاينت يرسل الكتابة إلى عدة نسخ في الوقت نفسه، ويقرأ من عدة نسخ ويأخذ الأحدث، وهي طريقة ورقة [Amazon Dynamo](https://www.allthingsdistributed.com/files/amazon-dynamo-sosp2007.pdf?ref=arabroot.io) المنشورة سنة 2007، ومنها أخذت Apache Cassandra وScyllaDB فكرتها كما يذكر [توثيق Cassandra](https://cassandra.apache.org/doc/latest/cassandra/architecture/dynamo.html?ref=arabroot.io). والقاعدة التي تحكمها بسيطة: إذا كان عندك N نسخة، وانتظرت تأكيد W منها عند الكتابة، وسألت R منها عند القراءة، فإذا كان R + W > N فلا بد أن تتقاطع مجموعة القراءة مع مجموعة الكتابة في نسخة واحدة على الأقل، وبالتالي ترى القراءة آخر كتابة. لنأخذ مثالاً: N = 3 وW = 2 وR = 2، فالمجموع 4 أكبر من 3، وأي نسختين تقرأ منهما فإحداهما على الأقل كانت ضمن النسختين اللتين أكدتا الكتابة.

وفي Cassandra يسمى هذا مستوى الاتساق Consistency Level، وتختاره مع كل استعلام: `ONE` للسرعة، و`QUORUM` لأغلبية النسخ، و`ALL` لكل النسخ، و`LOCAL_QUORUM` لأغلبية النسخ داخل مركز البيانات نفسه. ولكن انتبه إلى أن Daniel Abadi ذكر في [ورقته عن PACELC](https://www.cs.umd.edu/~abadi/papers/abadi-pacelc.pdf?ref=arabroot.io) أن هذه الأنظمة لا تصل إلى الاتساق الكامل بتعريف Gilbert وLynch حتى مع R + W > N، لذلك لا تعامل المستوى `QUORUM` كبديل عن نظام إجماع عندما تحتاج اتساقاً صارماً.

### النسخ المتزامن وغير المتزامن

السؤال الأهم في أي نسخ هو: متى يقول القائد للكلاينت «تم»؟ في النسخ غير المتزامن Asynchronous يكتب القائد التغيير عنده ويرد فوراً، ثم يرسله إلى التابعين بعد ذلك، وهو الوضع الافتراضي في PostgreSQL كما يذكر [توثيق النسخ](https://www.postgresql.org/docs/18/warm-standby.html?ref=arabroot.io#STREAMING-REPLICATION)، والتأخير عادة أقل من ثانية إذا كانت النسخة قادرة على اللحاق بالحمل، وكذلك في Redis وValkey كما في [توثيق Valkey](https://valkey.io/topics/replication/?ref=arabroot.io). ونقطة القوة أن الكتابة سريعة ولا تتأثر بتوقف تابع، ونقطة الضعف أن القائد إذا توقف قبل أن يرسل آخر التغييرات ثم رقي تابع مكانه، ضاعت تلك التغييرات رغم أن الكلاينت قيل له «تم».

أما في النسخ المتزامن Synchronous فينتظر القائد تأكيد التابع قبل أن يرد، وفي PostgreSQL تحدده بالإعداد `synchronous_standby_names`، وعندها كما يقول [التوثيق](https://www.postgresql.org/docs/18/warm-standby.html?ref=arabroot.io#SYNCHRONOUS-REPLICATION) لا تضيع البيانات إلا إذا توقف القائد والتابع في الوقت نفسه. والثمن أن كل كتابة تنتظر ذهاباً وعودة عبر الشبكة، والأخطر أن التابع المتزامن إذا توقف، فإن الكتابات تنتظر ولا تكتمل أبداً حتى يعود أو تغير الإعداد، ولذلك يوفر PostgreSQL صيغة النصاب `ANY 1 (s1, s2)` التي تنتظر أي نسخة من اثنتين، فلا يوقف تعطل نسخة واحدة الكتابة. والإعداد `synchronous_commit = remote_apply` يذهب أبعد فينتظر حتى تطبق النسخة التغيير وتصبح القراءة منها ترى الكتابة.

### تأخر النسخ: المستخدم الذي لا يرى ما كتبه

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

- **قراءة ما كتبته Read-Your-Writes:** المستخدم يغير اسمه، فتذهب الكتابة إلى القائد، ثم تفتح الصفحة فتقرأ من نسخة متأخرة بنصف ثانية، فيرى الاسم القديم ويظن أن الحفظ فشل فيغيره مرة ثانية. والحل أن تقرأ من القائد ما كتبه المستخدم نفسه في الثواني الأخيرة، أو أن تحفظ رقم آخر كتابة له في الجلسة وتقرأ من نسخة وصلت إليه، وقد ذكرنا هذا باختصار في دليل [تصميم قواعد البيانات](https://arabroot.io/articles/%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D9%82%D9%88%D8%A7%D8%B9%D8%AF-%D8%A7%D9%84%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA-%D8%A7%D9%84%D8%AA%D8%B7%D8%A8%D9%8A%D8%B9-%D9%88%D8%A7%D9%84%D8%AA%D9%88%D8%B3%D8%B9/#replicas).
- **القراءات الرتيبة Monotonic Reads:** المستخدم يحدث صفحة التعليقات فيرى تعليقاً جديداً لأن القراءة ذهبت إلى نسخة متقدمة، ثم يحدثها مرة ثانية فيختفي التعليق لأن القراءة ذهبت إلى نسخة متأخرة، وكأن الزمن رجع إلى الخلف. والحل أن تربط كل مستخدم بنسخة واحدة، مثلاً بالهاش لرقم المستخدم، بدلاً من أن تختار نسخة عشوائية لكل طلب.

والصورة التالية تبين الطرق الثلاث، ثم قصة المستخدم الذي لا يرى ما كتبه:

![مخطط من جزأين: في الأعلى ثلاث طرق للنسخ، قائد واحد يستقبل الكتابة ويرسلها إلى تابعين، وقائدان في الرياض وجدة يتبادلان التغييرات مع احتمال التعارض، ونسخ دون قائد يكتب الكلاينت إلى نسختين من ثلاث ويقرأ من نسختين بقاعدة R + W أكبر من N، وفي الأسفل قصة في أربع خطوات: المستخدم يغير اسمه إلى سارة فيكتب القائد ويرد تم، والنسخة متأخرة 800 ملي ثانية، والمستخدم يفتح ملفه فتذهب القراءة إلى النسخة فيرى الاسم القديم، والحل قراءة ما كتبه المستخدم من القائد](https://arabroot.io/content/images/2026/10/distributed-systems-02-replication.webp)

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

## تقسيم البيانات Partitioning: عندما لا تكفي نسخة كاملة على كل سيرفر

النسخ يضع البيانات نفسها على كل سيرفر، فلا يساعدك إذا كانت البيانات أكبر من قرص سيرفر واحد، أو كانت الكتابة أكثر مما يتحمله قائد واحد. والحل هنا أن تقسم البيانات نفسها، فيحمل كل سيرفر جزءاً منها، وتسمى الأجزاء Partitions أو Shards، والعملية Sharding. وفي الواقع يجتمع الاثنان دائماً: كل جزء له عدة نسخ على سيرفرات مختلفة. والسؤال الأول هو: كيف تعرف أي سطر يذهب إلى أي جزء؟ وهناك طريقتان أساسيتان.

### التقسيم بالمدى أم بالهاش؟

**التقسيم بمدى المفتاح Key Range:** تعطي كل جزء مدى متصلاً من المفاتيح، فالعملاء من A إلى F في الجزء الأول، ومن G إلى M في الثاني، وهكذا، أو الطلبات بحسب التاريخ. ونقطة القوة أن الاستعلام عن مدى، مثل «طلبات شهر أكتوبر»، يذهب إلى جزء واحد أو أجزاء متجاورة. ونقطة الضعف هي النقاط الساخنة Hot Spots، فإذا كان المفتاح هو الوقت فكل الكتابات الجديدة تذهب إلى الجزء الأخير، وتبقى الأجزاء الأخرى نائمة.

**التقسيم بالهاش Hash:** تحسب هاش المفتاح، ثم تختار الجزء بحسب الهاش، فيتوزع العملاء على الأجزاء بالتساوي تقريباً حتى لو كانت أسماؤهم متقاربة. ونقطة القوة هي التوزيع المتساوي، ونقطة الضعف أن استعلامات المدى تمر على كل الأجزاء، لأن العملاء المتجاورين في الترتيب أصبحوا متفرقين.

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

### الهاش المتسق Consistent Hashing وإعادة التوزيع

الطريقة البسيطة للتقسيم بالهاش هي باقي القسمة `hash(key) % N` حيث N عدد السيرفرات، وهي تعمل جيداً حتى تضيف سيرفراً، فعندها يتغير N من 4 إلى 5، ويتغير باقي القسمة لأغلب المفاتيح، فتنتقل أغلب البيانات بين السيرفرات دفعة واحدة، وهي أسوأ لحظة لنقل البيانات لأنك تضيف السيرفر أصلاً لأن الحمل زاد.

والحل الذي اقترحه Karger وزملاؤه في [ورقة سنة 1997](https://www.cs.princeton.edu/courses/archive/fall09/cos518/papers/chash.pdf?ref=arabroot.io) هو الهاش المتسق: تخيل دائرة عليها كل قيم الهاش الممكنة، وضع كل سيرفر على الدائرة في موضع هاش اسمه، ثم ضع كل مفتاح في موضع هاشه وامش مع اتجاه عقارب الساعة حتى تصل إلى أول سيرفر، فهو المالك. وعندما تضيف سيرفراً جديداً يأخذ فقط المفاتيح التي تقع بينه وبين السيرفر الذي قبله، وتبقى بقية المفاتيح مكانها. ولأن السيرفرات القليلة على الدائرة قد تقع في مواضع غير متساوية فيأخذ أحدها قوساً أكبر من غيره، يضع كل سيرفر في عدة مواضع تسمى العقد الافتراضية Virtual Nodes أو vnodes، وهذا ما تفعله Cassandra كما في [توثيقها](https://cassandra.apache.org/doc/latest/cassandra/architecture/dynamo.html?ref=arabroot.io). وبعض الأنظمة الأخرى مثل CockroachDB وTiDB تقسم بالمدى وتقسم الجزء الكبير أو الساخن تلقائياً إلى جزأين وتنقل أحدهما، فإعادة التوزيع Rebalancing تحدث باستمرار وبكميات صغيرة.

والصورة التالية تبين الطريقتين، ثم الدائرة وما يحدث عند إضافة سيرفر:

![مخطط من ثلاثة أجزاء: التقسيم بالمدى حيث الأسماء من A إلى F في السيرفر الأول ومن G إلى M في الثاني ومن N إلى Z في الثالث مع نقطة ساخنة عندما يكون المفتاح هو الوقت، والتقسيم بالهاش حيث تتوزع المفاتيح بالتساوي ولكن استعلام المدى يمر على كل السيرفرات، ثم دائرة الهاش المتسق عليها ثلاثة سيرفرات A وB وC ومفاتيح تمشي مع عقارب الساعة إلى أقرب سيرفر، وعند إضافة السيرفر D ينتقل إليه قوس واحد فقط من المفاتيح بينما تبقى البقية مكانها](https://arabroot.io/content/images/2026/10/distributed-systems-03-partitioning.webp)

بالهاش المتسق ينتقل جزء صغير من المفاتيح عند إضافة سيرفر، بدلاً من أن يتغير مكان أغلبها

## نظرية CAP كما هي، وليس كما تسمعها

في سنة 2000 طرح Eric Brewer فكرة أن النظام الموزع لا يستطيع أن يجمع ثلاث خصائص في الوقت نفسه، ثم أثبتها Seth Gilbert وNancy Lynch رياضياً في [ورقة سنة 2002](https://users.ece.cmu.edu/~adrian/731-sp04/readings/GL-cap.pdf?ref=arabroot.io)، وصارت تعرف بنظرية CAP. والخصائص الثلاث بتعريف الإثبات، وكما يعيد شرحها المؤلفان في [ورقة لاحقة](https://groups.csail.mit.edu/tds/papers/Gilbert/Brewer2.pdf?ref=arabroot.io)، هي:

- **الاتساق Consistency:** كل قراءة ترى آخر كتابة، وكأن البيانات على سيرفر واحد، وهو بالتحديد الاتساق الخطي Linearizability الذي سوف نشرحه بعد قليل.
- **التوفر Availability:** كل طلب يصل إلى سيرفر يعمل يجب أن يحصل على رد، وليس رسالة خطأ.
- **تحمل الانقسام Partition Tolerance:** يستمر النظام في العمل حتى لو ضاعت الرسائل بين مجموعتين من السيرفرات.

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

### ما يفهم من CAP خطأ

- **«اختر اثنين من ثلاثة»:** هذه الصياغة المشهورة مضللة كما كتب Brewer نفسه في مقاله [CAP بعد اثنتي عشرة سنة](https://www.infoq.com/articles/cap-twelve-years-later-how-the-rules-have-changed/?ref=arabroot.io) سنة 2012، والسبب أن الانقسام في الشبكة ليس خياراً تختاره أو ترفضه، فهو يحدث شئت أم أبيت، لذلك لا يوجد نظام موزع حقيقي من نوع CA. والاختيار الحقيقي هو ماذا تفعل وقت الانقسام فقط: C أو A.
- **«CAP تحكم النظام طوال الوقت»:** النظرية لا تقول شيئاً عن الوضع الطبيعي حين تكون الشبكة سليمة، وفي الوضع الطبيعي يمكن للنظام أن يكون متسقاً ومتوفراً معاً.
- **«C في CAP هي C في ACID»:** حرف C في ACID يعني أن البيانات تحترم القيود Constraints التي عرفتها، مثل المفتاح الأجنبي، أما C في CAP فتعني أن كل النسخ تبدو كنسخة واحدة، وهما شيئان مختلفان كما يشرح Brewer في المقال نفسه.
- **«النظام CP غير متوفر»:** التوفر في CAP صارم جداً، فهو يطلب أن يرد كل سيرفر يعمل حتى لو كان معزولاً وحده. أما عنقود etcd من ثلاثة سيرفرات، وهو نظام يختار الاتساق، فيبقى يعمل بشكل طبيعي إذا توقف سيرفر واحد، وسوف ترى ذلك في التجربة العملية، والذي يتوقف فقط هو الجزء الذي لا يملك الأغلبية.

### PACELC: السؤال الذي يهمك كل يوم

لأن CAP تتحدث عن لحظات الانقسام فقط، اقترح Daniel Abadi سنة 2012 في [ورقته في مجلة IEEE Computer](https://www.cs.umd.edu/~abadi/papers/abadi-pacelc.pdf?ref=arabroot.io) صياغة أوسع اسمها PACELC، وتقرأ كجملة شرطية: إذا حدث انقسام P فاختر بين التوفر A والاتساق C، وإلا Else، أي في الوضع الطبيعي، فاختر بين زمن الوصول Latency والاتساق C. والسبب في أهمية الشق الثاني أن الاتساق القوي يحتاج تنسيقاً بين النسخ في كل كتابة، والتنسيق يحتاج رسائل ذهاباً وعودة عبر الشبكة، فحتى دون أي عطل تدفع ثمن الاتساق بالانتظار. وقد صنف Abadi في الورقة نفسها Dynamo وCassandra وRiak بإعداداتها الافتراضية كأنظمة PA/EL، أي أنها تفضل التوفر عند الانقسام والسرعة في الوضع الطبيعي، وصنف الأنظمة التي تلتزم ب ACID بالكامل مثل VoltDB وMegastore وكذلك BigTable وHBase كأنظمة PC/EC.

### نماذج الاتساق بلغة بسيطة

كلمة «الاتساق» وحدها غامضة، والأنظمة تعد بدرجات مختلفة منه، وأوضح مرجع لها هو [خريطة نماذج الاتساق في Jepsen](https://jepsen.io/consistency?ref=arabroot.io). ولنأخذ مثالاً واحداً نطبقه على كل نموذج: رصيد حساب كان 100، ثم أودع العميل 50.

- **الاتساق الخطي Linearizable:** وهو الأقوى، فبمجرد أن يعود الرد «تم» للإيداع، فأي قراءة تبدأ بعده من أي مكان في العالم ترى 150، وكأن هناك نسخة واحدة فقط من البيانات، وكل عملية تحدث في لحظة واحدة بين إرسال الطلب ووصول الرد.
- **الاتساق التسلسلي Sequential:** كل القراء يرون العمليات بالترتيب نفسه، وعمليات كل كلاينت بترتيبها الذي أرسلها به، ولكن هذا الترتيب قد يتأخر عن الوقت الحقيقي، فقد يقرأ شخص 100 بعد أن عاد رد الإيداع لغيره، ولكنه لن يرى 150 ثم 100.
- **الاتساق السببي Causal:** الأحداث المرتبطة ببعضها تظهر بترتيبها للجميع، فإذا كتب أحمد سؤالاً ثم رد عليه سعيد، فلن يرى أحد الرد قبل السؤال، أما الأحداث غير المرتبطة فقد يراها كل شخص بترتيب مختلف.
- **الاتساق النهائي Eventual:** إذا توقفت الكتابات فسوف تتطابق كل النسخ في النهاية، ولكن قبل ذلك قد يقرأ أي شخص 100 أو 150، وقد يرى 150 ثم 100\. وهذا كاف تماماً لعدد الإعجابات تحت منشور، وغير مقبول إطلاقاً لرصيد يقرر هل تقبل عملية سحب.

### ماذا تختار الأنظمة المعروفة؟

والآن نضع الأنظمة التي تقابلها في مكانها، مع ملاحظة أن أغلبها يسمح لك بتغيير الاختيار من الإعداد، فالتصنيف هنا للإعداد الافتراضي أو الإعداد الذي نذكره:

| النظام والإعداد                     | عند الانقسام                                             | في الوضع الطبيعي                                                          | المصدر                                                                                                                 |
| ----------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| etcd                                | الجزء الذي لا يملك الأغلبية يرفض الكتابة والقراءة الخطية | كل العمليات خطية افتراضياً، مع خيار قراءة serializable أسرع قد تكون قديمة | [ضمانات etcd](https://etcd.io/docs/v3.7/learning/api%5Fguarantees/?ref=arabroot.io)                                    |
| PostgreSQL مع نسخة غير متزامنة      | القائد يستمر، وما لم يصل إلى النسخة قد يضيع عند الترقية  | الكتابة سريعة، والقراءة من النسخة قد تكون متأخرة                          | [توثيق النسخ](https://www.postgresql.org/docs/18/warm-standby.html?ref=arabroot.io#STREAMING-REPLICATION)              |
| PostgreSQL مع نسخة متزامنة          | إذا غابت النسخة المتزامنة تنتظر الكتابات ولا تكتمل       | كل كتابة تنتظر تأكيد النسخة                                               | [النسخ المتزامن](https://www.postgresql.org/docs/18/warm-standby.html?ref=arabroot.io#SYNCHRONOUS-REPLICATION)         |
| Cassandra وScyllaDB بالمستوى ONE    | تقبل الكتابة والقراءة من أي نسخة متاحة                   | أسرع رد، والقراءة قد تكون قديمة                                           | [توثيق Cassandra](https://cassandra.apache.org/doc/latest/cassandra/architecture/dynamo.html?ref=arabroot.io)          |
| Cassandra وScyllaDB بالمستوى QUORUM | تحتاج أغلبية النسخ، فالجزء الأقل يرفض                    | R + W > N، وزمن أطول                                                      | [توثيق Cassandra](https://cassandra.apache.org/doc/latest/cassandra/architecture/dynamo.html?ref=arabroot.io)          |
| Redis وValkey مع نسخ                | الكتابات المؤكدة قد تضيع عند الترقية حتى مع WAIT         | نسخ غير متزامن وسريع                                                      | [توثيق Redis](https://redis.io/docs/latest/operate/oss%5Fand%5Fstack/management/replication/?ref=arabroot.io)          |
| CockroachDB وTiDB وYugabyteDB       | كل جزء يحتاج أغلبية نسخه عبر Raft                        | الكتابة تنتظر الأغلبية                                                    | [طبقة النسخ في CockroachDB](https://docs.cockroachlabs.com/docs/stable/architecture/replication-layer?ref=arabroot.io) |

والصورة التالية تضع هذه الأنظمة على محوري PACELC، مع التذكير بأن الحدود بينها ليست صلبة لأن الإعداد يغير موقع النظام:

![مخطط من جزأين: في الأول انقسام في الشبكة بين سيرفرين، وخياران: الاتساق CP حيث يرد السيرفر المعزول بخطأ no leader، والتوفر AP حيث يرد بقيمة قد تكون قديمة، وفي الثاني جدول PACELC بأربع خانات: PC/EC وفيها etcd وZooKeeper وConsul وCockroachDB وTiDB وYugabyteDB وPostgreSQL مع نسخة متزامنة، وPA/EL وفيها Cassandra وScyllaDB بالمستوى ONE وDynamo وRedis وValkey مع نسخ وPostgreSQL مع نسخة غير متزامنة، وفي خانة PC/EC أيضاً Cassandra بالمستوى QUORUM مع ملاحظة أنها أبطأ ولا تصل إلى الاتساق الخطي الصارم، وفي الخانتين الباقيتين MongoDB وPNUTS كما صنفهما Abadi سنة 2012](https://arabroot.io/content/images/2026/10/distributed-systems-04-cap-pacelc.webp)

عند الانقسام تختار بين الخطأ والقيمة القديمة، وفي الوضع الطبيعي بين الانتظار والقيمة القديمة

## الوقت والترتيب: لماذا لا تصلح الساعة لترتيب الأحداث؟

الطريقة البديهية لمعرفة أي كتابتين حدثت أولاً هي أن تضع على كل واحدة وقتها، ثم تختار الأحدث، وهي بالضبط طريقة «آخر كتابة تفوز». والمشكلة أن ساعة كل سيرفر تنحرف عن غيرها بأجزاء من الثانية في أفضل الأحوال، وبدقائق إذا توقفت المزامنة، وقد ترجع إلى الخلف عند التصحيح كما شرحنا في دليل [التاريخ والوقت](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%A7%D8%B1%D9%8A%D8%AE-%D9%88%D8%A7%D9%84%D9%88%D9%82%D8%AA-utc-%D9%88%D8%A7%D9%84%D9%85%D9%86%D8%A7%D8%B7%D9%82-%D8%A7%D9%84%D8%B2%D9%85%D9%86%D9%8A%D8%A9-%D9%88%D8%A7%D9%84%D8%AA%D9%82%D9%88%D9%8A%D9%85-%D8%A7%D9%84%D9%87%D8%AC%D8%B1%D9%8A/#leap-seconds). لذلك إذا كانت ساعة السيرفر B متأخرة 200 ملي ثانية، فالكتابة التي حدثت عليه بعد كتابة السيرفر A قد تحمل وقتاً أقدم منها، فتفوز الكتابة القديمة وتضيع الجديدة دون أي رسالة خطأ. وهذا أيضاً سبب أن معرفات Snowflake تحتاج ساعة مضبوطة وتتوقف إذا رجعت الساعة، كما رأينا في دليل [المعرفات الفريدة](https://arabroot.io/articles/%D8%A7%D9%84%D9%85%D8%B9%D8%B1%D9%81%D8%A7%D8%AA-%D8%A7%D9%84%D9%81%D8%B1%D9%8A%D8%AF%D8%A9-uuid-%D9%88-ulid-%D9%88-snowflake/#snowflake).

### الساعات المنطقية: ترتيب دون وقت

في سنة 1978 نشر Leslie Lamport ورقة [Time, Clocks, and the Ordering of Events in a Distributed System](https://lamport.azurewebsites.net/pubs/time-clocks.pdf?ref=arabroot.io)، وهي من أشهر الأوراق في هذا المجال، وفكرتها أنك لا تحتاج الوقت الحقيقي لتعرف الترتيب، وإنما تحتاج أن تعرف أي حدث «سبق» غيره، أي كان يمكن أن يؤثر فيه. وساعة Lamport مجرد عداد في كل سيرفر، وقواعدها ثلاث:

1. كل سيرفر يزيد عداده بواحد قبل كل حدث عنده.
2. عند إرسال رسالة يرسل معها قيمة عداده.
3. عند استلام رسالة يجعل عداده أكبر من قيمته وقيمة الرسالة، ثم يزيده بواحد.

وبهذا إذا كان الحدث a سبباً في الحدث b، فلا بد أن يكون رقم a أصغر من رقم b، ولكن العكس غير صحيح، فالرقم الأصغر لا يثبت أن الحدث سبب الآخر، فقد يكونان متزامنين لا علاقة بينهما. ولذلك جاءت **الساعات المتجهة Vector Clocks**، حيث يحمل كل حدث قائمة فيها عداد لكل سيرفر، وبمقارنة القائمتين تعرف هل أحدهما سبق الآخر أم أنهما متزامنان فعلاً ويحتاجان دمجاً، وقد استخدمتها Dynamo لاكتشاف النسخ المتعارضة كما في [ورقتها](https://www.allthingsdistributed.com/files/amazon-dynamo-sosp2007.pdf?ref=arabroot.io). ونقطة ضعفها أن حجمها يكبر مع عدد الأطراف.

وأما **الساعة الهجينة Hybrid Logical Clock** واختصاراً HLC، فقد [اقترحها Kulkarni وزملاؤه سنة 2014](https://cse.buffalo.edu/tech-reports/2014-04.pdf?ref=arabroot.io)، وهي تجمع الاثنين: قيمة قريبة من الوقت الحقيقي يفهمها البشر، ومعها عداد منطقي يحفظ الترتيب السببي عندما تتقارب الأوقات أو تنحرف قليلاً. وتستخدمها CockroachDB كما يذكر [توثيق طبقة المعاملات](https://docs.cockroachlabs.com/docs/stable/architecture/transaction-layer?ref=arabroot.io)، الذي يذكر أيضاً أن السيرفر الذي تنحرف ساعته عن نصف السيرفرات على الأقل بأكثر من 80% من الحد المسموح يوقف نفسه فوراً، أي أن النظام يفضل أن يفقد سيرفراً على أن يعطي ترتيباً خاطئاً.

### رمز الحماية Fencing Token: عندما يظن سيرفران أنهما يملكان القفل

لنفرض أن عندك عاملين Workers، ولا تريد أن يعالج الاثنان الملف نفسه، فتستخدم قفلاً موزعاً Distributed Lock له مدة صلاحية Lease، فيأخذ العامل الأول القفل لمدة 10 ثوان. ثم يتوقف العامل الأول فجأة 15 ثانية بسبب جمع الذاكرة Garbage Collection أو لأن السيرفر الافتراضي أوقف مؤقتاً، فتنتهي صلاحية القفل ويأخذه العامل الثاني ويبدأ الكتابة، ثم يستيقظ الأول وهو يظن أنه ما زال يملك القفل فيكتب فوق عمل الثاني. والمشكلة هنا أنه لا يوجد أي قفل يحل هذه الحالة وحده، لأن العامل الأول لم يكن يعرف أنه توقف.

والحل الذي يشرحه Martin Kleppmann في مقاله [How to do distributed locking](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html?ref=arabroot.io) هو رمز الحماية: كل مرة يعطى فيها القفل يأتي معه رقم يزيد دائماً، فالعامل الأول أخذ الرقم 33 والثاني الرقم 34، والعامل يرسل الرقم مع كل كتابة إلى نظام التخزين، والتخزين يحفظ أكبر رقم رآه ويرفض أي كتابة برقم أصغر، فعندما يستيقظ الأول ويكتب بالرقم 33 ترفض الكتابة. ويذكر المقال أن رقم المعاملة `zxid` في ZooKeeper يصلح لهذا الغرض، وفي etcd كل كتابة تحمل رقم مراجعة Revision يزيد دائماً كما سوف ترى في التجربة، فيصلح للغرض نفسه.

## الإجماع Consensus: كيف تتفق السيرفرات على قرار واحد؟

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

ومشكلة الإجماع هي أن تتفق مجموعة من السيرفرات على قيمة واحدة، مثل «من هو القائد في الدورة 4» أو «ما هي العملية رقم 17 في السجل»، بحيث لا يمكن أن يقرر سيرفران قرارين مختلفين، ويستمر النظام في العمل ما دامت أغلبية السيرفرات تعمل وتستطيع التواصل. وأول حل معروف لها هو Paxos الذي نشره Leslie Lamport في ورقة [The Part-Time Parliament](https://lamport.azurewebsites.net/pubs/lamport-paxos.pdf?ref=arabroot.io)، وهو صحيح ولكنه مشهور بصعوبة فهمه وتطبيقه، لذلك صمم Diego Ongaro وJohn Ousterhout خوارزمية Raft ونشراها في [مؤتمر USENIX ATC سنة 2014](https://raft.github.io/raft.pdf?ref=arabroot.io) بهدف واحد معلن هو أن تكون سهلة الفهم، وصارت اليوم الأكثر استخداماً في الأدوات مفتوحة المصدر.

### Raft خطوة بخطوة

كل سيرفر في Raft في واحدة من ثلاث حالات: تابع Follower أو مرشح Candidate أو قائد Leader، والزمن مقسم إلى دورات Terms مرقمة 1 و2 و3، وكل دورة تبدأ بانتخابات وفيها قائد واحد على الأكثر، ويمكن أن تفهم الدورة كرقم الولاية. والخطوات كما يلي:

1. **الكل يبدأ تابعاً:** وكل تابع ينتظر رسائل نبض القلب Heartbeat من القائد، فإذا مرت مهلة الانتخاب Election Timeout دون أن يسمع شيئاً، يفترض أن القائد غاب. والمهلة عشوائية لكل سيرفر، وهذه هي الحيلة التي تجعل سيرفراً واحداً يبدأ الانتخابات غالباً قبل غيره، فلا يترشح الجميع معاً وتتوزع الأصوات.
2. **الترشح:** التابع الذي انتهت مهلته يزيد رقم الدورة، ويصوت لنفسه، ويطلب أصوات الآخرين.
3. **التصويت:** كل سيرفر يعطي صوتاً واحداً فقط في كل دورة، ولا يعطيه لمرشح سجله أقدم من سجله، وهذا الشرط هو الذي يضمن أن القائد الجديد يملك كل ما تم اعتماده من قبل.
4. **الفوز بالأغلبية:** المرشح الذي يحصل على أصوات أغلبية السيرفرات، أي اثنين من ثلاثة أو ثلاثة من خمسة، يصبح القائد ويبدأ إرسال نبض القلب فوراً حتى لا تبدأ انتخابات أخرى.
5. **نسخ السجل Log Replication:** كل كتابة من الكلاينت تذهب إلى القائد، فيضيفها إلى سجله ويرسلها إلى التابعين، وعندما تحفظها أغلبية السيرفرات تصبح معتمدة Committed، فيطبقها القائد ويرد على الكلاينت، ثم يطبقها التابعون.
6. **القائد القديم يتنحى:** إذا عاد قائد قديم من انقطاع ورأى رسالة برقم دورة أكبر من دورته، يعرف أن هناك قائداً جديداً فيصبح تابعاً، وأي كتابة لم يعتمدها في دورته تحذف لأنها لم تصل إلى الأغلبية أصلاً.

وتضيف مكتبة Raft التي يستخدمها etcd خاصيتين من أطروحة Ongaro كما في [مستودع etcd-io/raft](https://github.com/etcd-io/raft?ref=arabroot.io): الأولى التصويت المسبق PreVote، حيث يسأل المرشح أولاً هل سوف تصوتون لي قبل أن يزيد رقم الدورة، حتى لا يعود سيرفر معزول فيربك العنقود برقم دورة كبير، والثانية فحص النصاب CheckQuorum، حيث يتنحى القائد بنفسه إذا لم يسمع من الأغلبية خلال مهلة الانتخاب. وسوف ترى الاثنتين في سجلات التجربة.

والصورة التالية تبين الدورات والانتخابات ونسخ الكتابة إلى الأغلبية:

![مخطط من جزأين: في الأعلى خط زمني للدورات term 1 وterm 2 وterm 3، ثم الحالات الثلاث تابع ومرشح وقائد مع أسهم: انتهت المهلة فيصبح مرشحاً، حصل على الأغلبية فيصبح قائداً، رأى دورة أكبر فيعود تابعاً، وفي الأسفل نسخ السجل في خمس خطوات: الكلاينت يرسل put x=5 إلى القائد، القائد يضيفها إلى سجله في المدخل 7، يرسلها إلى التابعين، تابع واحد يؤكد فتصبح عند اثنين من ثلاثة وهي أغلبية، فيعتمدها القائد ويرد على الكلاينت OK بينما التابع الثالث متأخر وسوف يلحق](https://arabroot.io/content/images/2026/10/distributed-systems-05-raft.webp)

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

### لماذا ثلاثة أو خمسة، وليس اثنين أو أربعة؟

الأغلبية Majority أو النصاب Quorum في عنقود من n سيرفر هي أكثر من النصف، أي `floor(n/2) + 1`، وعدد السيرفرات التي تستطيع أن تفقدها ويبقى العنقود يعمل هو الباقي. ومن الجدول التالي ترى لماذا يوصي [توثيق etcd](https://etcd.io/docs/v3.7/faq/?ref=arabroot.io) و[توثيق Consul](https://developer.hashicorp.com/consul/docs/concept/consensus?ref=arabroot.io) بعدد فردي:

| عدد السيرفرات | الأغلبية المطلوبة | كم سيرفراً تستطيع أن تفقد |
| ------------- | ----------------- | ------------------------- |
| 1             | 1                 | 0                         |
| 2             | 2                 | 0                         |
| 3             | 2                 | 1                         |
| 4             | 3                 | 1                         |
| 5             | 3                 | 2                         |
| 7             | 4                 | 3                         |

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

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

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

![مخطط من ثلاثة أجزاء: في الأول عنقود من ثلاثة سيرفرات يفقد سيرفراً واحداً فيبقى اثنان وهما أغلبية فيستمر، وعنقود من خمسة يفقد اثنين فيبقى ثلاثة ويستمر، وفي الثاني عنقود من أربعة ينقسم إلى اثنين واثنين فلا يملك أي جزء الأغلبية ثلاثة فيتوقف الكل، وعنقود من اثنين يتوقف إذا فقد واحداً، وفي الثالث انقسام الدماغ في نظام من سيرفرين دون نصاب حيث يرقي كل سيرفر نفسه إلى قائد ويقبل كتابات مختلفة، مقابل عنقود من خمسة ينقسم إلى ثلاثة واثنين فيكتب الثلاثة فقط ويرفض الاثنان بخطأ no leader](https://arabroot.io/content/images/2026/10/distributed-systems-06-quorum.webp)

الأغلبية لا يملكها إلا جزء واحد، لذلك لا يمكن أن يوجد قائدان يكتبان في الوقت نفسه

📌

في 2 نوفمبر 2020 بدأ سويتش في أحد مراكز بيانات Cloudflare يعطل بعض حركة الشبكة دون غيرها لمدة ست دقائق تقريباً، فكان أحد سيرفرات عنقود etcd من ثلاثة يرى بعض زملائه ولا يرى غيرهم، وكما شرحت الشركة في [تقريرها عن الحادثة](https://blog.cloudflare.com/a-byzantine-failure-in-the-real-world/?ref=arabroot.io) كان السيرفر الأول يبدأ الانتخابات ويصوت لنفسه مراراً بينما يصوت الثاني للثالث الذي يستطيع الوصول إليه، فلم يستقر قائد يراه الجميع وتوقفت الكتابة على العنقود. وأدى ذلك إلى أن نظام إدارة قواعد البيانات الذي يعتمد على etcd رقى نسخة إلى قاعدة رئيسية، ثم احتاجت كل النسخ إلى إعادة بناء بسبب عيب في ذلك النظام، فبقيت قاعدة بيانات المصادقة دون نسخ قراءة ومضغوطة، واستمر تأثر ال API ولوحة التحكم ست ساعات و33 دقيقة. والدرس أن الانقطاع الجزئي أصعب من الكامل، وأن الترقية التلقائية المتسرعة قد تكون أسوأ من العطل نفسه.

### أين تقابل الإجماع في عملك؟

غالباً لن تكتب Raft بنفسك، ولكنك سوف تشغل أنظمة مبنية عليه، ومعرفة ذلك تفسر لك سلوكها عند العطل:

- **Kubernetes:** كل حالة العنقود محفوظة في etcd، الذي يصفه [توثيق Kubernetes](https://kubernetes.io/docs/concepts/architecture/?ref=arabroot.io) بأنه مخزن مفاتيح متسق ومتوفر لكل بيانات العنقود، لذلك عنقود Kubernetes بتوفر عال يشغل etcd على ثلاثة سيرفرات أو خمسة، وهو موضوع في [قسم Kubernetes في خارطة الطريق](https://arabroot.io/start/#stage-kubernetes).
- **PostgreSQL مع Patroni:** [Patroni](https://patroni.readthedocs.io/en/latest/?ref=arabroot.io) لا ينفذ الإجماع بنفسه، وإنما يضع قفل القيادة في مخزن موزع DCS هو etcd أو Consul أو ZooKeeper أو Kubernetes، فالسيرفر الذي يملك القفل هو الرئيسي، وإذا فشل في تجديده يحول PostgreSQL فوراً إلى القراءة فقط كما في [توثيق Patroni](https://patroni.readthedocs.io/en/latest/dcs%5Ffailsafe%5Fmode.html?ref=arabroot.io)، حتى لا يبقى رئيسيان. وهذا هو الحل الصحيح لمشكلة «من يرقي من» التي بدأنا بها، وهو ضمن [قسم التوفر العالي في خارطة الطريق](https://arabroot.io/start/#stage-ha).
- **Consul:** سيرفرات Consul تتفق عبر Raft كما في [توثيقها](https://developer.hashicorp.com/consul/docs/concept/consensus?ref=arabroot.io)، وجدول التحمل فيه هو نفسه جدولنا أعلاه.
- **Kafka:** منذ الإصدار 4.0 في مارس 2025 يعمل Kafka [دون ZooKeeper إطلاقاً](https://kafka.apache.org/blog/2025/03/18/apache-kafka-4.0.0-release-announcement/?ref=arabroot.io)، وتدار بياناته الوصفية بوضع KRaft المبني على بروتوكول Raft كما في [KIP-595](https://cwiki.apache.org/confluence/display/KAFKA/KIP-595%3A+A+Raft+Protocol+for+the+Metadata+Quorum?ref=arabroot.io)، والطوابير موضوع في [قسم الطوابير في خارطة الطريق](https://arabroot.io/start/#stage-queues).
- **قواعد البيانات الموزعة:** CockroachDB وTiDB عبر [TiKV](https://tikv.org/deep-dive/consensus-algorithm/raft/?ref=arabroot.io) وYugabyteDB تقسم البيانات إلى أجزاء صغيرة، ولكل جزء مجموعة Raft خاصة به من ثلاث نسخ عادة، فالعنقود الواحد فيه آلاف المجموعات.

## تجربة عملية: عنقود etcd من ثلاثة سيرفرات

الكلام عن الانتخابات والأغلبية يصبح أوضح بكثير عندما تراه في السجلات، لذلك سوف نشغل عنقود etcd من ثلاثة سيرفرات في Containers على جهاز واحد، ونكتب مفتاحاً، ثم نوقف القائد ونراقب الانتخابات، ثم نوقف سيرفراً ثانياً ونرى ماذا يحدث دون أغلبية. وكل الأسماء تبدأ ب `lab-dist` حتى تحذفها بسهولة في النهاية، والمنافذ مربوطة على `127.0.0.1` فقط. وإذا لم يكن Docker مثبتاً لديك فقد شرحنا تثبيته في دليل [تثبيت Docker على Ubuntu](https://arabroot.io/articles/%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-docker-%D8%B9%D9%84%D9%89-ubuntu/).

### تشغيل العنقود

احفظ الملف التالي باسم `compose.yaml` في مجلد فارغ:

```yaml
# 3-node etcd cluster for the lab: docker compose -p lab-dist up -d
x-etcd: &etcd
  image: gcr.io/etcd-development/etcd:v3.7.2
  networks: [lab-dist]

services:
  etcd1:
    <<: *etcd
    container_name: lab-dist-etcd1
    ports: ["127.0.0.1:23791:2379"]
    command: >
      etcd --name etcd1 --data-dir /etcd-data
      --listen-client-urls http://0.0.0.0:2379 --advertise-client-urls http://etcd1:2379
      --listen-peer-urls http://0.0.0.0:2380 --initial-advertise-peer-urls http://etcd1:2380
      --initial-cluster etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380
      --initial-cluster-token lab-dist --initial-cluster-state new
  etcd2:
    <<: *etcd
    container_name: lab-dist-etcd2
    ports: ["127.0.0.1:23792:2379"]
    command: >
      etcd --name etcd2 --data-dir /etcd-data
      --listen-client-urls http://0.0.0.0:2379 --advertise-client-urls http://etcd2:2379
      --listen-peer-urls http://0.0.0.0:2380 --initial-advertise-peer-urls http://etcd2:2380
      --initial-cluster etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380
      --initial-cluster-token lab-dist --initial-cluster-state new
  etcd3:
    <<: *etcd
    container_name: lab-dist-etcd3
    ports: ["127.0.0.1:23793:2379"]
    command: >
      etcd --name etcd3 --data-dir /etcd-data
      --listen-client-urls http://0.0.0.0:2379 --advertise-client-urls http://etcd3:2379
      --listen-peer-urls http://0.0.0.0:2380 --initial-advertise-peer-urls http://etcd3:2380
      --initial-cluster etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380
      --initial-cluster-token lab-dist --initial-cluster-state new

networks:
  lab-dist:
    name: lab-dist
```

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

- استخدمنا ال Docker Image الرسمية من `gcr.io/etcd-development/etcd` بالإصدار v3.7.2، وهي المستودع الأساسي الذي تذكره [صفحة الإصدار](https://github.com/etcd-io/etcd/releases/tag/v3.7.2?ref=arabroot.io).
- المنفذ 2379 للكلاينت، والمنفذ 2380 للتواصل بين السيرفرات، وهو الذي تمر عليه رسائل Raft.
- `--initial-cluster` يعرف كل سيرفر بزملائه عند الإنشاء الأول، كما في [دليل إنشاء العنقود](https://etcd.io/docs/v3.7/op-guide/clustering/?ref=arabroot.io)، و`--initial-cluster-token` اسم فريد يمنع أن ينضم سيرفر إلى عنقود آخر بالخطأ.
- الاتصال هنا HTTP دون تشفير لأنه مختبر على جهاز واحد، أما في الإنتاج Production فاستخدم TLS بين السيرفرات وللكلاينت كما يشرح [دليل الأمان في etcd](https://etcd.io/docs/v3.7/op-guide/security/?ref=arabroot.io).

الآن نشغل العنقود ونعرض حالة كل سيرفر:

```bash
docker compose -p lab-dist up -d
docker exec lab-dist-etcd1 etcdctl --endpoints=http://etcd1:2379 endpoint status --cluster -w table
```

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

```text
┌───────────────────┬──────────────────┬─────────┬─────────────────┬─────────┬────────┬───────────────────────┬────────┬───────────┬────────────┬───────────┬────────────┬────────────────────┬────────┬──────────────────────────┬───────────────────┐
│     ENDPOINT      │        ID        │ VERSION │ STORAGE VERSION │ DB SIZE │ IN USE │ PERCENTAGE NOT IN USE │ QUOTA  │ IS LEADER │ IS LEARNER │ RAFT TERM │ RAFT INDEX │ RAFT APPLIED INDEX │ ERRORS │ DOWNGRADE TARGET VERSION │ DOWNGRADE ENABLED │
├───────────────────┼──────────────────┼─────────┼─────────────────┼─────────┼────────┼───────────────────────┼────────┼───────────┼────────────┼───────────┼────────────┼────────────────────┼────────┼──────────────────────────┼───────────────────┤
│ http://etcd3:2379 │ 184ce8ad16c0df98 │   3.7.2 │           3.5.0 │   20 kB │  16 kB │                   20% │ 2.1 GB │     false │      false │         2 │          8 │                  8 │        │                          │             false │
│ http://etcd1:2379 │ 5d0260e6fe237843 │   3.7.2 │           3.5.0 │   20 kB │  16 kB │                   20% │ 2.1 GB │     false │      false │         2 │          8 │                  8 │        │                          │             false │
│ http://etcd2:2379 │ e461ace3030eb7ce │   3.7.2 │           3.5.0 │   20 kB │  16 kB │                   20% │ 2.1 GB │      true │      false │         2 │          8 │                  8 │        │                          │             false │
└───────────────────┴──────────────────┴─────────┴─────────────────┴─────────┴────────┴───────────────────────┴────────┴───────────┴────────────┴───────────┴────────────┴────────────────────┴────────┴──────────────────────────┴───────────────────┘
```

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

- العمود `IS LEADER` يبين أن القائد هو `etcd2`، ورقمه `e461ace3030eb7ce`، وسوف نحتاج الأرقام الثلاثة لقراءة السجلات: `5d0260e6fe237843` هو etcd1، و`184ce8ad16c0df98` هو etcd3.
- `RAFT TERM` قيمته 2 في السيرفرات الثلاثة، أي أن الانتخابات الأولى حدثت في الدورة الثانية، و`RAFT INDEX` رقم آخر مدخل في السجل، وهو متطابق في الثلاثة.
- من سيكون القائد في تجربتك قد يختلف، لأن مهلة الانتخاب عشوائية كما ذكرنا.

وتستطيع أن ترى الانتخابات الأولى في سجل أي سيرفر، والسجلات بصيغة JSON طويلة، لذلك نستخرج منها الوقت والرسالة فقط لأسطر Raft:

```bash
docker logs lab-dist-etcd1 2>&1 | grep '"logger":"raft"' \
  | sed -E 's/.*"ts":"([^"]+)".*"msg":"([^"]+)".*/\1 \2/' | tail -5
```

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

```text
2026-10-05T23:35:16.423719Z 5d0260e6fe237843 [logterm: 1, index: 3, vote: 0] cast MsgPreVote for e461ace3030eb7ce [logterm: 1, index: 3] at term 1
2026-10-05T23:35:16.425974Z 5d0260e6fe237843 [term: 1] received a MsgVote message with higher term from e461ace3030eb7ce [term: 2]
2026-10-05T23:35:16.426087Z 5d0260e6fe237843 became follower at term 2
2026-10-05T23:35:16.426107Z 5d0260e6fe237843 [logterm: 1, index: 3, vote: 0] cast MsgVote for e461ace3030eb7ce [logterm: 1, index: 3] at term 2
2026-10-05T23:35:16.427814Z raft.node: 5d0260e6fe237843 elected leader e461ace3030eb7ce at term 2
```

وهنا ترى خطوات Raft نفسها: etcd2 سأل أولاً بالتصويت المسبق `MsgPreVote` فحصل على الموافقة، ثم رفع الدورة إلى 2 وطلب الأصوات الحقيقية `MsgVote`، فصوت له etcd1، وبعد أقل من ملي ثانيتين أعلن `elected leader e461ace3030eb7ce at term 2`.

### الكتابة على سيرفر والقراءة من آخر

نكتب مفتاحاً عبر etcd1، ثم نقرؤه من etcd3 مع تفاصيله:

```bash
docker exec lab-dist-etcd1 etcdctl --endpoints=http://etcd1:2379,http://etcd2:2379,http://etcd3:2379 \
  put /config/feature-flag on
docker exec lab-dist-etcd3 etcdctl --endpoints=http://etcd3:2379 get /config/feature-flag -w fields
```

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

```text
OK
"ClusterID" : 1059549324329279948
"MemberID" : 1751030185249005464
"Revision" : 2
"RaftTerm" : 2
"Key" : "/config/feature-flag"
"CreateRevision" : 2
"ModRevision" : 2
"Version" : 1
"Value" : "on"
"Lease" : 0
"More" : false
"Count" : 1
```

ولاحظ أننا أرسلنا الكتابة إلى قائمة السيرفرات الثلاثة دون أن نهتم بمن هو القائد، والسبب أن السيرفر التابع الذي يستقبل الكتابة يمررها إلى القائد etcd2، الذي يعتمدها بعد أن تحفظها الأغلبية، ثم قرأناها من etcd3\. والحقل `ModRevision` هو رقم المراجعة الذي ذكرناه في رموز الحماية، وهو يزيد مع كل كتابة في العنقود كله.

### إيقاف القائد بلطف

نوقف القائد etcd2 بالأمر `docker stop` الذي يرسل إشارة الإيقاف SIGTERM، ثم نسأل السيرفرين الباقيين عن حالتهما:

```bash
docker stop lab-dist-etcd2
docker exec lab-dist-etcd1 etcdctl --endpoints=http://etcd1:2379,http://etcd3:2379 endpoint status -w table
```

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

```text
┌───────────────────┬──────────────────┬─────────┬─────────────────┬─────────┬────────┬───────────────────────┬────────┬───────────┬────────────┬───────────┬────────────┬────────────────────┬────────┬──────────────────────────┬───────────────────┐
│     ENDPOINT      │        ID        │ VERSION │ STORAGE VERSION │ DB SIZE │ IN USE │ PERCENTAGE NOT IN USE │ QUOTA  │ IS LEADER │ IS LEARNER │ RAFT TERM │ RAFT INDEX │ RAFT APPLIED INDEX │ ERRORS │ DOWNGRADE TARGET VERSION │ DOWNGRADE ENABLED │
├───────────────────┼──────────────────┼─────────┼─────────────────┼─────────┼────────┼───────────────────────┼────────┼───────────┼────────────┼───────────┼────────────┼────────────────────┼────────┼──────────────────────────┼───────────────────┤
│ http://etcd1:2379 │ 5d0260e6fe237843 │   3.7.2 │           3.7.0 │   20 kB │  16 kB │                   20% │ 2.1 GB │      true │      false │         3 │         11 │                 11 │        │                          │             false │
│ http://etcd3:2379 │ 184ce8ad16c0df98 │   3.7.2 │           3.7.0 │   20 kB │  16 kB │                   20% │ 2.1 GB │     false │      false │         3 │         11 │                 11 │        │                          │             false │
└───────────────────┴──────────────────┴─────────┴─────────────────┴─────────┴────────┴───────────────────────┴────────┴───────────┴────────────┴───────────┴────────────┴────────────────────┴────────┴──────────────────────────┴───────────────────┘
```

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

```text
2026-10-05T23:35:48.287902Z 5d0260e6fe237843 [term 2] received MsgTimeoutNow from e461ace3030eb7ce and starts an election to get leadership.
2026-10-05T23:35:48.287983Z 5d0260e6fe237843 became candidate at term 3
2026-10-05T23:35:48.289930Z 5d0260e6fe237843 has received 1 MsgVoteResp votes and 0 vote rejections
2026-10-05T23:35:48.291819Z 5d0260e6fe237843 has received 2 MsgVoteResp votes and 0 vote rejections
2026-10-05T23:35:48.291850Z 5d0260e6fe237843 became leader at term 3
```

القائد etcd2 عندما استلم إشارة الإيقاف لم يختف ببساطة، وإنما أرسل إلى etcd1 الرسالة `MsgTimeoutNow` التي تعني «ابدأ الانتخابات الآن ولا تنتظر المهلة»، فنقل القيادة خلال 4 ملي ثوان تقريباً. وهذا هو الفرق بين الإيقاف المخطط للتحديث والعطل الحقيقي، لذلك أوقف السيرفرات دائماً بالطريقة الصحيحة عند الصيانة. والآن نعيد etcd2 إلى العنقود بالأمر `docker start lab-dist-etcd2` فيعود تابعاً ويلحق بما فاته.

### عطل حقيقي: قتل القائد فجأة

القائد الآن هو etcd1، وهذه المرة نستخدم `docker kill` الذي يرسل SIGKILL فيتوقف البرنامج فوراً دون أي فرصة للوداع، كما يحدث عند انقطاع الكهرباء:

```bash
docker kill lab-dist-etcd1
docker logs --since 30s lab-dist-etcd2 2>&1 | grep -E '"logger":"raft"|peer became inactive' \
  | sed -E 's/.*"ts":"([^"]+)".*"msg":"([^"]+)".*/\1 \2/'
```

والمخرج سوف يكون كما يلي، وقد اختصرناه إلى الأسطر المهمة وحذفنا أسطر تأكيد استلام كل صوت:

```text
2026-10-05T23:36:15.190500Z peer became inactive (message send to peer failed)
2026-10-05T23:36:16.893183Z e461ace3030eb7ce is starting a new election at term 3
2026-10-05T23:36:16.893242Z e461ace3030eb7ce became pre-candidate at term 3
2026-10-05T23:36:16.893274Z e461ace3030eb7ce [logterm: 3, index: 12] sent MsgPreVote request to 184ce8ad16c0df98 at term 3
2026-10-05T23:36:16.893284Z e461ace3030eb7ce [logterm: 3, index: 12] sent MsgPreVote request to 5d0260e6fe237843 at term 3
2026-10-05T23:36:16.893329Z raft.node: e461ace3030eb7ce lost leader 5d0260e6fe237843 at term 3
2026-10-05T23:36:16.894114Z e461ace3030eb7ce has received 2 MsgPreVoteResp votes and 0 vote rejections
2026-10-05T23:36:16.894138Z e461ace3030eb7ce became candidate at term 4
2026-10-05T23:36:16.894147Z e461ace3030eb7ce [logterm: 3, index: 12] sent MsgVote request to 184ce8ad16c0df98 at term 4
2026-10-05T23:36:16.894153Z e461ace3030eb7ce [logterm: 3, index: 12] sent MsgVote request to 5d0260e6fe237843 at term 4
2026-10-05T23:36:16.897587Z e461ace3030eb7ce has received 2 MsgVoteResp votes and 0 vote rejections
2026-10-05T23:36:16.897609Z e461ace3030eb7ce became leader at term 4
2026-10-05T23:36:16.897618Z raft.node: e461ace3030eb7ce elected leader e461ace3030eb7ce at term 4
```

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

- في 23:36:15.19 لاحظ etcd2 أن الاتصال بالقائد انقطع، ولكنه لم يبدأ الانتخابات إلا في 23:36:16.89، أي بعد قرابة 1.7 ثانية، والسبب أن مهلة الانتخاب الافتراضية في etcd ثانية واحدة ونبض القلب كل 100 ملي ثانية كما في [دليل الضبط](https://etcd.io/docs/v3.7/tuning/?ref=arabroot.io)، والمكتبة تختار المهلة عشوائياً بين المهلة وضعفها كما في [الكود](https://github.com/etcd-io/raft/blob/main/raft.go?ref=arabroot.io).
- بدأ بالتصويت المسبق `pre-candidate` دون أن يغير الدورة، وحصل على صوتين: صوته وصوت etcd3، وهما أغلبية من ثلاثة، ثم فقط رفع الدورة إلى 4 وأصبح مرشحاً.
- أرسل طلب التصويت إلى etcd1 الميت أيضاً، لأنه لا يعرف أنه ميت، ولكنه لا يحتاج رده، فصوتان من ثلاثة يكفيان.
- من القتل إلى القائد الجديد مرت قرابة ثانيتين، وخلال هذه المدة لا يوجد قائد يعتمد الكتابات، فتنتظر الكتابات الجديدة أو تفشل بحسب مهلة الكلاينت، وهذا ثمن الاتساق في لحظة العطل.

والآن نكتب مرة ثانية بسيرفرين فقط لنتأكد أن العنقود ما زال يعمل:

```bash
docker exec lab-dist-etcd2 etcdctl --endpoints=http://etcd2:2379,http://etcd3:2379 put /config/feature-flag off
docker exec lab-dist-etcd2 etcdctl --endpoints=http://etcd2:2379,http://etcd3:2379 get /config/feature-flag
```

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

```text
OK
/config/feature-flag
off
```

### كسر الأغلبية: سيرفر واحد من ثلاثة

الآن نقتل etcd3 أيضاً، فيبقى etcd2 وحده، ثم نحاول الكتابة، ثم القراءة العادية، ثم القراءة بالخيار `--consistency=s` الذي يطلب قراءة متسلسلة Serializable من النسخة المحلية دون سؤال الأغلبية:

```bash
docker kill lab-dist-etcd3
docker exec lab-dist-etcd2 etcdctl --endpoints=http://etcd2:2379 put /config/feature-flag on
docker exec lab-dist-etcd2 etcdctl --endpoints=http://etcd2:2379 get /config/feature-flag
docker exec lab-dist-etcd2 etcdctl --endpoints=http://etcd2:2379 get /config/feature-flag --consistency=s
```

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

```text
{"level":"warn","ts":"2026-10-05T23:36:40.560625Z","logger":"etcd-client","caller":"v3/retry_interceptor.go:68","msg":"retrying of unary invoker failed","target":"etcd-endpoints://0x28978b9eec00/etcd2:2379","peer":"Peer{Addr: '192.168.48.2:2379', LocalAddr: '192.168.48.2:42836', AuthInfo: 'insecure'}","method":"/etcdserverpb.KV/Put","attempt":0,"error":"rpc error: code = DeadlineExceeded desc = stream terminated by RST_STREAM with error code: CANCEL"}
Error: context deadline exceeded
{"level":"warn","ts":"2026-10-05T23:36:46.027812Z","logger":"etcd-client","caller":"v3/retry_interceptor.go:68","msg":"retrying of unary invoker failed","target":"etcd-endpoints://0x169f2d2fd000/etcd2:2379","peer":"Peer{Addr: '192.168.48.2:2379', LocalAddr: '192.168.48.2:41072', AuthInfo: 'insecure'}","method":"/etcdserverpb.KV/Range","attempt":0,"error":"rpc error: code = DeadlineExceeded desc = stream terminated by RST_STREAM with error code: CANCEL"}
Error: context deadline exceeded
/config/feature-flag
off
```

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

- الكتابة انتظرت المهلة الافتراضية للأمر `etcdctl` وهي 5 ثوان ثم فشلت ب `context deadline exceeded`، والسبب أن سيرفراً واحداً لا يملك الأغلبية فلا يستطيع اعتماد أي كتابة.
- القراءة العادية فشلت أيضاً، لأنها خطية Linearizable افتراضياً كما في [ضمانات etcd](https://etcd.io/docs/v3.7/learning/api%5Fguarantees/?ref=arabroot.io)، وهي تحتاج تأكيد الأغلبية أن هذه أحدث قيمة. وهذا بالضبط اختيار C في CAP: السيرفر المعزول يرفض أن يجيب بدلاً من أن يجيب بقيمة قد تكون قديمة.
- القراءة المتسلسلة نجحت وأعادت `off` من النسخة المحلية، وهي هنا صحيحة بالمصادفة، ولكنها كانت ستعيد القيمة نفسها حتى لو كان سيرفران آخران في جزء آخر من الشبكة قد غيراها، وهذا اختيار A مع قيمة قد تكون قديمة.

ويبين الأمر `etcdctl --endpoints=http://etcd2:2379 endpoint status -w table` السبب مباشرة في العمود `ERRORS`:

```text
┌───────────────────┬──────────────────┬─────────┬─────────────────┬─────────┬────────┬───────────────────────┬────────┬───────────┬────────────┬───────────┬────────────┬────────────────────┬───────────────────────┬──────────────────────────┬───────────────────┐
│     ENDPOINT      │        ID        │ VERSION │ STORAGE VERSION │ DB SIZE │ IN USE │ PERCENTAGE NOT IN USE │ QUOTA  │ IS LEADER │ IS LEARNER │ RAFT TERM │ RAFT INDEX │ RAFT APPLIED INDEX │        ERRORS         │ DOWNGRADE TARGET VERSION │ DOWNGRADE ENABLED │
├───────────────────┼──────────────────┼─────────┼─────────────────┼─────────┼────────┼───────────────────────┼────────┼───────────┼────────────┼───────────┼────────────┼────────────────────┼───────────────────────┼──────────────────────────┼───────────────────┤
│ http://etcd2:2379 │ e461ace3030eb7ce │   3.7.2 │           3.7.0 │   20 kB │  16 kB │                   20% │ 2.1 GB │     false │      false │         4 │         14 │                 14 │ etcdserver: no leader │                          │             false │
└───────────────────┴──────────────────┴─────────┴─────────────────┴─────────┴────────┴───────────────────────┴────────┴───────────┴────────────┴───────────┴────────────┴────────────────────┴───────────────────────┴──────────────────────────┴───────────────────┘
```

وفي سجل etcd2، مختصراً، ترى خاصية فحص النصاب CheckQuorum، ثم محاولات التصويت المسبق التي لا تنجح:

```text
2026-10-05T23:36:31.892341Z e461ace3030eb7ce stepped down to follower since quorum is not active
2026-10-05T23:36:31.892445Z e461ace3030eb7ce became follower at term 4
2026-10-05T23:36:31.892493Z raft.node: e461ace3030eb7ce lost leader e461ace3030eb7ce at term 4
2026-10-05T23:36:33.593195Z e461ace3030eb7ce is starting a new election at term 4
2026-10-05T23:36:33.593269Z e461ace3030eb7ce became pre-candidate at term 4
2026-10-05T23:36:33.593485Z e461ace3030eb7ce has received 1 MsgPreVoteResp votes and 0 vote rejections
2026-10-05T23:36:35.293165Z e461ace3030eb7ce is starting a new election at term 4
2026-10-05T23:36:35.293232Z e461ace3030eb7ce became pre-candidate at term 4
```

كان etcd2 هو القائد، ولكنه لم يسمع من الأغلبية فتنحى بنفسه `stepped down to follower since quorum is not active`، ثم صار يحاول كل 1.7 ثانية تقريباً ويحصل على صوت واحد هو صوته. ولاحظ أن الدورة بقيت 4 ولم ترتفع مع كل محاولة، وهذا بفضل التصويت المسبق، فعندما يعود زملاؤه لن يربكهم برقم دورة كبير.

### إعادة العنقود

```bash
docker start lab-dist-etcd1 lab-dist-etcd3
docker exec lab-dist-etcd1 etcdctl --endpoints=http://etcd1:2379 endpoint status --cluster -w table
docker exec lab-dist-etcd1 etcdctl --endpoints=http://etcd1:2379 endpoint health --cluster
docker exec lab-dist-etcd1 etcdctl --endpoints=http://etcd1:2379,http://etcd2:2379,http://etcd3:2379 put /config/feature-flag on
docker exec lab-dist-etcd1 etcdctl --endpoints=http://etcd1:2379 get /config/feature-flag -w fields | grep -E 'Value|ModRevision|Version|RaftTerm'
```

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

```text
┌───────────────────┬──────────────────┬─────────┬─────────────────┬─────────┬────────┬───────────────────────┬────────┬───────────┬────────────┬───────────┬────────────┬────────────────────┬────────┬──────────────────────────┬───────────────────┐
│     ENDPOINT      │        ID        │ VERSION │ STORAGE VERSION │ DB SIZE │ IN USE │ PERCENTAGE NOT IN USE │ QUOTA  │ IS LEADER │ IS LEARNER │ RAFT TERM │ RAFT INDEX │ RAFT APPLIED INDEX │ ERRORS │ DOWNGRADE TARGET VERSION │ DOWNGRADE ENABLED │
├───────────────────┼──────────────────┼─────────┼─────────────────┼─────────┼────────┼───────────────────────┼────────┼───────────┼────────────┼───────────┼────────────┼────────────────────┼────────┼──────────────────────────┼───────────────────┤
│ http://etcd3:2379 │ 184ce8ad16c0df98 │   3.7.2 │           3.7.0 │   20 kB │  16 kB │                   20% │ 2.1 GB │      true │      false │         5 │         17 │                 17 │        │                          │             false │
│ http://etcd1:2379 │ 5d0260e6fe237843 │   3.7.2 │           3.7.0 │   20 kB │  16 kB │                   20% │ 2.1 GB │     false │      false │         5 │         17 │                 17 │        │                          │             false │
│ http://etcd2:2379 │ e461ace3030eb7ce │   3.7.2 │           3.7.0 │   20 kB │  16 kB │                   20% │ 2.1 GB │     false │      false │         5 │         17 │                 17 │        │                          │             false │
└───────────────────┴──────────────────┴─────────┴─────────────────┴─────────┴────────┴───────────────────────┴────────┴───────────┴────────────┴───────────┴────────────┴────────────────────┴────────┴──────────────────────────┴───────────────────┘
http://etcd3:2379 is healthy: successfully committed proposal: took = 3.199701ms
http://etcd1:2379 is healthy: successfully committed proposal: took = 3.266808ms
http://etcd2:2379 is healthy: successfully committed proposal: took = 3.277108ms
OK
"RaftTerm" : 5
"ModRevision" : 4
"Version" : 3
"Value" : "on"
```

عاد العنقود بثلاثة سيرفرات سليمة في الدورة 5، والقائد هذه المرة etcd3\. والأهم هو الحقل `Version` بقيمة 3: المفتاح تغير ثلاث مرات فقط، `on` ثم `off` ثم `on` الأخيرة، أما الكتابة التي فشلت وقت غياب الأغلبية فلم تعتمد إطلاقاً ولم تظهر بعد عودة السيرفرات، وهذا هو الوعد الذي يقدمه الإجماع: الكتابة التي قيل لك إنها فشلت لم تحدث، والتي قيل لك إنها نجحت لن تضيع.

### حذف المختبر

احذف ال Containers والشبكة التي أنشأتها التجربة فقط:

```bash
docker compose -p lab-dist down
```

ولم ننشئ أي Volume لأن البيانات داخل ال Containers نفسها، لذلك يكفي هذا الأمر. وإذا أردت تجربة إضافية، شغل خمسة سيرفرات بدلاً من ثلاثة، واقتل اثنين منها، وتأكد أن الكتابة ما زالت تعمل، ثم اقتل الثالث.

## كيف تتعامل مع الفشل في الطلبات بين الخدمات؟

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

### المهلة Timeout أولاً

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

### إعادة المحاولة مع التأخير المتزايد والعشوائية

الحل البديهي عند الفشل هو إعادة المحاولة فوراً، وهو حل خطير، والسبب أن الخدمة التي فشلت غالباً فشلت لأنها مضغوطة، فإذا أعاد ألف كلاينت المحاولة في اللحظة نفسها تضاعف الضغط عليها فلا تقوم أبداً. والحل الأفضل هو التأخير المتزايد Exponential Backoff، أي تنتظر 200 ملي ثانية ثم 400 ثم 800، ولكنه وحده لا يكفي، لأن الألف كلاينت الذين فشلوا معاً سوف يعيدون المحاولة معاً بعد 200 ملي ثانية بالضبط. والحل الصحيح هو إضافة العشوائية Jitter، وقد قارن Marc Brooker من AWS بين الطرق في مقاله [Exponential Backoff And Jitter](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/?ref=arabroot.io) سنة 2015، وأوصى بما سماه العشوائية الكاملة Full Jitter: `sleep = random(0, min(cap, base * 2 ** attempt))`، أي رقم عشوائي بين الصفر والحد الذي يتضاعف. وأضف إلى ذلك حداً أقصى لعدد المحاولات، وأن تعيد المحاولة في طبقة واحدة فقط من النظام، لأن ثلاث طبقات تحاول كل منها ثلاث مرات تعني 27 طلباً لكل طلب أصلي.

### مفتاح منع التكرار Idempotency Key

إعادة المحاولة آمنة فقط إذا كان تنفيذ الطلب مرتين يعطي النتيجة نفسها كتنفيذه مرة، وهذه هي خاصية Idempotency. فقراءة رصيد أو ضبط قيمة إلى 5 آمنة، أما «اخصم 150 ريالاً» فإذا أعيد بعد أن نفذ وضاع رده فقد خصمت مرتين. والحل أن يولد الكلاينت مفتاحاً فريداً للعملية مرة واحدة، ويرسله مع كل محاولة، والسيرفر يحفظ المفتاح مع نتيجته، فإذا وصله المفتاح نفسه مرة ثانية أعاد النتيجة المحفوظة دون أن ينفذ شيئاً. وقد طبقنا هذه الفكرة بقيد `UNIQUE` في جدول Outbox في دليل [البريد التفاعلي](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D8%B1%D9%8A%D8%AF-%D8%A7%D9%84%D8%AA%D9%81%D8%A7%D8%B9%D9%84%D9%8A-transactional-email/#developer)، وشرحنا كيف تنشئ المفتاح نفسه في دليل [المعرفات الفريدة](https://arabroot.io/articles/%D8%A7%D9%84%D9%85%D8%B9%D8%B1%D9%81%D8%A7%D8%AA-%D8%A7%D9%84%D9%81%D8%B1%D9%8A%D8%AF%D8%A9-uuid-%D9%88-ulid-%D9%88-snowflake/)، وهي الطريقة نفسها التي تستخدمها [Stripe في الترويسة Idempotency-Key](https://docs.stripe.com/api/idempotent%5Frequests?ref=arabroot.io).

المثال التالي `retry.py` يجمع الفكرتين: دالة تعيد المحاولة بتأخير متزايد وعشوائية كاملة وتستخدم مفتاحاً واحداً لكل المحاولات، وسيرفر دفع وهمي يفشل مرتين بانتهاء المهلة، ثم ينفذ الخصم في المرة الثالثة ولكن الرد يضيع:

```python
import random, time, uuid

class TemporaryError(Exception):
    pass

def call_with_retries(send, payload, attempts=5, base=0.2, cap=5.0):
    key = str(uuid.uuid4())                      # one key for every attempt of this operation
    for attempt in range(attempts):
        try:
            return send(payload, idempotency_key=key)
        except TemporaryError as e:
            if attempt == attempts - 1:
                raise
            sleep = random.uniform(0, min(cap, base * 2 ** attempt))   # exponential backoff + full jitter
            print(f"attempt {attempt + 1} failed ({e}), retry in {sleep:.2f}s")
            time.sleep(sleep)

# a fake payment server: it remembers keys, times out twice, then charges but the reply is lost
saved, calls = {}, 0
def send(payload, idempotency_key):
    global calls
    calls += 1
    if idempotency_key in saved:
        print(f"server: key {idempotency_key[:8]} already done, returning the saved result")
        return saved[idempotency_key]
    if calls <= 2:
        raise TemporaryError("timeout")
    saved[idempotency_key] = {"charge_id": f"ch_{calls}", "amount": payload["amount"]}
    if calls == 3:
        raise TemporaryError("charged, but the reply was lost")
    return saved[idempotency_key]

print("result:", call_with_retries(send, {"amount": 150}))
print("charges created:", len(saved))
```

```bash
docker run --rm --name lab-dist-py -v "$PWD/retry.py:/retry.py:ro" python:3.14-alpine python /retry.py
```

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

```text
attempt 1 failed (timeout), retry in 0.13s
attempt 2 failed (timeout), retry in 0.06s
attempt 3 failed (charged, but the reply was lost), retry in 0.04s
server: key 86f13ca9 already done, returning the saved result
result: {'charge_id': 'ch_3', 'amount': 150}
charges created: 1
```

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

- فترات الانتظار عشوائية، فالمحاولة الثالثة كان حدها الأقصى 0.8 ثانية ولكن الرقم العشوائي كان 0.04، وهذا ما يفرق الكلاينتات عن بعضها بدلاً من أن تضرب الخدمة معاً.
- المحاولة الثالثة خصمت المبلغ فعلاً ولكن الكلاينت لم يعرف، فأعاد المحاولة، وهذه هي الحالة التي لا يحلها أي قدر من الحذر في الكلاينت وحده.
- المحاولة الرابعة حملت المفتاح نفسه، فأعاد السيرفر النتيجة المحفوظة `ch_3`، وبقي عدد عمليات الخصم 1.

### قاطع الدائرة Circuit Breaker والحواجز Bulkheads

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

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

### فحص الصحة ونبض القلب

نبض القلب Heartbeat رسالة صغيرة دورية تقول «أنا حي»، ورأيته في Raft حيث يرسله القائد كل 100 ملي ثانية، وغيابه هو الطريقة الوحيدة التي يكتشف بها سيرفر أن الآخر غاب، وهو اكتشاف بالشك وليس باليقين كما رأينا. وفي تطبيقاتك يقابله فحص الصحة Health Check الذي يسأل فيه موزع الحمل أو Kubernetes تطبيقك دورياً، وفرق Kubernetes بين نوعين في [توثيق الفحوص](https://kubernetes.io/docs/concepts/workloads/pods/probes/?ref=arabroot.io): فحص الحياة Liveness الذي يعيد تشغيل ال Container إذا فشل، وفحص الجاهزية Readiness الذي يوقف إرسال الطلبات إليه فقط. ولا تقم بجعل فحص الحياة يفحص قاعدة البيانات، والسبب أن تعطل قاعدة البيانات عندها سوف يعيد تشغيل كل نسخ تطبيقك معاً دون أي فائدة، فهذا مكان فحص الجاهزية.

### مرة واحدة على الأكثر، أو على الأقل، أو «مرة واحدة بالضبط»

عندما ترسل رسالة عبر طابور أو بين خدمتين، فأمامك ثلاثة ضمانات للتسليم:

- **مرة على الأكثر At-Most-Once:** ترسل ولا تعيد المحاولة، فقد تضيع الرسالة ولكنها لا تتكرر، وهذا مقبول لقياسات المراقبة مثلاً.
- **مرة على الأقل At-Least-Once:** تعيد المحاولة حتى يصلك التأكيد، فلا تضيع الرسالة ولكنها قد تصل مرتين، وهذا هو الضمان الافتراضي في أغلب الطوابير، ومنها Kafka كما في [توثيق التصميم](https://kafka.apache.org/42/design/design/?ref=arabroot.io#semantics).
- **مرة واحدة بالضبط Exactly-Once:** وهي في الحقيقة «مرة على الأقل» مع إزالة التكرار عند الاستقبال، أي أن الرسالة قد تصل مرتين ولكن أثرها يحدث مرة واحدة، لذلك يسميها بعض الناس Effectively Once. وKafka تقدمها داخل Kafka نفسها عبر المرسل المعرف Idempotent Producer والمعاملات، ويذكر توثيقها صراحة أن تحقيقها مع أنظمة خارجية يحتاج تعاون تلك الأنظمة. والخلاصة العملية أن تجعل المستقبل يتحمل التكرار بمفتاح منع التكرار، وتختار «مرة على الأقل».

والصورة التالية تبين الفرق بين إعادة المحاولة المتزامنة والعشوائية، ودور المفتاح عندما يضيع الرد:

![مخطط من جزأين: في الأول خطان زمنيان، الأول دون عشوائية حيث تعيد ألف طلب المحاولة معاً بعد 200 و400 و800 ملي ثانية فتظهر قمم عالية تضرب الخدمة، والثاني مع عشوائية كاملة حيث تتوزع المحاولات على الفترة فيبقى الحمل منخفضاً، وفي الثاني قصة مفتاح منع التكرار: الكلاينت يرسل خصم 150 مع المفتاح 86f13ca9، السيرفر ينفذ الخصم ويحفظ المفتاح، الرد يضيع، الكلاينت يعيد المحاولة بالمفتاح نفسه، السيرفر يجد المفتاح فيعيد النتيجة المحفوظة ch_3 دون خصم ثان](https://arabroot.io/content/images/2026/10/distributed-systems-07-retries.webp)

العشوائية توزع المحاولات بدل أن تضرب الخدمة معاً، والمفتاح يجعل المحاولة المكررة دون أثر ثان

## المعاملات الموزعة: عندما تمتد العملية على أكثر من خدمة

في قاعدة بيانات واحدة، عملية «أنشئ الطلب واخصم المخزون واخصم المبلغ» معاملة واحدة Transaction، إما أن تنجح كلها أو تلغى كلها. ولكن إذا كان الطلب في خدمة والمخزون في خدمة والدفع في خدمة ثالثة، ولكل منها قاعدة بياناتها، فكيف تضمن أنك لن تخصم المبلغ ثم يفشل حجز المخزون؟

### الالتزام على مرحلتين 2PC: لماذا يتجنبه الناس؟

الحل الكلاسيكي هو الالتزام على مرحلتين Two-Phase Commit: منسق Coordinator يسأل كل المشاركين «هل أنتم مستعدون؟»، فيجهز كل واحد عمليته ويقفل السطور ويرد «مستعد»، ثم يرسل المنسق «نفذوا» للجميع. والفكرة صحيحة، ولكن مشكلتها في الفشل: إذا توقف المنسق بعد أن رد الجميع بالاستعداد وقبل أن يرسل القرار، يبقى كل مشارك قافلاً سطوره ينتظر قراراً لا يعرفه، ولا يستطيع أن ينفذ أو يلغي وحده. لذلك يقول توثيق PostgreSQL عن الأمر [PREPARE TRANSACTION](https://www.postgresql.org/docs/18/sql-prepare-transaction.html?ref=arabroot.io) الذي يستخدم لهذا الغرض إنه غير مخصص للتطبيقات، وإنما لمدير معاملات خارجي، وإنك على الأغلب لا تحتاجه إلا إذا كنت تكتب مدير معاملات. ويضاف إلى ذلك أن كل الخدمات يجب أن تكون متاحة في اللحظة نفسها، فتوفر العملية هو حاصل ضرب توفر كل الخدمات.

### نمط Saga: خطوات محلية وخطوات تعويض

نمط Saga اقترحه Hector Garcia-Molina وKenneth Salem في [ورقة سنة 1987](https://www.cs.cornell.edu/andru/cs711/2002fa/reading/sagas.pdf?ref=arabroot.io)، وفكرته أن تقسم العملية الطويلة إلى خطوات، كل خطوة معاملة محلية عادية في خدمة واحدة، ولكل خطوة خطوة تعويض Compensating Action تلغي أثرها. فإذا نجح إنشاء الطلب ثم حجز المخزون ثم فشل الدفع، تنفذ تعويضات الخطوات السابقة بالعكس: فك حجز المخزون، ثم إلغاء الطلب. وينفذ إما بمنسق Orchestrator يدير الخطوات، أو بأحداث Choreography تستمع إليها كل خدمة، كما يشرح [موقع microservices.io](https://microservices.io/patterns/data/saga.html?ref=arabroot.io).

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

### نمط Outbox: الكتابة والرسالة معاً

كل خطوة في Saga تكتب في قاعدة بياناتها ثم ترسل حدثاً إلى الخطوة التالية، وهنا مشكلة صغيرة خطيرة: إذا كتبت ثم توقف التطبيق قبل الإرسال ضاع الحدث، وإذا أرسلت ثم فشلت الكتابة أرسلت حدثاً عن شيء لم يحدث. والحل هو [نمط Transactional Outbox](https://microservices.io/patterns/data/transactional-outbox.html?ref=arabroot.io): تكتب الحدث في جدول `outbox` في قاعدة البيانات نفسها وداخل المعاملة نفسها مع التغيير، فإما أن يحفظا معاً أو لا يحفظ أي منهما، ثم يقرأ عامل خلفي الجدول ويرسل الأحداث ويعيد المحاولة إذا فشل. وهذا هو المثال الكامل الذي بنيناه في دليل [البريد التفاعلي](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D8%B1%D9%8A%D8%AF-%D8%A7%D9%84%D8%AA%D9%81%D8%A7%D8%B9%D9%84%D9%8A-transactional-email/#developer)، ولأن العامل قد يرسل الحدث مرتين إذا توقف بعد الإرسال وقبل أن يسجله، فالمستقبل يحتاج مفتاح منع التكرار، فترى كيف تتصل كل أنماط هذا المقال ببعضها.

### الاتساق النهائي بلغة العمل

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

والصورة التالية تقارن بين الطريقتين على عملية طلب واحدة:

![مخطط من جزأين: في الأول الالتزام على مرحلتين، منسق يرسل prepare إلى خدمات الطلبات والمخزون والدفع فترد كلها ready وتقفل سطورها، ثم يتوقف المنسق قبل إرسال commit فتبقى الخدمات الثلاث عالقة بأقفالها، وفي الثاني نمط Saga في خطوات مرقمة: إنشاء الطلب ثم حجز المخزون ثم الدفع الذي يفشل، ثم تعويض بالعكس: فك حجز المخزون ثم إلغاء الطلب، وكل خطوة تكتب حدثها في جدول outbox داخل معاملتها المحلية](https://arabroot.io/content/images/2026/10/distributed-systems-08-saga.webp)

في 2PC ينتظر الجميع قراراً واحداً، وفي Saga تنجح كل خطوة وحدها وتلغى بالتعويض

## خريطة الأدوات مفتوحة المصدر

هذه أشهر الأدوات التي تقابلها في هذا المجال، مع ترخيص كل واحدة كما هو اليوم، لأن عدداً منها انتقل في السنوات الأخيرة من ترخيص مفتوح المصدر إلى ترخيص «المصدر المتاح» Source Available، وهو ترخيص يسمح لك بقراءة الكود وتشغيله بشروط، ولكنه لا يحقق تعريف المصدر المفتوح، وقد شرحنا الفرق في دليل [تراخيص البرمجيات مفتوحة المصدر](https://arabroot.io/articles/%D9%85%D8%A7-%D9%87%D9%8A-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D8%A7%D8%AA-%D9%85%D9%81%D8%AA%D9%88%D8%AD%D8%A9-%D8%A7%D9%84%D9%85%D8%B5%D8%AF%D8%B1-%D9%88%D8%AA%D8%B1%D8%A7%D8%AE%D9%8A%D8%B5%D9%87%D8%A7/):

| الأداة                                                                                                                        | ما هي                                                                                                    | كيف تتفق أو تنسخ                                                                                             | الترخيص                                                                                                                                                                                                                                  |
| ----------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [etcd](https://etcd.io/?ref=arabroot.io)                                                                                      | مخزن مفاتيح متسق، قلب Kubernetes ومخزن Patroni الشائع                                                    | Raft                                                                                                         | Apache 2.0                                                                                                                                                                                                                               |
| [Consul](https://developer.hashicorp.com/consul?ref=arabroot.io)                                                              | اكتشاف الخدمات Service Discovery ومخزن مفاتيح                                                            | Raft                                                                                                         | BSL 1.1 منذ أغسطس 2023 [بإعلان HashiCorp](https://www.hashicorp.com/en/blog/hashicorp-adopts-business-source-license?ref=arabroot.io)، أي مصدر متاح وليس مفتوحاً، والإصدارات السابقة MPL 2.0                                             |
| [ZooKeeper](https://zookeeper.apache.org/?ref=arabroot.io)                                                                    | أقدم خدمة تنسيق، وما زال يدعمه Patroni وتعتمد عليه أنظمة قديمة، ولكن Kafka لم يعد يحتاجه منذ الإصدار 4.0 | بروتوكول [Zab](https://cwiki.apache.org/confluence/display/ZOOKEEPER/Zab1.0?ref=arabroot.io) القريب من Paxos | Apache 2.0                                                                                                                                                                                                                               |
| [Apache Kafka](https://kafka.apache.org/?ref=arabroot.io)                                                                     | سجل أحداث موزع للطوابير وبث الأحداث                                                                      | KRaft للبيانات الوصفية، ونسخ الأجزاء من قائد إلى تابعين                                                      | Apache 2.0                                                                                                                                                                                                                               |
| [CockroachDB](https://github.com/cockroachdb/cockroach?ref=arabroot.io)                                                       | قاعدة SQL موزعة متوافقة مع بروتوكول PostgreSQL                                                           | Raft لكل جزء، وساعة HLC                                                                                      | منذ الإصدار 24.3 في نوفمبر 2024 ترخيص CockroachDB Software License، [مصدر متاح](https://www.cockroachlabs.com/blog/enterprise-license-announcement/?ref=arabroot.io) ومجاني للشركات التي يقل دخلها عن 10 ملايين دولار، وليس مفتوح المصدر |
| [YugabyteDB](https://github.com/yugabyte/yugabyte-db?ref=arabroot.io)                                                         | قاعدة SQL موزعة تعيد استخدام كود PostgreSQL                                                              | Raft لكل جزء                                                                                                 | قاعدة البيانات Apache 2.0، وأداة الإدارة YugabyteDB Anywhere بترخيص Polyform تجريبي                                                                                                                                                      |
| [TiDB](https://github.com/pingcap/tidb?ref=arabroot.io)                                                                       | قاعدة SQL موزعة متوافقة مع MySQL، تخزن في TiKV                                                           | Raft لكل جزء                                                                                                 | Apache 2.0                                                                                                                                                                                                                               |
| [Apache Cassandra](https://cassandra.apache.org/?ref=arabroot.io)                                                             | قاعدة أعمدة واسعة دون قائد للكتابة الكثيفة                                                               | نسخ دون قائد ومستويات اتساق قابلة للضبط                                                                      | Apache 2.0                                                                                                                                                                                                                               |
| [ScyllaDB](https://www.scylladb.com/?ref=arabroot.io)                                                                         | بديل متوافق مع Cassandra مكتوب بلغة C++                                                                  | مثل Cassandra                                                                                                | منذ الإصدار 2025.1 [مصدر متاح](https://www.scylladb.com/2024/12/18/license/?ref=arabroot.io) مع طبقة مجانية محدودة، وآخر إصدار مفتوح المصدر AGPL هو 6.2                                                                                  |
| [Valkey](https://github.com/valkey-io/valkey?ref=arabroot.io)                                                                 | مخزن في الذاكرة، تفرع من Redis قبل تغيير ترخيصه مباشرة                                                   | نسخ غير متزامن من قائد إلى تابعين                                                                            | BSD 3-Clause                                                                                                                                                                                                                             |
| [Redis](https://github.com/redis/redis?ref=arabroot.io)                                                                       | مخزن في الذاكرة                                                                                          | نسخ غير متزامن، وWAIT لا يجعله متسقاً                                                                        | من 7.4 إلى 7.8 RSALv2 أو SSPLv1، ومن 8.0 يضاف خيار AGPLv3 المفتوح المصدر، والإصدارات حتى 7.2 BSD                                                                                                                                         |
| [NATS JetStream](https://docs.nats.io/running-a-nats-service/configuration/clustering/jetstream%5Fclustering?ref=arabroot.io) | رسائل وطوابير خفيفة مع تخزين دائم                                                                        | Raft لكل Stream                                                                                              | Apache 2.0                                                                                                                                                                                                                               |
| [Patroni](https://github.com/patroni/patroni?ref=arabroot.io)                                                                 | إدارة التوفر العالي لـ PostgreSQL                                                                        | يعتمد على etcd أو Consul أو ZooKeeper أو Kubernetes                                                          | MIT                                                                                                                                                                                                                                      |

وإذا كنت تبدأ اليوم وتريد أداة مفتوحة المصدر بالكامل، فالخيارات الآمنة من ناحية الترخيص هي etcd للتنسيق، وKafka أو NATS للأحداث، وValkey بدلاً من Redis إذا كان الترخيص يهمك، وTiDB أو YugabyteDB لقاعدة SQL موزعة، وCassandra لقاعدة دون قائد، وPatroni مع etcd لتوفر PostgreSQL. وكلها مواضيع في [قسم تصميم الأنظمة والأنظمة الموزعة في خارطة الطريق](https://arabroot.io/start/#stage-system-design).

## للتعمق أكثر

أفضل كتاب في هذا المجال بلا منافس تقريباً هو [Designing Data-Intensive Applications](https://dataintensive.net/?ref=arabroot.io) للكاتب Martin Kleppmann من O'Reilly، وهو يشرح كل ما في هذا المقال وأكثر بعمق وأمثلة من أنظمة حقيقية، ونصيحتنا أن تقرأه بعد أن تشغل التجربة أعلاه، لأن السجلات التي رأيتها بعينك سوف تجعل فصوله عن النسخ والإجماع أوضح بكثير. وإلى جانبه ورقة [Raft](https://raft.github.io/raft.pdf?ref=arabroot.io) نفسها، فهي مكتوبة لتفهم، و[موقع Raft](https://raft.github.io/?ref=arabroot.io) فيه عرض تفاعلي ترى فيه الانتخابات تحدث أمامك.

## الخلاصة

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

- النظام الموزع صعب لأن الشبكة تضيع الرسائل وتؤخرها وتكررها، ولأن الفشل جزئي فلا يعرف المرسل هل نفذ طلبه، ولأنه لا توجد ساعة واحدة يتفق عليها الجميع.
- النسخ إما بقائد واحد وهو الأبسط، أو بعدة قادة مع ثمن حل التعارضات، أو دون قائد بقاعدة R + W > N، والنسخ غير المتزامن سريع ولكنه يسبب مشاكل قراءة ما كتبته والقراءات الرتيبة، والمتزامن آمن ولكنه يوقف الكتابة إذا غابت النسخة.
- التقسيم بالمدى يناسب استعلامات المدى ويسبب نقاطاً ساخنة، والتقسيم بالهاش يوزع بالتساوي، والهاش المتسق ينقل جزءاً صغيراً من البيانات عند إضافة سيرفر.
- CAP تقول إنك عند الانقسام فقط تختار بين الخطأ والقيمة القديمة، و«اختر اثنين من ثلاثة» صياغة مضللة، وPACELC تضيف الاختيار اليومي بين الانتظار والقيمة القديمة.
- الساعة لا ترتب الأحداث بين السيرفرات، والساعات المنطقية وHLC تحفظ الترتيب السببي، ورمز الحماية يمنع العامل الذي استيقظ متأخراً من الكتابة فوق غيره.
- Raft ينتخب قائداً بأغلبية الأصوات ويعتمد الكتابة عندما تحفظها الأغلبية، لذلك استخدم ثلاثة أو خمسة سيرفرات، ورأيت في التجربة أن عنقود etcd انتخب قائداً جديداً خلال ثانيتين تقريباً، ورفض الكتابة والقراءة الخطية عندما بقي سيرفر واحد، ولم تظهر الكتابة الفاشلة بعد عودته.
- كل طلب عبر الشبكة يحتاج مهلة، وإعادة المحاولة تحتاج تأخيراً متزايداً مع عشوائية ومفتاح منع التكرار، و«مرة واحدة بالضبط» هي في الحقيقة «مرة على الأقل» مع إزالة التكرار.
- تجنب 2PC بين الخدمات، واستخدم Saga بخطوات تعويض، وOutbox لتكتب الحدث مع التغيير في معاملة واحدة، واسأل فريق العمل كم يتحمل كل رقم من التأخير.

[![](https://arabroot.io/content/images/2026/10/system-design-intro-cover-cover.webp)دليلمدخل إلى تصميم الأنظمة: من سيرفر واحد إلى ملايين المستخدمينكيف يكبر متجر إلكتروني من سيرفر واحد إلى نظام يخدم ملايين المستخدمين، وما الثمن الذي تدفعه في كل خطوة.](https://arabroot.io/articles/%D9%85%D8%AF%D8%AE%D9%84-%D8%A7%D9%84%D9%89-%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D8%A7%D9%84%D8%A7%D9%86%D8%B8%D9%85%D8%A9-%D9%85%D9%86-%D8%B3%D9%8A%D8%B1%D9%81%D8%B1-%D9%88%D8%A7%D8%AD%D8%AF-%D8%A7%D9%84%D9%89-%D9%85%D9%84%D8%A7%D9%8A%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/db-design-cover-cover.webp)دليلتصميم قواعد البيانات: التطبيع Normalization ومتى تكسرهلماذا يفشل الجدول الواحد الكبير، وكيف تقسمه إلى جداول لا تناقض نفسها، وما الذي يتغير عندما تكبر البيانات.](https://arabroot.io/articles/%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D9%82%D9%88%D8%A7%D8%B9%D8%AF-%D8%A7%D9%84%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA-%D8%A7%D9%84%D8%AA%D8%B7%D8%A8%D9%8A%D8%B9-%D9%88%D8%A7%D9%84%D8%AA%D9%88%D8%B3%D8%B9/)[![](https://arabroot.io/content/images/2026/10/unique-ids-cover-cover.webp)دليلالرقم المتسلسل أم UUID؟ كيف تختار المعرف الفريدلماذا يفشل الرقم المتسلسل في الروابط وعند تعدد السيرفرات، وكيف يؤثر نوع المعرف الذي تختاره على فهرس قاعدة البيانات.](https://arabroot.io/articles/%D8%A7%D9%84%D9%85%D8%B9%D8%B1%D9%81%D8%A7%D8%AA-%D8%A7%D9%84%D9%81%D8%B1%D9%8A%D8%AF%D8%A9-uuid-%D9%88-ulid-%D9%88-snowflake/)[![](https://arabroot.io/content/images/2026/10/date-time-cover-cover.webp)دليلالتاريخ والوقت للمطورين ومديري الأنظمةلماذا يتأخر التقرير ثلاث ساعات ولا تعمل المهمة المجدولة ليلة تغيير التوقيت، وكيف تضبط الوقت في السيرفر والكود.](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%A7%D8%B1%D9%8A%D8%AE-%D9%88%D8%A7%D9%84%D9%88%D9%82%D8%AA-utc-%D9%88%D8%A7%D9%84%D9%85%D9%86%D8%A7%D8%B7%D9%82-%D8%A7%D9%84%D8%B2%D9%85%D9%86%D9%8A%D8%A9-%D9%88%D8%A7%D9%84%D8%AA%D9%82%D9%88%D9%8A%D9%85-%D8%A7%D9%84%D9%87%D8%AC%D8%B1%D9%8A/)[![](https://arabroot.io/content/images/2026/10/transactional-email-cover-cover.webp)دليلTransactional Email: كيف تصل رسائل تطبيقك في وقتهالماذا تفصل رسائل تطبيقك مثل استعادة كلمة المرور عن النشرات البريدية، وكيف ترسلها بحيث تصل في وقتها ولا تذهب إلى Spam.](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D8%B1%D9%8A%D8%AF-%D8%A7%D9%84%D8%AA%D9%81%D8%A7%D8%B9%D9%84%D9%8A-transactional-email/)

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

- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة على عنقود etcd v3.7.2 من ثلاثة سيرفرات وعلى Python 3.14.8 في Docker Engine 29.8.0.