دراسات حالة واقعية لتصميم الأنظمة

تصميم نظام محادثة فوري

18 دقيقة الدرس 5 من 10

تصميم نظام محادثة فوري

تُعالج منصة واتساب ما يقارب 100 مليار رسالة يومياً عبر ملياري مستخدم، وتُتيح تيليغرام توصيل الرسائل إلى مجموعات تضم ما يصل إلى 200,000 عضو. يكمن خلف كلا المنتجَين تحدٍّ هندسي بالغ التعقيد رغم بساطته الظاهرة: كيف تنقل حمولة صغيرة كالرسالة النصية من هاتف إلى آخر في أي مكان على وجه الأرض، في أقل من ثانية واحدة، وبشكل موثوق وعلى نطاق واسع؟

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

المتطلبات والحجم المتوقع

قبل رسم أي مكون، حدد المتطلبات بدقة:

  • وظيفية: إرسال واستقبال رسائل نصية فورية؛ محادثات فردية وجماعية (حتى 500 عضو)؛ مؤشرات الحضور الفوري؛ إشعارات توصيل الرسائل (مُرسَلة، مُوصَّلة، مقروءة)؛ تخزين الرسائل للمستخدمين غير المتصلين وتوصيلها عند إعادة الاتصال.
  • غير وظيفية: 50 مليون مستخدم نشط يومياً؛ ذروة 500,000 اتصال WebSocket متزامن لكل مركز بيانات؛ زمن استجابة أقل من 200 مللي ثانية (النسبة المئوية p99)؛ توافر بخمسة تسعات؛ تخزين الرسائل لمدة 30 يوماً على الأقل.
  • خارج النطاق: الملفات الوسائطية، وإدارة مفاتيح التشفير التام بين الطرفين، والمكالمات الصوتية والمرئية.
فكرة أساسية: وضّح دائماً ما إذا كانت الرسائل تحتاج إلى تخزين من جانب الخادم بعد التوصيل. واتساب تاريخياً كان يحذف الرسائل من الخادم فور توصيلها، في حين يُخزّن تيليغرام كل شيء في السحابة. هذا القرار الواحد يُحدث فارقاً هائلاً في تكلفة التخزين والبنية المعمارية.

اختيار بروتوكول النقل: WebSocket مقابل HTTP Polling

يُهدر HTTP Polling (حيث يسأل العميل "هل توجد رسائل جديدة؟" كل بضع ثوانٍ) عرض النطاق الترددي ويُضيف زمن استجابة يتناسب مع فترة الاستطلاع. تحلّ WebSockets هذه المشكلة: بعد مصافحة ترقية HTTP واحدة، يُصبح الاتصال قناة TCP ثنائية الاتجاه ومستمرة، حيث يمكن للخادم دفع رسالة فور وصولها دون أي حلقة استطلاع.

يفتح كل عميل محمول اتصال WebSocket واحداً طويل الأمد مع خادم المحادثة. بما أن اتصالات TCP ذات حالة (stateful)، فإن خادم المحادثة الذي يحتفظ بمقبس المستخدم هو الذي يجب أن يُوصّل الرسائل إليه — وهذا القيد المعماري الأساسي الذي يشكّل كل شيء آخر.

البنية المعمارية العليا

Chat system high-level architecture Client A Client B WS Load Balancer Chat Server Instance 1 Chat Server Instance 2 Message Queue (Kafka) Presence Service Message DB (Cassandra) Session Cache (Redis) API Gateway (REST/auth) WS persist WS push
البنية المعمارية العليا لنظام المحادثة: مسار WebSocket، وقائمة انتظار الرسائل، وتخزين Cassandra، وذاكرة Redis للجلسات، وخدمة الحضور.

المسارات الرئيسية:

  1. يتصل العميل A عبر WebSocket من خلال موازن الحمل بخادم المحادثة (Instance 1).
  2. يُرسل A رسالة. يُنشرها خادم المحادثة 1 في Kafka ويُرسل فوراً إقراراً للعميل A (علامة "مُرسَلة").
  3. تبحث خدمة التوصيل (مستهلك Kafka) عن خادم المحادثة الذي يحتفظ بمقبس العميل B (عبر Redis)، ثم تدفع الرسالة عبر ذلك الاتصال.
  4. بالتوازي، تكتب خدمة الاستمرارية (مستهلك Kafka آخر) الرسالة في Cassandra للتاريخ والتوصيل اللاحق.

