مشروع · 2022 · قائد

Bytro: تحديث backend لعبة multiplayer قديم

قاد تحديث backend لعبة PHP في Bytro نحو microservices قائمة على الأحداث مع CQRS، خفّض الزمن الاستجابي ~35% مع إبقاء اللاعبين في مباريات نشطة.

Screenshot of bytro.com - Bytro Labs multiplayer strategy games studio

تبني Bytro ألعاب استراتيجية real-time. Supremacy 1914. Conflict of Nations: WW3. مستندة للمتصفح، real-time، ضخمة multiplayer. يُنسّق اللاعبون حملات حرب عالمية عبر أيام أو أسابيع. المباريات تضم مئات اللاعبين. الحالة - مواقع الوحدات، وأعداد الموارد، والاتفاقيات الدبلوماسية، ونتائج المعارك - يجب أن تكون متسقة ومرئية لكل مشارك في كل الأوقات.

هذا هو النظام الذي طُلب مني تحديثه.

ما “legacy” تعني فعلاً

الـbackend الأصلي كان PHP. ليس PHP 8 مع معالجة async حديثة وعقود مُكتَّبة بأنواع - بل PHP قديم، سنوات منه، مع القرارات المتراكمة لقاعدة كود نمت لخدمة لاعبيها بإخلاص لكن وصلت إلى النقطة التي صار فيها كل ميزة جديدة تفاوضاً مع الدَّيْن التقني.

المشاكل لم تكن خفية. تحوّلات الحالة لأحداث اللعبة - وحدة تتحرك، معركة تحسم، رسالة دبلوماسية تُرسَل - كانت تحدث عبر خيوط من الاستدعاءات المتزامنة بملكية غير واضحة. حين تصل أحداث متعددة في آنٍ واحد (وهو دائماً وليس أحياناً في لعبة حيّة بمئات اللاعبين)، كانت ضمانات الاتساق في النظام تعتمد على الحمل. خادم هادئ يتعامل معها. خادم محمّل لديه تعارضات.

حمل القراءة كان مشكلة أيضاً. استعلامات حالة اللعبة - كل لاعب يُحدّث خريطته، كل عميل يستطلع للتحديثات - كانت تضرب نفس مسار قاعدة البيانات كالكتابات. لم يكن ثمة استراتيجية caching يمكنها استيعاب ذرى المباريات بشكل نظيف.

زمن الاستجابة عند p99 كان يؤثر بشكل مرئي على تجربة اللاعب. عند الحجوم التي تعمل بها Bytro، هذه مشكلة استبقاء، لا مجرد إزعاج هندسي.

الرهان المعماري: CQRS + event sourcing + Kafka

كان القرار الأساسي فصل معالجة الأوامر عن معالجة الاستعلامات على مستوى المعمار، لا فقط في تنظيم الكود.

الأوامر - “وحدة تتحرك من المقاطعة A إلى B”، “لاعب يُعلن الحرب على الفصيل X”، “تداول موارد يُنفَّذ” - تمر عبر معالج أوامر يُنشر حدث نطاق إلى Kafka. الحدث هو سجل الحقيقة. معالج الأوامر لا يكتب حالة التطبيق مباشرة.

الحالة مشتقة من الأحداث. نماذج القراءة - الإسقاطات المُجسَّدة التي يستعلم عنها اللاعبون حين ينظرون إلى خريطتهم - تُبنى بواسطة مستهلكي أحداث يُحدّثون PostgreSQL read replicas وRedis caches. طلب القراءة لا يلمس مسار الأوامر أبداً. الكتابة لا تلمس مسار القراءة أبداً. يتوسعان بشكل مستقل.

Event sourcing يعني أن حالة اللعبة في أي نقطة زمنية قابلة لإعادة البناء من سجل الأحداث. هذا ليس ترفاً معمارياً - إنه الجواب على نزاعات “ماذا حدث لوحدتي”، وهي فئة حقيقية من تذاكر دعم اللاعبين في لعبة تكون فيها القرارات ذات أثر ويُولي اللاعبون انتباهاً وثيقاً.

إبقاء اللاعبين الحيّين طوال الترحيل

ده الجزء اللي بعدين تقول عليه واضح، وأثناءه تقول يا رب عدّي.

لا يمكنك إيقاف Supremacy لعطلة نهاية أسبوع ترحيل. اللاعبون في وسط مباراة. بعض المباريات تدوم أسابيع. لا يمكنك القول “مباريات بدأت قبل التحويل ستُرحَّل إلى النظام الجديد؛ ما بعده يعمل على الجديد” - عدد حالات اللعبة الجارية يجعل ذلك مستحيلاً تشغيلياً دون فريق ترحيل مخصص غير موجود.

كانت استراتيجية الترحيل strangler fig قائمة على الأحداث: الوظائف الجديدة نُفِّذت كخدمات تُصدر أحداثاً من اليوم الأول. مسار الكود القديم بقي حياً وسلطوياً. شغّلنا dual-write خلال فترة الانتقال - أحداث جديدة نُشرت إلى Kafka، الحالة القديمة لا تزال تُحدَّث بشكل متزامن - مما أتاح التحقق من أن نماذج القراءة المشتقة من الأحداث متسقة مع مصدر الحقيقة القديم قبل تحويل حركة القراءة.

