跳至内容
نهج تصميم جديد لمحرك خادم ألعاب عبر الإنترنت

نهج تصميم جديد لمحرك خادم ألعاب عبر الإنترنت

2024-05-08 03:15

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

المنشأ

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

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

الخادم هو قاعدة البيانات

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

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

سنشير إلى خادم اللعبة باسم db، وإلى قاعدة البيانات الحقيقية في الخلفية باسم backend.

  • أولاً، يمكن لعملاء اللعبة الاشتراك مباشرة في استعلامات db (select ... where ...). أي تغيير في البيانات ضمن الاشتراك، سيدفعه db تلقائيًا، بما في ذلك أحداث مثل on_insert / on_update / on_delete.
  • يجب تعيين أذونات بسيطة للجداول، بحيث يمكن للعميل الاستعلام فقط عن البيانات المتعلقة به. لكن بعض الجداول يجب أن تُعيّن بأذونات ضيف، مثل حالة عدد اللاعبين المتصلين بالخادم، ليتمكن العملاء غير المسجلين من الاستعلام.
  • يمكن للعميل الاشتراك فقط، وليس لديه إذن كتابة. تتم الكتابة من خلال كود المنطق في db، وهو المنطق التقليدي لخادم الألعاب. بما أنه db، يمكن أيضًا تسميته إجراءات مخزنة. العميل يستدعي الاسم مباشرة، وينفذ db الوظيفة المقابلة. في معظم الحالات، لا حاجة لإعادة نتيجة التنفيذ، لأنها ستظهر تلقائيًا عبر الدفع.
  • يجب تغليف عمليات القراءة/الكتابة في كود منطق db داخل معاملة واحدة، ثم يتم إرسالها تلقائيًا إلى backend. إذا حدث تعارض في المعاملة، تتم إعادة المحاولة تلقائيًا. بهذه الطريقة، لا داعي للقلق بشأن السباق عند الكتابة، وسيكون النشر أكثر مرونة.
    graph TD;
    subgraph "خادم اللعبة (DB)"
        DB1["اشتراك"];
        DB2["منطق"];
    end
    Client["عميل اللعبة"]<--تدفق بيانات الاشتراك-->DB1;
    Client--Call-->DB2;
    subgraph "Backend"
        DB1<--اشتراك للقراءة فقط-->نسخة;
        DB2--معاملة قراءة/كتابة-->Master;
    end
  

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

مقايضة الخصائص

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

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

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

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

    ---
title: مخطط البنية
---
graph TD;
    Client1["عميل"]-->DNS;
    Client2@{ shape: processes, label: "عميل..."}-->DNS;
    DNS["DNS موازنة الحمل"]-->Node_A;
    DNS-->Node_B;
    subgraph "Node1"
    Node_A["خادم الألعاب"]-->Worker_A1["Worker"];
    Node_A-->Worker_A2@{ shape: processes, label: "Worker..."};
    end
    subgraph "Nodes..."
    Node_B["خادم الألعاب..."]-->Worker_B1["Worker"];
    Node_B-->Worker_B2@{ shape: processes, label: "Worker..."};
    end
    Worker_A1-->Backend["Backend</br>(Redis Cluster/Replica)"];
    Worker_A2-->Backend;
    Worker_B1-->Backend;
    Worker_B2-->Backend;
  

اللغة والمزيد من اعتبارات الأداء

في الواقع، مقارنة باللغات الحديثة، لغة Lua ليست مريحة جدًا، بل إن العديد من ميزات C++11 أكثر راحة من Lua. بالإضافة إلى ذلك، مكتبات Lua قليلة وقليلة الصيانة، لذا أصبح استبدال Lua في خوادم الألعاب خيارًا حتميًا.

Rust خيار جيد، الأمان، الأداء، الحداثة، كلها تلبي المتطلبات. لكنني أميل إلى اختيار لغة ديناميكية، ويفضل أن تكون لغة ديناميكية مريحة في الكتابة مثل Python. في الواقع، طالما تم دعم التوزيع، فإن الاختناق سيكون بالكامل في Redis، وأداء اللغة لا يهم. الآن المعالج أرخص من الإنسان، وإذا كانت صيانة الخادم مريحة، يمكن خفض التكلفة بنسبة 50% أخرى من خلال خوادم التسعير التنافسية. حتى أن البعض لا يهتم بانخفاض الأداء بنسبة 30% بسبب Docker، فالراحة أهم.