توجيه الجلسات: كيف تجد خدمة التوصيل العميل B؟

هذه هي صلب التصميم. عندما يفتح العميل B اتصال WebSocket، يكتب خادم المحادثة المعني سجل جلسة في Redis:

SET session:{userId} {chatServerHostname} EX 3600

عند وصول رسالة للمستخدم B، تقرأ خدمة التوصيل session:{userId} من Redis. إن كان B متصلاً، يوجد المفتاح ويُشير إلى خادم المحادثة الذي يحتفظ بالمقبس المفتوح، فترسل خدمة التوصيل طلب RPC داخلي لذلك الخادم الذي يدفع الرسالة عبر WebSocket. أما إن كان المفتاح غائباً فالمستخدم B غير متصل — تبقى الرسالة في Cassandra وتُوصَّل دفعةً واحدة عند إعادة اتصاله.

أفضل ممارسة: احرص على الفصل التام بين مخزن الجلسات (Redis) ومخزن الرسائل (Cassandra). بيانات الجلسات ساخنة ومؤقتة وصغيرة جداً، بينما تاريخ الرسائل بارد ودائم وضخم. دمجهما يُضطرك إلى الإفراط في توفير ذاكرة التخزين المؤقت أو تقليص قاعدة البيانات.

تخزين الرسائل: لماذا Cassandra؟

يتبع الوصول إلى رسائل المحادثة نمطاً طبيعياً: أعطني جميع رسائل المحادثة X مرتبةً زمنياً، الأحدث أولاً. يناسب نموذج بيانات Cassandra هذا النمط تماماً؛ مفتاح التقسيم (conversation_id) مع مفتاح التجميع (created_at DESC, message_id) يُخزّن جميع رسائل محادثة واحدة على نفس العقد، مما يُتيح عمليات المسح النطاقي على قسم واحد.

على نطاق واتساب، معدل الكتابة ضخم جداً — 100 مليار رسالة يومياً تساوي ما يقارب 1.16 مليون كتابة في الثانية. تستوعب محرك تخزين LSM-tree في Cassandra دفعات الكتابة بكفاءة أعلى بكثير مقارنة بـ B-tree (PostgreSQL / MySQL)، لأن الكتابات تذهب دائماً إلى MemTable في الذاكرة أولاً، ثم تُدمج دفعيّاً في SSTables على القرص دون تضخيم I/O عشوائي.

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

رسائل المجموعات: التوزيع الجماعي (Fan-Out)

تتطلب رسائل المجموعات توصيل رسالة واحدة إلى N مستلم. هناك استراتيجيتان:

  • التوزيع عند الكتابة: عندما ترسل أليس رسالة في مجموعة من 50 شخصاً، ينشئ النظام فوراً 50 مهمة توصيل (واحدة لكل عضو). بسيط في التنفيذ وزمن الاستجابة متوقع، لكنه مكلف على نطاق المجموعات الكبيرة (200,000 عضو في تيليغرام).
  • التوزيع عند القراءة: تُخزَّن نسخة واحدة من الرسالة، ويجلب عميل كل عضو الرسائل منذ آخر قراءة. يحقق اقتصاداً في التخزين لكن يزيد تعقيد القراءة.

تعتمد معظم أنظمة المحادثة هجيناً: توزيع عند الكتابة للمجموعات الصغيرة (أقل من ~500 عضو)، وتوزيع عند القراءة للقنوات والبث الكبير. العتبة تعتمد على طاقة الكتابة لديك.

الحضور: متصل / غير متصل / آخر ظهور

Presence heartbeat and TTL mechanism Client (Mobile App) Chat Server (WebSocket) Presence Service Redis presence:{uid} TTL=65s Redis last_seen:{uid} heartbeat/30s update SET EX 65 SET timestamp On disconnect / TTL expiry: presence key gone = user Offline
آلية الحضور المعتمدة على نبضات القلب (Heartbeat): يُرسل العميل ping كل 30 ثانية؛ تُحدّث خدمة الحضور مفتاح Redis بمدة صلاحية 65 ثانية. الصمت = غير متصل.

