نهج تصميم جديد لمحرك خادم ألعاب الشبكة
هل يوجد بنية خادم ألعاب: تكون موزعة، وبسيطة مثل كتابة برنامج أحادي الخيط؛ تدفع البيانات تلقائيًا، تخفي واجهات برمجة التطبيقات غير الضرورية، وتركز فقط على منطق الأعمال؟
المنشأ
في الماضي، تم تطوير معظم خوادم الألعاب باستخدام لغة C، وكانت الدورة بطيئة والتصحيح صعبًا. لاحقًا بدأ التحول إلى LuaJIT، وكنا متقدمين نسبيًا، حيث تحولنا في عام 2009. بعد كل شيء، كانت ألعاب الشبكة المبكرة تولي أهمية كبيرة للأداء، ومع تقدم التكنولوجيا أصبح الأداء أقل أهمية.
خلال تحسين الوظائف وإعادة الهيكلة المستمرة، أدركنا أن عبء مهام الكود لا يزال ثقيلًا، ولا يواكب متطلبات التكرار المتسارع. العديد من الميزات لا يمكن اختبارها وتجربتها على الفور، وللتحسين، يجب إعادة تصميم خادم اللعبة من الناحية المفاهيمية.
الخادم كقاعدة بيانات (المخطط هو واجهة برمجة التطبيقات)
كان الهيكل السابق عمومًا: يستقبل خادم اللعبة أوامر من العميل، ثم يتلاعب منطق الخادم بقاعدة البيانات، وأخيرًا يعيد البيانات المحدثة إلى العميل. هذه العملية نفسها جلبت التعقيد، لذا من الأفضل إخفاء قاعدة البيانات مباشرة، أو دمجها في كيان واحد - خادم اللعبة هو قاعدة البيانات.
هذه الفكرة طبيعية في الواقع. دائمًا ما كانت ألعاب الشبكة لا تفضل قواعد البيانات العلائقية: إما استخدام MemoryMapping البسيط لتحقيق أعلى أداء، أو استخدام NoSQL مثل Redis. هناك مقولة تقول “أفضل قاعدة بيانات هي دائمًا قاعدة بيانات مخصصة”، لذا لماذا لا يتم تصميم الخادم مباشرة كواجهة قاعدة بيانات مخصصة لخدمة عميل اللعبة. بالطبع، ستظل قاعدة البيانات الحقيقية في الخلفية مسؤولة عن الثبات، ولكن يتم إخفاء عمليات قاعدة البيانات تمامًا ومعالجتها داخليًا تلقائيًا.
سنشير أدناه إلى خادم اللعبة باسم db، وإلى قاعدة البيانات الحقيقية في الخلفية باسم backend.
- أولاً، يمكن لعملاء اللعبة إجراء استعلامات اشتراك (
select ... where ...) مباشرة علىdb. أي تغيير في البيانات ضمن نطاق الاشتراك، سيقومdbبدفعه تلقائيًا، بما في ذلك أحداث مثلon_insert/on_update/on_delete. - يجب تعيين أذونات بسيطة على مستوى الصف للجداول، بحيث يمكن للعميل الاستعلام فقط عن البيانات المتعلقة به. ولكن بعض الجداول يجب أن تكون قابلة للتعيين كأذونات
guest، مثل حالة عدد اللاعبين المتصلين بالخادم، مما يسمح للعملاء غير المسجلين بالاستعلام أيضًا. - يمكن للعميل الاشتراك فقط، وليس لديه أذونات كتابة؛ تتم الكتابة من خلال وظائف
db، أي منطق خادم اللعبة التقليدي. بما أنهdb، يمكن أيضًا تسميتها إجراءات مخزنة. لكن هذا الإجراء المخزن يركز على الحساب الذاكري ذو الحالة، وهو ما لا تمتلكه إجراءات قاعدة البيانات المخزنة عادةً. - يتم تغليف عمليات القراءة/الكتابة في كود منطق
dbضمن معاملة واحدة، ثم يتم إرسالها تلقائيًا إلىbackend. إذا حدث تعارض في المعاملة، يتم إعادة المحاولة تلقائيًا. بهذه الطريقة، لا داعي للتفكير في السباق عند كتابة الكود، ويكون النشر أكثر مرونة.
graph TD;
subgraph "خادم اللعبة (DB)"
DB1["اشتراك"];
DB2["منطق"];
end
Client["عميل اللعبة"]<--تدفق بيانات الاشتراك-->DB1;
Client--Call-->DB2;
subgraph "Backend"
DB1<--اشتراك للقراءة فقط-->نسخة;
DB2--معاملة قراءة/كتابة-->Master;
DB2--معاملة قراءة-->نسخة;
end
بهذا الشكل، سواء كان على جانب العميل أو الخادم، ستكون الكتابة مريحة للغاية.
يحتاج العميل فقط إلى الاشتراك في البيانات، ومن خلال البرمجة التفاعلية يمكنه معالجة كائنات UI أو المشهد تلقائيًا، وفصل اقترانه بشكل كبير عن منطق الخادم.
يمكن للخادم كتابة المنطق الحقيقي فقط، دون الحاجة إلى الاهتمام بكتابة البيانات وتحديثها ودفعها.
المفاضلة بين الخصائص
علينا أيضًا تحقيق التوازن بين المثلث المستحيل (المتغير): الأداء، المرونة، واتساق البيانات. تحسين جانب واحد عادة ما يقلل من الجوانب الأخرى.
الأداء
يمكن اعتماد هيكل التوزيع متوازن الحمل الشائع في Web App، وبالتالي يكون الأداء مقيدًا بشكل أساسي بقاعدة البيانات backend. إذا تم استخدام Redis عالي الأداء، فإن مزاياه وعيوبه هي كما يلي:
- يحتوي على
MQمدمج، لا حاجة لإضافة طبقة أخرى. - يمكن التوسع الرأسي لتحسين الأداء، لكنه يتضمن مفاضلة في اتساق البيانات.
- صيانة وعرض البيانات غير مريحين، ولكن يمكن الآن الاعتماد على
AIللمساعدة في حله.
المرونة
تشمل المرونة جوانب الأعطال والصيانة:
- يمكن تحقيق توفر عالٍ تقريبي، مع إعادة اتصال غير محسوسة من قبل العميل عند حدوث أخطاء في الخادم.
- تشغيل خادم بنقرة واحدة، يسهل الزيادة والنقصان الديناميكي للتخصيص.
- لا ينبغي أن يؤثر عطل خادم واحد على المجموعة، وهذا يتطلب اتساقًا جيدًا في البيانات، بحيث لا تكون هناك حاجة لعمليات استعادة/إصلاح البيانات عند حدوث عطل.
- يجب أيضًا مراعاة الزيادة والنقصان الديناميكي للتخصيص في
backend.
اتساق البيانات
تتطلب الألعاب مستوى عالٍ من اتساق البيانات. كيف يتم حل سباق البيانات الموزع؟ كيف يتم التعامل مع البيانات التي تمت كتابتها جزئيًا عند تعطل البرنامج / توقف الخادم؟ لا يمكن خصم أموال اللاعب دون منح العناصر، هذا أمر إلزامي.
بما أن خيار الأداء هو التوزيع، في هذه البنية، اتساق البيانات هو ببساطة المعاملات، لكن معاملات WATCH + MULTI ستقلل بشكل كبير من أداء Redis، كما ستقلل المرونة، على سبيل المثال لا يمكن استخدامها تحت Cluster أو وكيل Redis.
الخيار بعد المفاضلة هو طريقة “قفل تفاؤلي للإصدار + معاملة Lua”: فقط عندما يكون رقم الإصدار متطابقًا تمامًا وتمر جميع الفحوصات، تنفذ Lua الكتابة المجمعة، مما يضمن إما اكتمال كتابة بيانات متعددة بالكامل، أو فشلها جميعًا. هذه الطريقة تدعم أنواع Fork وهياكل Redis المختلفة. على الرغم من استخدام Lua، إلا أن الأداء الفعلي أسرع من WATCH + MULTI.
من أجل المرونة، تم التخلي عن قفل الفهارس، أي لا يمكن منع القراءة الوهمية (الاستعلام عما إذا كان المستخدم موجودًا، وإذا لم يكن موجودًا يتم إدراج مستخدم جديد)، ولكن عادة ما يتم حل مثل هذه المشكلات من خلال إضافة فهرس فريد. التضحية باتساق استعلام الفهارس، جلبت تحسينات هائلة في المرونة والأداء.
---
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 خيار جيد: الأمان، الأداء، الحداثة كلها شاملة. لكنني أميل أكثر إلى اللغة الديناميكية، وهذا بناءً على اعتبارات المرونة، ومن الأفضل أن تكون مناسبة للكتابة من قبل البشر وAI. نعم، الإجابة واضحة: Python.
في الواقع، طالما هناك دعم للتوزيع، فإن الاختناق يكون محصورًا تمامًا في Redis، وأداء اللغة ليس بالغ الأهمية. الآن CPU أرخص من الإنسان؛ إذا كانت صيانة الخادم مريحة أيضًا، فيمكن تخفيض السعر بنسبة 50% أخرى من خلال خوادم المزايدة.
Python لديها العديد من المكتبات، على سبيل المثال يمكن تغليف قراءة وكتابة الجداول تمامًا باستخدام NumPy array، وسيكون معالجة وتصفية المصفوفات مريحة للغاية. إليك مثال على الفهرس المتقاطع:
array = money_table.range("last_update", left=now - 3600, right=now)
poor = array[array.money < 999]
poor.money += poor.money.mean()إجراء التصفية الثانوية محليًا، واختيار جميع البيانات التي يكون فيها money < 999، ثم المعالجة المتجهية في النهاية.
هذا النمط من البث/المتجهية على طريقة Fortran مناسب جدًا لأنماط معالجة البيانات في الألعاب. المتجهية تسمح تلقائيًا بـ SIMD، وهي أسرع بعدة مرات من حساب for loop البسيط في C. هذا التغليف يحل أيضًا مشكلة عدم راحة صيانة بيانات Redis المذكورة سابقًا؛ في النهاية، معالجة وعرض البيانات هي نقاط قوة Python.
AI لـ NPC الخادم، في الماضي كنا نكتبه يدويًا باستخدام أشجار السلوك وآلات الحالة وما شابه. باستخدام Python ستكون الاحتمالات أكبر، يمكن اعتماد نماذج AI حقيقية بالكامل، مثل التعلم المعزز Q-learning وما شابه: من خلال المكافآت المستقبلية استنتاج قيمة توقيتات السلوك المختلفة في الماضي، واستخلاص منطق السلوك الأمثل لـ NPC بقوة. بالطبع، Boss الذي يتطلب مستوى عالٍ من اللعب يحتاج إلى مزيد من الضبط، لأن السلوك المنطقي لا يعني بالضرورة أنه ممتع.
اختناق أداء Redis وتصميم هياكل البيانات
أداء Redis يقيد قدرة معالجة خادم اللعبة.
يجب أولاً فصل أحمال العميل (الاشتراك) والخادم (المعاملات) من خلال فصل القراءة عن الكتابة. جميع اشتراكات العميل تتم من خلال نسخة Redis للقراءة فقط، دون التأثير على master Redis الذي يعالج المعاملات؛ يتم تشغيل جميع أكواد كتابة المعاملات للخادم على master. هذه الخطة كافية لمعظم الألعاب.
إذا أردنا التفكير في ألعاب أكبر حجمًا (مثل MMO أحادي الخادم في الماضي)، يجب مراعاة قابلية التوسع لـ master. يمكن لـ master التوسع من خلال عدة خوادم Redis تشكل Cluster، ولكن هناك قيد رئيسي: لا يمكن للمعاملات عبور خوادم Redis. لذلك، يجب على المحرك حساب الارتباط بين الجداول، ووضع الجداول المرتبطة على نفس خادم Redis. الجداول الكبيرة في قواعد البيانات العلائقية السابقة غالبًا ما تكون مرتبطة ببعضها ويصعب فصلها، وهذا يتطلب إدخال بنية ECS.
ببساطة، يتم تقسيم السمات إلى جداول صغيرة. على سبيل المثال، تحويل سمة money إلى جدول اسمه money، يسمى مكون Component، يحتوي فقط على حقلي money و owner، ثم من خلال owner يتم ربطه (attach) بـ player id ذي الصلة، مشابهًا لمكونات البرنامج النصي في Unity. بهذه الطريقة لم تعد هناك جداول كبيرة، ويمكن تفكيك الارتباط بشكل أكبر. بالإضافة إلى ذلك، player (لديه فقط id، ولا يوجد فعليًا) هو الكيان 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
الخلاصة
حسنًا، لم تعد بحاجة إلى كتابة أي أقفال مزعجة، ولا داعي للتفكير في تعارض البيانات، يمكنك التركيز بكفاءة كما لو كنت تكتب برنامجًا أحادي الخيط؛ لم تعد هناك حاجة أيضًا لـ C في MVC الخاص بالعميل.
هذه التصميمات في الواقع لا تزال خفيفة الوزن، الكود المطلوب估计 ليس كثيرًا، لكنه يتطلب تفكيرًا أكثر. أنا حاليًا أقوم بالتطوير وقد قمت بفتح المصدر:
Heerozh/hetu، وسيتم استخدامه في اللعبة SLG القادمة، وأرحب بالمساهمات.
7d577a3