Une Nouvelle Approche de Conception pour un Moteur de Serveur de Jeux en Ligne
Existe-t-il une architecture de serveur de jeu qui soit à la fois distribuée, mais aussi simple à écrire qu’un programme monothread, où les données sont automatiquement poussées, cachant les API inutiles pour se concentrer uniquement sur la logique ?
Origine
Les serveurs de jeu étaient initialement développés en langage C, ce qui était lent et fastidieux à déboguer. Plus tard, nous sommes passés au luajit ; nous avons été assez radicaux en changeant dès 2009. Étant donné que les jeux en ligne accordent une grande importance aux performances, en réalité, la facilité de développement offerte par les langages dynamiques et leurs caractéristiques flexibles sont plus importantes que la performance pure.
En améliorant continuellement les fonctionnalités et en refactorisant, nous avons pris conscience que la charge de codage restait lourde, ne suivant pas le rythme des exigences d’itération de plus en plus rapides. De nombreuses fonctionnalités ne pouvaient pas être immédiatement expérimentées et testées. Pour s’améliorer, il faudrait changer à nouveau de langage et repenser conceptuellement le serveur de jeu.
Le Serveur en tant que Base de Données
La structure traditionnelle est la suivante : le serveur de jeu reçoit les commandes du client, traite la logique, puis manipule la base de données, et enfin renvoie les données mises à jour au client. Ce processus introduit lui-même de la complexité. Alors pourquoi ne pas simplement cacher la base de données, ou plutôt la fusionner en une seule entité ? Le serveur de jeu devient la base de données.
Cette idée est en fait assez naturelle. Depuis longtemps, les jeux en ligne n’aiment pas les bases de données relationnelles. Soit ils utilisent directement un simple mapping mémoire, efficace et rapide, soit des bases NoSQL comme Redis. Ensuite, la meilleure base de données est toujours une base de données sur mesure, car les compromis varient selon les différents besoins métier. Alors, pourquoi ne pas concevoir directement le serveur comme une interface de base de données spécialement conçue pour les clients de jeu ? Bien sûr, le backend serait toujours géré par une véritable base de données pour la persistance, mais les opérations sur la base de données seraient complètement cachées, traitées automatiquement en interne.
Dans la suite, nous appellerons le serveur de jeu db, et la véritable base de données backend backend.
- Tout d’abord, le client de jeu peut s’abonner directement à des requêtes sur la
db(select ... where ...). Toute modification des données dans l’abonnement sera automatiquement poussée par ladb, y compris les événementson_insert/on_update/on_delete, etc. - Les tables doivent avoir des permissions simples, de sorte que le client ne puisse interroger que les données qui le concernent. Cependant, certaines tables devraient pouvoir être définies avec des permissions
guest, par exemple l’état du nombre de joueurs en ligne sur le serveur, permettant aux clients non connectés de les interroger. - Le client ne peut que s’abonner, il n’a pas de permissions d’écriture. L’écriture doit passer par le code logique de la
db, c’est-à-dire la logique traditionnelle du serveur de jeu. Puisqu’il s’agit d’unedb, on pourrait aussi appeler cela des procédures stockées. Le client appelle directement un nom, et ladbexécute la fonction correspondante. Dans la plupart des cas, il n’est pas nécessaire de renvoyer le résultat de l’exécution, car il sera automatiquement reflété via les notifications. - Les opérations de lecture/écriture dans le code logique de la
dbdoivent être encapsulées dans une transaction, puis automatiquement validées auprès dubackend. En cas de conflit de transaction, une nouvelle tentative est effectuée automatiquement. Ainsi, lors de l’écriture, il n’est pas nécessaire de penser aux conditions de concurrence, et le déploiement sera plus flexible.
graph TD;
subgraph "Serveur de Jeu (DB)"
DB1["Abonnement"];
DB2["Logique"];
end
Client["Client de Jeu"]<--Flux de données d'abonnement-->DB1;
Client--Appel-->DB2;
subgraph "Backend"
DB1<--Lecture seule/Abonnement-->Réplique;
DB2--Lecture-Écriture/Transaction-->Master;
end
Cette forme sera très agréable à utiliser, que ce soit du côté client ou serveur. Le client, en s’abonnant simplement aux données, peut automatiquement gérer l’interface utilisateur ou les objets dans la scène via les événements de données, découplant largement la logique serveur. Le côté serveur peut se concentrer uniquement sur l’écriture de la véritable logique, sans avoir à se soucier de transmettre les données mises à jour au client de jeu.
Compromis sur les Caractéristiques
Il s’agit principalement d’équilibrer une variante du triangle de l’impossibilité : performance, flexibilité et cohérence des données. Améliorer l’un de ces aspects en dégrade un autre.
Tout d’abord, la performance. Une structure distribuée avec équilibrage de charge similaire à celle d’un serveur web peut être adoptée. Par conséquent, la performance ne sera limitée que par la base de données backend. Ainsi, l’utilisation de Redis, qui offre de bonnes performances, est un choix judicieux. De plus, Redis dispose intégralement d’une file de messages (MQ), évitant ainsi d’ajouter une couche supplémentaire. Redis peut être étendu horizontalement pour améliorer les performances, mais cela implique des compromis sur la cohérence des données. Enfin, la maintenance et la visualisation des données dans Redis ne sont pas pratiques. Généralement, Redis n’est utilisé que comme cache, avec une couche supplémentaire comme MySQL pour la persistance, mais cela augmente considérablement la complexité et réduit fortement la flexibilité. Redis est capable de gérer la persistance et peut également implémenter des index. Il vaut donc mieux n’utiliser que Redis. Je prévois de confier la maintenance et la visualisation des données à des bibliothèques liées à la science des données par la suite.
La flexibilité inclut les aspects de panne et de maintenance. Sur ce point, nous abandonnons la haute disponibilité. En cas d’erreur, le client se déconnecte directement et se connecte à un autre serveur. Le jeu peut être conçu pour une reconnexion transparente, ou simplement nécessiter une nouvelle connexion. Pour la maintenance, il est préférable de pouvoir lancer un serveur en un clic, permettant d’ajouter ou de réduire dynamiquement la capacité, ce qui peut réduire considérablement les coûts. La récupération automatique après une panne peut être réalisée en configurant un redémarrage automatique, ce qui nécessite une bonne cohérence des données pour éviter les opérations de restauration/réparation de données en cas de panne.
Enfin, les jeux ont des exigences élevées en matière de cohérence des données. Tout d’abord, comment résoudre les conditions de concurrence dans un environnement distribué, ainsi que les données partiellement écrites en cas de plantage/panne du programme ? On ne peut pas déduire l’argent d’un joueur sans lui donner l’objet, c’est une exigence obligatoire. La meilleure méthode est de l’implémenter via des transactions sur le backend, mais les transactions réduisent considérablement les performances du backend, même avec Redis, d’environ deux ordres de grandeur. Sans utiliser de transactions, en raison de l’implication des index, il faudrait recourir à des verrous ou d’autres méthodes, ce qui est très fastidieux à écrire et sacrifierait également toute la flexibilité de maintenance. C’est pourquoi nous choisissons les transactions ici, en sacrifiant les performances du backend. Ensuite, le backend peut prendre en charge l’extension horizontale tout en maintenant la cohérence en partitionnant les transactions, comme nous en parlerons plus tard.
---
title: Diagramme d'Architecture
---
graph TD;
Client1["Client"]-->DNS;
Client2@{ shape: processes, label: "Client..."}-->DNS;
DNS["DNS à Équilibrage de Charge"]-->Node_A;
DNS-->Node_B;
subgraph "Nœud1"
Node_A["Serveur de Jeu"]-->Worker_A1["Worker"];
Node_A-->Worker_A2@{ shape: processes, label: "Worker..."};
end
subgraph "Nœuds..."
Node_B["Serveur de Jeu..."]-->Worker_B1["Worker"];
Node_B-->Worker_B2@{ shape: processes, label: "Worker..."};
end
Worker_A1-->Backend["Backend</br>(Cluster/Réplique Redis)"];
Worker_A2-->Backend;
Worker_B1-->Backend;
Worker_B2-->Backend;
Langage et Considérations de Performance Supplémentaires
En réalité, comparé aux langages modernes, Lua n’est pas si pratique, et même de nombreuses fonctionnalités de C++11 sont plus agréables que Lua. De plus, Lua a peu de bibliothèques et un manque de maintenance, donc éliminer Lua des serveurs de jeu est déjà une nécessité.
Rust est un bon choix, offrant sécurité, performance et modernité, répondant à tous les aspects. Cependant, je privilégie un langage dynamique, de préférence un langage très pratique à écrire comme Python. En fait, tant que la distribution est prise en charge, le goulot d’étranglement est entièrement bloqué au niveau de Redis, et la performance du langage n’a pas d’importance. Aujourd’hui, le CPU est moins cher que la main-d’œuvre. Si la maintenance du serveur est également pratique, l’utilisation de serveurs spot peut réduire les coûts de 50 % supplémentaires. Même une réduction de 30 % des performances due à Docker est acceptable, la commodité prime.
Python possède de nombreuses bibliothèques. Par exemple, la lecture/écriture des tables peut être entièrement encapsulée avec des tableaux NumPy, rendant le traitement et le filtrage des tableaux très pratiques. Par exemple, pour un index croisé :
array = money_table.query('last_update', left=now - 3600, right=now)
poor = array[array.money < 999]
poor.money += poor.money.mean()Effectuer un filtrage secondaire localement, sélectionner toutes les lignes où money est inférieur à 999, puis traiter les données de manière vectorielle.
Ce style de diffusion/vectorisation de type Fortran convient très bien aux modèles de traitement de données dans les jeux. La vectorisation active automatiquement le SIMD, ce qui peut être des dizaines de fois plus rapide qu’une boucle for en C. Même MySQL, pour les index multiples, effectue un filtrage secondaire via le CPU, sans même utiliser le SIMD. Cet encapsulement résout également le problème mentionné précédemment concernant la difficulté de maintenance des données dans Redis. Non seulement la maintenance, mais aussi la création de rapports, l’analyse des revenus, etc., deviennent plus faciles. Après tout, le traitement et la visualisation des données sont des points forts de Python.
Auparavant, pour l’IA des PNJ serveur, nous utilisions des arbres de comportement, des machines à états, etc., écrits manuellement. Avec Python, les possibilités deviennent infinies. On peut complètement implémenter de véritables modèles d’IA, comme l’apprentissage par renforcement avec Q-learning, etc. Ils déduisent la valeur des différentes actions passées en fonction des récompenses futures, obtenant ainsi de manière brutale la meilleure logique de comportement pour les PNJ. Bien sûr, pour les boss qui nécessitent une haute qualité de gameplay, des ajustements supplémentaires sont nécessaires, car un comportement raisonnable ne signifie pas nécessairement qu’il est amusant.
Goulot d’Étranglement des Performances de Redis et Conception des Structures de Données
Les performances de Redis limitent la capacité de traitement du serveur de jeu. Heureusement, les MMO massifs sont moins courants aujourd’hui, et pour des connexions en ligne inférieures à dix mille joueurs, on n’atteint pas la limite transactionnelle de Redis. La capacité transactionnelle avec un seul index dans Redis est d’environ 30 000 opérations de lecture+écriture par seconde.
Tout d’abord, il devrait y avoir un mode de séparation client/serveur. Qu’est-ce que cela signifie ? Tous les abonnements des clients passent par une réplique Redis en lecture seule, n’affectant pas le Redis maître qui gère les transactions. Tout le code logique du serveur s’exécute sur le maître. Cela augmentera considérablement la capacité de traitement.
Ensuite, la conception doit toujours prendre en compte l’évolutivité future du maître. Le maître peut être étendu en utilisant plusieurs serveurs Redis formant un cluster, mais il y a une limitation clé : les transactions ne peuvent pas traverser plusieurs serveurs Redis. Par conséquent, le moteur doit calculer les corrélations entre les tables et placer toutes les tables corrélées sur un seul et même serveur Redis. Cependant, dans une base de données relationnelle, les grandes tables finissent par être corrélées et ne peuvent être découplées. C’est là qu’intervient la structure ECS.
En termes simples, il s’agit de décomposer les attributs en petites tables individuelles. Par exemple, l’attribut money devient une table appelée money, appelée Composant (Component), contenant uniquement les attributs money et owner. Ensuite, via l’attribut owner, il est associé (attach) à l’ID du joueur concerné, similaire aux composants de script d’Unity. Ainsi, il n’y a plus de grandes tables, et les corrélations peuvent être davantage dispersées. De plus, le player (qui n’a qu’un ID et n’existe pas réellement) est l’Entité (Entity) dans ECS, et le code logique est le Système (System).
---
title: Exemple de Cluster de Composants
---
graph TD;
subgraph "Cluster 1"
System_A-->Component1;
System_B-->Component1;
end
subgraph "Cluster 2"
System_D-->Component3;
System_D-->Component2;
System_C-->Component2;
end
Conclusion
Avec une telle structure, lors de l’écriture du code, il n’est pas nécessaire d’utiliser de verrous désagréables, de penser aux conflits de données. On peut écrire de manière aussi efficace et concentrée que dans un programme monothread, avec moins de bugs. Le projet doit finalement seulement considérer la décomposition des dépendances entre les Composants pour contrôler la distribution et les performances.
Ces conceptions sont en fait encore assez légères, le code nécessaire est probablement peu important, mais cela demande beaucoup de réflexion. Je suis actuellement en train de le développer et l’ai déjà open-source : https://github.com/Heerozh/hetu. Il sera utilisé dans le prochain jeu SLG, et les contributions sont les bienvenues.
248520b