الحضور خادع في تعقيده على نطاق واسع. الاعتماد على أحداث الاتصال/الانقطاع في WebSocket يفشل لأن العميل قد يفقد الاتصال دون إرسال إطار إغلاق نظيف (شبكات الجوال، والتطبيقات في الخلفية). النمط المتين:

  1. يُرسل العميل نبضة قلب كل 30 ثانية عبر WebSocket المفتوح.
  2. يُحيل خادم المحادثة هذه النبضة لخدمة الحضور التي تُنفّذ SET presence:{userId} 1 EX 65 في Redis.
  3. إن لم تصل أي نبضة خلال 65 ثانية، تنتهي صلاحية المفتاح ويختفي — المستخدم غير متصل.
  4. تكتب خدمة الحضور أيضاً الطابع الزمني الحالي في last_seen:{userId} عند كل نبضة، ليرى الأصدقاء "آخر ظهور قبل دقيقتين".

مع 50 مليون مستخدم نشط يومياً وحوالي 10 ملايين متزامن، يعني ذلك 10 ملايين كتابة في Redis كل 30 ثانية أي ما يقارب 333,000 كتابة/ثانية. تستخدم الأنظمة الإنتاجية تجزئة مجموعة Redis الخاصة بالحضور وتُحدّث الحضور دفعيّاً بدلاً من استدعاء SET منفصل لكل مستخدم.

ضمانات توصيل الرسائل

تعتمد أنظمة المحادثة عادةً نموذج توصيل ثلاثي الحالات:

  • مُرسَلة (علامة واحدة): قبل الخادم الرسالة وأقرّ باستلامها. Kafka يحتفظ بها وستُستمر.
  • مُوصَّلة (علامتان): دُفعت الرسالة إلى جهاز المستلم وأقرّ الجهاز باستلامها.
  • مقروءة (علامتان زرقاوتان): أبلغ تطبيق المستلم أن الرسالة شوهدت.

كل انتقال حالة يستلزم إرسال إقرار صغير من جهاز المستلم عبر WebSocket. تُقاطَر هذه الإقرارات أيضاً وتُوصَّل بشكل غير متزامن للمُرسِل.

أفضل ممارسة: استخدم معرفات رسائل متزايدة رتيباً لكل محادثة (وليس UUIDs عالمية). عند إعادة الاتصال، يُرسل العميل آخر معرف رسالة رآه ويُعيد الخادم كل ما بعده. يُجنّب هذا النمط "مؤشر المزامنة" التصفح عبر كامل السجل ورخيص التنفيذ مع مسح نطاقي Cassandra.

معالجة المستخدمين غير المتصلين

عندما لا تجد خدمة التوصيل مفتاح جلسة للمستخدم في Redis، فالرسالة محفوظة بأمان في Cassandra. عند إعادة اتصال المستخدم، يُرسل العميل طلب SYNC {lastSeenMessageId} لبوابة API، التي تستعلم Cassandra عن جميع الرسائل في محادثات المستخدم بعد ذلك المعرف. هذا النمط "اللحاق عند إعادة الاتصال" أكفأ بكثير من إعادة محاولة دفع كل رسالة للمستخدمين غير المتصلين.

ملخص قرارات التصميم الرئيسية

  • WebSocket بدلاً من Polling — توصيل دون ثانية، اتصالات دائمة قابلة للإدارة عبر توجيه الجلسات في Redis.
  • Kafka كعمود فقري للرسائل — يفصل خادم المحادثة (القبول والإقرار السريع) عن مستهلكَي التوصيل والاستمرارية؛ يوفر المتانة في حال توقف مستهلك.
  • Cassandra لتخزين الرسائل — مُحسَّن للكتابة، قابل للتوسع أفقياً، ملائم طبيعياً لتقسيمات المحادثات المرتبة زمنياً.
  • Redis للجلسات والحضور — استجابات دون مللي ثانية، TTL مدمج لانتهاء صلاحية الحضور، قابل للتجزئة الأفقية.
  • استراتيجية التوزيع تعتمد على حجم المجموعة — توزيع عند الكتابة للمجموعات الصغيرة؛ عند القراءة للقنوات الكبيرة.