عندما ترسل رسالة في تطبيق محادثة تظهر بجانبها علامة واحدة خلال جزء من الثانية، ثم علامتان عندما تصل إلى هاتف صديقك، ثم تتغير ألوانهما عندما يقرؤها، وإذا كان هاتفه مغلقاً في الطائرة وصلته الرسائل كلها بالترتيب عندما يفتحه بعد الهبوط. وقد يبدو هذا من الوهلة الأولى سيرفراً يستقبل الرسالة ويرسلها، لكن في الواقع فخلف هذه العلامات الصغيرة أسئلة صعبة: كيف تبقي ملايين الهواتف متصلة بسيرفراتك طوال اليوم، وكيف تعرف أي سيرفر يحمل اتصال صديقك الآن، وكيف تضمن ألا تضيع رسالة ولا تتكرر ولا يختلف ترتيبها، وكيف تحفظها لمن انقطع اتصاله، وكيف تشفرها بحيث لا يستطيع السيرفر نفسه قراءتها.
وهذا المقال من قسم دراسات الحالة في خارطة الطريق، ونأخذ فيه WhatsApp مثالاً لأنه نشر أجزاء من قراراته الهندسية في مدونته وفي الورقة التقنية للتشفير، ولن نكرر المفاهيم العامة التي شرحناها في مقالات مستقلة، مثل الإشعارات الفورية والطوابير وطريقة عمل مكالمات الفيديو، وإنما نربطها بما يحتاجه تطبيق المحادثة.
دليلكيف تعمل الإشعارات الفورية Push Notifications؟كيف يصل الإشعار إلى تطبيق مغلق عبر خدمات Apple وGoogle، وما الذي تراه هذه الخدمات من بيانات مستخدميك.
دليلالطابور Queue للمبتدئين: لماذا تحتاجه وكيف يعمللماذا تضع العمل البطيء في طابور بدل أن ينتظره المستخدم، وكيف تضمن ألا تضيع الرسالة إذا توقف ال Worker فجأة.
دليلWebRTC من الداخل: كيف تعمل مكالمات الفيديو في المتصفحكيف تبدأ مكالمة الفيديو بين متصفحين وكيف تشفر وتتكيف مع الشبكة، مع مكالمة حقيقية نقرأ رسائلها وأرقامها سطراً بسطر.وسوف نناقش في هذا المقال ما يلي:
- ما الذي تبنيه بالضبط، وما تقوله أرقام WhatsApp المنشورة عن الحجم.
- المعمارية الكاملة في مخطط واحد، مع الأدوات ونموذج البيانات والواجهات وتقدير السعة ومراحل النمو وما يحدث عندما يتعطل كل جزء.
- الاتصال الدائم عبر WebSocket وسيرفرات الاتصال Gateways، وكيف تعرف أين يتصل المستخدم الآن.
- معرف الرسالة وترتيبها داخل المحادثة، ومنع تكرارها عند إعادة الإرسال.
- علامات الإرسال والتسليم والقراءة، والحفظ لمن انقطع اتصاله Store-and-Forward مع الإشعارات.
- رسائل المجموعات: النسخ عند الكتابة Fan-out on Write أم القراءة من سجل مشترك Fan-out on Read.
- الصور والملفات عبر تخزين الكائنات، وحالة الاتصال Presence.
- التشفير بين الطرفين End-to-End Encryption بلمحة سريعة عن بروتوكول Signal.
- تجربة عملية: سيرفرا اتصال خلف Valkey Pub/Sub، ورسالة تعبر بينهما، وصندوق للمستخدم غير المتصل يسلم عند عودته، وعلامات التسليم والقراءة، ثم نوقف أحد السيرفرين فجأة.
ما الذي تبنيه بالضبط؟
الوظائف الأساسية لتطبيق المحادثة معروفة: محادثة بين شخصين، ومجموعات، وإرسال صور وملفات وتسجيلات صوتية، وعلامات التسليم والقراءة، وحالة الاتصال وآخر ظهور، وإشعار عندما يكون التطبيق مغلقاً، ثم المكالمات الصوتية والمرئية. أما المتطلبات غير الوظيفية فهي التي تجعل التطبيق صعباً:
- زمن وصول قصير Low Latency: الرسالة يجب أن تصل في أقل من ثانية عندما يكون الطرفان متصلين، وهذا يعني أن السيرفر يدفع الرسالة إلى الهاتف ولا ينتظر أن يسأله الهاتف كل عدة ثوان.
- الوثوقية Reliability: الرسالة التي ظهرت عليها علامة واحدة لا تضيع أبداً، حتى لو توقف السيرفر في نفس اللحظة.
- الترتيب Ordering: الرسائل داخل المحادثة الواحدة تظهر لكل الأطراف بنفس الترتيب.
- العمل مع اتصال ضعيف: الهاتف ينتقل بين الشبكات ويفقد الاتصال ويعود، والتطبيق يجب أن يكمل من حيث توقف.
- الخصوصية: المحتوى مشفر بين الطرفين، فالسيرفر يوصل ما لا يستطيع قراءته.
أما الحجم فيكفي رقمان من مصادر WhatsApp نفسها: في 6 يناير 2012 نشر فريقه في مدونة WhatsApp أنه وصل إلى أكثر من مليوني اتصال TCP على سيرفر واحد، يعمل بنظام FreeBSD 8.2 وبرنامج مكتوب بلغة Erlang على معالج فيه 24 نواة وذاكرة 103 جيجابايت، وفي 12 فبراير 2020 أعلن في تدوينة أخرى أن التطبيق يخدم أكثر من ملياري مستخدم. والرقم الأول هو المهم لنا هنا، لأنه يبين أن المشكلة الأولى في تطبيق المحادثة ليست قوة المعالجة وإنما عدد الاتصالات المفتوحة في نفس الوقت.
المعمارية الكاملة: كيف تتصل الأجزاء ببعضها؟
قبل أن ندخل في كل جزء بالتفصيل، من المفيد أن ترى النظام كاملاً مرة واحدة، فلديك خمسة أجزاء رئيسية: سيرفرات اتصال Gateways لا تحمل منطقاً وتتوسع أفقياً بعدد الاتصالات، وخدمة رسائل تحفظ وتعطي الأرقام وتوزع، ومخزن للرسائل والصناديق مقسم حسب المحادثة والمستخدم، وسجل حضور سريع بمهلات قصيرة، وطابور للإشعارات يتحدث مع خدمات Apple وGoogle، ثم تخزين الكائنات للملفات. والصورة التالية تبين كيف تتصل هذه الأجزاء، والخط المتصل فيها طلب ينتظر رداً Sync، والخط المتقطع حدث يرسل ولا ينتظر Async:

