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

تصميم خلاصة الأخبار

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

تصميم خلاصة الأخبار

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

يتمحور هذا الدرس حول القرار المعماري الأهم في تصميم الخلاصة: التوزيع عند الكتابة مقابل التوزيع عند القراءة — وكيف تجمع المنصات الحقيقية بين الأسلوبين.

ما الذي يجب أن يفعله النظام؟

قبل اختيار المعمارية، حدّد المتطلبات:

  • النشر: يكتب مستخدم منشوراً، ويجب أن يراه كل متابعيه في خلاصتهم في نهاية المطاف.
  • قراءة الخلاصة: يفتح المستخدم التطبيق ويحصل على أحدث ~20 منشوراً ممن يتابعهم، مرتبة زمنياً أو حسب التفاعل.
  • افتراضات الحجم: 300 مليون مستخدم نشط يومياً، ~5 ملايين منشور في الدقيقة، متوسط عدد المتابَعين ~200، وبعض المشاهير لديهم 50–100 مليون متابع.
  • هدف زمن الاستجابة: قراءة الخلاصة أقل من 200 مللي ثانية عند P99. كتابة المنشور يمكن أن تكون متسقة في نهاية المطاف (تأخير بضع ثوانٍ مقبول).
الفكرة الأساسية: عمليات القراءة تفوق عمليات الكتابة بفارق كبير. تشهد المنصات الاجتماعية عادةً نسبة قراءة إلى كتابة تبلغ 100:1 أو أكثر. يجب أن تكون المعمارية محسّنة للقراءة السريعة والرخيصة — حتى لو كان ذلك على حساب مزيد من العمل عند الكتابة.

الخيار الأول — التوزيع عند الكتابة (نموذج الدفع)

حين ينشر مستخدم، يقوم النظام فوراً بدفع ذلك المنشور إلى ذاكرة التخزين المؤقت للخلاصة لكل متابع. يملك كل مستخدم صندوق بريد للخلاصة (مثل Redis Sorted Set مفتاحه معرّف المستخدم). قراءة الخلاصة بسيطة جداً: جلب أفضل N إدخال من صندوق البريد.

  • مسار الكتابة: منشور ← جلب كل المتابعين ← كتابة معرّف المنشور في ذاكرة خلاصة كل متابع. هذه هي خطوة "التوزيع".
  • مسار القراءة: جلب قائمة الخلاصة المُعدّة مسبقاً ← تعبئة تفاصيل المنشورات ← إعادتها للعميل. O(1) لكل مستخدم.

المزايا: القراءة سريعة جداً (بحث واحد في التخزين المؤقت)؛ لا دمج معقد عند القراءة؛ يعمل بكفاءة عند المستخدمين ذوي أعداد المتابعين المعتدلة.

العيوب: نشر منشور واحد لمشهور لديه 50 مليون متابع يعني 50 مليون عملية كتابة في التخزين المؤقت — عاصفة "مفتاح ساخن". يُعرف هذا بـمشكلة المشاهير. يجب تأجيل مهام التوزيع بشكل غير متزامن عبر طابور، مما يُدخل تأخيراً في الكتابة. تكلفة التخزين مرتفعة لأن كل منشور يُنسخ مرة لكل متابع.

Fan-Out on Write (Push Model) Author (writes a post) Post Service + Fan-out Queue Fan-out Workers (async) Feed Cache User A mailbox Feed Cache User B mailbox Feed Cache User C mailbox Post DB (source of truth) Follower (reads feed) POST push to all followers read Write path: async fan-out to N mailboxes. Read path: single cache fetch.
التوزيع عند الكتابة: يُدفع المنشور إلى ذاكرة خلاصة كل متابع فور الكتابة، مما يجعل القراءة سريعة جداً.

الخيار الثاني — التوزيع عند القراءة (نموذج السحب)

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

  • مسار الكتابة: منشور ← كتابة مرة واحدة في قاعدة البيانات. انتهى. O(1) بغض النظر عن عدد المتابعين.
  • مسار القراءة: جلب قائمة المتابَعين ← N قراءة متوازية من مصادر المنشورات ← دمج ← ترتيب ← إعادة. O(N) حيث N هو عدد المتابَعين.

المزايا: الكتابة رخيصة جداً؛ لا مشكلة مشاهير؛ لا تضاعف في التخزين؛ الخلاصة دائماً حديثة (المنشورات المحذوفة تختفي فوراً).

العيوب: القراءة مكلفة وبطيئة — مستخدم يتابع 2000 حساب يُطلق 2000 استعلام فرعي عند كل قراءة. يُضاعف هذا الحمل على قاعدة البيانات بشكل هائل للمستخدمين النشطين. يصعب الالتزام بـ200 مللي ثانية عند P99.