فترة الـdual-write هي حيث تجد كل افتراض اتخذه النظام القديم لم يكن في الكود. كل ضمان ترتيب ضمني. كل تعارض منعه تنفيذ النظام القديم أحادي الخيط بشكل عرضي. المتعة؟ صفر. الفكرة؟ ما تظهرش في الإنتاج.

تخفيض الزمن الاستجابي بـ~35%

الرقم من زمن استجابة استعلام حالة اللعبة عند p95 وp99 قبل وبعد ترحيل نموذج القراءة. مسارات القراءة التي تضرب Redis materialized projections ليست في نفس فئة العمليات كمسارات القراءة التي تضرب جدول PostgreSQL متنازَعاً عليه يمتص أيضاً الكتابات. هذا ليس مفاجأة. المفاجأة كانت لو لم يتحسّن.

الرقم الأكثر إثارة هو زمن استجابة مسار الكتابة، الذي تحسّن أقل - زمن استجابة نشر Kafka حقيقي، وتأخر مستهلك الأحداث خلال الذرى حقيقي، ومسار الأوامر الآن غير متزامن بينما كان متزاماً سابقاً. اللاعبون المعتادون على رؤية وحدتهم تتحرك فوراً بعد النقر كانوا يرون الآن تأخيراً async قصيراً. هذا مقايضة UX تطلبت تعاملاً حذراً - نمط optimistic update على جانب العميل غطّى معظمها، لكن معايرة سلوك timeout-and-reconcile للحالات التي يكون فيها مستهلك الأحداث متأخراً مؤقتاً تطلّب تكراراً.

تنسيق فرق متعددة

ترحيل Bytro شمل فرقاً متعددة: فرقة منصة تتعامل مع البنية التحتية (Kafka وKubernetes وخطوط النشر)، وفرق نطاق تتعامل مع أنظمة لعبة فردية (قتال، دبلوماسية، اقتصاد)، وفرقة عميل تتعامل مع تغييرات مزامنة الحالة الأمامية.

Lead Developer عبر تلك الفرق يعني إدارة العقد بينها. مخطط الأحداث كان العقد. حين احتاجت فرقة القتال إضافة حقل إلى حدث حسم المعركة، كان ذلك ترحيل مخطط تحتاج فرقة العميل التعامل معه، ومسار التحليلات يحتاج التعامل معه، ومستهلكو نماذج القراءة يحتاجون التعامل معه - كل ذلك دون “يوم العلم”. أصدرنا الأحداث. يبدو هذا واضحاً. تطبيقه في قاعدة كود لم تفعله من قبل ثلاثة أسابيع عمل لا أحد يريدها، وبعدها كل الناس تقول: الحمد لله إنك عملتها.

Kubernetes: الأداة الصحيحة، مُطبَّقة بعناية

التوسع التلقائي لمستهلكي الأحداث على Kubernetes خلال ذرى المباريات - أحداث البطولات، والتحديثات الكبيرة، والسنام الأسبوعي الذي يمسك دائماً من لا يراقب - كان القرار الصحيح. وكان أيضاً المرة الأولى التي تعمل فيها قاعدة الكود هذه على Kubernetes، مما يعني أن التمييز stateless/stateful الذي يجبرك Kubernetes على جعله صريحاً كان يجب تطبيقه بأثر رجعي على قاعدة كود جعلت ذلك التمييز ضمنياً وغير متسق.

جلسات PHP المخزّنة في الذاكرة المحلية على instance واحدة ليست Kubernetes-native. كنا نعرف ذلك بالفعل. العمل خلال كل مكان اتخذ الكود القديم فيه ذلك الافتراض كان الشرط المسبق غير المثير للترحيل لكل شيء آخر.

ما تملكته

  • قرارات معمارية لنموذج CQRS/event sourcing وطوبولوجيا أحداث Kafka
  • تسلسل الترحيل واستراتيجية dual-write لحالة اللعبة الحيّة
  • تصميم مخطط الأحداث وعقود الإصدار عبر الفرق
  • تصميم نموذج القراءة (PostgreSQL projections، طبقات Redis cache)
  • تنسيق فرق متعددة: منصة ونطاق وهندسة عميل
  • تصميم نشر Kubernetes لمستهلكي الأحداث عديمي الحالة

الألعاب متغيرة الحمل بطرق لا تكونها معظم برمجيات المؤسسات. إعلان بطولة الساعة 2:00 يوم السبت ليس في جدول تخطيط طاقتك. بناء backend يمكنه استيعاب ذلك السنام من غير ما تسيب اللاعبين يبصوا على spinner هو فئة مختلفة من المشكلة عن معالجة منحنى طلبات B2B قابل للتنبؤ. تعلمت أشياء عن backpressure مستهلك الأحداث وتنبيهات التأخر في Bytro أستخدمها في كل نظام موزع أصممه الآن.

Conflict of Nations: WW3 لا يزال يعمل. Supremacy 1914 لا يزال يعمل. الـbackend الذي يخدمهما مختلف اختلافاً جوهرياً عما ورثته. هذه هي النتيجة.