مكتبات Python كثيرة، على سبيل المثال، قراءة وكتابة الجداول يمكن تغليفها باستخدام مصفوفات numpy array، مما يجعل معالجة وتصفية المصفوفات مريحة للغاية، مثال على ذلك، الفهرس المتقاطع:

array = money_table.query('last_update', left=now - 3600, right=now)
poor = array[array.money < 999]
poor.money += poor.money.mean()

إجراء تصفية ثانوية محليًا، لاختيار جميع الصفوف التي يكون فيها money أقل من 999، ثم معالجة البيانات بشكل متجه

هذا النمط من البث/المتجهات على طريقة Fortran مناسب جدًا لأنماط معالجة البيانات في الألعاب. المتجهات تستخدم SIMD تلقائيًا، وتكون أسرع بعشرات المرات من حلقة for في C. حتى MySQL، بالنسبة للفهارس المتعددة، تقوم بتصفية ثانوية باستخدام المعالج، دون استخدام SIMD. هذا التغليف يحل أيضًا مشكلة عدم راحة صيانة بيانات Redis المذكورة سابقًا، ليس فقط الصيانة، بل يصبح إعداد التقارير وتحليل الإيرادات أسهل، فمعالجة وعرض البيانات هي من نقاط قوة Python.

في الماضي، كنا نكتب ذكاء الشخصيات غير القابلة للعب (NPC) في الخادم يدويًا باستخدام أشجار السلوك أو آلات الحالة. مع Python، تصبح الاحتمالات لا حصر لها، يمكن تحقيقها باستخدام نماذج الذكاء الاصطناعي الحقيقية، مثل التعلم المعزز Q-learning، والتي تستنتج قيمة توقيتات السلوك المختلفة في الماضي من خلال المكافآت المستقبلية، لتحديد منطق سلوك NPC الأمثل بالقوة. بالطبع، الزعماء (bosses) التي تتطلب قدرًا عاليًا من اللعب تحتاج إلى مزيد من الضبط، لأن السلوك المنطقي لا يعني بالضرورة أنه ممتع.

اختناق أداء Redis وتصميم هياكل البيانات

أداء Redis يحد من قدرة معالجة خادم الألعاب. لحسن الحظ، ألعاب MMO الضخمة أصبحت نادرة الآن، وأداء Redis لا يصل إلى حدوده في الألعاب التي تضم أقل من عشرة آلاف لاعب متصل. قدرة Redis على الفهرس الواحد + المعاملات تبلغ حوالي 30 ألف عملية قراءة + كتابة في الثانية.

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

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

ببساطة، يتم تقسيم السمات إلى جداول صغيرة، على سبيل المثال، سمة money تُصنع كجدول يسمى money، ويُسمى مكونًا (Component)، يحتوي فقط على سمة money و owner، ثم يرتبط (يُرفق) عبر سمة owner بمعرف اللاعب (player id) ذي الصلة، مشابهًا لمكونات السكربت في Unity. بهذا لم تعد هناك جداول كبيرة، ويمكن تفكيك الارتباطات بشكل أكبر. بالإضافة إلى ذلك، player (الذي يحتوي فقط على معرف، ولا يوجد فعليًا) هو الكيان (Entity) في ECS، وكود المنطق هو النظام (System).

    ---
title: مثال على مجموعة مكونات
---
graph TD;
    subgraph "مجموعة 1"
    System_A-->Component1;
    System_B-->Component1;
    end
    subgraph "مجموعة 2"
    System_D-->Component3;
    System_D-->Component2;
    System_C-->Component2;
    end
  

الخلاصة

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

هذه التصاميم لا تزال خفيفة الوزن، والكود المطلوب تقديره ليس كثيرًا، لكنه يتطلب تفكيرًا أكثر. أنا حاليًا في طور التطوير وقد قمت بفتح المصدر على https://github.com/Heerozh/hetu، وسيُستخدم في لعبة SLG القادمة، ومرحبًا بالمساهمات.

آخر تعديل
hugo-builder
hugo-builder · · 自动翻译 about.md 2... · 248520b
مساهمون آخرون
...