Fan-Out on Read (Pull Model) Follower (opens feed) Feed Service merge + rank Follow DB (who do I follow?) Post Cache Author A Post Cache Author B Post DB Author C … N GET feed 1. who to follow? 2. parallel fetch posts Read path: fan-out to N authors on every feed request — expensive at scale.
التوزيع عند القراءة: تُجلب المنشورات من كل مؤلف متابَع وقت الطلب ثم تُدمج. الكتابة رخيصة؛ القراءة مكلفة.

النهج الهجين — ما تفعله Twitter وFacebook فعلياً

لا تنجح الاستراتيجية الخالصة في أي من الحالتين على نطاق الإنترنت. توصّلت كلٌّ من Twitter وFacebook إلى نموذج هجين:

  1. المستخدمون العاديون (عدد متابعين صغير): التوزيع عند الكتابة. يُدفع منشورهم فوراً إلى ذاكرة خلاصة جميع المتابعين بشكل غير متزامن عبر طابور.
  2. المشاهير (ملايين المتابعين): التوزيع عند القراءة. منشوراتهم لا تُدفع — التكلفة باهظة جداً. بدلاً من ذلك، تُخزَّن في ذاكرة تخزين مؤقت ساخنة مخصصة. عند القراءة، تقوم خدمة الخلاصة بـحقن منشورات المشاهير في خلاصة المستخدم المُعدّة مسبقاً.
  3. المستخدمون غير النشطين: لا يُبنى صندوق البريد الخاص بالخلاصة مسبقاً على الإطلاق. حين يعودون، تُجمَّع خلاصتهم عند القراءة (لا تكلفة صيانة تخزين مستمرة).
حدّ Twitter التاريخي (~10 آلاف متابع): فوق هذا العدد، تتجاوز منشورات المستخدم مسار التوزيع عند الكتابة وتذهب إلى مسار الحقن عند القراءة. هذا يحمي طابور التوزيع غير المتزامن من الارتفاع المفاجئ حين يُغرّد مشهور.

هيكل بيانات ذاكرة الخلاصة

صندوق بريد الخلاصة عادةً ما يكون Redis Sorted Set لكل مستخدم، مفتاحه feed:{user_id}. يخزن كل إدخال معرّف المنشور (ليس المنشور كاملاً) عضواً والطابع الزمني للمنشور درجةً. جلب الخلاصة استدعاء واحد ZREVRANGE feed:{user_id} 0 19 — بتعقيد O(log N + 20) — يليه MGET دُفعي على ذاكرة تخزين تفاصيل المنشورات لتعبئة المحتوى.

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

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

الترتيب ما وراء الترتيب الزمني

الخلاصات الحقيقية ليست زمنية بالكامل. تستخدم Facebook وInstagram نماذج ترتيب بالتعلم الآلي تُعطي درجات لكل منشور بناءً على إشارات التفاعل (إعجابات، تعليقات، مشاركات، وقت المشاهدة) مع الحداثة. معمارياً يعني هذا:

  • تجلب خدمة الخلاصة مجموعة مرشحين أكبر (مثلاً 200 منشور).
  • تُعطي خدمة الترتيب درجات وتُعيد ترتيبها.
  • تُعاد أفضل N للعميل.

يصبح صندوق البريد المُعدّ مسبقاً مجمع مرشحين، وليس القائمة المرتبة النهائية. الترتيب يحدث وقت القراءة على مجموعة مرشحين صغيرة، مما يُبقيه سريعاً مع السماح بتخصيص متطور.

ملخص المكوّنات

  • Post Service — يقبل عمليات الكتابة، يخزّن في Post DB، ينشر في طابور التوزيع.
  • Fan-out Workers — مستهلكون غير متزامنين؛ يكتبون معرّفات المنشورات في صناديق البريد للمستخدمين العاديين؛ يتخطّون المشاهير.
  • Feed Service — يعالج طلبات القراءة؛ يجلب صندوق البريد، يحقن منشورات المشاهير عند القراءة، يُطلق الترتيب.
  • Post Cache — طبقة Redis/Memcached أمام Post DB؛ تحتفظ بكائنات المنشورات الكاملة.
  • Follow Graph Store — قاعدة بيانات رسم بياني أو جدول MySQL مُجزَّأ؛ قراءات مكثفة، مُخفَّف للبحث السريع.
  • Ranking Service — مُعطي درجات قائم على التعلم الآلي؛ يُستدعى وقت القراءة على مجموعة المرشحين.
المقايضة الجوهرية في جملة واحدة: التوزيع عند الكتابة يتاجر بتضخم التخزين والكتابة للحصول على قراءة سريعة؛ التوزيع عند القراءة يتاجر بزمن استجابة القراءة والمعالج للحصول على كتابة رخيصة. النموذج الهجين يرسم الحدّ عند حسابات المشاهير — الحالة الوحيدة التي يصبح فيها تضخم الكتابة كارثياً.