وفي التجربة العملية جمعنا ال Gateway وخدمة الرسائل في برنامج واحد حتى يبقى الكود قصيراً، ووضعنا الرسائل والصناديق في Valkey، أما في الإنتاج ففصلهما مفيد، والسبب أن ال Gateway يتوسع بعدد الاتصالات المفتوحة، وخدمة الرسائل تتوسع بعدد الرسائل في الثانية، وتحديث منطق الرسائل لا يجب أن يقطع ملايين الاتصالات.
المكونات والأدوات المقترحة
| المكون | مسؤوليته | أداة مفتوحة المصدر مقترحة | لماذا هي |
|---|---|---|---|
| موزع الحمل Load Balancer | إنهاء TLS وتوزيع اتصالات WebSocket الطويلة على ال Gateways | HAProxy (GPLv2) | يتحمل عدداً كبيراً من الاتصالات الطويلة، وله مهلة منفصلة للاتصال بعد ترقيته إلى WebSocket، ويسمح بإخراج سيرفر من التوزيع بالتدريج |
| سيرفر الاتصال Gateway | يحمل الاتصالات المفتوحة ويجدد سجل الحضور، ولا يحمل منطق التطبيق | Node.js مع ws (MIT)، كما في التجربة | الحلقة غير المتزامنة Event Loop تحمل آلاف الاتصالات الخاملة بذاكرة قليلة، والمكتبة صغيرة ومستقرة |
| خدمة الرسائل Chat Service | التحقق من العضوية ومنع التكرار والرقم المتسلسل والحفظ والتوزيع Fan-out | كودك أنت بنفس اللغة | هنا منطق منتجك، ولا توجد أداة جاهزة تغني عنه |
| سجل الحضور وPub/Sub | «أين يتصل bob الآن؟» وقناة لكل Gateway | Valkey (BSD-3-Clause) | مفاتيح في الذاكرة بمهلة TTL وPub/Sub في نفس الأداة، وهو ما استخدمناه في التجربة |
| مخزن الرسائل Message Store | سجل كل محادثة بالترتيب وصناديق المستخدمين | Apache Cassandra (Apache-2.0) | المفتاح الطبيعي هو رقم المحادثة، وCassandra توزع البيانات على السيرفرات حسب هذا المفتاح، وتزيد سعة الكتابة كلما أضفت سيرفراً، وتنسخ البيانات بين مراكز البيانات |
| طابور الإشعارات Push Queue | مهام «أيقظ هذا الجهاز» للمستخدمين غير المتصلين | NATS JetStream (Apache-2.0) | طابور دائم خفيف يعيد المهمة إذا توقف ال Worker قبل تأكيدها |
| Push Worker | الإرسال إلى APNs وFCM وحذف التوكنات الملغاة | Gorush (MIT) | سيرفر جاهز يتحدث مع خدمتي Apple وGoogle، فلا تكتب بروتوكوليهما بنفسك |
| تخزين الملفات Object Storage | الصور والفيديو مشفرة، برفع مباشر عبر رابط موقع | SeaweedFS (Apache-2.0) | واجهة متوافقة مع S3، ومصمم لعدد كبير جداً من الملفات الصغيرة |
| الهوية والتوكنات | تسجيل الجهاز وإصدار التوكن الذي يتحقق منه ال Gateway عند الاتصال | Keycloak (Apache-2.0) | توكنات OIDC قياسية يتحقق منها ال Gateway محلياً بالمفتاح العام دون أن يسأل سيرفراً آخر مع كل اتصال |
| المراقبة | عدد الاتصالات لكل Gateway وزمن التوصيل وطول الصناديق والطوابير | Prometheus (Apache-2.0) مع Grafana (AGPL-3.0) | المعيار المفتوح لجمع المقاييس ورسمها، مع تنبيه عندما يكبر صندوق أو طابور |
نموذج البيانات
في التجربة كل شيء مفاتيح في Valkey: msg:<id> وlog:<conv> وinbox:<user> وconv:<conv>:members، وفي الإنتاج تنتقل البيانات الدائمة إلى Cassandra بنفس الأسماء تقريباً، ويبقى في Valkey ما يتغير كل ثوان. والجداول التالية بلغة CQL الخاصة ب Cassandra:
-- the conversation history (lab: log:<conv> and msg:<id>), one partition per conversation, ordered by seq
CREATE TABLE messages (
conv_id text,
seq bigint, -- the number inside the conversation (lab: INCR seq:<conv>)
sender text,
client_msg_id text, -- the id the phone created, kept for deduplication
body blob, -- end-to-end encrypted: the server cannot read it
sent_at timestamp,
PRIMARY KEY ((conv_id), seq)
) WITH CLUSTERING ORDER BY (seq DESC);
-- what a user's devices have not confirmed yet (lab: inbox:<user>), deleted on ack, 30 days at most
CREATE TABLE inbox (
user_id text,
added_at timestamp,
item_id text, -- 'alice-bob:42' or a receipt id
frame blob,
PRIMARY KEY ((user_id), added_at, item_id)
) WITH default_time_to_live = 2592000;
CREATE TABLE conversation_members ( -- lab: conv:<conv>:members
conv_id text, user_id text, role text, joined_at timestamp,
PRIMARY KEY ((conv_id), user_id)
);
CREATE TABLE devices ( -- where to send the push, and which public keys belong to which device
user_id text, device_id text, platform text, push_token text, identity_key blob,
PRIMARY KEY ((user_id), device_id)
);
-- Valkey (short-lived, rebuilt by itself):
-- presence:<user> = gw2 TTL 30 s, renewed every 10 s
-- dedup:<user>:<clientMsgId> = alice-bob:1 TTL 24 h
-- channel gw:<gateway> Pub/Sub, one per gatewayولاحظ نقطتين في هذا النموذج: الأولى أن العداد seq في التجربة أمر INCR في Valkey، وهو في الإنتاج يجب أن يكون دائماً، فإما أن تملك كل محادثة خدمة واحدة تعطي أرقامها، أو تزيده في جدول المحادثة بتحديث مشروط Lightweight Transaction. والثانية أن الحذف المتكرر في Cassandra يترك علامات حذف Tombstones تبطئ القراءة، لذلك تحفظ أنظمة كثيرة لكل جهاز آخر رقم سلمه في كل محادثة بدل أن تحذف كل عنصر من الصندوق.
الواجهات الرئيسية
| النوع | المسار أو الإطار | ماذا يفعل |
|---|---|---|
| WSS | /v1/connect مع التوكن في الترويسة | يفتح الاتصال الدائم، فيجدد ال Gateway سجل الحضور ويرسل ما في الصندوق بالترتيب. وفي التجربة يرسل الاسم في الرابط دون تحقق، أما في الإنتاج فلا يقبل اتصال دون توكن صالح |
إطار send | {conv, clientMsgId, text} | يرسل رسالة، ويرد السيرفر sent بالمعرف والرقم، وclientMsgId هو مفتاح منع التكرار Idempotency Key، فنفس المعرف يعيد نفس الرد |
إطار ack | {id} | الجهاز حفظ العنصر، فيحذف من الصندوق ويرسل إيصال التسليم delivered إلى المرسل |
إطار read | {conv, upTo} | إيصال قراءة واحد حتى رقم معين بدل إيصال لكل رسالة |
| GET | /v1/conversations/{conv}/messages?after_seq=41 | يملأ الفجوة عندما يرى الجهاز الرقم 43 قبل 42، أو عند إضافة جهاز جديد |
| POST | /v1/media/uploads | يعطي رابط رفع موقعاً Presigned URL لمدة قصيرة، والملف يرفع مشفراً إلى تخزين الكائنات مباشرة |
| PUT | /v1/devices/{deviceId}/push-token | يسجل توكن APNs أو FCM للجهاز، ويحدثه عندما يتغير |
تقدير السعة على ظهر ورقة
الأرقام التالية تقدير لنظام افتراضي، وطريقة الحساب هي المهمة وليست الأرقام نفسها، وقد شرحنا هذا النوع من التقدير في مدخل إلى تصميم الأنظمة. نفترض ما يلي، وكلها افتراضات وليست أرقام WhatsApp:
- مليون مستخدم نشط في اليوم، يرسل كل منهم 40 رسالة في اليوم.
- ساعة الذروة أعلى من المتوسط بخمس مرات، و20% من المستخدمين متصلون في نفس اللحظة وقت الذروة.
- الرسالة مع بياناتها الوصفية 300 بايت، و10% من الرسائل تحمل ملفاً متوسط حجمه 200 كيلوبايت، ونصف الرسائل تصل إلى مستلم غير متصل فتحتاج إشعاراً.
messages per day = 1,000,000 users x 40 = 40,000,000
average per second = 40,000,000 / 86,400 ~ 463 messages/s
peak per second = 463 x 5 ~ 2,300 messages/s
writes per message = store 1 + inbox 1 + delete on ack 1 + delivered receipt 2 = 5
peak writes = 2,300 x 5 ~ 11,500 writes/s
open connections = 1,000,000 x 20% = 200,000 WebSockets
presence renewals = 200,000 / 10 s = 20,000 SET/s on Valkey
text storage = 40,000,000 x 300 B = 12 GB/day ~ 4.4 TB/year
with 3 replicas = 4.4 TB x 3 ~ 13 TB/year
media = 4,000,000 files x 200 KB = 800 GB/day ~ 292 TB/year
media upload = 800 GB x 8 / 86,400 s ~ 74 Mbit/s average, ~ 370 Mbit/s at peak
push notifications = 20,000,000 / 86,400 x 5 ~ 1,160 pushes/s at peakوفي الحساب أعلاه لاحظ التالي:
- أكبر مصدر للكتابة ليس الرسائل وإنما تجديد سجل الحضور: 20 ألف أمر في الثانية لمئتي ألف اتصال، وهذا خفيف على Valkey لأنه في الذاكرة، لكنه يعني أن تختار مدة التجديد بعناية، فالتجديد كل دقيقة بمهلة ثلاث دقائق يقسم الرقم على ستة، والثمن أن اكتشاف ال Gateway الذي توقف يتأخر.
- مئتا ألف اتصال تكفيها أربعة Gateways إذا افترضت 50 ألف اتصال لكل سيرفر، وهذا افتراض متحفظ بعيد عن رقم WhatsApp في 2012، فقس الرقم الحقيقي على سيرفرك باختبار أحمال، وأضف سيرفرين احتياطاً حتى تستطيع تحديث أحدها دون ضغط على البقية.
- الملفات أكبر من النص بأكثر من ستين ضعفاً في التخزين، لذلك فقرار مدة حفظ الملفات على السيرفر يؤثر في التكلفة أكثر من أي قرار آخر، وإذا حذفت الرسالة بعد تسليمها كما يفعل WhatsApp، فلن يبقى من ال 13 تيرابايت إلا ما لم يسلم بعد.
مراحل النمو
| الجزء | البداية MVP (سيرفر أو اثنان) | النمو (آلاف المتصلين في نفس الوقت) | الحجم الكبير (ملايين) |
|---|---|---|---|
| الاتصالات | برنامج واحد فيه ال Gateway والمنطق، خلف Reverse Proxy | عدة Gateways خلف HAProxy، وخدمة رسائل منفصلة | مئات ال Gateways في عدة مناطق، والهاتف يتصل بأقربها |
| البيانات | PostgreSQL واحدة للرسائل والأعضاء، وValkey واحد | نسخة قراءة Read Replica، وجدول رسائل مقسم بالزمن Partitioning | Cassandra بعدة مراكز بيانات، والمحادثة هي مفتاح التقسيم |
| الحضور وPub/Sub | Valkey واحد | Valkey مع نسخة احتياطية Replica ومراقبة | عنقود Valkey Cluster، أو توجيه مباشر بين ال Gateways دون وسيط |
| الإشعارات | استدعاء APNs وFCM من نفس البرنامج | طابور وWorker منفصل | Workers لكل منطقة وحدود إرسال لكل تطبيق |
| الملفات | مجلد على القرص أو تخزين S3 جاهز | تخزين كائنات مستقل بروابط موقعة | تخزين في عدة مناطق وCDN للتحميل |
ولا تقم ببناء Cassandra وعدة مناطق والتوجيه المباشر بين ال Gateways في اليوم الأول، والسبب أن سيرفراً واحداً يحمل عشرات الآلاف من اتصالات WebSocket، وما تحتاجه من اليوم الأول هو الأفكار التي لا تتغير مع الحجم: الحفظ قبل التوصيل، ومعرف من الكلاينت ضد التكرار، ورقم متسلسل داخل المحادثة، وتأكيد من الجهاز قبل الحذف. وقد يتساءل البعض: هل أحتاج هذا لتطبيق دعم فني أو محادثة داخلية لشركة؟ والإجابة لا، فمنصات جاهزة مثل Mattermost تكفي محادثة الشركة.
ماذا لو تعطل جزء؟
| ماذا لو تعطل…؟ | الأثر | الحل |
|---|---|---|
| Gateway | تنقطع اتصالاته، ويبقى سجل الحضور يشير إليه حتى تنتهي مهلته، فتضيع النسخة المرسلة عبر Pub/Sub | الحفظ قبل التوصيل، فالرسالة في الصندوق، والتطبيق يعيد الاتصال بأي Gateway آخر فيستلمها، وهذا ما سوف تراه في التجربة العملية عندما نوقف أحد السيرفرين فجأة |
| Valkey | لا يعرف أحد أين المستخدمون، ولا تصل الرسائل لحظياً | نسخة احتياطية مع Sentinel للانتقال التلقائي، والرسائل محفوظة في المخزن فتسلم عند إعادة الاتصال، وسجل الحضور يعاد بناؤه وحده من تجديد ال Gateways |
| سيرفر من مخزن الرسائل | المحادثات التي على ذلك السيرفر تفقد نسخة من بياناتها | ثلاث نسخ لكل بيانات، والكتابة والقراءة بمستوى اتساق QUORUM، فيتحمل النظام غياب سيرفر واحد دون توقف |
| APNs أو FCM | المستخدمون غير المتصلين لا يعرفون أن لديهم رسائل | المهام تبقى في الطابور وتعاد بانتظار متزايد، والرسائل نفسها في الصندوق فلا يضيع شيء، وتظهر عند فتح التطبيق |
| موزع الحمل | لا يتصل أحد | اثنان بعنوان IP عائم Floating IP عبر Keepalived، أو موزع في كل منطقة |
| تخزين الملفات | الصور لا ترفع ولا تحمل، والنص يعمل | الرسالة النصية لا تعتمد على الملف، والتطبيق يعيد الرفع لاحقاً، وفصل الملفات عن المحادثة هو ما يجعل هذا العطل محدوداً |
الاتصال الدائم وسيرفرات الاتصال Gateways
الحل البسيط أن يسأل التطبيق السيرفر كل ثانيتين «هل لدي رسائل جديدة؟»، وهذا ما يسمى الاستطلاع Polling، وهو يعمل مع عشرة مستخدمين، لكنه يعني مع مليون مستخدم نصف مليون طلب في الثانية أغلبها يعود فارغاً، ويستهلك بطارية الهاتف، ويؤخر الرسالة حتى السؤال التالي. والطريقة الصحيحة أن يفتح التطبيق اتصالاً دائماً واحداً مع السيرفر ويبقيه مفتوحاً، فيدفع السيرفر الرسالة فور وصولها، والمعيار المستخدم في الويب والتطبيقات هو WebSocket الموصوف في RFC 6455، وهو يبدأ كطلب HTTP عادي ثم يتحول إلى قناة في الاتجاهين، وقد شرحنا ما يحتاجه خلف ال Reverse Proxy من مهلات وترويسات في تطبيقك خلف Reverse Proxy.
والسيرفر الذي يحمل هذه الاتصالات يسمى سيرفر الاتصال أو ال Gateway، وهو الذي رأيته في قسم المعمارية الكاملة أعلاه، ووظيفته بسيطة: يحفظ الاتصالات المفتوحة، ويستقبل منها، ويرسل إليها، ولا يحمل منطق التطبيق. ولأن السيرفر الواحد له حد من الاتصالات فسوف تحتاج عدداً منها خلف موزع حمل، وهنا تظهر المشكلة الأساسية في التصميم: Alice متصلة بال Gateway الأول، وBob متصل بالثاني، والسيرفر الأول لا يعرف شيئاً عن اتصالات الثاني، فكيف تصل الرسالة؟ والحل أن يكون هناك سجل للحضور Presence يقول أين يتصل كل مستخدم الآن، وقناة لكل Gateway ينشر عليها غيره ما يريد إيصاله إليه، والصورة التالية تبين الخطوات:

وفي الإعداد أعلاه لاحظ التالي:
- سجل الحضور مفتاح بمهلة قصيرة مثل 30 ثانية، يجدده ال Gateway كل عشر ثوان ما دام الاتصال مفتوحاً، والسبب أن ال Gateway قد يتوقف فجأة دون أن يحذف مفاتيحه، فتنتهي المفاتيح وحدها بعد نصف دقيقة.
- القناة لكل Gateway وليس لكل مستخدم، فالسيرفر يشترك في قناة واحدة باسمه بدل مليون قناة، وهذا ما سوف تراه في التجربة العملية بقناتي
gw:gw1وgw:gw2. - الرسالة تحفظ قبل أي محاولة توصيل، والسبب أن ال Pub/Sub في Valkey وRedis يرسل لمن يستمع الآن فقط ولا يحفظ شيئاً، كما توضح وثائق Valkey، فإذا ضاعت الرسالة في الطريق تبقى نسختها المحفوظة.
- موزع الحمل أمام اتصالات طويلة يختلف عن موزع الطلبات العادية، فعندما تضيف Gateway جديداً لن تنتقل إليه الاتصالات القديمة وحدها، وعندما تريد تحديث Gateway عليك أن تغلق اتصالاته بالتدريج حتى لا يعيد مليون هاتف الاتصال في نفس الثانية.
معرف الرسالة وترتيبها، ومنع تكرارها
كل رسالة تحتاج معرفين: الأول ينشئه الهاتف قبل الإرسال Client Message ID، والثاني يعطيه السيرفر بعد الحفظ. والسبب في الأول أن الهاتف قد يرسل الرسالة ثم ينقطع الاتصال قبل أن يصله التأكيد، فلا يعرف هل وصلت أم لا، فيعيد إرسالها، ولو لم يكن هناك معرف ثابت من الكلاينت لحفظ السيرفر نسختين وظهرت الرسالة مرتين عند صديقك. والحل أن يحفظ السيرفر لكل مرسل المعرفات التي استلمها مدة معقولة، فإذا جاء نفس المعرف مرة ثانية أعاد نفس الرد دون أن يحفظ شيئاً جديداً، وهذه فكرة ال Idempotency التي شرحناها في تصميم واجهات API.
أما الترتيب فقد يتساءل البعض: لماذا لا نرتب الرسائل بوقت الإرسال؟ والإجابة أن ساعة كل هاتف مختلفة، وساعات السيرفرات نفسها لا تتطابق تماماً كما شرحنا في الأنظمة الموزعة، والحل العملي أن يعطي السيرفر كل رسالة رقماً متسلسلاً داخل المحادثة نفسها Sequence Number، فالرسالة 41 قبل 42 دائماً في كل الأجهزة، والجهاز يعرف أن لديه فجوة إذا وصلته 43 قبل 42 فيطلب ما فاته. ولاحظ أن الترتيب مطلوب داخل المحادثة الواحدة فقط، وليس بين كل رسائل النظام، وهذا يجعل المشكلة أسهل كثيراً، لأن كل محادثة يمكن أن تكون على سيرفر مختلف بعداد مستقل. وإذا احتجت معرفاً عاماً فريداً لكل رسالة في النظام كله فهناك طرق مثل Snowflake شرحناها في المعرفات الفريدة.
علامات الإرسال والتسليم والقراءة
العلامات الثلاث ليست ألواناً لنفس الحدث، وإنما ثلاثة أحداث مختلفة يرسل كل منها في وقت مختلف، والصورة التالية تبين الترتيب:

- علامة واحدة Sent: السيرفر حفظ الرسالة وأعطاها رقمها، ومن هذه اللحظة أصبحت مسؤولية السيرفر.
- علامتان Delivered: جهاز المستقبل نفسه أكد أنه حفظ الرسالة، وليس فقط أن السيرفر أرسلها، والسبب أن الإرسال قد يضيع في اتصال ينقطع، أما تأكيد الجهاز Ack فيعني أنها وصلت فعلاً.
- القراءة Read: يرسلها المستقبل عندما يفتح المحادثة، وترسل مرة واحدة برقم آخر رسالة قرأها بدل إيصال لكل رسالة، فإذا فتح محادثة فيها خمسون رسالة جديدة أرسل إيصالاً واحداً.
ولاحظ أن الإيصالات نفسها رسائل صغيرة تمر بنفس الطريق، فإذا كان المرسل غير متصل عندما قرأ صديقه الرسالة فإن الإيصال يحفظ له حتى يعود، تماماً مثل أي رسالة.
المستقبل غير متصل: الحفظ ثم التوصيل Store-and-Forward
الهاتف في الجيب غالباً لا يحمل اتصالاً مفتوحاً، فنظاما iOS وAndroid يوقفان التطبيقات في الخلفية لتوفير البطارية، لذلك لا تعتمد على اتصال WebSocket لتوصيل الرسائل للتطبيق المغلق. والطريقة المستخدمة أن يكون لكل مستخدم صندوق وارد Inbox على السيرفر، تدخله كل رسالة موجهة إليه، ولا تحذف منه إلا عندما يؤكد جهازه استلامها، فإذا لم يكن متصلاً يرسل السيرفر إشعاراً عبر خدمة Apple أو Google من خلال طابور الإشعارات وال Push Worker الذي رأيته في مخطط المعمارية أعلاه، فيستيقظ التطبيق ويتصل ويسحب ما في صندوقه بالترتيب، والصورة التالية تبين ذلك:

وهنا ثلاث ملاحظات مهمة:
- الإشعار ليس مكان تخزين، فبحسب وثائق Apple عن APNs إذا كان الجهاز غير متصل تحفظ الخدمة إشعاراً واحداً فقط لكل تطبيق، وغالباً الأخير، وحجم الإشعار لا يتجاوز 4 كيلوبايت، لذلك الإشعار يقول «لديك رسائل» والرسائل نفسها تأتي من صندوقك عندما يتصل التطبيق. وقد شرحنا خدمات الإشعارات وما يمر عبرها في كيف تعمل الإشعارات الفورية.
- الرسائل في الصندوق لا تبقى للأبد، وتذكر سياسة خصوصية WhatsApp أن الرسالة التي لا يمكن توصيلها تحفظ مشفرة على سيرفراتها حتى 30 يوماً أثناء محاولة التوصيل.
- التوصيل هنا مرة واحدة على الأقل At-least-once، أي أن الجهاز قد يستلم نفس الرسالة مرتين إذا انقطع الاتصال بعد الاستلام وقبل التأكيد، لذلك يتجاهل الجهاز أي رسالة يعرف معرفها، وقد شرحنا لماذا لا يوجد توصيل مرة واحدة بالضبط في مفاهيم الطوابير.
رسائل المجموعات: Fan-out on Write أم Fan-out on Read؟
رسالة في مجموعة فيها خمسون عضواً يجب أن تصل إلى خمسين صندوقاً، وهناك طريقتان لذلك، والصورة التالية تقارن بينهما:

النسخ عند الكتابة Fan-out on Write يعني أن السيرفر يكتب نسخة في صندوق كل عضو لحظة الإرسال، فتبقى القراءة بسيطة ونفس طريق المحادثة الخاصة، والثمن أن الرسالة الواحدة تتحول إلى عدد من الكتابات بعدد الأعضاء. أما القراءة من سجل مشترك Fan-out on Read فتكتب الرسالة مرة واحدة في سجل المجموعة، وكل جهاز يطلب ما بعد آخر رقم رآه، وهذا يناسب القنوات التي فيها آلاف أو ملايين المتابعين، والثمن أن السجل يصبح ساخناً عندما يقرؤه الجميع، وأن معرفة من استلم ومن لم يستلم تصبح أصعب. وأغلب التطبيقات تجمع الطريقتين: نسخ للمجموعات الصغيرة، وسجل للقنوات الكبيرة، وهذه نفس المشكلة التي واجهناها في دردشة البث المباشر في تطبيق البث المباشر حيث تنسخ رسالة واحدة لآلاف المشاهدين.
ويضيف التشفير بين الطرفين بعداً آخر لهذه المسألة، فبحسب الورقة التقنية لWhatsApp يولد المرسل في المجموعة مفتاحاً اسمه Sender Key ويوزعه على أجهزة الأعضاء عبر الجلسات المشفرة بين كل جهازين، ثم يشفر كل رسالة جماعية مرة واحدة بهذا المفتاح، فيقوم السيرفر بالنسخ إلى الأعضاء Server-side Fan-out دون أن يقرأ المحتوى، وعندما يغادر عضو المجموعة يمسح كل الأعضاء مفاتيحهم ويبدؤون من جديد.
الصور والملفات
الصورة أو الفيديو لا يمر عبر اتصال المحادثة، والسبب أن ملفاً حجمه عشرون ميجابايت يحجز الاتصال ويؤخر كل الرسائل خلفه، لذلك يرفع الكلاينت الملف إلى تخزين الكائنات Object Storage مباشرة عبر رابط موقع Presigned URL كما شرحنا في أنواع التخزين، ثم يرسل في المحادثة رسالة صغيرة فيها مؤشر إلى الملف. ومع التشفير بين الطرفين تصف الورقة التقنية الخطوات كما يلي: يولد جهاز المرسل مفتاح AES256 مؤقتاً ومفتاح HMAC-SHA256، ويشفر الملف قبل رفعه، ثم يرسل في الرسالة المشفرة العادية المفتاحين وهاش SHA256 للملف المشفر ومكانه في التخزين، فيحمله المستقبل ويتحقق منه ويفك تشفيره، والتخزين نفسه لا يرى إلا بايتات مشفرة. ولاحظ أن الصورة المصغرة Thumbnail ترسل داخل الرسالة نفسها حتى تظهر فوراً قبل تحميل الملف الكامل.
حالة الاتصال Presence وآخر ظهور
عبارة «متصل الآن» تبدو بسيطة، لكن الحل البسيط أن تبلغ كل جهات اتصالك كلما فتحت التطبيق أو أغلقته، ومستخدم لديه 500 جهة اتصال يفتح التطبيق ويغلقه عشرات المرات في اليوم ينتج آلاف الرسائل التي لا يقرؤها أحد. والطريقة الأخف أن يشترك الجهاز في حالة شخص معين فقط عندما تكون محادثته مفتوحة على الشاشة، ويلغي الاشتراك عندما يغلقها، فلا ترسل حالة الاتصال إلا لمن ينظر إليها الآن. وسجل الحضور الذي يستخدمه السيرفر للتوجيه يختلف عن «آخر ظهور» الذي يراه الناس، فالأول داخلي سريع التغير بمهلة ثوان، والثاني إعداد خصوصية يتحكم فيه المستخدم.
التشفير بين الطرفين بلمحة سريعة
في التشفير العادي بين الهاتف والسيرفر عبر TLS يفك السيرفر التشفير ويقرأ الرسالة ثم يشفرها من جديد للمستقبل، أما في التشفير بين الطرفين End-to-End Encryption فالمفاتيح الخاصة لا تخرج من الأجهزة، والسيرفر ينقل نصاً مشفراً لا يستطيع فتحه. وتقول الورقة التقنية لWhatsApp إن تشفيره مبني على بروتوكول Signal، والصورة التالية تبين الفكرة دون تفاصيل الرياضيات:

والبروتوكول موصوف في وثائق Signal في عدة مواصفات، أهمها لهذا المقال ثلاث:
- إنشاء الجلسة عبر X3DH، ونسخته الأحدث PQXDH التي تضيف حماية من الحواسيب الكمية المستقبلية للسرية الأمامية، والفكرة أن جهاز Bob يرفع مسبقاً إلى السيرفر حزمة من مفاتيحه العامة، فتستطيع Alice أن تبدأ جلسة مشفرة وترسل أول رسالة وBob غير متصل أصلاً، وهذا مهم في تطبيق محادثة حيث الطرف الآخر نائم غالباً.
- تشفير الرسائل عبر Double Ratchet، حيث يشتق مفتاح جديد لكل رسالة، فسرقة مفتاح اليوم لا تفتح رسائل الأمس، وهذا ما يسمى السرية الأمامية Forward Secrecy.
- إدارة الجلسات بين عدة أجهزة عبر Sesame، فلكل جهاز مفاتيحه، والرسالة تشفر لكل جهاز من أجهزة المستقبل. وقد ذكرت Meta في تدوينة دعم الأجهزة المتعددة في 14 يوليو 2021 أن المرسل يشفر الرسالة ويرسلها N مرة إلى N جهاز، وأن كل جهاز صار له مفتاح هوية خاص به بدل أن يكون نسخة من الهاتف.
والفرق مع GPG الذي شرحناه في شرح GPG وOpenPGP أن GPG يشفر بمفتاح طويل العمر، فمن يسرق المفتاح الخاص بعد سنة يفتح كل الرسائل القديمة التي احتفظ بنسخها، أما Signal فيغير المفاتيح مع كل رسالة، لذلك يناسب المحادثة الفورية، ولا يناسب توقيع الحزم والملفات الذي صمم له GPG. ولاحظ حدود التشفير بين الطرفين: السيرفر ما زال يعرف من يتحدث مع من ومتى وكم مرة، وهذه البيانات الوصفية Metadata قد تكشف الكثير، والنسخ الاحتياطية للمحادثات في السحابة تحتاج تشفيراً خاصاً بها، وإلا كانت الطريق الأسهل إلى المحتوى.
والمكالمات؟
المكالمات الصوتية والمرئية لا تمر عبر نفس طريق الرسائل، فاتصال المحادثة يستخدم فقط لتنسيق المكالمة Signaling، أي إرسال العرض والجواب بين الطرفين، ثم تتدفق الوسائط مباشرة بين الجهازين أو عبر سيرفر وسيط، وقد شرحنا ذلك كاملاً في WebRTC من الداخل ومكالمات WebRTC خلف NAT.
تجربة عملية: سيرفرا اتصال وصندوق لمن غاب
في هذه التجربة نشغل Valkey وسيرفري اتصال gw1 وgw2 مكتوبين بلغة Node.js، ثم سكربتاً يمثل ثلاثة مستخدمين: Alice على gw1، وBob وCarol على gw2، ويمر بالسيناريو كاملاً ويطبع ما يستلمه كل مستخدم مع الوقت منذ البداية. والكود مختصر عمداً ليبقى مقروءاً، فلا توجد مصادقة ولا تشفير بين الطرفين، والاسم يرسل في رابط الاتصال.
المتطلبات: جهاز عليه Docker Engine وDocker Compose، والمنفذان 38081 و38082 منشوران على 127.0.0.1 فقط إذا أردت أن تتصل بنفسك بأداة مثل wscat.
ملفات التجربة
الملف compose.yaml:
name: lab-case2-chat
services:
valkey:
image: valkey/valkey:9.1.2-alpine
container_name: lab-case2-chat-valkey
gw1:
build: .
image: lab-case2-chat-gw:1
container_name: lab-case2-chat-gw1
environment: { GATEWAY_ID: gw1, VALKEY_URL: "redis://valkey:6379" }
ports: ["127.0.0.1:38081:8080"]
depends_on: [valkey]
gw2:
image: lab-case2-chat-gw:1
container_name: lab-case2-chat-gw2
environment: { GATEWAY_ID: gw2, VALKEY_URL: "redis://valkey:6379" }
ports: ["127.0.0.1:38082:8080"]
depends_on: [valkey, gw1]
client:
image: lab-case2-chat-gw:1
container_name: lab-case2-chat-client
entrypoint: ["node", "client.mjs"]
profiles: ["run"]
depends_on: [gw1, gw2]والملفان Dockerfile وpackage.json:
FROM node:24.21.0-alpine3.24
WORKDIR /app
COPY package.json .
RUN npm install --omit=dev --no-audit --no-fund
COPY gateway.mjs client.mjs ./
CMD ["node", "gateway.mjs"]{
"name": "lab-chat",
"private": true,
"type": "module",
"dependencies": { "ioredis": "6.0.0", "ws": "8.22.0" }
}وسيرفر الاتصال gateway.mjs، وفيه كل ما شرحناه: القناة لكل Gateway، والحفظ قبل التوصيل، ومنع التكرار، والرقم داخل المحادثة، والإيصالات، وتسليم الصندوق عند الاتصال:
// A WebSocket gateway for the chat lab. Run several of them; they share state through Valkey.
// GATEWAY_ID=gw1 VALKEY_URL=redis://valkey:6379 node gateway.mjs
import { WebSocketServer } from 'ws';
import Redis from 'ioredis';
const GW = process.env.GATEWAY_ID;
const db = new Redis(process.env.VALKEY_URL); // commands
const sub = new Redis(process.env.VALKEY_URL); // a connection in subscribe mode cannot run other commands
const sockets = new Map(); // user -> WebSocket, only for users connected to THIS gateway
const log = (...a) => console.log(new Date().toISOString().slice(11, 23), GW, ...a);
// 1) Routing: every gateway listens on its own channel. To reach a user, look up his gateway and publish there.
await sub.subscribe(`gw:${GW}`);
sub.on('message', (_ch, raw) => {
const { to, frame } = JSON.parse(raw);
const ws = sockets.get(to);
if (ws) ws.send(JSON.stringify(frame)); // if he left a moment ago, the copy in his inbox is still there
});
// 2) Every message and receipt goes to the recipient's inbox first, then to his gateway if he is online.
async function deliver(to, frame) {
const id = frame.id;
await db.multi()
.hset(`msg:${id}`, 'frame', JSON.stringify(frame))
.expire(`msg:${id}`, 30 * 24 * 3600) // undelivered items do not live forever
.zadd(`inbox:${to}`, Date.now(), id)
.exec();
const gw = await db.get(`presence:${to}`);
if (gw) {
await db.publish(`gw:${gw}`, JSON.stringify({ to, frame }));
log(`route ${frame.type} ${id} -> ${to}@${gw}`);
} else if (frame.type === 'message') {
await db.rpush('push:outbox', JSON.stringify({ to, conv: frame.conv, from: frame.from }));
log(`${to} is offline: stored ${id}, queued a push notification`);
} else {
log(`${to} is offline: stored ${frame.type} ${id}`);
}
}
async function onSend(user, ws, m) {
// Idempotency: the same clientMsgId from the same sender always maps to the same server id
const dedupKey = `dedup:${user}:${m.clientMsgId}`;
const seen = await db.get(dedupKey);
if (seen) {
log(`duplicate ${m.clientMsgId} from ${user}, answering with ${seen}`);
return ws.send(JSON.stringify({ type: 'sent', clientMsgId: m.clientMsgId, id: seen, duplicate: true }));
}
const members = await db.smembers(`conv:${m.conv}:members`);
if (!members.includes(user)) return ws.send(JSON.stringify({ type: 'error', error: 'not a member' }));
const seq = await db.incr(`seq:${m.conv}`); // order inside one conversation
const id = `${m.conv}:${seq}`;
await db.set(dedupKey, id, 'EX', 24 * 3600);
await db.rpush(`log:${m.conv}`, JSON.stringify({ id, seq, from: user, text: m.text })); // the conversation history
ws.send(JSON.stringify({ type: 'sent', clientMsgId: m.clientMsgId, id, seq })); // one tick: the server has it
const frame = { type: 'message', id, conv: m.conv, seq, from: user, text: m.text };
for (const to of members) if (to !== user) await deliver(to, frame); // fan-out on write
}
async function onAck(user, m) {
// The device confirms it stored the item: remove it from the inbox, and tell the sender
const raw = await db.hget(`msg:${m.id}`, 'frame');
await db.zrem(`inbox:${user}`, m.id);
if (!raw) return;
const frame = JSON.parse(raw);
if (frame.type === 'message') {
const receipt = { type: 'delivered', id: `rcpt:${m.id}:${user}`, of: m.id, by: user };
await deliver(frame.from, receipt); // two ticks
}
}
async function onRead(user, m) {
const receipt = { type: 'read', id: `read:${m.conv}:${m.upTo}:${user}`, conv: m.conv, upTo: m.upTo, by: user };
for (const to of await db.smembers(`conv:${m.conv}:members`)) if (to !== user) await deliver(to, receipt);
}
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', async (ws, req) => {
const user = new URL(req.url, 'http://x').searchParams.get('user'); // the lab trusts the name; a real app checks a token
sockets.set(user, ws);
await db.set(`presence:${user}`, GW, 'EX', 30);
const beat = setInterval(() => db.set(`presence:${user}`, GW, 'EX', 30), 10_000);
log(`${user} connected`);
// 3) Store-and-forward: whatever arrived while he was away, in order
const pending = await db.zrange(`inbox:${user}`, 0, -1);
if (pending.length) log(`${user} has ${pending.length} waiting item(s), sending them now`);
for (const id of pending) {
const raw = await db.hget(`msg:${id}`, 'frame');
if (raw) ws.send(raw);
}
ws.on('message', async (data) => {
const m = JSON.parse(data);
if (m.type === 'send') await onSend(user, ws, m);
else if (m.type === 'ack') await onAck(user, m);
else if (m.type === 'read') await onRead(user, m);
});
ws.on('close', async () => {
clearInterval(beat);
if (sockets.get(user) === ws) {
sockets.delete(user);
// only remove presence if it still points to us (he may already be connected elsewhere)
if (await db.get(`presence:${user}`) === GW) await db.del(`presence:${user}`);
}
log(`${user} disconnected`);
});
});
log('listening on :8080');أما سكربت المستخدمين client.mjs فهو في البطاقة التالية:
client.mjs كاملاً
// Scripted users for the chat lab: three people on two gateways, one goes offline and comes back.
import WebSocket from 'ws';
import Redis from 'ioredis';
const db = new Redis('redis://valkey:6379');
const t0 = Date.now();
const say = (...a) => console.log(`+${String(Date.now() - t0).padStart(5)}ms`, ...a);
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// fresh lab data: two conversations
if (process.argv[2] !== 'crash') await db.flushall();
await db.sadd('conv:alice-bob:members', 'alice', 'bob');
await db.sadd('conv:trip:members', 'alice', 'bob', 'carol');
function connect(user, gw) {
const ws = new WebSocket(`ws://${gw}:8080/?user=${user}`);
ws.on('message', (data) => {
const f = JSON.parse(data);
const what = f.type === 'message' ? `message ${f.id} from ${f.from}: "${f.text}"`
: f.type === 'sent' ? `sent ✓ ${f.clientMsgId} -> ${f.id}${f.duplicate ? ' (duplicate, same id)' : ''}`
: f.type === 'delivered' ? `delivered ✓✓ ${f.of} to ${f.by}`
: f.type === 'read' ? `read ✓✓ ${f.conv} up to seq ${f.upTo} by ${f.by}` : JSON.stringify(f);
say(`${user}@${gw} <-`, what);
if (f.id && f.type !== 'sent') ws.send(JSON.stringify({ type: 'ack', id: f.id })); // stored on the device
});
return new Promise(r => ws.on('open', () => { say(`${user} connected to ${gw}`); r(ws); }));
}
const send = (ws, conv, clientMsgId, text) => ws.send(JSON.stringify({ type: 'send', conv, clientMsgId, text }));
if (process.argv[2] === 'crash') {
// gw2 is killed from the host about 2 s after start (docker kill), while carol is connected to it
const alice = await connect('alice', 'gw1');
const carol = await connect('carol', 'gw2');
carol.on('close', () => say('carol: connection to gw2 lost'));
await sleep(3000);
say(`valkey: presence carol=${await db.get('presence:carol')} ttl=${await db.ttl('presence:carol')}s (gw2 is dead, the key does not know yet)`);
send(alice, 'trip', 'c-101', 'did everyone get the tickets?');
await sleep(300);
say(`valkey: inbox:carol has ${await db.zcard('inbox:carol')} item(s)`);
const carol2 = await connect('carol', 'gw1'); // the app reconnects to any healthy gateway
await sleep(500);
say(`valkey: inbox:carol has ${await db.zcard('inbox:carol')} item(s)`);
for (const ws of [alice, carol2]) ws.close();
await sleep(200);
process.exit(0);
}
say('--- 1) alice on gw1, bob and carol on gw2');
const alice = await connect('alice', 'gw1');
let bob = await connect('bob', 'gw2');
const carol = await connect('carol', 'gw2');
await sleep(200);
say('--- 2) alice -> bob across gateways, then bob reads it');
send(alice, 'alice-bob', 'c-001', 'hello bob');
await sleep(300);
bob.send(JSON.stringify({ type: 'read', conv: 'alice-bob', upTo: 1 }));
await sleep(300);
say('--- 3) the ack was lost, alice\'s phone sends c-001 again');
send(alice, 'alice-bob', 'c-001', 'hello bob');
await sleep(300);
say('--- 4) bob loses his connection, alice sends two more');
bob.close();
await sleep(300);
send(alice, 'alice-bob', 'c-002', 'are you there?');
send(alice, 'alice-bob', 'c-003', 'call me when you land');
await sleep(300);
say(`valkey: inbox:bob has ${await db.zcard('inbox:bob')} item(s), push:outbox has ${await db.llen('push:outbox')} item(s)`);
say('--- 5) bob comes back, this time through gw1');
bob = await connect('bob', 'gw1');
await sleep(500);
say('--- 6) group "trip": one send, fan-out to bob (gw1) and carol (gw2)');
send(alice, 'trip', 'c-004', 'bus leaves at 7');
await sleep(500);
say(`valkey: inbox sizes alice=${await db.zcard('inbox:alice')} bob=${await db.zcard('inbox:bob')} carol=${await db.zcard('inbox:carol')}`);
say(`valkey: log:alice-bob = ${(await db.lrange('log:alice-bob', 0, -1)).map(x => JSON.parse(x).seq).join(',')} | presence bob=${await db.get('presence:bob')} ttl=${await db.ttl('presence:bob')}s`);
for (const ws of [alice, bob, carol]) ws.close();
await sleep(200);
process.exit(0);
في الإعداد أعلاه لاحظ التالي:
- كل Gateway يفتح اتصالين بقاعدة Valkey، والسبب أن الاتصال الذي يدخل وضع الاشتراك
SUBSCRIBEلا يستطيع تنفيذ أوامر أخرى. - الدالة
deliverتكتب الرسالة فيinbox:<user>قبل أن تسأل عن الحضور، فالتوصيل عبر القناة طريق سريع فقط، والنسخة الحقيقية في الصندوق. - الصندوق مجموعة مرتبة Sorted Set بوقت الإضافة، والرسالة نفسها في مفتاح
msg:<id>بمهلة 30 يوماً. - الكلاينت يرسل
ackلكل ما يستلمه، والسيرفر يحذف العنصر من الصندوق ثم يرسل إيصال التسليم إلى المرسل، والإيصال نفسه يمر بنفس الدالةdeliver.
شغل الخدمات ثم السيناريو:
docker compose up -d --build
docker compose run --rm clientماذا استلم كل مستخدم؟
والمخرج سوف يكون كما يلي:
+ 26ms --- 1) alice on gw1, bob and carol on gw2
+ 40ms alice connected to gw1
+ 47ms bob connected to gw2
+ 51ms carol connected to gw2
+ 252ms --- 2) alice -> bob across gateways, then bob reads it
+ 258ms alice@gw1 <- sent ✓ c-001 -> alice-bob:1
+ 262ms bob@gw2 <- message alice-bob:1 from alice: "hello bob"
+ 269ms alice@gw1 <- delivered ✓✓ alice-bob:1 to bob
+ 557ms alice@gw1 <- read ✓✓ alice-bob up to seq 1 by bob
+ 853ms --- 3) the ack was lost, alice's phone sends c-001 again
+ 855ms alice@gw1 <- sent ✓ c-001 -> alice-bob:1 (duplicate, same id)
+ 1154ms --- 4) bob loses his connection, alice sends two more
+ 1461ms alice@gw1 <- sent ✓ c-002 -> alice-bob:2
+ 1462ms alice@gw1 <- sent ✓ c-003 -> alice-bob:3
+ 1759ms valkey: inbox:bob has 2 item(s), push:outbox has 2 item(s)
+ 1759ms --- 5) bob comes back, this time through gw1
+ 1762ms bob connected to gw1
+ 1764ms bob@gw1 <- message alice-bob:2 from alice: "are you there?"
+ 1765ms bob@gw1 <- message alice-bob:3 from alice: "call me when you land"
+ 1768ms alice@gw1 <- delivered ✓✓ alice-bob:2 to bob
+ 1768ms alice@gw1 <- delivered ✓✓ alice-bob:3 to bob
+ 2262ms --- 6) group "trip": one send, fan-out to bob (gw1) and carol (gw2)
+ 2264ms alice@gw1 <- sent ✓ c-004 -> trip:1
+ 2266ms bob@gw1 <- message trip:1 from alice: "bus leaves at 7"
+ 2268ms carol@gw2 <- message trip:1 from alice: "bus leaves at 7"
+ 2270ms alice@gw1 <- delivered ✓✓ trip:1 to bob
+ 2270ms alice@gw1 <- delivered ✓✓ trip:1 to carol
+ 2766ms valkey: inbox sizes alice=0 bob=0 carol=0
+ 2767ms valkey: log:alice-bob = 1,2,3 | presence bob=gw1 ttl=29sفي المخرج أعلاه لاحظ التالي:
- في الخطوة 2 خرجت الرسالة من
gw1ووصلت إلى Bob علىgw2في 4 ملي ثانية تقريباً على نفس الجهاز، ثم عاد إيصال التسليم بعد 7 ملي ثوان، ثم إيصال القراءة عندما أرسله Bob. - في الخطوة 3 أعاد هاتف Alice إرسال
c-001كأن التأكيد ضاع، فرد السيرفر بنفس المعرفalice-bob:1معduplicate، ولم يستلم Bob شيئاً جديداً. - في الخطوة 4 انقطع Bob، فحفظت الرسالتان في صندوقه ودخل طابور الإشعارات عنصران، وفي الخطوة 5 عاد من
gw1وليس منgw2، ومع ذلك استلم الرسالتين بالترتيب 2 ثم 3، ووصلت إلى Alice علامتا التسليم. - في الخطوة 6 أرسلت Alice رسالة واحدة إلى المجموعة، فنسخها السيرفر إلى Bob على
gw1وCarol علىgw2، وعاد إيصالان. - في النهاية كل الصناديق فارغة لأن كل شيء تم تأكيده، وسجل المحادثة فيه الأرقام 1 و2 و3 دون تكرار، وسجل الحضور يقول إن Bob على
gw1بمهلة 29 ثانية.
وسجلات سيرفري الاتصال تبين الطريق نفسه من الداخل:
docker compose logs --no-log-prefix gw1 gw2 | sort15:05:07.855 gw1 listening on :8080
15:05:08.124 gw2 listening on :8080
15:05:12.222 gw1 alice connected
15:05:12.235 gw2 bob connected
15:05:12.236 gw2 carol connected
15:05:12.446 gw1 route message alice-bob:1 -> bob@gw2
15:05:12.453 gw2 route delivered rcpt:alice-bob:1:bob -> alice@gw1
15:05:12.741 gw2 route read read:alice-bob:1:bob -> alice@gw1
15:05:13.039 gw1 duplicate c-001 from alice, answering with alice-bob:1
15:05:13.346 gw2 bob disconnected
15:05:13.648 gw1 bob is offline: stored alice-bob:2, queued a push notification
15:05:13.648 gw1 bob is offline: stored alice-bob:3, queued a push notification
15:05:13.947 gw1 bob connected
15:05:13.948 gw1 bob has 2 waiting item(s), sending them now
15:05:13.952 gw1 route delivered rcpt:alice-bob:2:bob -> alice@gw1
15:05:13.953 gw1 route delivered rcpt:alice-bob:3:bob -> alice@gw1
15:05:14.450 gw1 route message trip:1 -> bob@gw1
15:05:14.452 gw1 route message trip:1 -> carol@gw2
15:05:14.454 gw1 route delivered rcpt:trip:1:bob -> alice@gw1
15:05:14.455 gw2 route delivered rcpt:trip:1:carol -> alice@gw1
15:05:14.956 gw2 carol disconnected
15:05:14.957 gw1 alice disconnected
15:05:14.958 gw1 bob disconnectedنكسر التجربة: سيرفر الاتصال يتوقف فجأة
الآن نشغل سيناريو آخر تتصل فيه Carol ب gw2، ثم نوقف gw2 بالقوة من جهازك بعد ثانيتين تقريباً كأن السيرفر انطفأ، ثم ترسل Alice رسالة إلى المجموعة:
docker compose run --rm client crash &
sleep 2; docker kill lab-case2-chat-gw2; wait+ 37ms alice connected to gw1
+ 41ms carol connected to gw2
+ 986ms carol: connection to gw2 lost
+ 3044ms valkey: presence carol=gw2 ttl=27s (gw2 is dead, the key does not know yet)
+ 3048ms alice@gw1 <- sent ✓ c-101 -> trip:2
+ 3347ms valkey: inbox:carol has 1 item(s)
+ 3350ms carol connected to gw1
+ 3351ms carol@gw1 <- message trip:2 from alice: "did everyone get the tickets?"
+ 3354ms alice@gw1 <- delivered ✓✓ trip:2 to carol
+ 3852ms valkey: inbox:carol has 0 item(s)15:05:17.694 gw1 alice connected
15:05:20.708 gw1 bob is offline: stored trip:2, queued a push notification
15:05:20.710 gw1 route message trip:2 -> carol@gw2
15:05:21.010 gw1 carol connected
15:05:21.010 gw1 carol has 1 waiting item(s), sending them now
15:05:21.013 gw1 route delivered rcpt:trip:2:carol -> alice@gw1
15:05:21.515 gw1 alice disconnected
15:05:21.515 gw1 carol disconnectedلاحظ أن سجل الحضور ما زال يقول إن Carol على gw2 بعد موت السيرفر، والسبب أن gw2 لم يجد وقتاً ليحذف المفتاح، فنشر gw1 الرسالة على قناة gw:gw2 التي لا يستمع لها أحد، وضاعت هذه النسخة بصمت. ومع ذلك لم تضع الرسالة، لأن نسختها في الصندوق، فعندما أعاد تطبيق Carol الاتصال إلى gw1 سلمها فوراً ووصل إيصال التسليم إلى Alice. وهذا هو السبب في القاعدة «الحفظ قبل التوصيل»: لو اعتمد التصميم على Pub/Sub وحده لضاعت الرسالة دون أي خطأ في السجلات.
حذف التجربة
docker compose down
docker image rm lab-case2-chat-gw:1أخطاء شائعة
- الاعتماد على Pub/Sub أو على الاتصال المفتوح وحده لتوصيل الرسائل دون نسخة محفوظة.
- ترتيب الرسائل بساعة الهاتف أو ساعة السيرفر بدل رقم متسلسل داخل المحادثة.
- غياب معرف من الكلاينت، فتظهر الرسالة مرتين كلما ضعف الاتصال.
- حذف الرسالة من الصندوق عند إرسالها لا عند تأكيد الجهاز.
- وضع محتوى الرسالة كاملاً في الإشعار، فيمر عبر خدمات Apple وGoogle ويتجاوز حجمها.
- سجل حضور دون مهلة، فيبقى المستخدم «متصلاً» على سيرفر مات منذ ساعة.
- تحديث كل سيرفرات الاتصال في نفس اللحظة، فيعيد ملايين الهواتف الاتصال معاً.
- تمرير الصور والفيديو عبر اتصال المحادثة بدل تخزين الكائنات.
- كتابة بروتوكول تشفير خاص بدل مكتبة مراجعة.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- المشكلة الأولى في تطبيق المحادثة عدد الاتصالات المفتوحة، وWhatsApp نشر في 2012 أنه وصل إلى أكثر من مليوني اتصال على سيرفر واحد.
- سيرفرات الاتصال Gateways تحمل الاتصالات فقط، وسجل الحضور بمهلة قصيرة يقول أين المستخدم، وقناة لكل Gateway توصل إليه الرسائل.
- الرسالة تحفظ قبل أي توصيل، وتحذف من صندوق المستقبل فقط بعد تأكيد جهازه، والتوصيل مرة واحدة على الأقل مع تجاهل المكرر.
- معرف من الكلاينت يمنع التكرار، ورقم متسلسل داخل المحادثة يضمن الترتيب.
- علامة واحدة من السيرفر، والثانية من جهاز المستقبل، والقراءة حدث مستقل برقم آخر رسالة.
- النسخ عند الكتابة للمجموعات الصغيرة، والسجل المشترك للقنوات الكبيرة.
- الإشعار يوقظ التطبيق ولا يحمل الرسائل، والملفات تذهب إلى تخزين الكائنات مشفرة.
- التشفير بين الطرفين مبني على بروتوكول Signal بمفاتيح على الأجهزة ومفتاح جديد لكل رسالة، والسيرفر ما زال يرى البيانات الوصفية.
وفي مقالات هذا القسم نكمل بأنظمة أخرى تعرفها:
دليلكيف تبني نظام إشعارات يصل إلى ملايين المستخدمينكيف تصل الرسالة الصحيحة إلى المستخدم مرة واحدة وفي وقتها عبر الإشعار والرسائل والبريد، حتى عندما يتعطل المزود.
دليلكيف تبني نظام حجز تذاكر يتحمل لحظة فتح البيعكيف تمنع بيع المقعد الواحد مرتين، وكيف يصمد نظام الحجز عندما يدخل الجميع في الدقيقة نفسها لشراء التذاكر.
دليلأنظمة الحشود الكبرى: كيف تصمد في مواسم الحج والعمرةكيف تصمم نظام حجز وتصاريح يتحمل ملايين الطلبات في مواعيد معروفة مسبقاً، وماذا تطفئ عندما يتجاوز الحمل خطتك.
دليلكيف تبني تطبيق بث مباشر مثل Jaco وTwitchكيف يصل بث المذيع إلى آلاف المشاهدين بتأخير ثانية أو ثانيتين، ومعه دردشة وهدايا لا تتوقف عند الذروة.
دليلإشعارات فورية من سيرفرك إلى هاتفك مع ntfyكيف تشغل سيرفر ntfy خاصاً، وتصلك على هاتفك رسالة عندما يمتلئ القرص أو تفشل النسخة الاحتياطية أو ينتهي النشر.
دليلGPG وOpenPGP: شرح التشفير والتوقيع بالمفتاح العامكيف يعمل GPG بالمفتاح العام والخاص، ولماذا تقارن البصمة دائماً، وأين يحميك اليوم وأين لا يحميك.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة العملية على Docker Engine 29.8.0 وDocker Compose 5.5.1 مع Node.js 24.21.0 وws 8.22.0 وioredis 6.0.0 وValkey 9.1.2.
- أكتوبر 2026: إضافة قسم المعمارية الكاملة: المخطط والمكونات ونموذج البيانات والواجهات وتقدير السعة ومراحل النمو.